From 34cf190184c3688f2ce8f69cda0a7ca098ea8a4c Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sun, 2 Oct 2022 22:32:13 -0400 Subject: [PATCH 001/407] Initial implementation of HashDetector. --- scenedetect/detectors/hash_detector.py | 168 +++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 scenedetect/detectors/hash_detector.py diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py new file mode 100644 index 00000000..bf072115 --- /dev/null +++ b/scenedetect/detectors/hash_detector.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://pyscenedetect.readthedocs.org/ ] +# +# Copyright (C) 2014-2022 Brandon Castellano . +# +# PySceneDetect is licensed under the BSD 3-Clause License; see the included +# LICENSE file, or visit one of the following pages for details: +# - https://github.com/Breakthrough/PySceneDetect/ +# - http://www.bcastell.com/projects/PySceneDetect/ +# +# This software uses Numpy, OpenCV, click, tqdm, simpletable, and pytest. +# See the included LICENSE files or one of the above URLs for more information. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# + +""" ``scenedetect.detectors.hash_detector`` Module + +This module implements the :py:class:`HashDetector`, which calculates a hash +value for each from of a video using a perceptual hashing algorithm. Then, the +differences in hash value between frames is calculated. If this difference +exceeds a set threshold, a scene cut is triggered. + +This detector is available from the command-line interface by using the +`detect-hash` command. +""" + +# Third-Party Library Imports +import numpy +import cv2 + +# PySceneDetect Library Imports +from scenedetect.scene_detector import SceneDetector + + +class HashDetector(SceneDetector): + """Detects cuts using a perceptual hashing algorithm. For more information + on the perceptual hashing algorithm see references below. + + 1. https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html + 2. https://github.com/JohannesBuchner/imagehash + + Since the difference between frames is used, unlike the ThresholdDetector, + only fast cuts are detected with this method. + """ + + def __init__(self, threshold=100.0, min_scene_len=15, hash_size=16, highfreq_factor=2): + super(HashDetector, self).__init__() + self.threshold = threshold + # Minimum length of any given scene, in frames (int) or FrameTimecode + self.min_scene_len = min_scene_len + # Size of square of low frequency data to include from the discrete cosine transform + self.hash_size = hash_size + # How much high frequency data should be thrown out from the DCT + # A value of 2 means only keep 1/2 of the freq data, a value of 4 means only keep 1/4 + self.highfreq_factor = highfreq_factor + self.last_frame = None + self.last_scene_cut = None + self.last_hash = numpy.array([]) + self._metric_keys = ['hash_dist'] + self.cli_name = 'detect-hash' + + def get_metrics(self): + return self._metric_keys + + def process_frame(self, frame_num, frame_img): + """ Similar to ContentDetector, but using a perceptual hashing algorithm + to calculate a hash for each frame and then calculate a hash difference + frame to frame. + + Arguments: + frame_num (int): Frame number of frame that is being passed. + + frame_img (Optional[int]): Decoded frame image (numpy.ndarray) to perform scene + detection on. Can be None *only* if the self.is_processing_required() method + (inhereted from the base SceneDetector class) returns True. + + Returns: + List[int]: List of frames where scene cuts have been detected. There may be 0 + or more frames in the list, and not necessarily the same as frame_num. + """ + + cut_list = [] + metric_keys = self._metric_keys + _unused = '' + + # Initialize last scene cut point at the beginning of the frames of interest. + if self.last_scene_cut is None: + self.last_scene_cut = frame_num + + # We can only start detecting once we have a frame to compare with. + if self.last_frame is not None: + # We obtain the change in hash value between subsequent frames as + # well as the actual hash value. This is refered to in a statsfile + # as their respective metric keys. + if (self.stats_manager is not None and + self.stats_manager.metrics_exist(frame_num, metric_keys)): + hash_dist = self.stats_manager.get_metrics(frame_num, metric_keys) + else: + # Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy + # https://github.com/JohannesBuchner/imagehash + + # Convert to grayscale + curr_gray = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) + # Resize image to square to help with DCT + imsize = self.hash_size * self.highfreq_factor + curr_resized = cv2.resize(curr_gray, (imsize, imsize), interpolation=cv2.INTER_AREA) + # Calculate discrete cosine tranformation of the image + curr_resized = numpy.float32(curr_resized) / numpy.max(numpy.max(curr_resized)) + curr_dct = cv2.dct(curr_resized) + # Only keep the low frequency information + curr_dct_low_freq = curr_dct[:self.hash_size, :self.hash_size] + # Calculate the median of the low frequency information + curr_med = numpy.median(curr_dct_low_freq) + # Transform the low frequency information into a binary image based on > or < median + curr_hash = curr_dct_low_freq > curr_med + + last_hash = self.last_hash + + if last_hash.size == 0: + # Calculate hash as above + last_gray = cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2GRAY) + last_resized = cv2.resize(last_gray, (imsize, imsize), interpolation=cv2.INTER_AREA) + last_resized = numpy.float32(last_resized) / numpy.max(numpy.max(last_resized)) + last_dct = cv2.dct(last_resized) + last_dct_low_freq = last_dct[:self.hash_size, :self.hash_size] + last_med = numpy.median(last_dct_low_freq) + last_hash = last_dct_low_freq > last_med + + # Hamming distance is calculated to compare to last frame + hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) + + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, { + metric_keys[0]: hash_dist}) + + self.last_hash = curr_hash + + + # We consider any frame over the threshold a new scene, but only if + # the minimum scene length has been reached (otherwise it is ignored). + if hash_dist >= self.threshold and ( + (frame_num - self.last_scene_cut) >= self.min_scene_len): + cut_list.append(frame_num) + self.last_scene_cut = frame_num + + if self.last_frame is not None and self.last_frame is not _unused: + del self.last_frame + + # If we have the next frame computed, don't copy the current frame + # into last_frame since we won't use it on the next call anyways. + if (self.stats_manager is not None and + self.stats_manager.metrics_exist(frame_num+1, metric_keys)): + self.last_frame = _unused + else: + self.last_frame = frame_img.copy() + + return cut_list \ No newline at end of file From f05b68012c84cb9557863d9c08d6b4f661dca7d4 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sun, 2 Oct 2022 22:36:21 -0400 Subject: [PATCH 002/407] Added cli and init calls. --- scenedetect/__init__.py | 2 +- scenedetect/cli/__init__.py | 41 +++++++++++++++++++++++++++++++ scenedetect/detectors/__init__.py | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index a5e5be68..dada1cb6 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -43,7 +43,7 @@ from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.backends import AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv from scenedetect.stats_manager import StatsManager, StatsFileCorrupt -from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector +from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HashDetector from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.platform import init_logger diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index dfa32a7c..bf006ded 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -613,6 +613,47 @@ def detect_threshold_command( ) +@click.command('detect-hash') +@click.option( + '--threshold', '-t', metavar='VAL', + type=click.FLOAT, default=100.0, show_default=True, help= + 'Threshold value (float) that the hash_dist metric must exceed to trigger' + ' a new scene. Refers to frame metric hash_dist in the stats file.') +@click.option( + '--size', '-s', metavar='VAL', + type=click.IntRange(min=2), default=16, show_default=True, help= + 'Size of the hash used in the perceptual hasing algorithm. Must be an ' + 'integer >=2.') +@click.option( + '--freq_factor', '-f', metavar='VAL', + type=click.IntRange(min=1), default=2, show_default=True, help= + 'Parameter used to specify the amount of high frequency image information ' + 'used for the perceptual hashing algorithm. A high value uses less high ' + 'frequency image information, meaning that the algorithm is less sensitive ' + 'to small changes. A low value causes the algorithm to be more sensitive to' + ' small changes. Must be an integer >0.') +@click.pass_context +def detect_hash_command(ctx, threshold, size, freq_factor): + """ Perform perceptual hashing based scene detection on input video(s). + detect-hash + detect-hash --threshold 27.5 + detect-hash --threshold 100 --size 16 --freq_factor 2 + """ + + min_scene_len = 0 if ctx.obj.drop_short_scenes else ctx.obj.min_scene_len + + logging.debug('Detecting scenes using hash detector. parameters:\n' + ' threshold: %d, min-scene-len: %d, hash-size: %d,' + ' freq-factor: %d', threshold, min_scene_len, size, freq_factor) + + # Initialize the detector and add it to the scene manager + ctx.obj.add_detector(scenedetect.detectors.HashDetector( + threshold=threshold, + min_scene_len=min_scene_len, + hash_size=size, + highfreq_factor=freq_factor)) + + @click.command('export-html') @click.option( '--filename', diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index d274eeec..869a9595 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -76,6 +76,7 @@ from scenedetect.detectors.content_detector import ContentDetector from scenedetect.detectors.threshold_detector import ThresholdDetector from scenedetect.detectors.adaptive_detector import AdaptiveDetector +from scenedetect.detectors.hash_detector import HashDetector # Algorithms being ported: #from scenedetect.detectors.motion_detector import MotionDetector From 44003d4ca565cfbd503798386aec9cfb6d758c4c Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sun, 2 Oct 2022 23:21:21 -0400 Subject: [PATCH 003/407] Fixing cli command. --- scenedetect/cli/__init__.py | 44 ++++++++++++++++++++++++++----------- scenedetect/cli/config.py | 6 +++++ scenedetect/cli/context.py | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index bf006ded..5fa89b9e 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -22,6 +22,7 @@ """ import logging +from optparse import Option from typing import AnyStr, Optional import click @@ -618,12 +619,14 @@ def detect_threshold_command( '--threshold', '-t', metavar='VAL', type=click.FLOAT, default=100.0, show_default=True, help= 'Threshold value (float) that the hash_dist metric must exceed to trigger' - ' a new scene. Refers to frame metric hash_dist in the stats file.') + ' a new scene. Refers to frame metric hash_dist in the stats file.' +) @click.option( '--size', '-s', metavar='VAL', type=click.IntRange(min=2), default=16, show_default=True, help= 'Size of the hash used in the perceptual hasing algorithm. Must be an ' - 'integer >=2.') + 'integer >=2.' +) @click.option( '--freq_factor', '-f', metavar='VAL', type=click.IntRange(min=1), default=2, show_default=True, help= @@ -631,27 +634,41 @@ def detect_threshold_command( 'used for the perceptual hashing algorithm. A high value uses less high ' 'frequency image information, meaning that the algorithm is less sensitive ' 'to small changes. A low value causes the algorithm to be more sensitive to' - ' small changes. Must be an integer >0.') + ' small changes. Must be an integer >0.' +) +@click.option( + '--min-scene-len', + '-m', + metavar='TIMECODE', + type=click.STRING, + default=None, + help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' + ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' + ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % + ('' if USER_CONFIG.is_default('detect-hash', 'min-scene-len') else + USER_CONFIG.get_help_string('detect-hash', 'min-scene-len')) +) @click.pass_context -def detect_hash_command(ctx, threshold, size, freq_factor): +def detect_hash_command( + ctx: click.Context, + threshold: Optional[float], + size: Optional[int], + freq_factor: Optional[int], + min_scene_len: Optional[str] + ): """ Perform perceptual hashing based scene detection on input video(s). detect-hash detect-hash --threshold 27.5 detect-hash --threshold 100 --size 16 --freq_factor 2 """ + assert isinstance(ctx.obj, CliContext) - min_scene_len = 0 if ctx.obj.drop_short_scenes else ctx.obj.min_scene_len - - logging.debug('Detecting scenes using hash detector. parameters:\n' - ' threshold: %d, min-scene-len: %d, hash-size: %d,' - ' freq-factor: %d', threshold, min_scene_len, size, freq_factor) - - # Initialize the detector and add it to the scene manager - ctx.obj.add_detector(scenedetect.detectors.HashDetector( + ctx.obj.handle_detect_hash( threshold=threshold, min_scene_len=min_scene_len, hash_size=size, - highfreq_factor=freq_factor)) + highfreq_factor=freq_factor + ) @click.command('export-html') @@ -1063,3 +1080,4 @@ def save_images_command( _add_cli_command(scenedetect_cli, detect_content_command) _add_cli_command(scenedetect_cli, detect_threshold_command) _add_cli_command(scenedetect_cli, detect_adaptive_command) +_add_cli_command(scenedetect_cli, detect_hash_command) diff --git a/scenedetect/cli/config.py b/scenedetect/cli/config.py index 891a8d80..650f9efa 100644 --- a/scenedetect/cli/config.py +++ b/scenedetect/cli/config.py @@ -96,6 +96,12 @@ def __str__(self) -> str: 'min-scene-len': TimecodeValue(0), 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, + 'detect-hash': { + 'threshold': RangeValue(100, min_val=0.0, max_val=65536.0), + 'size': RangeValue(16, min_val=2, max_val=65536), + 'freq_factor': RangeValue(2, min_val=1, max_val=65536), + 'min_scene_len': TimecodeValue(0) + }, 'export-html': { 'filename': '$VIDEO_NAME-Scenes.html', 'image-height': 0, diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py index cb678819..5783e1e3 100644 --- a/scenedetect/cli/context.py +++ b/scenedetect/cli/context.py @@ -396,6 +396,46 @@ def handle_detect_threshold( )) self.options_processed = options_processed_orig + + def handle_detect_hash( + self, + threshold: Optional[float], + min_scene_len: Optional[str], + hash_size: Optional[int], + highfreq_factor: Optional[int] + ): + """Handle detect-hash command options.""" + self._check_input_open() + options_processed_orig = self.options_processed + self.options_processed = False + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-hash", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-hash", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + + threshold = self.config.get_value("detect-hash", "threshold", threshold) + hash_size = self.config.get_value("detect-hash", "size", hash_size) + highfreq_factor = self.config.get_value("detect-hash", "freq_factor", highfreq_factor) + + logger.debug("Adding detector: HashDetector(threshold=%f, min_scene_len=%d," + " hash_size=%d, highfreq_factor=%d)", threshold, min_scene_len, hash_size, highfreq_factor) + + self._add_detector( + scenedetect.detectors.HashDetector( + threshold=threshold, + min_scene_len=min_scene_len, + hash_size=hash_size, + highfreq_factor=highfreq_factor + ) + ) + + self.options_processed = options_processed_orig def handle_export_html( self, From 9ff494c306d28a3083805457a97d08f7fd4d515c Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sun, 2 Oct 2022 23:23:19 -0400 Subject: [PATCH 004/407] Cleaned up extra import. --- scenedetect/cli/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index 5fa89b9e..3f6d6bd1 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -22,7 +22,6 @@ """ import logging -from optparse import Option from typing import AnyStr, Optional import click From cb45225adbe9ff411276f91ae10f9e1da8004b50 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sat, 8 Oct 2022 00:22:10 -0400 Subject: [PATCH 005/407] Formatting updates. --- scenedetect/__init__.py | 4 +- scenedetect/cli/__init__.py | 56 +++++++++++++------------- scenedetect/cli/context.py | 25 +++++------- scenedetect/detectors/hash_detector.py | 40 +++++++++--------- 4 files changed, 60 insertions(+), 65 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 47784cda..975cba10 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -39,8 +39,8 @@ from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.scene_detector import SceneDetector from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HashDetector -from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, VideoStreamMoviePy, - VideoCaptureAdapter) +from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, + VideoStreamMoviePy, VideoCaptureAdapter) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt from scenedetect.scene_manager import SceneManager, save_images diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index 3fb82285..1a632d3d 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -712,26 +712,35 @@ def detect_threshold_command( @click.command('detect-hash') @click.option( - '--threshold', '-t', metavar='VAL', - type=click.FLOAT, default=100.0, show_default=True, help= - 'Threshold value (float) that the hash_dist metric must exceed to trigger' - ' a new scene. Refers to frame metric hash_dist in the stats file.' -) + '--threshold', + '-t', + metavar='VAL', + type=click.FLOAT, + default=100.0, + show_default=True, + help='Threshold value (float) that the hash_dist metric must exceed to trigger' + ' a new scene. Refers to frame metric hash_dist in the stats file.') @click.option( - '--size', '-s', metavar='VAL', - type=click.IntRange(min=2), default=16, show_default=True, help= - 'Size of the hash used in the perceptual hasing algorithm. Must be an ' - 'integer >=2.' -) + '--size', + '-s', + metavar='VAL', + type=click.IntRange(min=2), + default=16, + show_default=True, + help='Size of the hash used in the perceptual hasing algorithm. Must be an ' + 'integer >=2.') @click.option( - '--freq_factor', '-f', metavar='VAL', - type=click.IntRange(min=1), default=2, show_default=True, help= - 'Parameter used to specify the amount of high frequency image information ' + '--freq_factor', + '-f', + metavar='VAL', + type=click.IntRange(min=1), + default=2, + show_default=True, + help='Parameter used to specify the amount of high frequency image information ' 'used for the perceptual hashing algorithm. A high value uses less high ' 'frequency image information, meaning that the algorithm is less sensitive ' 'to small changes. A low value causes the algorithm to be more sensitive to' - ' small changes. Must be an integer >0.' -) + ' small changes. Must be an integer >0.') @click.option( '--min-scene-len', '-m', @@ -741,17 +750,11 @@ def detect_threshold_command( help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-hash', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-hash', 'min-scene-len')) -) + ('' if USER_CONFIG.is_default('detect-hash', 'min-scene-len') else USER_CONFIG.get_help_string( + 'detect-hash', 'min-scene-len'))) @click.pass_context -def detect_hash_command( - ctx: click.Context, - threshold: Optional[float], - size: Optional[int], - freq_factor: Optional[int], - min_scene_len: Optional[str] - ): +def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], + freq_factor: Optional[int], min_scene_len: Optional[str]): """ Perform perceptual hashing based scene detection on input video(s). detect-hash detect-hash --threshold 27.5 @@ -763,8 +766,7 @@ def detect_hash_command( threshold=threshold, min_scene_len=min_scene_len, hash_size=size, - highfreq_factor=freq_factor - ) + highfreq_factor=freq_factor) @click.command('export-html') diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py index 3a4937e9..08c02372 100644 --- a/scenedetect/cli/context.py +++ b/scenedetect/cli/context.py @@ -442,14 +442,9 @@ def handle_detect_threshold( )) self.options_processed = options_processed_orig - - def handle_detect_hash( - self, - threshold: Optional[float], - min_scene_len: Optional[str], - hash_size: Optional[int], - highfreq_factor: Optional[int] - ): + + def handle_detect_hash(self, threshold: Optional[float], min_scene_len: Optional[str], + hash_size: Optional[int], highfreq_factor: Optional[int]): """Handle detect-hash command options.""" self._check_input_open() options_processed_orig = self.options_processed @@ -464,22 +459,22 @@ def handle_detect_hash( else: min_scene_len = self.config.get_value("detect-hash", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - + threshold = self.config.get_value("detect-hash", "threshold", threshold) hash_size = self.config.get_value("detect-hash", "size", hash_size) highfreq_factor = self.config.get_value("detect-hash", "freq_factor", highfreq_factor) - logger.debug("Adding detector: HashDetector(threshold=%f, min_scene_len=%d," - " hash_size=%d, highfreq_factor=%d)", threshold, min_scene_len, hash_size, highfreq_factor) - + logger.debug( + "Adding detector: HashDetector(threshold=%f, min_scene_len=%d," + " hash_size=%d, highfreq_factor=%d)", threshold, min_scene_len, hash_size, + highfreq_factor) + self._add_detector( scenedetect.detectors.HashDetector( threshold=threshold, min_scene_len=min_scene_len, hash_size=hash_size, - highfreq_factor=highfreq_factor - ) - ) + highfreq_factor=highfreq_factor)) self.options_processed = options_processed_orig diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index bf072115..a44d8fad 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -23,7 +23,6 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # - """ ``scenedetect.detectors.hash_detector`` Module This module implements the :py:class:`HashDetector`, which calculates a hash @@ -69,10 +68,10 @@ def __init__(self, threshold=100.0, min_scene_len=15, hash_size=16, highfreq_fac self.last_hash = numpy.array([]) self._metric_keys = ['hash_dist'] self.cli_name = 'detect-hash' - + def get_metrics(self): return self._metric_keys - + def process_frame(self, frame_num, frame_img): """ Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference @@ -97,19 +96,19 @@ def process_frame(self, frame_num, frame_img): # Initialize last scene cut point at the beginning of the frames of interest. if self.last_scene_cut is None: self.last_scene_cut = frame_num - + # We can only start detecting once we have a frame to compare with. if self.last_frame is not None: - # We obtain the change in hash value between subsequent frames as + # We obtain the change in hash value between subsequent frames as # well as the actual hash value. This is refered to in a statsfile # as their respective metric keys. - if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num, metric_keys)): + if (self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys)): hash_dist = self.stats_manager.get_metrics(frame_num, metric_keys) else: # Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy # https://github.com/JohannesBuchner/imagehash - + # Convert to grayscale curr_gray = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) # Resize image to square to help with DCT @@ -124,45 +123,44 @@ def process_frame(self, frame_num, frame_img): curr_med = numpy.median(curr_dct_low_freq) # Transform the low frequency information into a binary image based on > or < median curr_hash = curr_dct_low_freq > curr_med - + last_hash = self.last_hash - + if last_hash.size == 0: # Calculate hash as above last_gray = cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2GRAY) - last_resized = cv2.resize(last_gray, (imsize, imsize), interpolation=cv2.INTER_AREA) + last_resized = cv2.resize( + last_gray, (imsize, imsize), interpolation=cv2.INTER_AREA) last_resized = numpy.float32(last_resized) / numpy.max(numpy.max(last_resized)) last_dct = cv2.dct(last_resized) last_dct_low_freq = last_dct[:self.hash_size, :self.hash_size] last_med = numpy.median(last_dct_low_freq) last_hash = last_dct_low_freq > last_med - + # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, { - metric_keys[0]: hash_dist}) - + self.stats_manager.set_metrics(frame_num, {metric_keys[0]: hash_dist}) + self.last_hash = curr_hash - # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). if hash_dist >= self.threshold and ( - (frame_num - self.last_scene_cut) >= self.min_scene_len): + (frame_num - self.last_scene_cut) >= self.min_scene_len): cut_list.append(frame_num) self.last_scene_cut = frame_num if self.last_frame is not None and self.last_frame is not _unused: del self.last_frame - + # If we have the next frame computed, don't copy the current frame # into last_frame since we won't use it on the next call anyways. - if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num+1, metric_keys)): + if (self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num + 1, metric_keys)): self.last_frame = _unused else: self.last_frame = frame_img.copy() - return cut_list \ No newline at end of file + return cut_list From 0f54efc9deab6af944cb33196e98adcad512576f Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sat, 8 Oct 2022 01:05:53 -0400 Subject: [PATCH 006/407] Added a hash calculation helper function and removed deprecated StatsManager usage. --- scenedetect/detectors/hash_detector.py | 108 +++++++++++++------------ 1 file changed, 56 insertions(+), 52 deletions(-) diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index a44d8fad..081a2620 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -42,6 +42,36 @@ from scenedetect.scene_detector import SceneDetector +def calculate_frame_hash(frame_img, hash_size, highfreq_factor): + """Helper function that calculates the hash of a frame and returns it. + + Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy + https://github.com/JohannesBuchner/imagehash + """ + + # Transform to grayscale + gray_img = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) + + # Resize image to square to help with DCT + imsize = hash_size * highfreq_factor + resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) + + # Calculate discrete cosine tranformation of the image + resized_img = numpy.float32(resized_img) / numpy.max(numpy.max(resized_img)) + dct_complete = cv2.dct(resized_img) + + # Only keep the low frequency information + dct_low_freq = dct_complete[:hash_size, :hash_size] + + # Calculate the median of the low frequency informations + med = numpy.median(dct_low_freq) + + # Transform the low frequency information into a binary image based on > or < median + hash_img = dct_low_freq > med + + return hash_img + + class HashDetector(SceneDetector): """Detects cuts using a perceptual hashing algorithm. For more information on the perceptual hashing algorithm see references below. @@ -55,14 +85,19 @@ class HashDetector(SceneDetector): def __init__(self, threshold=100.0, min_scene_len=15, hash_size=16, highfreq_factor=2): super(HashDetector, self).__init__() + # How much of a difference between subsequent hash values should trigger a cut self.threshold = threshold + # Minimum length of any given scene, in frames (int) or FrameTimecode self.min_scene_len = min_scene_len + # Size of square of low frequency data to include from the discrete cosine transform self.hash_size = hash_size + # How much high frequency data should be thrown out from the DCT # A value of 2 means only keep 1/2 of the freq data, a value of 4 means only keep 1/4 self.highfreq_factor = highfreq_factor + self.last_frame = None self.last_scene_cut = None self.last_hash = numpy.array([]) @@ -99,51 +134,26 @@ def process_frame(self, frame_num, frame_img): # We can only start detecting once we have a frame to compare with. if self.last_frame is not None: - # We obtain the change in hash value between subsequent frames as - # well as the actual hash value. This is refered to in a statsfile - # as their respective metric keys. - if (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys)): - hash_dist = self.stats_manager.get_metrics(frame_num, metric_keys) - else: - # Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy - # https://github.com/JohannesBuchner/imagehash - - # Convert to grayscale - curr_gray = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) - # Resize image to square to help with DCT - imsize = self.hash_size * self.highfreq_factor - curr_resized = cv2.resize(curr_gray, (imsize, imsize), interpolation=cv2.INTER_AREA) - # Calculate discrete cosine tranformation of the image - curr_resized = numpy.float32(curr_resized) / numpy.max(numpy.max(curr_resized)) - curr_dct = cv2.dct(curr_resized) - # Only keep the low frequency information - curr_dct_low_freq = curr_dct[:self.hash_size, :self.hash_size] - # Calculate the median of the low frequency information - curr_med = numpy.median(curr_dct_low_freq) - # Transform the low frequency information into a binary image based on > or < median - curr_hash = curr_dct_low_freq > curr_med - - last_hash = self.last_hash - - if last_hash.size == 0: - # Calculate hash as above - last_gray = cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2GRAY) - last_resized = cv2.resize( - last_gray, (imsize, imsize), interpolation=cv2.INTER_AREA) - last_resized = numpy.float32(last_resized) / numpy.max(numpy.max(last_resized)) - last_dct = cv2.dct(last_resized) - last_dct_low_freq = last_dct[:self.hash_size, :self.hash_size] - last_med = numpy.median(last_dct_low_freq) - last_hash = last_dct_low_freq > last_med - - # Hamming distance is calculated to compare to last frame - hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) - - if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {metric_keys[0]: hash_dist}) - - self.last_hash = curr_hash + # We obtain the change in hash value between subsequent frames. + curr_hash = calculate_frame_hash( + frame_img=frame_img, hash_size=self.hash_size, highfreq_factor=self.highfreq_factor) + + last_hash = self.last_hash + + if last_hash.size == 0: + # Calculate hash of last frame + last_hash = calculate_frame_hash( + frame_img=self.last_frame, + hash_size=self.hash_size, + highfreq_factor=self.highfreq_factor) + + # Hamming distance is calculated to compare to last frame + hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) + + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, {metric_keys[0]: hash_dist}) + + self.last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). @@ -155,12 +165,6 @@ def process_frame(self, frame_num, frame_img): if self.last_frame is not None and self.last_frame is not _unused: del self.last_frame - # If we have the next frame computed, don't copy the current frame - # into last_frame since we won't use it on the next call anyways. - if (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num + 1, metric_keys)): - self.last_frame = _unused - else: - self.last_frame = frame_img.copy() + self.last_frame = frame_img.copy() return cut_list From efa4023e22f60397d94217012b97c32b42b9f5e5 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Sun, 16 Oct 2022 00:51:28 -0400 Subject: [PATCH 007/407] Redefined default values for compatibility with config files. --- scenedetect/cli/__init__.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index 1a632d3d..1779bd83 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -715,32 +715,34 @@ def detect_threshold_command( '--threshold', '-t', metavar='VAL', - type=click.FLOAT, - default=100.0, - show_default=True, + type=click.FloatRange(CONFIG_MAP['detect-hash']['threshold'].min_val, + CONFIG_MAP['detect-hash']['threshold'].max_val), + default=None, help='Threshold value (float) that the hash_dist metric must exceed to trigger' - ' a new scene. Refers to frame metric hash_dist in the stats file.') + ' a new scene. Refers to frame metric hash_dist in the stats file.%s' % + (USER_CONFIG.get_help_string('detect-hash', 'threshold'))) @click.option( '--size', '-s', metavar='VAL', - type=click.IntRange(min=2), - default=16, - show_default=True, + type=click.IntRange(CONFIG_MAP['detect-hash']['size'].min_val, + CONFIG_MAP['detect-hash']['size'].max_val), + default=None, help='Size of the hash used in the perceptual hasing algorithm. Must be an ' - 'integer >=2.') + 'integer >=2.%s' % (USER_CONFIG.get_help_string('detect-hash', 'size'))) @click.option( '--freq_factor', '-f', metavar='VAL', - type=click.IntRange(min=1), - default=2, - show_default=True, + type=click.IntRange(CONFIG_MAP['detect-hash']['freq_factor'].min_val, + CONFIG_MAP['detect-hash']['freq_factor'].max_val), + default=None, help='Parameter used to specify the amount of high frequency image information ' 'used for the perceptual hashing algorithm. A high value uses less high ' 'frequency image information, meaning that the algorithm is less sensitive ' 'to small changes. A low value causes the algorithm to be more sensitive to' - ' small changes. Must be an integer >0.') + ' small changes. Must be an integer >0.%s' % + (USER_CONFIG.get_help_string('detect-hash', 'freq_factor'))) @click.option( '--min-scene-len', '-m', From 688d67e894100f1f965b57ddb2bcebbf28e737b8 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Mon, 17 Oct 2022 00:31:14 -0400 Subject: [PATCH 008/407] Added private function to return min_scene_len for use by detectors. --- scenedetect/cli/config.py | 2 +- scenedetect/cli/context.py | 70 +++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/scenedetect/cli/config.py b/scenedetect/cli/config.py index 39ca4fc5..8c5cacf7 100644 --- a/scenedetect/cli/config.py +++ b/scenedetect/cli/config.py @@ -259,7 +259,7 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal 'threshold': RangeValue(100, min_val=0.0, max_val=65536.0), 'size': RangeValue(16, min_val=2, max_val=65536), 'freq_factor': RangeValue(2, min_val=1, max_val=65536), - 'min_scene_len': TimecodeValue(0) + 'min-scene-len': TimecodeValue(0) }, 'export-html': { 'filename': '$VIDEO_NAME-Scenes.html', diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py index 08c02372..04759a41 100644 --- a/scenedetect/cli/context.py +++ b/scenedetect/cli/context.py @@ -30,7 +30,7 @@ from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect.cli.config import ConfigRegistry, ConfigLoadFailure, CHOICE_MAP +from scenedetect.cli.config import CONFIG_MAP, ConfigRegistry, ConfigLoadFailure, CHOICE_MAP logger = logging.getLogger('pyscenedetect') @@ -305,15 +305,7 @@ def handle_detect_content( options_processed_orig = self.options_processed self.options_processed = False - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default('detect-content', 'min-scene-len'): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value('detect-content', 'min-scene-len') - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self._get_min_scene_len("detect-content") if weights is not None: try: @@ -363,15 +355,7 @@ def handle_detect_adaptive( self.config.config_dict["detect-adaptive"]["min-content-val"] = ( self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-adaptive", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self._get_min_scene_len("detect-adaptive") if weights is not None: try: @@ -412,16 +396,7 @@ def handle_detect_threshold( options_processed_orig = self.options_processed self.options_processed = False - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-threshold", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - + min_scene_len = self._get_min_scene_len("detect-threshold") threshold = self.config.get_value("detect-threshold", "threshold", threshold) fade_bias = self.config.get_value("detect-threshold", "fade-bias", fade_bias) # TODO(v1.0): This cannot be disabled right now. @@ -450,16 +425,7 @@ def handle_detect_hash(self, threshold: Optional[float], min_scene_len: Optional options_processed_orig = self.options_processed self.options_processed = False - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hash", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - + min_scene_len = self._get_min_scene_len("detect-hash") threshold = self.config.get_value("detect-hash", "threshold", threshold) hash_size = self.config.get_value("detect-hash", "size", hash_size) highfreq_factor = self.config.get_value("detect-hash", "freq_factor", highfreq_factor) @@ -888,3 +854,29 @@ def _on_duplicate_command(self, command: str) -> None: raise click.BadParameter( '\n Command %s may only be specified once.' % command, param_hint='%s command' % command) + + def _get_min_scene_len(self, command=None): + """Called when a detector needs to get the min_scene_len before initialization. + + Arguments: + command: string of the detector command e.g. 'detect-adaptive' + + Returns: + min_scene_len + """ + # Raise an error if this function is called without a valid command + assert command in CONFIG_MAP and "min-scene-len" in CONFIG_MAP[command] + + min_scene_len = None + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default(command, "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value(command, "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + + return min_scene_len From a5841e4b07f16552228d2eb3cf48ceef37877eef Mon Sep 17 00:00:00 2001 From: wjs018 Date: Tue, 18 Oct 2022 02:12:21 -0400 Subject: [PATCH 009/407] Added API reference for detect-hash. --- manual/cli/detectors.rst | 94 ++++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/manual/cli/detectors.rst b/manual/cli/detectors.rst index 6df39415..867a7623 100644 --- a/manual/cli/detectors.rst +++ b/manual/cli/detectors.rst @@ -5,12 +5,13 @@ Detectors *********************************************************************** -There are currently two implemented scene detection algorithms, threshold -based detection (``detect-threshold``), and content-aware detection -(``detect-content``). Each detector can be selected by adding the -respective `detect-` command, and any relevant options, after setting -the main ``scenedetect`` command global options. In general, commands -should follow the form: +There are currently four implemented scene detection algorithms, threshold +based detection (``detect-threshold``), content-aware detection +(``detect-content``), adaptive content-aware detection (``detect-adaptive``), +and perceptual hashing based detection (``detect-hash``). Each detector can be +selected by adding the respective `detect-` command, and any relevant options, +after setting the main ``scenedetect`` command global options. In general, +commands should follow the form: ``scenedetect [global options] [detector] [commands]`` @@ -138,13 +139,6 @@ Detector Options seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. -Usage Examples ------------------------------------------------------------------------ - - ``detect-threshold`` - - ``detect-threshold --threshold 15`` - ======================================================================= ``detect-adaptive`` @@ -190,3 +184,77 @@ Detector Options specified as exact number of frames, a time in seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. + + +======================================================================= +``detect-hash`` +======================================================================= + +Perform detection using a perceptual hashing algorithm on input video. + +When processing each frame, the frame is converted into a hash and this is +compared to the previously analyzed frame. If the difference between these two +hashes exceeds the value set for `-t`/`--threshold`, then a scene change is +triggered. + +This detector is only available when using the OpenCV backend. + +The hashing algorithm used is based on the implementation of `phash `_. +The basic steps of the hashing algorithm are detailed below: + +1. The image is first converted to grayscale (meaning this detector is not +sensitive to color transitions). +2. The resulting grayscale image is then scaled down in size to a square image +with the length of each side equal to `-s`/`--size` \* `-f`/`--freq_factor`. +3. The discrete cosine transform (DCT) of the resized image is calculated. +4. Only the low frequency information from the DCT is retained. This is +accomplished by discarding all but the upper left values of the resulting DCT +matrix. The size of the resulting submatrix is set as a square with the length +of each side determined by `-s`/`--size`. +5. The median of the retained DCT information is determined. +6. The hash is calculated by converting the retained DCT matrix into a binary +array by comparing each element to the median. The resulting binary values are +True if the value is greater than the median and False if it is less than or +equal to the median. + +The metric used for scene detection is the difference between the hashes of +subsequent frames. This difference is calculated using the Hamming distance +between two hashes. This is defined as the number of elements that differ +between two hashes. This metric is recorded in the statsfile as `hash_dist` if +a statsfile is specified. + +Examples: + + ``detect-hash`` + + ``detect-hash --threshold 80`` + +Detector Options +----------------------------------------------------------------------- + + -t, --threshold VAL Threshold value (float) that the calculated + frame score must exceed to trigger a new scene + (see frame metric hash_dist in stats file). + [default: 100.0] + + -s, --size VAL Hash size (int) that is used for the detector. + Larger values can help increase sensitivity to + small changes, but can increase computation + time. [default: 16] + + -f, --freq_factor VAL Frequency factor (int) used to determing how + much high frequency data is discarded in the + hashing algorithm. For example a value of 4 + corresponds to keeping only 1/4 of the + frequency information of the image (a value of + 2 would be 1/2 of the frequency information, + etc.). Smaller values make the detector more + sensitive to smaller sized features in the + frame, but can increase computation time. + [default: 2] + + -m, --min-scene-len TIMECODE Minimum length of any scene. Overrides global + min-scene-len (-m) setting. TIMECODE can be + specified as exact number of frames, a time in + seconds followed by s, or a timecode in the + format HH:MM:SS or HH:MM:SS.nnn. From 6a877db2d255f20f1bdfa5510510400890970187 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Tue, 18 Oct 2022 02:37:56 -0400 Subject: [PATCH 010/407] Updated docs with detect-hash info. --- docs/reference/command-line.md | 2 +- docs/reference/detection-methods.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/reference/command-line.md b/docs/reference/command-line.md index 80d82a6e..685e7cf9 100644 --- a/docs/reference/command-line.md +++ b/docs/reference/command-line.md @@ -13,6 +13,6 @@ The `scenedetect` command reference is available as part of [the PySceneDetect M - Exporting scene list as HTML (`export-html`) - [Detector Reference](http://scenedetect.com/projects/Manual/en/latest/cli/detectors.html): - - Detectors, e.g. `detect-content`, `detect-threshold`, `detect-adaptive` + - Detectors, e.g. `detect-content`, `detect-threshold`, `detect-adaptive`, `detect-hash` You can also run `scenedetect help all` locally for the full `scenedetect command reference. diff --git a/docs/reference/detection-methods.md b/docs/reference/detection-methods.md index 95ae2777..2db5eefa 100644 --- a/docs/reference/detection-methods.md +++ b/docs/reference/detection-methods.md @@ -21,6 +21,10 @@ The adaptive content detector (`detect-adaptive`) compares the difference in con The threshold-based scene detector (`detect-threshold`) is how most traditional scene detection methods work (e.g. the `ffmpeg blackframe` filter), by comparing the intensity/brightness of the current frame with a set threshold, and triggering a scene cut/break when this value crosses the threshold. In PySceneDetect, this value is computed by averaging the R, G, and B values for every pixel in the frame, yielding a single floating point number representing the average pixel value (from 0.0 to 255.0). +## Perceptual Hash Detector + +The perceptual hash detector (`detect-hash`) calculates a hash for a frame and compares that hash to the previous frame's hash. If the hashes differ by more than the defined threshold, then a scene change is recorded. The hashing algorithm used for this detector is an implementation of `phash` from the [imagehash](https://github.com/JohannesBuchner/imagehash) library. In practice, this detector works similarly to `detect-content` in that it picks up large differences between adjacent frames. One important note is that the hashing algorithm converts the frames to grayscale, so this detector is insensitive to changes in colors if the brightness remains constant. In general, this algorithm is very computationally efficient compared to `detect-content` or `detect-adaptive`, especially if downscaling is not used. See [here](https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html) for an overview of how a perceptual hashing algorithm can be used for detecting similarity (or otherwise) of images and a visual depiction of the algorithm. + # Creating New Detection Algorithms All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). From ea66b3711c7a5c7114a70a0cd6d871cbb53d314b Mon Sep 17 00:00:00 2001 From: wjs018 Date: Thu, 20 Oct 2022 20:27:25 -0400 Subject: [PATCH 011/407] Minor docs formatting update. --- manual/cli/detectors.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/manual/cli/detectors.rst b/manual/cli/detectors.rst index 867a7623..0cd5093c 100644 --- a/manual/cli/detectors.rst +++ b/manual/cli/detectors.rst @@ -199,8 +199,9 @@ triggered. This detector is only available when using the OpenCV backend. -The hashing algorithm used is based on the implementation of `phash `_. -The basic steps of the hashing algorithm are detailed below: +The hashing algorithm used is based on the implementation of +`phash `_. The basic steps of the +hashing algorithm are detailed below: 1. The image is first converted to grayscale (meaning this detector is not sensitive to color transitions). From 14f2c8cce6935a981a34b0c9f18438df858a99cb Mon Sep 17 00:00:00 2001 From: wjs018 Date: Thu, 20 Oct 2022 22:58:48 -0400 Subject: [PATCH 012/407] Updated default threshold. --- scenedetect/cli/config.py | 2 +- scenedetect/detectors/hash_detector.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scenedetect/cli/config.py b/scenedetect/cli/config.py index 8c5cacf7..bc49f4bf 100644 --- a/scenedetect/cli/config.py +++ b/scenedetect/cli/config.py @@ -256,7 +256,7 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, 'detect-hash': { - 'threshold': RangeValue(100, min_val=0.0, max_val=65536.0), + 'threshold': RangeValue(101, min_val=0.0, max_val=65536.0), 'size': RangeValue(16, min_val=2, max_val=65536), 'freq_factor': RangeValue(2, min_val=1, max_val=65536), 'min-scene-len': TimecodeValue(0) diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 081a2620..f5370096 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -83,7 +83,7 @@ class HashDetector(SceneDetector): only fast cuts are detected with this method. """ - def __init__(self, threshold=100.0, min_scene_len=15, hash_size=16, highfreq_factor=2): + def __init__(self, threshold=101.0, min_scene_len=15, hash_size=16, highfreq_factor=2): super(HashDetector, self).__init__() # How much of a difference between subsequent hash values should trigger a cut self.threshold = threshold From 88e3ad0b0ca6e65e2b36d810868236cf3c952143 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Thu, 20 Oct 2022 22:59:19 -0400 Subject: [PATCH 013/407] Added cli tests. --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 89bd6d54..4e8bb45a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. DEFAULT_DETECTOR = 'detect-content' DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] +ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hash'] ALL_BACKENDS = ['opencv', 'pyav', 'moviepy'] From 604d756930db116c9699ff951a347f80dcb3d5d2 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Thu, 20 Oct 2022 23:04:28 -0400 Subject: [PATCH 014/407] Updated default value in docs. --- manual/cli/detectors.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/cli/detectors.rst b/manual/cli/detectors.rst index 0cd5093c..c48f5869 100644 --- a/manual/cli/detectors.rst +++ b/manual/cli/detectors.rst @@ -236,7 +236,7 @@ Detector Options -t, --threshold VAL Threshold value (float) that the calculated frame score must exceed to trigger a new scene (see frame metric hash_dist in stats file). - [default: 100.0] + [default: 101.0] -s, --size VAL Hash size (int) that is used for the detector. Larger values can help increase sensitivity to From fda2fae92056ad92cc868d47965ba03f13a5ab10 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Fri, 21 Oct 2022 00:15:19 -0400 Subject: [PATCH 015/407] Updated tests to include detect-hash. --- scenedetect/detectors/hash_detector.py | 11 ++++++++++- tests/test_detectors.py | 26 ++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index f5370096..f6f2a453 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -56,8 +56,14 @@ def calculate_frame_hash(frame_img, hash_size, highfreq_factor): imsize = hash_size * highfreq_factor resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) + # Check to avoid dividing by zero + max_value = numpy.max(numpy.max(resized_img)) + if max_value == 0: + # Just set the max to 1 to not change the values + max_value = 1 + # Calculate discrete cosine tranformation of the image - resized_img = numpy.float32(resized_img) / numpy.max(numpy.max(resized_img)) + resized_img = numpy.float32(resized_img) / max_value dct_complete = cv2.dct(resized_img) # Only keep the low frequency information @@ -107,6 +113,9 @@ def __init__(self, threshold=101.0, min_scene_len=15, hash_size=16, highfreq_fac def get_metrics(self): return self._metric_keys + def is_processing_required(self, frame_num): + return True + def process_frame(self, frame_num, frame_img): """ Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 2a41d2b7..f69290ab 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -20,7 +20,7 @@ import time from scenedetect import detect, SceneManager, FrameTimecode, StatsManager -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HashDetector from scenedetect.backends.opencv import VideoStreamCv2 # TODO(v1.0): Parameterize these tests like VideoStreams are. @@ -87,6 +87,28 @@ def test_adaptive_detector(test_movie_clip): assert scene_list[-1][1] == end_time +def test_hash_detector(test_movie_clip): + """ Test SceneManager with VideoStreamCv2 and HashDetector. """ + video = VideoStreamCv2(test_movie_clip) + scene_manager = SceneManager() + scene_manager.add_detector(HashDetector()) + scene_manager.auto_downscale = True + + video_fps = video.frame_rate + start_time = FrameTimecode('00:00:50', video_fps) + end_time = FrameTimecode('00:01:19', video_fps) + + video.seek(start_time) + scene_manager.detect_scenes(video=video, end_time=end_time) + + scene_list = scene_manager.get_scene_list() + assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) + detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] + assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames + # Ensure last scene's end timecode matches the end time we set. + assert scene_list[-1][1] == end_time + + def test_threshold_detector(test_video_file): """ Test SceneManager with VideoStreamCv2 and ThresholdDetector. """ video = VideoStreamCv2(test_video_file) @@ -103,7 +125,7 @@ def test_threshold_detector(test_video_file): def test_detectors_with_stats(test_video_file): """ Test all detectors functionality with a StatsManager. """ # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). - for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector]: + for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector, HashDetector]: video = VideoStreamCv2(test_video_file) stats = StatsManager() scene_manager = SceneManager(stats_manager=stats) From 26181d1c732d73da60aa5c19ea326c619d60bd77 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Fri, 21 Oct 2022 22:22:59 -0400 Subject: [PATCH 016/407] Updated requirements due to breaking change in PyAV. --- requirements.txt | 2 +- requirements_headless.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2bd81bb6..bc6dca1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # TODO: Replace appdirs with platformdirs. appdirs av<=8.0.3; python_version <= "3.6" -av; python_version > "3.6" +av<10.0; python_version > "3.6" click moviepy numpy diff --git a/requirements_headless.txt b/requirements_headless.txt index 59a02c95..9a7f196e 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -3,7 +3,7 @@ # appdirs av<=8.0.3; python_version <= "3.6" -av; python_version > "3.6" +av<10.0; python_version > "3.6" click moviepy numpy From ff3849bddf68f40077731b44e654b8b8e24c653d Mon Sep 17 00:00:00 2001 From: wjs018 Date: Fri, 21 Oct 2022 22:40:20 -0400 Subject: [PATCH 017/407] Update appveyor CI config. --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 7347ddc1..0d2f6165 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -59,7 +59,7 @@ test_script: - python -m scenedetect version - python -m scenedetect -i tests/resources/testvideo.mp4 -b opencv detect-content time -e 2s # Test with optional PyAV backend - - python -m pip install av + - python -m pip install av<10.0 - python -m scenedetect -i tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s # Cleanup - python -m pip uninstall -y scenedetect av From abeca415fab824e38db0a11d445e4a2c278761d2 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Fri, 21 Oct 2022 22:54:58 -0400 Subject: [PATCH 018/407] Updated pip commands for appveyor. --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 0d2f6165..29f20215 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -28,7 +28,7 @@ install: - python -m pip install --upgrade pip build wheel virtualenv setuptools # Make sure we get latest binary packages of the video input libraries. - - python -m pip install av opencv-python-headless --only-binary ":all:" + - python -m pip install 'av<10.0' opencv-python-headless --only-binary ":all:" # Install other PySceneDetect dependencies and checkout resources required for tests. - python -m pip install -r requirements_headless.txt @@ -59,7 +59,7 @@ test_script: - python -m scenedetect version - python -m scenedetect -i tests/resources/testvideo.mp4 -b opencv detect-content time -e 2s # Test with optional PyAV backend - - python -m pip install av<10.0 + - python -m pip install 'av<10.0' - python -m scenedetect -i tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s # Cleanup - python -m pip uninstall -y scenedetect av From fd9de34ae5ad60a01d15bc17e0c7805e5ad38114 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Fri, 21 Oct 2022 23:03:23 -0400 Subject: [PATCH 019/407] Correcting escape characters for Windows terminal. --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 29f20215..aa058c33 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -28,7 +28,7 @@ install: - python -m pip install --upgrade pip build wheel virtualenv setuptools # Make sure we get latest binary packages of the video input libraries. - - python -m pip install 'av<10.0' opencv-python-headless --only-binary ":all:" + - python -m pip install "av<10.0" opencv-python-headless --only-binary ":all:" # Install other PySceneDetect dependencies and checkout resources required for tests. - python -m pip install -r requirements_headless.txt @@ -59,7 +59,7 @@ test_script: - python -m scenedetect version - python -m scenedetect -i tests/resources/testvideo.mp4 -b opencv detect-content time -e 2s # Test with optional PyAV backend - - python -m pip install 'av<10.0' + - python -m pip install "av<10.0" - python -m scenedetect -i tests/resources/testvideo.mp4 -b pyav detect-content time -e 2s # Cleanup - python -m pip uninstall -y scenedetect av From cc6b863a6ee8aec6ecaec45724d655d9e65806c5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 16 Apr 2024 21:10:24 -0400 Subject: [PATCH 020/407] [tests] Ensure Pytest does not treat TestCase as a unit test --- tests/test_detectors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index b3661871..1a9547f4 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -49,6 +49,7 @@ def get_absolute_path(relative_path: str) -> str: @dataclass class TestCase: + __test__ = False """Properties for detector test cases.""" path: str """Path to video for test case.""" From 24501445959d528747ee3510b494f1776a51ac21 Mon Sep 17 00:00:00 2001 From: Walter Schwenger Date: Tue, 16 Apr 2024 21:54:07 -0400 Subject: [PATCH 021/407] Color Histogram Detector (#295) * Initial implementation of HistogramDetector. * Added check for color channels * Added tests for detect-hist. * Added documentation for detect-hist. * Add detect-hist to test_cli * Fix formatting * Fix test_histogram_detector * Move detect-hist to new location. * Delete scenedetect/cli/__init__.py Moved to scenedetect/_cli/__init__.py * Add config options for detect-hist * Update config.py * Update __init__.py * Update config.py --------- Co-authored-by: Brandon Castellano --- scenedetect/_cli/__init__.py | 46 +++++ scenedetect/_cli/config.py | 5 + scenedetect/_cli/context.py | 31 ++++ scenedetect/detectors/__init__.py | 14 +- scenedetect/detectors/histogram_detector.py | 189 ++++++++++++++++++++ tests/test_cli.py | 2 +- tests/test_detectors.py | 29 ++- website/pages/api.md | 4 + 8 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 scenedetect/detectors/histogram_detector.py diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 33ad89e3..ecdb1429 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -710,6 +710,52 @@ def detect_threshold_command( ctx.obj.add_detector(ThresholdDetector(**detector_args)) +@click.command('detect-hist', cls=_Command) +@click.option( + '--threshold', + '-t', + metavar='VAL', + type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, + CONFIG_MAP['detect-hist']['threshold'].max_val), + default=None, + help='Threshold value (float) that the rgb histogram difference must exceed to trigger' + ' a new scene. Refer to frame metric hist_diff in stats file.%s' % + (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) +@click.option( + '--bits', + '-b', + metavar='NUM', + type=click.INT, + default=None, + help='The number of most significant figures to keep when quantizing the RGB color channels.%s' + % (USER_CONFIG.get_help_string("detect-hist", "bits"))) +@click.option( + '--min-scene-len', + '-m', + metavar='TIMECODE', + type=click.STRING, + default=None, + help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' + ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' + ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % + ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( + 'detect-hist', 'min-scene-len'))) +@click.pass_context +def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Perform detection of scenes by comparing differences in the RGB histograms of adjacent + frames. + + Examples: + + detect-hist + + detect-hist --threshold 20000.0 + """ + assert isinstance(ctx.obj, CliContext) + ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) + + @click.command('load-scenes', cls=_Command) @click.option( '--input', diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 6588d909..2f72e9ca 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -275,6 +275,11 @@ def format(self, timecode: FrameTimecode) -> str: 'min-scene-len': TimecodeValue(0), 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, + 'detect-hist': { + 'bits': 4, + 'min-scene-len': TimecodeValue(0), + 'threshold': RangeValue(20000.0, min_val=0.0, max_val=10000000000.0), + }, 'load-scenes': { 'start-col-name': 'Start Frame', }, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 6f0e1386..36275744 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -449,6 +449,37 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) + def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Handle `detect-hist` command options.""" + self._check_input_open() + options_processed_orig = self.options_processed + self.options_processed = False + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-hist", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-hist", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + + threshold = self.config.get_value("detect-hist", "threshold", threshold) + bits = self.config.get_value("detect-hist", "bits", bits) + + # Log detector args for debugging before we construct it. + logger.debug( + 'Adding detector: HistogramDetector(threshold=%f, bits=%d,' + ' min_scene_len=%d)', threshold, bits, min_scene_len) + + self._add_detector( + scenedetect.detectors.HistogramDetector( + threshold=threshold, bits=bits, min_scene_len=min_scene_len)) + + self.options_processed = options_processed_orig + def handle_export_html( self, filename: Optional[AnyStr], diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 6aed26ae..0142f1b1 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -32,6 +32,7 @@ from scenedetect.detectors.content_detector import ContentDetector from scenedetect.detectors.threshold_detector import ThresholdDetector from scenedetect.detectors.adaptive_detector import AdaptiveDetector +from scenedetect.detectors.histogram_detector import HistogramDetector # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -51,19 +52,6 @@ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# class HistogramDetector(SceneDetector): -# """Detects fast cuts via histogram changes between sequential frames. -# -# Detects fast cuts between content (using histogram deltas, much like the -# ContentDetector uses HSV colourspace deltas), as well as both fades and -# cuts to/from black (using a threshold, much like the ThresholdDetector). -# """ -# -# def __init__(self): -# super(DissolveDetector, self).__init__() -# -# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# # class MotionDetector(SceneDetector): # """Detects motion events in scenes containing a static background. # diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py new file mode 100644 index 00000000..28d00eb5 --- /dev/null +++ b/scenedetect/detectors/histogram_detector.py @@ -0,0 +1,189 @@ +# -*- 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 . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +""":py:class:`HistogramDetector` compares the difference in the RGB histograms of subsequent +frames. If the difference exceeds a given threshold, a cut is detected. + +This detector is available from the command-line as the `detect-hist` command. +""" + +from typing import List + +import numpy + +# PySceneDetect Library Imports +from scenedetect.scene_detector import SceneDetector + + +class HistogramDetector(SceneDetector): + """Compares the difference in the RGB histograms of subsequent + frames. If the difference exceeds a given threshold, a cut is detected.""" + + METRIC_KEYS = ['hist_diff'] + + def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int = 15): + """ + Arguments: + threshold: Threshold value (float) that the calculated difference between subsequent + histograms must exceed to trigger a new scene. + bits: Number of most significant bits to keep of the pixel values. Most videos and + images are 8-bit rgb (0-255) and the default is to just keep the 4 most siginificant + bits. This compresses the 3*8bit (24bit) image down to 3*4bits (12bits). This makes + quantizing the rgb histogram a bit easier and comparisons more meaningful. + min_scene_len: Minimum length of any scene. + """ + super().__init__() + self.threshold = threshold + self.bits = bits + self.min_scene_len = min_scene_len + self._hist_bins = range(2**(3 * self.bits)) + self._last_hist = None + self._last_scene_cut = None + + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + """First, compress the image according to the self.bits value, then build a histogram for + the input frame. Afterward, compare against the previously analyzed frame and check if the + difference is large enough to trigger a cut. + + Arguments: + frame_num: Frame number of frame that is being passed. + frame_img: Decoded frame image (numpy.ndarray) to perform scene + detection on. + + Returns: + List of frames where scene cuts have been detected. There may be 0 + or more frames in the list, and not necessarily the same as frame_num. + """ + cut_list = [] + + np_data_type = frame_img.dtype + + if np_data_type != numpy.uint8: + raise ValueError('Image must be 8-bit rgb for HistogramDetector') + + if frame_img.shape[2] != 3: + raise ValueError('Image must have three color channels for HistogramDetector') + + # Initialize last scene cut point at the beginning of the frames of interest. + if not self._last_scene_cut: + self._last_scene_cut = frame_num + + # Quantize the image and separate the color channels + quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self.bits) + + # Perform bit shifting operations and bitwise combine color channels into one array + composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self.bits) + + # Create the histogram with a bin for every rgb value + hist, _ = numpy.histogram(composite_img, bins=self._hist_bins) + + # We can only start detecting once we have a frame to compare with. + if self._last_hist is not None: + # Compute histogram difference between frames + hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) + + # Check if a new scene should be triggered + if hist_diff >= self.threshold and ((frame_num - self._last_scene_cut) + >= self.min_scene_len): + cut_list.append(frame_num) + self._last_scene_cut = frame_num + + # Save stats to a StatsManager if it is being used + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, {self.METRIC_KEYS[0]: hist_diff}) + + self._last_hist = hist + + return cut_list + + def _quantize_frame(self, frame_img, bits): + """Quantizes the image based on the number of most significant figures to be preserved. + + Arguments: + frame_img: The 8-bit rgb image of the frame being analyzed. + bits: The number of most significant bits to keep during quantization. + + Returns: + [red_img, green_img, blue_img]: + The three separated color channels of the frame image that have been quantized. + """ + # First, find the value of the number of most significant bits, padding with zeroes + bit_value = int(bin(2**bits - 1).ljust(10, '0'), 2) + + # Separate R, G, and B color channels and cast to int for easier bitwise operations + red_img = frame_img[:, :, 0].astype(int) + green_img = frame_img[:, :, 1].astype(int) + blue_img = frame_img[:, :, 2].astype(int) + + # Quantize the frame images + red_img = red_img & bit_value + green_img = green_img & bit_value + blue_img = blue_img & bit_value + + return [red_img, green_img, blue_img] + + def _shift_bits(self, quantized_imgs, bits): + """Takes care of the bit shifting operations to combine the RGB color + channels into a single array. + + Arguments: + quantized_imgs: A list of the three quantized images of the RGB color channels + respectively. + bits: The number of most significant bits to use for quantizing the image. + + Returns: + composite_img: The resulting array after all bitwise operations. + """ + # First, figure out how much each shift needs to be + blue_shift = 8 - bits + green_shift = 8 - 2 * bits + red_shift = 8 - 3 * bits + + # Separate our color channels for ease + red_img = quantized_imgs[0] + green_img = quantized_imgs[1] + blue_img = quantized_imgs[2] + + # Perform the bit shifting for each color + red_img = self._shift_images(img=red_img, img_shift=red_shift) + green_img = self._shift_images(img=green_img, img_shift=green_shift) + blue_img = self._shift_images(img=blue_img, img_shift=blue_shift) + + # Join our rgb arrays together + composite_img = numpy.bitwise_or(red_img, numpy.bitwise_or(green_img, blue_img)) + + return composite_img + + def _shift_images(self, img, img_shift): + """Do bitwise shifting operations for a color channel image checking for shift direction. + + Arguments: + img: A quantized image of a single color channel + img_shift: How many bits to shift the values of img. If the value is negative, the shift + direction is to the left and 8 is added to make it a positive value. + + Returns: + shifted_img: The bitwise shifted image. + """ + if img_shift < 0: + img_shift += 8 + shifted_img = numpy.left_shift(img, img_shift) + else: + shifted_img = numpy.right_shift(img, img_shift) + + return shifted_img + + def is_processing_required(self, frame_num: int) -> bool: + return True + + def get_metrics(self) -> List[str]: + return HistogramDetector.METRIC_KEYS diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ba6db8a..8e5fc27f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. SCENEDETECT_CMD = 'python -m scenedetect' -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] +ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist'] ALL_BACKENDS = ['opencv', 'pyav'] DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 1a9547f4..1344ad94 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -24,7 +24,7 @@ import pytest from scenedetect import detect, SceneManager, FrameTimecode, StatsManager, SceneDetector -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.backends.opencv import VideoStreamCv2 @@ -47,6 +47,31 @@ def get_absolute_path(relative_path: str) -> str: return abs_path +# TODO: Add a test case for this in the fixtures defined below. +def test_histogram_detector(test_movie_clip): + """ Test SceneManager with VideoStreamCv2 and HistogramDetector. """ + TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] + """Ground truth of start frame for each fast cut in `test_movie_clip`.""" + video = VideoStreamCv2(test_movie_clip) + scene_manager = SceneManager() + scene_manager.add_detector(HistogramDetector()) + scene_manager.auto_downscale = True + + video_fps = video.frame_rate + start_time = FrameTimecode('00:00:50', video_fps) + end_time = FrameTimecode('00:01:19', video_fps) + + video.seek(start_time) + scene_manager.detect_scenes(video=video, end_time=end_time) + + scene_list = scene_manager.get_scene_list() + assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) + detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] + assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames + # Ensure last scene's end timecode matches the end time we set. + assert scene_list[-1][1] == end_time + + @dataclass class TestCase: __test__ = False @@ -178,7 +203,7 @@ def test_detect_fades(test_case: TestCase): def test_detectors_with_stats(test_video_file): """ Test all detectors functionality with a StatsManager. """ # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). - for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector]: + for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector, HistogramDetector]: video = VideoStreamCv2(test_video_file) stats = StatsManager() scene_manager = SceneManager(stats_manager=stats) diff --git a/website/pages/api.md b/website/pages/api.md index 517d4175..6620ce5a 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -25,6 +25,10 @@ The adaptive content detector (`detect-adaptive`) compares the difference in con The threshold-based scene detector (`detect-threshold`) is how most traditional scene detection methods work (e.g. the `ffmpeg blackframe` filter), by comparing the intensity/brightness of the current frame with a set threshold, and triggering a scene cut/break when this value crosses the threshold. In PySceneDetect, this value is computed by averaging the R, G, and B values for every pixel in the frame, yielding a single floating point number representing the average pixel value (from 0.0 to 255.0). +## Histogram Detector + +The color histogram detector uses color information to detect fast cuts. The input video for this detector must be in 8-bit color. The detection algorithm consists of separating the three RGB color channels and then quantizing them by eliminating all but the given number of most significant bits (`--bits/-b`). The resulting quantized color channels are then bit shifted and joined together into a new, composite image. A histogram is then constructed from the pixel values in the new, composite image. This histogram is compared element-wise with the histogram from the previous frame and if the total difference between the two adjacent histograms exceeds the given threshold (`--threshold/-t`), then a new scene is triggered. + # Creating New Detection Algorithms All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). From 0b472ae2a09114deadec473fbfd658fd9571eb66 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 16 Apr 2024 22:20:47 -0400 Subject: [PATCH 022/407] [detectors] Finalize initial implementation of detect-hist #53 Thank you @wjs018 for spearheading this work in PR #295 --- scenedetect.cfg | 19 ++++++++- scenedetect/_cli/__init__.py | 10 ++++- scenedetect/_cli/context.py | 34 ++++++--------- scenedetect/detectors/histogram_detector.py | 19 +++++---- tests/test_detectors.py | 46 ++++++++++----------- website/pages/changelog.md | 5 +++ 6 files changed, 74 insertions(+), 59 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 73b7e671..adc1e94e 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -26,7 +26,7 @@ #output = /usr/tmp/scenedetect/ # Default detector to use. -# Must be one of: detect-adaptive, detect-content, detect-threshold +# Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist #default-detector = detect-adaptive # Video backend interface, must be one of: opencv, pyav. @@ -87,7 +87,6 @@ #min-scene-len = 0.6s - [detect-threshold] # Average pixel intensity from 0-255 at which a fade event is triggered. #threshold = 12 @@ -126,6 +125,22 @@ #kernel-size = -1 +[detect-hist] +# +# IN DEVELOPMENT, SUBJECT TO CHANGE +# + +# Threshold value (float) that the calculated difference between subsequent +# histograms must exceed to trigger a new scene. +#threshold = 20000.0 + +# Number of bits to use for image quantization before binning. +#bits = 4 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + + # # COMMAND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ecdb1429..d5823703 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,7 +27,7 @@ import click import scenedetect -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.platform import get_system_version_info @@ -753,7 +753,12 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Op detect-hist --threshold 20000.0 """ assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) + + assert isinstance(ctx.obj, CliContext) + detector_args = ctx.obj.get_detect_hist_params( + threshold=threshold, bits=bits, min_scene_len=min_scene_len) + logger.debug('Adding detector: HistogramDetector(%s)', detector_args) + ctx.obj.add_detector(HistogramDetector(**detector_args)) @click.command('load-scenes', cls=_Command) @@ -1188,4 +1193,5 @@ def save_images_command( scenedetect.add_command(detect_content_command) scenedetect.add_command(detect_threshold_command) scenedetect.add_command(detect_adaptive_command) +scenedetect.add_command(detect_hist_command) scenedetect.add_command(load_scenes_command) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 36275744..c1ceb48d 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -28,7 +28,7 @@ from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation @@ -288,6 +288,8 @@ def handle_options( self.default_detector = (ContentDetector, self.get_detect_content_params()) elif default_detector == 'detect-threshold': self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) + elif default_detector == 'detect-hist': + self.default_detector = (HistogramDetector, self.get_detect_hist_params()) else: raise click.BadParameter("Unknown detector type!", param_hint='default-detector') @@ -449,13 +451,10 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) - def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], - min_scene_len: Optional[str]): - """Handle `detect-hist` command options.""" - self._check_input_open() - options_processed_orig = self.options_processed - self.options_processed = False - + def get_detect_hist_params(self, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]) -> Dict[str, Any]: + """Handle detect-hist command options and return dict to construct one with.""" + self._ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 else: @@ -465,20 +464,11 @@ def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], else: min_scene_len = self.config.get_value("detect-hist", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - - threshold = self.config.get_value("detect-hist", "threshold", threshold) - bits = self.config.get_value("detect-hist", "bits", bits) - - # Log detector args for debugging before we construct it. - logger.debug( - 'Adding detector: HistogramDetector(threshold=%f, bits=%d,' - ' min_scene_len=%d)', threshold, bits, min_scene_len) - - self._add_detector( - scenedetect.detectors.HistogramDetector( - threshold=threshold, bits=bits, min_scene_len=min_scene_len)) - - self.options_processed = options_processed_orig + return { + 'bits': self.config.get_value("detect-hist", "bits", bits), + 'min_scene_len': min_scene_len, + 'threshold': self.config.get_value("detect-hist", "threshold", threshold), + } def handle_export_html( self, diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 28d00eb5..937b7e13 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -42,10 +42,10 @@ def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int min_scene_len: Minimum length of any scene. """ super().__init__() - self.threshold = threshold - self.bits = bits - self.min_scene_len = min_scene_len - self._hist_bins = range(2**(3 * self.bits)) + self._threshold = threshold + self._bits = bits + self._min_scene_len = min_scene_len + self._hist_bins = range(2**(3 * self._bits)) self._last_hist = None self._last_scene_cut = None @@ -78,10 +78,10 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: self._last_scene_cut = frame_num # Quantize the image and separate the color channels - quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self.bits) + quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self._bits) # Perform bit shifting operations and bitwise combine color channels into one array - composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self.bits) + composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self._bits) # Create the histogram with a bin for every rgb value hist, _ = numpy.histogram(composite_img, bins=self._hist_bins) @@ -92,8 +92,11 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) # Check if a new scene should be triggered - if hist_diff >= self.threshold and ((frame_num - self._last_scene_cut) - >= self.min_scene_len): + + # TODO(#53): We should probably normalize the threshold based on the frame size, as + # larger images will have more pixels in each bin. + if hist_diff >= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 1344ad94..4751f521 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -27,6 +27,11 @@ from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.backends.opencv import VideoStreamCv2 +# TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores +# regardless of resolution. This will ensure that threshold values will hold true for different +# input sources. Most detectors already provide this guarantee, so this is more to prevent any +# regressions in the future. + # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: @@ -47,31 +52,6 @@ def get_absolute_path(relative_path: str) -> str: return abs_path -# TODO: Add a test case for this in the fixtures defined below. -def test_histogram_detector(test_movie_clip): - """ Test SceneManager with VideoStreamCv2 and HistogramDetector. """ - TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] - """Ground truth of start frame for each fast cut in `test_movie_clip`.""" - video = VideoStreamCv2(test_movie_clip) - scene_manager = SceneManager() - scene_manager.add_detector(HistogramDetector()) - scene_manager.auto_downscale = True - - video_fps = video.frame_rate - start_time = FrameTimecode('00:00:50', video_fps) - end_time = FrameTimecode('00:01:19', video_fps) - - video.seek(start_time) - scene_manager.detect_scenes(video=video, end_time=end_time) - - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames - # Ensure last scene's end timecode matches the end time we set. - assert scene_list[-1][1] == end_time - - @dataclass class TestCase: __test__ = False @@ -115,6 +95,14 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), id="adaptive_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=HistogramDetector(), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), + id="histogram_default"), pytest.param( TestCase( path=get_absolute_path("resources/goldeneye.mp4"), @@ -131,6 +119,14 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1260, 1334, 1365]), id="adaptive_min_scene_len"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=HistogramDetector(min_scene_len=30), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365]), + id="histogram_min_scene_len"), ] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0b16ee03..1bdb8be6 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,11 @@ Releases ## PySceneDetect 0.6 +### 0.6.4 (In Development) + + - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + + ### 0.6.3 (March 9, 2024) #### Release Notes From 8dd7615914adce839f1ed621c5f7cbd521e701e8 Mon Sep 17 00:00:00 2001 From: oliviernguyenquoc Date: Wed, 17 Apr 2024 23:07:14 +0200 Subject: [PATCH 023/407] Add tools to similar list (#378) --- website/pages/similar.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/pages/similar.md b/website/pages/similar.md index c7f20bc5..6e5173be 100644 --- a/website/pages/similar.md +++ b/website/pages/similar.md @@ -8,4 +8,6 @@ The following is a list of programs or commands also performing scene cut analys - [Shotdetect](http://johmathe.name/shotdetect.html) - appears to be only for *NIX, content mode only - [Matlab Scene Change Detection](http://www.mathworks.com/help/vision/examples/scene-change-detection.html) - requires Matlab and Simulink/Computer Vision Toolbox, uses feature extraction and edge detection - [chaptertool](https://github.com/Mtillmann/chaptertool) - CLI/Web tool that converts PySceneDetect output to other formats + - [TransNetV2](https://github.com/soCzech/TransNetV2) - Shot Boundary Detection Neural Network (2020) + - [AutoShot] https://github.com/wentaozhu/AutoShot - Shot Boundary Detection Neural Network, based on a neural architecture search (2023) From 859d3e05eec568890b2c90f952e5e2fcf2bd98d4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 20 Sep 2023 23:00:48 -0400 Subject: [PATCH 024/407] [docs] Update backend docs for MoviePy backend. --- .github/workflows/build.yml | 1 + docs/cli/backends.rst | 15 +++++++++++++++ scenedetect/backends/moviepy.py | 25 +++++++++++++------------ scenedetect/backends/pyav.py | 5 +---- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f5024b3..4d6adb68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,7 @@ jobs: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ + # TODO: Cache this: https://github.com/actions/cache # TODO: Install ffmpeg/mkvtoolnix on all runners. - name: Download FFMPEG if: ${{ matrix.os == 'windows-latest' }} diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst index 2dac6519..8df76a58 100644 --- a/docs/cli/backends.rst +++ b/docs/cli/backends.rst @@ -7,6 +7,8 @@ Backends PySceneDetect supports multiple backends for video input. Some can be configured by using :ref:`a config file `. Installed backends can be verified by running ``scenedetect version --all``. +Note that the `scenedetect` command output is generated as a post-processing step, after scene detection completes. Most commands require the ability for the input to be replayed, and preferably it should also support seeking. Network streams and other input types are supported with certain backends, however integration with live streams requires use of the Python API. + ======================================================================= OpenCV @@ -27,3 +29,16 @@ PyAV The `PyAV `_ backend (`av package `_) is a more robust backend that handles multiple audio tracks and frame decode errors gracefully. This backend can be used by specifying ``-b pyav`` via command line, or setting ``backend = pyav`` under the ``[global]`` section of your :ref:`config file `. + + +======================================================================= +MoviePy +======================================================================= + +MoviePy launches ffmpeg as a subprocess, and can be used with various types of inputs. If the input supports seeking it should work fine with most operations, for example, image sequences or AviSynth scripts. + +.. warning:: + + The MoviePy backend is still under development and is not included with current Windows distribution. To enable MoviePy support, you must install PySceneDetect using `python` and `pip`. + +This backend can be used by specifying ``-b moviepy`` via command line, or setting ``backend = moviepy`` under the ``[global]`` section of your :ref:`config file `. diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index e99c3f98..f41ad196 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -12,11 +12,9 @@ # """:class:`VideoStreamMoviePy` provides an adapter for MoviePy's `FFMPEG_VideoReader`. -Uses string identifier ``'moviepy'``. - -.. warning:: - - The MoviePy backend is still under development. Some features are not yet supported. +MoviePy launches ffmpeg as a subprocess, and can be used with various types of inputs. Generally, +the input should support seeking, but does not necessarily have to be a video. For example, +image sequences or AviSynth scripts are supported as inputs. """ from logging import getLogger @@ -72,13 +70,7 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: self._frame_number = 0 # We need to manually keep track of EOF as duration may not be accurate. self._eof = False - # MoviePy doesn't support extracting the aspect ratio yet, so for now we just fall - # back to using OpenCV to determine it. - try: - self._aspect_ratio = VideoStreamCv2(self._path).aspect_ratio - except VideoOpenFailure as ex: - logger.warning("Unable to determine aspect ratio: %s", str(ex)) - self._aspect_ratio = 1.0 + self._aspect_ratio: float = None # # VideoStream Methods/Properties @@ -121,6 +113,15 @@ def duration(self) -> Optional[FrameTimecode]: @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" + # TODO: Use cached_property once Python 3.7 support is deprecated. + if self._aspect_ratio is None: + # MoviePy doesn't support extracting the aspect ratio yet, so for now we just fall + # back to using OpenCV to determine it. + try: + self._aspect_ratio = VideoStreamCv2(self._path).aspect_ratio + except VideoOpenFailure as ex: + logger.warning("Unable to determine aspect ratio: %s", str(ex)) + self._aspect_ratio = 1.0 return self._aspect_ratio @property diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 761b63f3..a13ee449 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -10,10 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""":class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object. - -Uses string identifier ``'pyav'``. -""" +""":class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" from logging import getLogger from typing import AnyStr, BinaryIO, Optional, Tuple, Union From 52f0691a4e78d6cf723f07a96ef85f831981e9bc Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 25 Jul 2023 19:53:46 -0400 Subject: [PATCH 025/407] [site] Fix edit link for main website. --- website/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 010d0fb3..7626dbdd 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -6,6 +6,7 @@ site_author: "Brandon Castellano" docs_dir: "pages" site_dir: "build" repo_url: https://github.com/Breakthrough/PySceneDetect +edit_uri: 'blob/main/website/pages/' repo_name: "PySceneDetect on Github" copyright: 'Copyright © 2014-2023 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' theme: readthedocs From 8ec19f07bd7c63a7f0a347f23a32da324d1363f7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 13 Aug 2023 01:04:27 -0400 Subject: [PATCH 026/407] [site] Update style. --- website/mkdocs.yml | 11 ++++++--- website/overrides/main.html | 15 +++++++++++++ website/pages/changelog.md | 2 +- website/pages/download.md | 9 ++++---- .../pages/img/pyscenedetect_logo_small.png | Bin 3318 -> 3128 bytes website/pages/style.css | 21 ++++++++++++++++++ website/requirements.txt | 2 +- 7 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 website/overrides/main.html create mode 100644 website/pages/style.css diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 7626dbdd..d63f7a0b 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -9,12 +9,14 @@ repo_url: https://github.com/Breakthrough/PySceneDetect edit_uri: 'blob/main/website/pages/' repo_name: "PySceneDetect on Github" copyright: 'Copyright © 2014-2023 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' -theme: readthedocs +theme: + name: readthedocs + logo: img/pyscenedetect_logo_small.png + custom_dir: overrides # TODO: deprecated option for this theme google_analytics: ['UA-72551323-1', 'auto'] -# TODO: `pages` is deprecated, use `nav` instead -pages: +nav: - 'PySceneDetect': - 'Home': 'index.md' - 'Features': 'features.md' @@ -34,3 +36,6 @@ pages: - 'License & Copyright': 'copyright.md' markdown_extensions: [fenced_code] + +extra_css: + - style.css diff --git a/website/overrides/main.html b/website/overrides/main.html new file mode 100644 index 00000000..253cc886 --- /dev/null +++ b/website/overrides/main.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block site_name %} +{% if page.is_index %} + +{% else %} + +{% endif %} +{% if page.is_index %} + 🎥 {{ config.site_name }} +{% else %} + +{% endif %} + +{% endblock %} diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e44f2030..b65da319 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -1,5 +1,5 @@ -PySceneDetect Releases +Releases ========================================================== ## PySceneDetect 0.6 diff --git a/website/pages/download.md b/website/pages/download.md index ea1a943c..a192f541 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -1,13 +1,12 @@ -# Obtaining PySceneDetect +# Download PySceneDetect is completely free software, and can be downloaded from the links below. See the [license and copyright information](copyright.md) page for details. If you have trouble running PySceneDetect, ensure that you have all the required dependencies listed in the [Dependencies](#dependencies) section below. PySceneDetect requires at least Python 3.7 or higher. -## Download and Installation -### Install via pip       +## Install via pip      

Including OpenCV (recommended):

@@ -18,7 +17,7 @@ PySceneDetect requires at least Python 3.7 or higher. PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi.org/project/scenedetect/). -### Windows Build (64-bit Only)   +## Windows Build (64-bit Only)  

Latest Release: v0.6.2

@@ -28,7 +27,7 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi   Getting Started
-### Post Installation +## Post Installation After installation, you can call PySceneDetect from any terminal/command prompt by typing `scenedetect` (try running `scenedetect --help`, or `scenedetect version`). If you encounter any runtime errors while running PySceneDetect, ensure that you have all the required dependencies listed in the System Requirements section above (you should be able to `import numpy` and `import cv2`). If you encounter any issues or want to make a feature request, feel free to [report any bugs or share some feature requests/ideas](contributing.md) on the [issue tracker](https://github.com/Breakthrough/PySceneDetect/issues) and help make PySceneDetect even better. diff --git a/website/pages/img/pyscenedetect_logo_small.png b/website/pages/img/pyscenedetect_logo_small.png index 334bf6551882ae1d86fe31be9ecbe723bd538126..5214519621782c522a66d8d052f9529a9def5643 100644 GIT binary patch literal 3128 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 VwoSsT!={{$n8`A4 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, +.wy-side-nav-search>a { + color:#3B3F47; + font-size:100%; + font-weight:700; + display:inline-block; + padding:4px 6px; + margin-bottom:.809em; + max-width:100% +} \ No newline at end of file diff --git a/website/requirements.txt b/website/requirements.txt index d4493d23..94d022cd 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ -mkdocs==1.2.3 +mkdocs==1.5.2 jinja2==3.0.3 From adec1b2ed8bbf1eaa157d81d7e76835c6a33d611 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 27 Aug 2023 22:59:17 -0400 Subject: [PATCH 027/407] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f633ba98..9ab6e282 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Skip the first 10 seconds of the input video: scenedetect -i video.mp4 time -s 10s -More examples can be found throughout [the documentation](https://www.scenedetect.com/docs/latest/cli/global_options.html). +More examples can be found throughout [the documentation](https://www.scenedetect.com/docs/latest/cli.html). **Quick Start (Python API)**: From 95a704bff67abdd62af7a39d9a3203c274f7fe77 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 17 Nov 2023 21:31:41 -0500 Subject: [PATCH 028/407] [build] Add cron job for daily builds --- .github/workflows/build-windows.yml | 2 ++ .github/workflows/build.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2b2b589d..d89323f3 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -3,6 +3,8 @@ name: Windows Distribution on: + schedule: + - cron: '0 0 * * *' pull_request: paths: - dist/** diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d6adb68..c0a32586 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,8 @@ name: Python Distribution on: + schedule: + - cron: '0 0 * * *' pull_request: paths: - dist/** From 1a14fac4d21b8ea88074e5d1339e6089938863d4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 20 Nov 2023 20:05:02 -0500 Subject: [PATCH 029/407] [build] Remove checkout of deprecated branch. --- .github/workflows/build-windows.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index d89323f3..952005e2 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -45,8 +45,6 @@ jobs: run: | git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ - git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/build-windows:refs/remotes/origin/build-windows - git checkout refs/remotes/origin/build-windows -- dist/ - name: Download FFMPEG uses: dsaltares/fetch-gh-release-asset@1.1.1 From 451711ba1c592fce70f02ab5dfd9c82f19285ae0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 1 Dec 2023 21:34:17 -0500 Subject: [PATCH 030/407] [pyav] Handle missing display_aspect_ratio property gracefully Add additional checks to guard against divide by zero. Fixes #355. --- scenedetect/backends/pyav.py | 11 ++++++++--- website/pages/changelog.md | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index a13ee449..9f9f4253 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -207,9 +207,14 @@ def frame_number(self) -> int: @property def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" - display_aspect_ratio = ( - self._codec_context.display_aspect_ratio.numerator / - self._codec_context.display_aspect_ratio.denominator) + if not hasattr(self._codec_context, + "display_aspect_ratio") or self._codec_context.display_aspect_ratio is None: + return 1.0 + ar_denom = self._codec_context.display_aspect_ratio.denominator + if ar_denom <= 0: + return 1.0 + display_aspect_ratio = self._codec_context.display_aspect_ratio.numerator / ar_denom + assert self.frame_size[0] > 0 and self.frame_size[1] > 0 frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio diff --git a/website/pages/changelog.md b/website/pages/changelog.md index b65da319..dae45a3c 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,17 @@ Releases ## PySceneDetect 0.6 +### 0.6.3 (In Development) + +**Program Changes:** + + - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + +**API Changes:** + + - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + + ### 0.6.2 (July 23, 2023) #### Release Notes From 22995aa403c58b28211c7229c4f57c97ed091333 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 1 Dec 2023 22:42:17 -0500 Subject: [PATCH 031/407] [bugfix] Fix for circular import errors #350 --- scenedetect/backends/moviepy.py | 12 ++++++------ scenedetect/backends/opencv.py | 14 +++++++------- scenedetect/backends/pyav.py | 8 ++++---- scenedetect/detectors/adaptive_detector.py | 6 +++--- scenedetect/video_manager.py | 8 ++++---- scenedetect/video_stream.py | 8 ++++---- website/pages/changelog.md | 1 + 7 files changed, 29 insertions(+), 28 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index f41ad196..36473bb8 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -22,7 +22,7 @@ import cv2 from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader -from numpy import ndarray +import numpy as np from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_file_name @@ -63,8 +63,8 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame # will always be the current frame, and self._reader.lastread will be the next. - self._last_frame: Union[bool, ndarray] = False - self._last_frame_rgb: Optional[ndarray] = None + self._last_frame: Union[bool, np.ndarray] = False + self._last_frame_rgb: Optional[np.ndarray] = None # Older versions don't track the video position when calling read_frame so we need # to keep track of the current frame number. self._frame_number = 0 @@ -193,15 +193,15 @@ def reset(self): self._frame_number = 0 self._eof = False - def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]: - """Read and decode the next frame as a numpy.ndarray. Returns False when video ends. + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: decode: Decode and return the frame. advance: Seek to the next frame. If False, will return the current (last) frame. Returns: - If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video. + If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ if not advance: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index ca9d318c..464a5512 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -24,7 +24,7 @@ import os.path import cv2 -from numpy import ndarray +import numpy as np from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.platform import get_file_name @@ -262,8 +262,8 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) - def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]: - """Read and decode the next frame as a numpy.ndarray. Returns False when video ends, + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. Arguments: @@ -271,7 +271,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool advance: Seek to the next frame. If False, will return the current (last) frame. Returns: - If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video. + If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ if not self._cap.isOpened(): @@ -497,8 +497,8 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]: - """Read and decode the next frame as a numpy.ndarray. Returns False when video ends, + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. Arguments: @@ -506,7 +506,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool advance: Seek to the next frame. If False, will return the current (last) frame. Returns: - If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video. + If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ if not self._cap.isOpened(): diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 9f9f4253..fadace09 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -17,7 +17,7 @@ # pylint: disable=c-extension-no-member import av -from numpy import ndarray +import numpy as np from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.platform import get_file_name @@ -261,15 +261,15 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]: - """Read and decode the next frame as a numpy.ndarray. Returns False when video ends. + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: decode: Decode and return the frame. advance: Seek to the next frame. If False, will return the current (last) frame. Returns: - If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video. + If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ has_advanced = False diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index a0154996..bc8dac31 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -20,7 +20,7 @@ from logging import getLogger from typing import List, Optional -from numpy import ndarray +import numpy as np from scenedetect.detectors import ContentDetector @@ -114,14 +114,14 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame(self, frame_num: int, frame_img: Optional[ndarray]) -> List[int]: + def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). Arguments: frame_num: Frame number of frame that is being passed. - frame_img: Decoded frame image (numpy.ndarray) to perform scene + frame_img: Decoded frame image (np.ndarray) to perform scene detection on. Can be None *only* if the self.is_processing_required() method (inhereted from the base SceneDetector class) returns True. diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index a4ce5cfd..626bfa69 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -24,7 +24,7 @@ from logging import getLogger from typing import Iterable, List, Optional, Tuple, Union -from numpy import ndarray +import numpy as np import cv2 from scenedetect.platform import get_file_name @@ -630,14 +630,14 @@ def grab(self) -> bool: self._correct_frame_length() return grabbed - def retrieve(self) -> Tuple[bool, Optional[ndarray]]: + def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: """ Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. Frame returned corresponds to last call to :meth:`grab()`. Returns: Tuple of (True, frame_image) if a frame was grabbed during the last call to grab(), - and where frame_image is a numpy ndarray of the decoded frame. Otherwise (False, None). + and where frame_image is a numpy np.ndarray of the decoded frame. Otherwise (False, None). """ if not self._started: self.start() @@ -653,7 +653,7 @@ def retrieve(self) -> Tuple[bool, Optional[ndarray]]: self._last_frame = None return (retrieved, self._last_frame) - def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """ Return next frame (or current if advance = False), or False if end of video. Arguments: diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index a801e68c..34116be0 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -36,7 +36,7 @@ from logging import getLogger from typing import Tuple, Optional, Union -from numpy import ndarray +import numpy as np from scenedetect.frame_timecode import FrameTimecode @@ -178,15 +178,15 @@ def frame_number(self) -> int: # @abstractmethod - def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]: - """Read and decode the next frame as a numpy.ndarray. Returns False when video ends. + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: decode: Decode and return the frame. advance: Seek to the next frame. If False, will return the current (last) frame. Returns: - If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video. + If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ raise NotImplementedError diff --git a/website/pages/changelog.md b/website/pages/changelog.md index dae45a3c..4069a1a7 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -13,6 +13,7 @@ Releases **API Changes:** - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) ### 0.6.2 (July 23, 2023) From 1904e6949e56d7917e4ce324ab4ff827d263c01c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 9 Dec 2023 10:59:43 -0500 Subject: [PATCH 032/407] [cli] Only print cut list when `list-scenes` is specified Ensures cut list can be suppressed when using `list-scenes --quiet`. Fixes #356. --- scenedetect/__init__.py | 2 +- scenedetect/_cli/controller.py | 9 ++++----- scenedetect/video_splitter.py | 3 +++ website/pages/changelog.md | 5 +++++ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index c090c535..26a89150 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.2' +__version__ = '0.6.3.dev0' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 65d4cddc..da77e954 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -162,17 +162,16 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s ------------------------------------------------------------------------ -""", '\n'.join([ +-----------------------------------------------------------------------""", '\n'.join([ ' | %5d | %11d | %s | %11d | %s |' % (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), end_time.get_frames(), end_time.get_timecode()) for i, (start_time, end_time) in enumerate(scene_list) ])) - if cut_list: - logger.info('Comma-separated timecode list:\n %s', - ','.join([cut.get_timecode() for cut in cut_list])) + if cut_list: + logger.info('Comma-separated timecode list:\n %s', + ','.join([cut.get_timecode() for cut in cut_list])) def _save_images( diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 34550ad4..865889bd 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -178,6 +178,7 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, scene_list: Iterable[TimecodePair], + output_dir: Optional[str] = None, output_file_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4', video_name: Optional[str] = None, arg_override: str = DEFAULT_FFMPEG_ARGS, @@ -193,6 +194,8 @@ def split_video_ffmpeg( input_video_path: Path to the video to be split. scene_list (List[Tuple[FrameTimecode, FrameTimecode]]): List of scenes (pairs of FrameTimecodes) denoting the start/end frames of each scene. + output_dir: Directory to output videos. If not set, the output is created in the working + directory. output_file_template (str): Template to use for generating the output filenames. Can use $VIDEO_NAME and $SCENE_NUMBER in this format, for example: `$VIDEO_NAME - Scene $SCENE_NUMBER.mp4` diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 4069a1a7..215571ef 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -9,6 +9,11 @@ Releases **Program Changes:** - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) + - TODO: [general] Add `output-format` option under `[list-scenes]` to configure output of `list-scenes` + - Valid values: `scenes`, `cuts`, `both` + - TODO: [general] Add `cut-format` option under `[list-scenes]` to configure cut list format for `list-scenes` [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) + - Valid values: `frames`, `timecode`, `seconds` **API Changes:** From 00119cfe210468778ca3c6865a7cff25c93f8ed7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 16 Dec 2023 21:28:42 -0500 Subject: [PATCH 033/407] [config] Add `display-scenes` and `display-cuts` option to config file under `[list-scenes]` Allows controlling which fields are displayed. --- scenedetect/_cli/config.py | 6 ++++-- scenedetect/_cli/context.py | 22 +++++++++++++--------- scenedetect/_cli/controller.py | 26 +++++++++++++------------- website/pages/changelog.md | 9 +++++---- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index c6379a52..1a4affb5 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -263,9 +263,11 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal 'no-images': False, }, 'list-scenes': { - 'output': '', + 'display-cuts': True, + 'display-scenes': True, 'filename': '$VIDEO_NAME-Scenes.csv', - 'no-output-file': False, + 'output': '', + 'no-output-file': False, # TODO(v0.6.3): Rename this to 'save'. 'quiet': False, 'skip-cuts': False, }, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 83827ec9..474c005b 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -152,11 +152,13 @@ def __init__(self): # `list-scenes` Command Options self.list_scenes: bool = False - self.print_scene_list: bool = None # list-scenes -q/--quiet + self.list_scenes_quiet: bool = None # list-scenes -q/--quiet self.scene_list_directory: str = None # list-scenes -o/--output self.scene_list_name_format: str = None # list-scenes -f/--filename self.scene_list_output: bool = None # list-scenes -n/--no-output self.skip_cuts: bool = None # list-scenes -s/--skip-cuts + self.display_cuts: bool = True # [list-scenes] display-cuts + self.display_scenes: bool = True # [list-scenes] display-scenes # `export-html` Command Options self.export_html: bool = False @@ -473,20 +475,22 @@ def handle_list_scenes( """Handle `list-scenes` command options.""" self._ensure_input_open() if self.list_scenes: - self._on_duplicate_command('list-scenes') + self._on_duplicate_command("list-scenes") - self.skip_cuts = skip_cuts or self.config.get_value('list-scenes', 'skip-cuts') - self.print_scene_list = not (quiet or self.config.get_value('list-scenes', 'quiet')) - no_output_file = no_output_file or self.config.get_value('list-scenes', 'no-output-file') + self.display_cuts = self.config.get_value("list-scenes", "display-cuts") + self.display_scenes = self.config.get_value("list-scenes", "display-scenes") + self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts") + self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") + no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") self.scene_list_directory = self.config.get_value( - 'list-scenes', 'output', output, ignore_default=True) - self.scene_list_name_format = self.config.get_value('list-scenes', 'filename', filename) + "list-scenes", "output", output, ignore_default=True) + self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) if self.scene_list_name_format is not None and not no_output_file: - logger.info('Scene list filename format:\n %s', self.scene_list_name_format) + logger.info("Scene list filename format:\n %s", self.scene_list_name_format) self.scene_list_output = not no_output_file if self.scene_list_directory is not None: - logger.info('Scene list output directory:\n %s', self.scene_list_directory) + logger.info("Scene list output directory:\n %s", self.scene_list_directory) self.list_scenes = True diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index da77e954..ed58bfcc 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -154,24 +154,24 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram scene_list=scene_list, include_cut_list=not context.skip_cuts, cut_list=cut_list) - - if context.print_scene_list: - logger.info( - """Scene List: + if not context.list_scenes_quiet: + if context.display_scenes: + logger.info( + """Scene List: ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s -----------------------------------------------------------------------""", '\n'.join([ - ' | %5d | %11d | %s | %11d | %s |' % - (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), - end_time.get_frames(), end_time.get_timecode()) - for i, (start_time, end_time) in enumerate(scene_list) - ])) - - if cut_list: - logger.info('Comma-separated timecode list:\n %s', - ','.join([cut.get_timecode() for cut in cut_list])) + " | %5d | %11d | %s | %11d | %s |" % + (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), + end_time.get_frames(), end_time.get_timecode()) + for i, (start_time, end_time) in enumerate(scene_list) + ])) + + if cut_list and context.display_cuts: + logger.info("Comma-separated timecode list:\n %s", + ",".join([cut.get_timecode() for cut in cut_list])) def _save_images( diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 215571ef..e28687f8 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -10,10 +10,11 @@ Releases - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) - - TODO: [general] Add `output-format` option under `[list-scenes]` to configure output of `list-scenes` - - Valid values: `scenes`, `cuts`, `both` - - TODO: [general] Add `cut-format` option under `[list-scenes]` to configure cut list format for `list-scenes` [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) - - Valid values: `frames`, `timecode`, `seconds` + - [general] Several changes to `[list-scenes]` config file options: + - Add `display-scenes` and `display-cuts` options to control output + - [TODO] Rename `no-output-file` to `save` + - [TODO] Add `cut-format` to control formatting of cut points [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) + - Valid values: `frames`, `timecode`, `seconds` **API Changes:** From 5c8d2d5a51a69418beea2ed783701f6316430506 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 16 Dec 2023 21:31:53 -0500 Subject: [PATCH 034/407] [cli] Indent progress bar Improves visual alignment and makes it easier to see the number of detected scenes when scrolling through the output. --- scenedetect/scene_manager.py | 4 +++- website/pages/changelog.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 9d3122c6..cceb3664 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -111,7 +111,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): MAX_FRAME_QUEUE_LENGTH: int = 4 """Maximum number of decoded frames which can be buffered while waiting to be processed.""" -PROGRESS_BAR_DESCRIPTION = 'Detected: %d | Progress' +PROGRESS_BAR_DESCRIPTION = ' Detected: %d | Progress' """Template to use for progress bar.""" @@ -892,6 +892,8 @@ def detect_scenes(self, progress_bar.update(1 + frame_skip) if progress_bar is not None: + progress_bar.set_description( + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True) progress_bar.close() # Unblock any puts in the decode thread before joining. This can happen if the main # processing thread stops before the decode thread. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e28687f8..6af36a38 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -15,6 +15,7 @@ Releases - [TODO] Rename `no-output-file` to `save` - [TODO] Add `cut-format` to control formatting of cut points [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) - Valid values: `frames`, `timecode`, `seconds` + - [general] Increase progress bar indent to improve visibility and visual alignment **API Changes:** From 5a57b89a94ce36ff5a04d5430c6bc52900ec7a98 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 20 Jan 2024 22:33:31 -0500 Subject: [PATCH 035/407] [video_splitter] Add `output_dir` argument to split_video_* functions. Add `formatter` argument to split_video_ffmpeg to customize filename generation when required. Fixes #359 and #298. --- scenedetect.cfg | 30 +++++--- scenedetect/_cli/controller.py | 11 +-- scenedetect/video_splitter.py | 134 ++++++++++++++++++++++++--------- 3 files changed, 123 insertions(+), 52 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index a7de3aea..c60db3a7 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -25,14 +25,15 @@ # Output directory for written files. If unset, defaults to working directory. #output = /usr/tmp/scenedetect/ -# Default detector to use, must be one of: detect-adaptive, detect-content, detect-threshold. +# Default detector to use. +# Must be one of: detect-adaptive, detect-content, detect-threshold #default-detector = detect-adaptive # Video backend interface, must be one of: opencv, pyav. #backend = opencv -# Downscale frame using a ratio of N. Set to 1 for no downscaling. If unset, applied -# automatically based on input video resolution. Must be an integer value. +# Downscale frame using a ratio of N. Set to 1 for no downscaling. If unset, +# applied automatically based on input video resolution. Must be an integer value. #downscale = 1 # Method to use for downscaling (nearest, linear, cubic, area, lanczos4). @@ -208,20 +209,31 @@ [list-scenes] +# Save scene/cut list as a CSV file. +#save = yes + # Folder to output scene list. Overrides [global] output option. #output = /usr/tmp/images -# Filename format of created scene list. Can use $VIDEO_NAME in the name. +# Filename format to use when saving scene list. $VIDEO_NAME can be used to +# represent the name of the video being processed. #filename = $VIDEO_NAME-Scenes.csv -# Skip the cutting list as the first row in the CSV file (yes/no). -# Set this option if compliance with RFC 4180 is required. +# Display a table with the start/end boundaries for each scene (yes/no). +#display-scenes = yes + +# Display list of cut points generated from scene boundaries (yes/no). +#display-cuts = yes + +# Skip writing cut points as the first row in the CSV file (yes/no). +# Set for RFC 4180 compliance. #skip-cuts = no -# Output only to command-line, don't write file (yes/no). -#no-output-file = no +# Format to use for list of cut points (frames, seconds, timecode). +#cut-format = timecode -# Suppress printing of scene list. +# Suppress all display output of list-scenes command. +# Overrides `display-scenes` and `display-cuts`. #quiet = no diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index ed58bfcc..098c7da3 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -243,19 +243,15 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. elif not 2 <= extension_length <= 4: output_path_template += '.mp4' - # Pre-expand $VIDEO_NAME so it can be used for a directory. - # TODO: Do this elsewhere in a future version for all output options. - output_path_template = Template(output_path_template).safe_substitute( - VIDEO_NAME=get_file_name(context.video_stream.path, include_extension=False)) - output_path_template = get_and_create_path( - output_path_template, context.split_directory - if context.split_directory is not None else context.output_directory) + # Ensure the appropriate tool is available before handling split-video. check_split_video_requirements(context.split_mkvmerge) + if context.split_mkvmerge: split_video_mkvmerge( input_video_path=context.video_stream.path, scene_list=scene_list, + output_dir=context.split_directory, output_file_template=output_path_template, show_output=not (context.quiet_mode or context.split_quiet), ) @@ -263,6 +259,7 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, split_video_ffmpeg( input_video_path=context.video_stream.path, scene_list=scene_list, + output_dir=context.split_directory, output_file_template=output_path_template, arg_override=context.split_args, show_progress=not context.quiet_mode, diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 865889bd..90cce52f 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -33,19 +33,20 @@ available on the computer, depending on the specified command-line options. """ +from dataclasses import dataclass import logging -import subprocess import math +from pathlib import Path +import subprocess import time -from typing import Iterable, Optional, Tuple +import typing as ty -from scenedetect.platform import (tqdm, invoke_command, CommandTooLong, get_file_name, - get_ffmpeg_path, Template) +from scenedetect.platform import (tqdm, invoke_command, CommandTooLong, get_ffmpeg_path, Template) from scenedetect.frame_timecode import FrameTimecode logger = logging.getLogger('pyscenedetect') -TimecodePair = Tuple[FrameTimecode, FrameTimecode] +TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" COMMAND_TOO_LONG_STRING = """ @@ -57,8 +58,8 @@ for details. Sorry about that! """ -FFMPEG_PATH: Optional[str] = get_ffmpeg_path() -"""Relative path to the Ffmpeg binary on this system, if any (will be None if not available).""" +FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() +"""Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" DEFAULT_FFMPEG_ARGS = '-map 0 -c:v libx264 -preset veryfast -crf 22 -c:a aac' """Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" @@ -93,6 +94,56 @@ def is_ffmpeg_available() -> bool: return FFMPEG_PATH is not None +## +## Output Naming +## + + +@dataclass +class SceneMetadata: + """Information about the scenes being exported.""" + index: int + """0-based index of this scene.""" + start: FrameTimecode + """First frame.""" + end: FrameTimecode + """Last frame.""" + + +@dataclass +class VideoMetadata: + """Information about the video.""" + name: str + """Expected name of the video. May differ from `path`.""" + path: Path + """Path to the input file.""" + total_scenes: int + """Total number of scenes that will be written.""" + + +PathFormatter = ty.Callable[[SceneMetadata, VideoMetadata], ty.AnyStr] + + +def default_formatter(template: str) -> PathFormatter: + """Formats filenames using a template string which allows the following variables: + + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + """ + MIN_DIGITS = 3 + format_scene_number: PathFormatter = lambda scene, video: ( + ('%0' + str(max(MIN_DIGITS, + math.floor(math.log(video.total_scenes, 10)) + 1)) + 'd') % + (scene.index + 1)) + formatter: PathFormatter = lambda scene, video: Template(template).safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=format_scene_number(scene, video), + START_TIME=str(scene.start.get_timecode().replace(":", ";")), + END_TIME=str(scene.end.get_timecode().replace(":", ";")), + START_FRAME=str(scene.start.get_frames()), + END_FRAME=str(scene.end.get_frames())) + return formatter + + ## ## Split Video Functions ## @@ -100,9 +151,10 @@ def is_ffmpeg_available() -> bool: def split_video_mkvmerge( input_video_path: str, - scene_list: Iterable[TimecodePair], + scene_list: ty.Iterable[TimecodePair], + output_dir: ty.Optional[Path] = None, output_file_template: str = '$VIDEO_NAME.mkv', - video_name: Optional[str] = None, + video_name: ty.Optional[str] = None, show_output: bool = False, suppress_output=None, ): @@ -112,8 +164,10 @@ def split_video_mkvmerge( Arguments: input_video_path: Path to the video to be split. scene_list : List of scenes as pairs of FrameTimecodes denoting the start/end times. - output_file_template: Template to use for output files. Mkvmerge always adds the suffix - "-$SCENE_NUMBER". Can use $VIDEO_NAME as a template parameter (e.g. "$VIDEO_NAME.mkv"). + output_dir: Directory to output videos. If not set, output will be in working directory. + output_file_template: Template to use for generating output files. Note that mkvmerge always + adds the suffix "-$SCENE_NUMBER" to the output paths. Only the $VIDEO_NAME variable + is supported by this function. video_name (str): Name of the video to be substituted in output_file_template for $VIDEO_NAME. If not specified, will be obtained from the filename. show_output: If False, adds the --quiet flag when invoking `mkvmerge`.. @@ -139,20 +193,25 @@ def split_video_mkvmerge( output_file_template) if video_name is None: - video_name = get_file_name(input_video_path, include_extension=False) + video_name = Path(input_video_path).stem ret_val = 0 - # mkvmerge automatically appends '-$SCENE_NUMBER', so we remove it if present. - output_file_template = output_file_template.replace('-$SCENE_NUMBER', - '').replace('$SCENE_NUMBER', '') - output_file_name = Template(output_file_template).safe_substitute(VIDEO_NAME=video_name) + + # mkvmerge doesn't support adding scene metadata to filenames. It always adds the scene + # number prefixed with a dash to the filenames. + template = Template(output_file_template) + output_path = template.safe_substitute(VIDEO_NAME=video_name) + if output_dir: + output_path = Path(output_dir) / output_path + output_path.parent.mkdir(parents=True, exist_ok=True) try: call_list = ['mkvmerge'] if not show_output: call_list.append('--quiet') call_list += [ - '-o', output_file_name, '--split', + '-o', + str(output_path), '--split', 'parts:%s' % ','.join([ '%s-%s' % (start_time.get_timecode(), end_time.get_timecode()) for start_time, end_time in scene_list @@ -177,28 +236,28 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, - scene_list: Iterable[TimecodePair], - output_dir: Optional[str] = None, + scene_list: ty.Iterable[TimecodePair], + output_dir: ty.Optional[Path] = None, output_file_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4', - video_name: Optional[str] = None, + video_name: ty.Optional[str] = None, arg_override: str = DEFAULT_FFMPEG_ARGS, show_progress: bool = False, show_output: bool = False, suppress_output=None, hide_progress=None, + formatter: ty.Optional[PathFormatter] = None, ): """ Calls the ffmpeg command on the input video, generating a new video for each scene based on the start/end timecodes. Arguments: input_video_path: Path to the video to be split. - scene_list (List[Tuple[FrameTimecode, FrameTimecode]]): List of scenes + scene_list (List[ty.Tuple[FrameTimecode, FrameTimecode]]): List of scenes (pairs of FrameTimecodes) denoting the start/end frames of each scene. - output_dir: Directory to output videos. If not set, the output is created in the working - directory. - output_file_template (str): Template to use for generating the output filenames. - Can use $VIDEO_NAME and $SCENE_NUMBER in this format, for example: - `$VIDEO_NAME - Scene $SCENE_NUMBER.mp4` + output_dir: Directory to output videos. If not set, output will be in working directory. + output_file_template (str): Template to use for generating output filenames. + The following variables will be replaced in the template for each scene: + $VIDEO_NAME, $SCENE_NUMBER, $START_TIME, $END_TIME, $START_FRAME, $END_FRAME video_name (str): Name of the video to be substituted in output_file_template. If not passed will be calculated from input_video_path automatically. arg_override (str): Allows overriding the arguments passed to ffmpeg for encoding. @@ -206,6 +265,7 @@ def split_video_ffmpeg( show_output (bool): If True, will show output from ffmpeg for first split. suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. hide_progress: [DEPRECATED] DO NOT USE. For backwards compatibility only. + formatter: Custom formatter callback. Overrides `output_file_template`. Returns: Return code of invoking ffmpeg (0 on success). If scene_list is empty, will @@ -231,7 +291,7 @@ def split_video_ffmpeg( output_file_template) if video_name is None: - video_name = get_file_name(input_video_path, include_extension=False) + video_name = Path(input_video_path).stem arg_override = arg_override.replace('\\"', '"') @@ -240,6 +300,11 @@ def split_video_ffmpeg( scene_num_format = '%0' scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' + if formatter is None: + formatter = default_formatter(output_file_template) + video_metadata = VideoMetadata( + name=video_name, path=input_video_path, total_scenes=len(scene_list)) + try: progress_bar = None total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() @@ -248,14 +313,11 @@ def split_video_ffmpeg( processing_start_time = time.time() for i, (start_time, end_time) in enumerate(scene_list): duration = (end_time - start_time) - # Format output filename with template variable - output_file_template_iter = Template(output_file_template).safe_substitute( - VIDEO_NAME=video_name, - SCENE_NUMBER=scene_num_format % (i + 1), - START_TIME=str(start_time.get_timecode().replace(":", ";")), - END_TIME=str(end_time.get_timecode().replace(":", ";")), - START_FRAME=str(start_time.get_frames()), - END_FRAME=str(end_time.get_frames())) + scene_metadata = SceneMetadata(index=i, start=start_time, end=end_time) + output_path = Path(formatter(scene=scene_metadata, video=video_metadata)) + if output_dir: + output_path = Path(output_dir) / output_path + output_path.parent.mkdir(parents=True, exist_ok=True) # Gracefully handle case where FFMPEG_PATH might be unset. call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else 'ffmpeg'] @@ -273,7 +335,7 @@ def split_video_ffmpeg( ] call_list += arg_override call_list += ['-sn'] - call_list += [output_file_template_iter] + call_list += [str(output_path)] ret_val = invoke_command(call_list) if show_output and i == 0 and len(scene_list) > 1: logger.info( From 7a63aac5a992d8914ef5c58be1785e9d27bdd1c2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Jan 2024 22:01:43 -0500 Subject: [PATCH 036/407] Update changelog and build docs on develop. --- .github/workflows/generate-docs.yml | 1 + website/pages/changelog.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 2c6b7677..53a6ab73 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - develop - 'releases/**' paths: - 'docs/**' diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6af36a38..1ddafa4f 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -21,6 +21,8 @@ Releases - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) + - [feature] Add `output_dir` argument to split_video_* functions to customize output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) + - [feature] Add `formatter` argument to split_video_ffmpeg to customize filename generation [#359](https://github.com/Breakthrough/PySceneDetect/issues/359) ### 0.6.2 (July 23, 2023) From d63dcc9841d44088baa593cc9f4752e74f1d0d9c Mon Sep 17 00:00:00 2001 From: Kian-Meng Ang Date: Sun, 3 Dec 2023 10:28:45 +0800 Subject: [PATCH 037/407] [docs] Fix typos (#358) * [docs] Fix typos Found via `codespell -S *.aip,*.enc -L datas,bu,te` * Revert licence change --- docs/cli.rst | 2 +- scenedetect/_cli/__init__.py | 2 +- scenedetect/_scene_loader.py | 2 +- scenedetect/platform.py | 2 +- scenedetect/scene_detector.py | 2 +- scenedetect/scene_manager.py | 2 +- website/pages/changelog.md | 6 +++--- website/pages/cli.md | 4 ++-- website/pages/features.md | 2 +- website/pages/literature.md | 4 ++-- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index d70dbd9a..e329e65e 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -50,7 +50,7 @@ Options .. option:: -o DIR, --output DIR - Output directory for created files. If unset, working directory will be used. May be overriden by command options. + Output directory for created files. If unset, working directory will be used. May be overridden by command options. .. option:: -c FILE, --config FILE diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 7eb0c73f..66c750df 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -172,7 +172,7 @@ def _print_command_help(ctx: click.Context, command: click.Command): required=False, metavar='DIR', type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help='Output directory for created files. If unset, working directory will be used. May be overriden by command options.%s' + help='Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s' % (USER_CONFIG.get_help_string("global", "output", show_default=False)), ) @click.option( diff --git a/scenedetect/_scene_loader.py b/scenedetect/_scene_loader.py index cc9ae7a1..fc0d70db 100644 --- a/scenedetect/_scene_loader.py +++ b/scenedetect/_scene_loader.py @@ -94,7 +94,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int frame_num: Frame number of frame that is being passed. frame_img: Decoded frame image (numpy.ndarray) to perform scene detection on. This is unused for this detector as the video is not analyzed, but is allowed for - compatiblity. + compatibility. Returns: cut_list: List of cuts (as provided by input csv file) diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 56bd5477..01dc15fa 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -274,7 +274,7 @@ def get_ffmpeg_version() -> Optional[str]: ffmpeg_path = get_ffmpeg_path() if ffmpeg_path is None: return None - # If get_ffmpeg_path() returns a value, the path it returns should be invokable. + # If get_ffmpeg_path() returns a value, the path it returns should be invocable. output = subprocess.check_output(args=[ffmpeg_path, '-version'], text=True) output_split = output.split() if len(output_split) >= 3 and output_split[1] == 'version': diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 8189957f..6beddca6 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -115,7 +115,7 @@ def event_buffer_length(self) -> int: class SparseSceneDetector(SceneDetector): - """Base class to inheret from when implementing a sparse scene detection algorithm. + """Base class to inherit from when implementing a sparse scene detection algorithm. This class will be removed in v1.0 and should not be used. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index cceb3664..38e32736 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -141,7 +141,7 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI effective_width: Desired minimum width in pixels. Returns: - int: The defalt downscale factor to use to achieve at least the target effective_width. + int: The default downscale factor to use to achieve at least the target effective_width. """ assert not (frame_width < 1 or effective_width < 1) if frame_width < effective_width: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1ddafa4f..fd3bdc28 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -89,7 +89,7 @@ Includes [MoviePy support](https://github.com/Zulko/moviepy), edge detection cap - Edge differences are typically larger than other components, so you may need to increase `-t`/`--threshold` higher when increasing the edge weight (the last component) with `detect-content, for example: `detect-content -w 1.0 0.5 1.0 0.25 -t 32` - May be enabled by default in the future once it has been more thoroughly tested, further improvements for `detect-content` are being investigated as well (e.g. motion compensation, flash suppression) - - Short-form of `detect-content` option `--frame-window` has been changed from `-w` to `-f` to accomodate this change + - Short-form of `detect-content` option `--frame-window` has been changed from `-w` to `-f` to accommodate this change - [enhancement] Progress bar now displays number of detections while processing, no longer conflicts with log message output - [enhancement] When using ffmpeg to split videos, `-map 0` has been added to the default arguments so other audio tracks are also included when present ([#271](https://github.com/Breakthrough/PySceneDetect/issues/271)) - [enhancement] Add `-a` flag to `version` command to print more information about versions of dependencies/tools being used @@ -299,7 +299,7 @@ Both the Windows installer and portable distributions now include signed executa * [api] Support for live video stream callbacks by adding new `callback` argument to the `detect_scenes()` method of `SceneManager` ([#5](https://github.com/Breakthrough/PySceneDetect/issues/5), thanks @mhashim6) * [bugfix] Fix unhandled exception causing improper error message when a video fails to load on non-Windows platforms ([#192](https://github.com/Breakthrough/PySceneDetect/issues/192)) * [enhancement] Enabled dynamic resizing for progress bar ([#193](https://github.com/Breakthrough/PySceneDetect/issues/193)) - * [enhancement] Always ouptut version number via logger to assist with debugging ([#171](https://github.com/Breakthrough/PySceneDetect/issues/171)) + * [enhancement] Always output version number via logger to assist with debugging ([#171](https://github.com/Breakthrough/PySceneDetect/issues/171)) * [bugfix] Resolve RuntimeWarning when running as module ([#181](https://github.com/Breakthrough/PySceneDetect/issues/181)) * [api] Add `save_images()` function to `scenedetect.scene_manager` module which exposes the same functionality as the CLI `save-images` command ([#88](https://github.com/Breakthrough/PySceneDetect/issues/88)) * [api] Removed `close_captures()` and `release_captures()` functions from `scenedetect.video_manager` module @@ -343,7 +343,7 @@ Both the Windows installer and portable distributions now include signed executa * Resolved long-standing bug where `split-video` command would duplicate certain frames at the beginning/end of the output ([#93](https://github.com/Breakthrough/PySceneDetect/issues/93)) * This was determined to be caused by copying (instead of re-encoding) the audio track, causing extra frames to be brought in when the audio samples did not line up on a frame boundary (thank you @joshcoales for your assistance) - * Default behavior is to now re-encode audio tracks using the `aac` codec when using `split-video` (it can be overriden in both the command line and Python interface) + * Default behavior is to now re-encode audio tracks using the `aac` codec when using `split-video` (it can be overridden in both the command line and Python interface) * Improved timestamp accuracy when using `split-video` command to further reduce instances of duplicated or off-by-one frame issues * Fixed application crash when using the `-l`/`--logfile` argument diff --git a/website/pages/cli.md b/website/pages/cli.md index a518e69b..df0c5eb2 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -79,7 +79,7 @@ PySceneDetect can look for fades in/out using `detect-threshold` (comparing each Each mode has slightly different parameters, and is described in detail below. Most detector parameters can also be [set with a config file](http://scenedetect.com/projects/Manual/en/latest/cli/config_file.html). -In general, use `detect-threshold` mode if you want to detect scene boundaries using fades/cuts in/out to black. If the video uses a lot of fast cuts between content, and has no well-defined scene boundaries, you should use the `detect-adaptive` or `detect-content` modes. Once you know what detection mode to use, you can try the parameters recommended below, or generate a statistics file (using the `-s` / `--stats` flag) in order to determine the correct paramters - specifically, the proper threshold value. +In general, use `detect-threshold` mode if you want to detect scene boundaries using fades/cuts in/out to black. If the video uses a lot of fast cuts between content, and has no well-defined scene boundaries, you should use the `detect-adaptive` or `detect-content` modes. Once you know what detection mode to use, you can try the parameters recommended below, or generate a statistics file (using the `-s` / `--stats` flag) in order to determine the correct parameters - specifically, the proper threshold value. ### Content-Aware Detection @@ -140,7 +140,7 @@ Coming soon: If more are specified via the `-n` flag, they will start from `00` The following arguments are global program options, and need to be applied before any commands (e.g. `detect-content`, `list-scenes`). They can be used to achieve performance gains for some source material with a variable loss of accuracy. -Assuming the input video is of a high enough resolution, a significant performance gain can be achieved by sub-sampling (down-scaling) the input image by a specific integer factor (2x, 3x, 4x, 5x...). This is applied automatically to some degree based on the input video size, but can be overriden manually with the `-d` / `--downscale` option. +Assuming the input video is of a high enough resolution, a significant performance gain can be achieved by sub-sampling (down-scaling) the input image by a specific integer factor (2x, 3x, 4x, 5x...). This is applied automatically to some degree based on the input video size, but can be overridden manually with the `-d` / `--downscale` option. This factor represents how many pixels are "skipped" in both the x- and y- directions, effectively down-scaling the image (using nearest-neighbor sampling) by the factor specified (the new resolution being `W/factor x H/factor` if the old resolution is `W x H`). diff --git a/website/pages/features.md b/website/pages/features.md index 5bbfcebc..09b26963 100644 --- a/website/pages/features.md +++ b/website/pages/features.md @@ -55,7 +55,7 @@ Future version roadmaps are now [tracked as milestones (link)](https://github.co The following features are under consideration for future releases. Any contributions towards completing these features are most welcome (pull requests may be accpeted via Github). - graphical interface (GUI) - - automatic threshold detection for the current scene detection methods (or just ouptut message indicating "Predicted Threshold: X") + - automatic threshold detection for the current scene detection methods (or just output message indicating "Predicted Threshold: X") - suppression of short-length flashes/bursts of light [#35](https://github.com/Breakthrough/PySceneDetect/issues/35) - histogram-based detection algorithm in HSV/HSL color space [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - [perceptual hash](https://en.wikipedia.org/wiki/Perceptual_hashing) based scene detection ([prototype by @wjs018 in PR#290](https://github.com/Breakthrough/PySceneDetect/pull/290)) diff --git a/website/pages/literature.md b/website/pages/literature.md index 7a9f315d..65d49b3f 100644 --- a/website/pages/literature.md +++ b/website/pages/literature.md @@ -19,9 +19,9 @@ PySceneDetect is a useful tool for statistical analysis of video. Below are lin - [Story Understanding in Video Advertisements](https://arxiv.org/pdf/1807.11122) by Keren Ye, Kyle Buettner, Adriana Kovashka (2018) -This list is only provided for academic and research purposes, and is far from an exhaustive source of the uses of PySceneDetect in literature. If you think a particular submission is relevant and should be added to this list, feel free to [raise an issue](https://github.com/Breakthrough/PySceneDetect/issues/new/choose) with your suggestion. Publically available material is preferred, although not a requirement. +This list is only provided for academic and research purposes, and is far from an exhaustive source of the uses of PySceneDetect in literature. If you think a particular submission is relevant and should be added to this list, feel free to [raise an issue](https://github.com/Breakthrough/PySceneDetect/issues/new/choose) with your suggestion. Publicly available material is preferred, although not a requirement. # Scene Detection Methodology -You can find the source code for each scene detector in [the scenedetect/detectors folder](https://github.com/Breakthrough/PySceneDetect/tree/main/scenedetect/detectors). Also see [Issue #62: Reference of paper for the methods used](https://github.com/Breakthrough/PySceneDetect/issues/62) on Github for a futher discussion on detection methodologies. You are more than welcome to propose any new ideas on the [issue tracker](https://github.com/Breakthrough/PySceneDetect/issues), or share a proof of concept using the Python API by creating a pull request. +You can find the source code for each scene detector in [the scenedetect/detectors folder](https://github.com/Breakthrough/PySceneDetect/tree/main/scenedetect/detectors). Also see [Issue #62: Reference of paper for the methods used](https://github.com/Breakthrough/PySceneDetect/issues/62) on Github for a further discussion on detection methodologies. You are more than welcome to propose any new ideas on the [issue tracker](https://github.com/Breakthrough/PySceneDetect/issues), or share a proof of concept using the Python API by creating a pull request. From a8c5b667ff98de84ebbaa11d1f0090a88327d714 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Jan 2024 22:03:59 -0500 Subject: [PATCH 038/407] Build docs on develop. --- .github/workflows/generate-docs.yml | 5 +++++ website/pages/docs.md | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 53a6ab73..78e24737 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -36,6 +36,11 @@ jobs: run: | echo "scenedetect_docs_dest=latest" >> "$GITHUB_ENV" + - name: Set Destination (Develop) + if: ${{ github.ref_name == 'develop' }} + run: | + echo "scenedetect_docs_dest=develop" >> "$GITHUB_ENV" + - name: Set Destination (Releases) if: ${{ github.ref_name != 'main' }} run: | diff --git a/website/pages/docs.md b/website/pages/docs.md index ee4d79ce..5e9e2455 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -1,6 +1,12 @@ # Documentation +## Stable + * [latest](latest/) * [v0.6.2](0.6.2/) * [v0.6.1](0.6.1/) + +## In Development + + * [develop](develop/) \ No newline at end of file From 0a4f452d51f62030093278bbc6f9aa7187db88f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Jan 2024 22:06:38 -0500 Subject: [PATCH 039/407] Bump jinja2 from 3.0.3 to 3.1.3 in /website (#369) Bumps [jinja2](https://github.com/pallets/jinja) from 3.0.3 to 3.1.3. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.0.3...3.1.3) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index 94d022cd..3c69294b 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.0.3 +jinja2==3.1.3 From a7200564bd71e9254a6a8cf87cefe245e5c83da0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Jan 2024 11:07:45 -0500 Subject: [PATCH 040/407] Fix docs build action. --- .github/workflows/generate-docs.yml | 26 +++++++++++--------------- website/pages/docs.md | 2 +- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 78e24737..36e7a378 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -1,6 +1,4 @@ -# Generate PySceneDetect documentation. Inputs are currently , where -# is what commit/branch/tag to use for the build, and is the same as -# scenedetect.com/docs/ +# Generate PySceneDetect documentation and updates the gh-pages branch. name: Generate Documentation on: @@ -16,6 +14,8 @@ on: jobs: update_docs: runs-on: ubuntu-latest + env: + scenedetect_docs_dest: ${{ github.ref_name == 'refs/heads/main' && 'latest' || 'develop' }} steps: - uses: actions/checkout@v3 @@ -30,27 +30,23 @@ jobs: run: | python -m pip install --upgrade pip build wheel virtualenv pip install -r docs/requirements.txt - - - name: Set Destination (Latest) - if: ${{ github.ref_name == 'main' }} - run: | - echo "scenedetect_docs_dest=latest" >> "$GITHUB_ENV" - - - name: Set Destination (Develop) - if: ${{ github.ref_name == 'develop' }} - run: | - echo "scenedetect_docs_dest=develop" >> "$GITHUB_ENV" + echo "scenedetect_docs_dest=unknown" >> "$GITHUB_ENV" - name: Set Destination (Releases) - if: ${{ github.ref_name != 'main' }} + if: ${{ contains(github.ref_name, 'releases') }} run: | echo "scenedetect_docs_dest=$(echo ${{ github.ref_name }} | cut -b 10-)" >> "$GITHUB_ENV" + - name: Check Destination + if: ${{ env.scenedetect_docs_dest == '' }} + run: | + echo "Failing build: destination must be set!" + - name: Generate Docs run: | sphinx-build -b html docs build - - name: Add/Update Docs + - name: Update gh-pages Branch run: | git fetch origin gh-pages git checkout gh-pages diff --git a/website/pages/docs.md b/website/pages/docs.md index 5e9e2455..dd3cbcd1 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -9,4 +9,4 @@ ## In Development - * [develop](develop/) \ No newline at end of file + * [develop](develop/) From 42c590d4e757e29f3e78b6c47bf20e7f3eb858f7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Jan 2024 11:11:57 -0500 Subject: [PATCH 041/407] Fix docs action path. --- .github/workflows/generate-docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 36e7a378..2dcb69f4 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -30,7 +30,6 @@ jobs: run: | python -m pip install --upgrade pip build wheel virtualenv pip install -r docs/requirements.txt - echo "scenedetect_docs_dest=unknown" >> "$GITHUB_ENV" - name: Set Destination (Releases) if: ${{ contains(github.ref_name, 'releases') }} From 44ed7c6aaa1cd92533f2fe2b1646651f08b66e1c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Jan 2024 20:28:00 -0500 Subject: [PATCH 042/407] [docs] Set version from module. --- docs/conf.py | 58 ++++++++++++++++++-------------------------------- docs/index.rst | 6 +++--- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0ce761d9..0cb4f243 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,20 +15,20 @@ import os import sys - sys.path.insert(0, os.path.abspath('..')) +from scenedetect import __version__ as scenedetect_version # -- Project information ----------------------------------------------------- project = 'PySceneDetect' -copyright = '2014-2023, Brandon Castellano' +copyright = '2014-2024, Brandon Castellano' author = 'Brandon Castellano' # The short X.Y version -version = '0.6.2' +version = scenedetect_version # The full version, including alpha/beta/rc tags -release = '0.6.2' +release = scenedetect_version # -- General configuration --------------------------------------------------- @@ -48,7 +48,6 @@ # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] - # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # @@ -73,7 +72,6 @@ # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' - # -- Options for HTML output ------------------------------------------------- # Add any paths that contain custom static files (such as style sheets) here, @@ -92,51 +90,43 @@ # # html_sidebars = {} - # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'PySceneDetectdoc' - # -- Options for LaTeX output ------------------------------------------------ latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (root_doc, 'PySceneDetect.tex', 'PySceneDetect Documentation', - 'Brandon Castellano', 'manual'), + (root_doc, 'PySceneDetect.tex', 'PySceneDetect Documentation', 'Brandon Castellano', 'manual'), ] - # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (root_doc, 'pyscenedetect', 'PySceneDetect Documentation', - [author], 1) -] - +man_pages = [(root_doc, 'pyscenedetect', 'PySceneDetect Documentation', [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -144,28 +134,23 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (root_doc, 'PySceneDetect', 'PySceneDetect Documentation', - author, 'PySceneDetect', 'Python API and `scenedetect` command reference.', - 'Miscellaneous'), + (root_doc, 'PySceneDetect', 'PySceneDetect Documentation', author, 'PySceneDetect', + 'Python API and `scenedetect` command reference.', 'Miscellaneous'), ] - # -- Theme ------------------------------------------------- # TODO: Consider switching to sphinx_material. - html_theme = 'alabaster' html_theme_options = { 'sidebar_width': '235px', - 'description': 'CLI/API Documentation [%s]' % (release), + 'description': 'Version: [%s]' % (release), 'show_relbar_bottom': True, 'show_relbar_top': False, - 'github_user': 'Breakthrough', 'github_repo': 'PySceneDetect', 'github_type': 'star', - 'tip_bg': '#f0f6fa', 'tip_border': '#c2dcf2', 'hint_bg': '#f0faf0', @@ -176,5 +161,4 @@ 'attention_border': '#ffaaaa', 'logo': 'pyscenedetect_logo.png', 'logo_name': False, - #'logo_name': True, } diff --git a/docs/index.rst b/docs/index.rst index da06a5e4..642e1d6f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ .. PySceneDetect documentation index file (contains toctree directive). - Copyright (C) 2014-2023 Brandon Castellano. All rights reserved. + Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. ####################################################################### PySceneDetect Documentation @@ -25,7 +25,7 @@ Table of Contents .. toctree:: :maxdepth: 2 - :caption: Command-Line Interface [CLI]: + :caption: Command-Line Interface: :name: clitoc cli @@ -39,7 +39,7 @@ Table of Contents .. toctree:: :maxdepth: 2 - :caption: Python API Documentation: + :caption: API Documentation: :name: apitoc api From c58c601dcc99c77eb1f690a28d0a15125d1060f1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Jan 2024 22:30:50 -0500 Subject: [PATCH 043/407] [split_video] Fix not respecting global output directory option. --- scenedetect/_cli/context.py | 36 ++++++++++---------- scenedetect/_cli/controller.py | 29 +++++++--------- scenedetect/video_splitter.py | 36 ++++++++++---------- tests/test_cli.py | 53 +++++++++++++++++++---------- tests/test_video_splitter.py | 61 ++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 72 deletions(-) create mode 100644 tests/test_video_splitter.py diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 474c005b..c9307132 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -112,7 +112,7 @@ def __init__(self): self.stats_manager: StatsManager = None # Global `scenedetect` Options - self.output_directory: str = None # -o/--output + self.output_dir: str = None # -o/--output self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet self.stats_file_path: str = None # -s/--stats self.drop_short_scenes: bool = None # --drop-short-scenes @@ -131,7 +131,7 @@ def __init__(self): # `save-images` Command Options self.save_images: bool = False self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_directory: str = None # save-images -o/--output + self.image_dir: str = None # save-images -o/--output self.image_param: int = None # save-images -q/--quality if -j/-w, # otherwise -c/--compression if -p self.image_name_format: str = None # save-images -f/--name-format @@ -146,14 +146,14 @@ def __init__(self): self.split_video: bool = False self.split_mkvmerge: bool = None # split-video -m/--mkvmerge self.split_args: str = None # split-video -a/--args, -c/--copy - self.split_directory: str = None # split-video -o/--output + self.split_dir: str = None # split-video -o/--output self.split_name_format: str = None # split-video -f/--filename self.split_quiet: bool = None # split-video -q/--quiet # `list-scenes` Command Options self.list_scenes: bool = False self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_directory: str = None # list-scenes -o/--output + self.scene_list_dir: str = None # list-scenes -o/--output self.scene_list_name_format: str = None # list-scenes -f/--filename self.scene_list_output: bool = None # list-scenes -n/--no-output self.skip_cuts: bool = None # list-scenes -s/--skip-cuts @@ -256,9 +256,9 @@ def handle_options( framerate=framerate, backend=self.config.get_value("global", "backend", backend, ignore_default=True)) - self.output_directory = output if output else self.config.get_value("global", "output") - if self.output_directory: - logger.info('Output directory set:\n %s', self.output_directory) + self.output_dir = output if output else self.config.get_value("global", "output") + if self.output_dir: + logger.info('Output directory set:\n %s', self.output_dir) self.min_scene_len = parse_timecode( min_scene_len if min_scene_len is not None else self.config.get_value( @@ -271,7 +271,7 @@ def handle_options( # Create StatsManager if --stats is specified. if stats_file: - self.stats_file_path = get_and_create_path(stats_file, self.output_directory) + self.stats_file_path = get_and_create_path(stats_file, self.output_dir) self.stats_manager = StatsManager() # Initialize default detector with values in the config file. @@ -483,14 +483,14 @@ def handle_list_scenes( self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") - self.scene_list_directory = self.config.get_value( + self.scene_list_dir = self.config.get_value( "list-scenes", "output", output, ignore_default=True) self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) if self.scene_list_name_format is not None and not no_output_file: logger.info("Scene list filename format:\n %s", self.scene_list_name_format) self.scene_list_output = not no_output_file - if self.scene_list_directory is not None: - logger.info("Scene list output directory:\n %s", self.scene_list_directory) + if self.scene_list_dir is not None: + logger.info("Scene list output directory:\n %s", self.scene_list_dir) self.list_scenes = True @@ -523,10 +523,9 @@ def handle_split_video( self.split_video = True self.split_quiet = quiet or self.config.get_value('split-video', 'quiet') - self.split_directory = self.config.get_value( - 'split-video', 'output', output, ignore_default=True) - if self.split_directory is not None: - logger.info('Video output path set: \n%s', self.split_directory) + self.split_dir = self.config.get_value('split-video', 'output', output, ignore_default=True) + if self.split_dir is not None: + logger.info('Video output path set: \n%s', self.split_dir) self.split_name_format = self.config.get_value('split-video', 'filename', filename) # We only load the config values for these flags/options if none of the other @@ -656,8 +655,7 @@ def handle_save_images( logger.debug('\n'.join(error_strs)) raise click.BadParameter('\n'.join(error_strs), param_hint='save-images') - self.image_directory = self.config.get_value( - 'save-images', 'output', output, ignore_default=True) + self.image_dir = self.config.get_value('save-images', 'output', output, ignore_default=True) self.image_name_format = self.config.get_value('save-images', 'filename', filename) self.num_images = self.config.get_value('save-images', 'num-images', num_images) @@ -667,8 +665,8 @@ def handle_save_images( image_param_type = 'Compression' if png else 'Quality' image_param_type = ' [%s: %d]' % (image_param_type, self.image_param) logger.info('Image output format set: %s%s', image_type, image_param_type) - if self.image_directory is not None: - logger.info('Image output directory set:\n %s', os.path.abspath(self.image_directory)) + if self.image_dir is not None: + logger.info('Image output directory set:\n %s', os.path.abspath(self.image_dir)) self.save_images = True diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 098c7da3..fd13ebcc 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -145,8 +145,8 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram if not scene_list_filename.lower().endswith('.csv'): scene_list_filename += '.csv' scene_list_path = get_and_create_path( - scene_list_filename, context.scene_list_directory - if context.scene_list_directory is not None else context.output_directory) + scene_list_filename, + context.scene_list_dir if context.scene_list_dir is not None else context.output_dir) logger.info('Writing scene list to CSV file:\n %s', scene_list_path) with open(scene_list_path, 'wt') as scene_list_file: write_scene_list( @@ -180,11 +180,8 @@ def _save_images( """Handles the `save-images` command.""" if not context.save_images: return None - - image_output_dir = context.output_directory - if context.image_directory is not None: - image_output_dir = context.image_directory - + # Command can override global output directory setting. + output_dir = (context.output_dir if context.image_dir is None else context.image_dir) return save_images( scene_list=scene_list, video=context.video_stream, @@ -193,7 +190,7 @@ def _save_images( image_extension=context.image_extension, encoder_param=context.image_param, image_name_template=context.image_name_format, - output_dir=image_output_dir, + output_dir=output_dir, show_progress=not context.quiet_mode, scale=context.scale, height=context.height, @@ -207,14 +204,13 @@ def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram """Handles the `export-html` command.""" if not context.export_html: return - + # Command can override global output directory setting. + output_dir = (context.output_dir if context.image_dir is None else context.image_dir) html_filename = Template( context.html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) if not html_filename.lower().endswith('.html'): html_filename += '.html' - html_path = get_and_create_path( - html_filename, context.image_directory - if context.image_directory is not None else context.output_directory) + html_path = get_and_create_path(html_filename, output_dir) logger.info('Exporting to html file:\n %s:', html_path) if not context.html_include_images: image_filenames = None @@ -232,7 +228,6 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, """Handles the `split-video` command.""" if not context.split_video: return - output_path_template = context.split_name_format # Add proper extension to filename template if required. dot_pos = output_path_template.rfind('.') @@ -243,15 +238,15 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. elif not 2 <= extension_length <= 4: output_path_template += '.mp4' - # Ensure the appropriate tool is available before handling split-video. check_split_video_requirements(context.split_mkvmerge) - + # Command can override global output directory setting. + output_dir = context.output_dir if context.split_dir is None else context.split_dir if context.split_mkvmerge: split_video_mkvmerge( input_video_path=context.video_stream.path, scene_list=scene_list, - output_dir=context.split_directory, + output_dir=output_dir, output_file_template=output_path_template, show_output=not (context.quiet_mode or context.split_quiet), ) @@ -259,7 +254,7 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, split_video_ffmpeg( input_video_path=context.video_stream.path, scene_list=scene_list, - output_dir=context.split_directory, + output_dir=output_dir, output_file_template=output_path_template, arg_override=context.split_args, show_progress=not context.quiet_mode, diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 90cce52f..50f88a5c 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -99,20 +99,9 @@ def is_ffmpeg_available() -> bool: ## -@dataclass -class SceneMetadata: - """Information about the scenes being exported.""" - index: int - """0-based index of this scene.""" - start: FrameTimecode - """First frame.""" - end: FrameTimecode - """Last frame.""" - - @dataclass class VideoMetadata: - """Information about the video.""" + """Information about the video being split.""" name: str """Expected name of the video. May differ from `path`.""" path: Path @@ -121,7 +110,18 @@ class VideoMetadata: """Total number of scenes that will be written.""" -PathFormatter = ty.Callable[[SceneMetadata, VideoMetadata], ty.AnyStr] +@dataclass +class SceneMetadata: + """Information about the scene being extracted.""" + index: int + """0-based index of this scene.""" + start: FrameTimecode + """First frame.""" + end: FrameTimecode + """Last frame.""" + + +PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], ty.AnyStr] def default_formatter(template: str) -> PathFormatter: @@ -130,13 +130,13 @@ def default_formatter(template: str) -> PathFormatter: `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` """ MIN_DIGITS = 3 - format_scene_number: PathFormatter = lambda scene, video: ( + format_scene_number: PathFormatter = lambda video, scene: ( ('%0' + str(max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1)) + 'd') % (scene.index + 1)) - formatter: PathFormatter = lambda scene, video: Template(template).safe_substitute( + formatter: PathFormatter = lambda video, scene: Template(template).safe_substitute( VIDEO_NAME=video.name, - SCENE_NUMBER=format_scene_number(scene, video), + SCENE_NUMBER=format_scene_number(video, scene), START_TIME=str(scene.start.get_timecode().replace(":", ";")), END_TIME=str(scene.end.get_timecode().replace(":", ";")), START_FRAME=str(scene.start.get_frames()), @@ -157,7 +157,7 @@ def split_video_mkvmerge( video_name: ty.Optional[str] = None, show_output: bool = False, suppress_output=None, -): +) -> int: """ Calls the mkvmerge command on the input video, splitting it at the passed timecodes, where each scene is written in sequence from 001. @@ -246,7 +246,7 @@ def split_video_ffmpeg( suppress_output=None, hide_progress=None, formatter: ty.Optional[PathFormatter] = None, -): +) -> int: """ Calls the ffmpeg command on the input video, generating a new video for each scene based on the start/end timecodes. diff --git a/tests/test_cli.py b/tests/test_cli.py index 59016440..4deb755a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,6 +16,7 @@ from typing import Optional import subprocess import pytest +from pathlib import Path import cv2 @@ -40,7 +41,11 @@ # logic by creating a CLI context with the desired parameters. SCENEDETECT_CMD = 'python -m scenedetect' -VIDEO_PATH = 'tests/resources/goldeneye.mp4' +ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] +ALL_BACKENDS = ['opencv', 'pyav'] + +DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' +DEFAULT_VIDEO_NAME = Path(DEFAULT_VIDEO_PATH).stem DEFAULT_BACKEND = 'opencv' DEFAULT_STATSFILE = 'statsfile.csv' DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. @@ -65,7 +70,7 @@ def invoke_scenedetect( Default values are set for any arguments found in the command: VIDEO -> VIDEO_PATH - VIDEO_NAME -> basename of VIDEO_PATH + VIDEO_NAME -> VIDEO_NAME DETECTOR -> DEFAULT_DETECTOR TIME -> DEFAULT_TIME STATS -> DEFAULT_STATSFILE @@ -73,8 +78,8 @@ def invoke_scenedetect( CONFIG_FILE -> DEFAULT_CONFIG_FILE """ value_dict = dict( - VIDEO=VIDEO_PATH, - VIDEO_NAME=os.path.splitext(os.path.basename(VIDEO_PATH))[0], + VIDEO=DEFAULT_VIDEO_PATH, + VIDEO_NAME=DEFAULT_VIDEO_NAME, TIME=DEFAULT_TIME, DETECTOR=DEFAULT_DETECTOR, STATS=DEFAULT_STATSFILE, @@ -113,7 +118,7 @@ def test_cli_frame_numbers(): """ output = subprocess.check_output( SCENEDETECT_CMD.split(' ') + - ['-i', VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], + ['-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], text=True) assert """ ----------------------------------------------------------------------- @@ -167,7 +172,7 @@ def test_cli_time(): assert invoke_scenedetect(base_command, TIME='-s 2s -e 8s -d 6s ') != 0 -def test_cli_list_scenes(tmp_path): +def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" # Regular invocation assert invoke_scenedetect( @@ -189,24 +194,36 @@ def test_cli_list_scenes(tmp_path): @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") -def test_cli_split_video_ffmpeg(tmp_path): +def test_cli_split_video_ffmpeg(tmp_path: Path): """Test `split-video` command using ffmpeg.""" + # Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER. assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video', output_dir=tmp_path) == 0 + entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) + assert (len(entries) == DEFAULT_NUM_SCENES), entries + [entry.unlink() for entry in entries] + assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c', output_dir=tmp_path) == 0 + entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) + assert (len(entries) == DEFAULT_NUM_SCENES) + [entry.unlink() for entry in entries] + assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER', output_dir=tmp_path) == 0 - # -a/--args and -c/--copy are mutually exclusive + entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*")) + assert (len(entries) == DEFAULT_NUM_SCENES), entries + [entry.unlink() for entry in entries] + + # -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero) assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a "-c:v libx264"', + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a \"-c:v libx264\"", output_dir=tmp_path) - # TODO: Check for existence of split video files. @pytest.mark.skipif(condition=not is_mkvmerge_available(), reason="mkvmerge is not available") -def test_cli_split_video_mkvmerge(tmp_path): +def test_cli_split_video_mkvmerge(tmp_path: Path): """Test `split-video` command using mkvmerge.""" assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m', output_dir=tmp_path) == 0 @@ -222,7 +239,7 @@ def test_cli_split_video_mkvmerge(tmp_path): # TODO: Check for existence of split video files. -def test_cli_save_images(tmp_path): +def test_cli_save_images(tmp_path: Path): """Test `save-images` command.""" assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images', output_dir=tmp_path) == 0 @@ -249,7 +266,7 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path): assert image.shape == (1280, 544, 3) -def test_cli_export_html(tmp_path): +def test_cli_export_html(tmp_path: Path): """Test `export-html` command.""" base_command = '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}' assert invoke_scenedetect( @@ -292,7 +309,7 @@ def test_cli_load_scenes_with_time_frames(): output = subprocess.check_output( SCENEDETECT_CMD.split(' ') + [ '-i', - VIDEO_PATH, + DEFAULT_VIDEO_PATH, 'load-scenes', '-i', 'test_scene_list.csv', @@ -329,14 +346,14 @@ def test_cli_load_scenes_round_trip(): f.write(scenes_csv) ground_truth = subprocess.check_output( SCENEDETECT_CMD.split(' ') + [ - '-i', VIDEO_PATH, 'detect-content', 'list-scenes', '-f', 'testout.csv', 'time', '-s', - '200', '-e', '400' + '-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-f', 'testout.csv', 'time', + '-s', '200', '-e', '400' ], text=True) loaded_first_pass = subprocess.check_output( SCENEDETECT_CMD.split(' ') + [ - '-i', VIDEO_PATH, 'load-scenes', '-i', 'testout.csv', 'time', '-s', '200', '-e', '400', - 'list-scenes', '-f', 'testout2.csv' + '-i', DEFAULT_VIDEO_PATH, 'load-scenes', '-i', 'testout.csv', 'time', '-s', '200', '-e', + '400', 'list-scenes', '-f', 'testout2.csv' ], text=True) SPLIT_POINT = ' | Scene # | Start Frame | Start Time | End Frame | End Time |' diff --git a/tests/test_video_splitter.py b/tests/test_video_splitter.py new file mode 100644 index 00000000..2cd77cbb --- /dev/null +++ b/tests/test_video_splitter.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tests for scenedetect.video_splitter module.""" + +# pylint: disable=no-self-use,missing-function-docstring + +from pathlib import Path +import pytest + +from scenedetect import open_video +from scenedetect.video_splitter import (split_video_ffmpeg, is_ffmpeg_available, SceneMetadata, + VideoMetadata) + + +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): + video = open_video(test_movie_clip) + # Extract three hard-coded scenes for testing, each 60 frames. + scenes = [ + (video.base_timecode + 60, video.base_timecode + 120), + (video.base_timecode + 120, video.base_timecode + 180), + (video.base_timecode + 180, video.base_timecode + 240), + ] + assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path) == 0 + # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. + video_name = Path(test_movie_clip).stem + entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) + assert (len(entries) == len(scenes)) + + +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): + video = open_video(test_movie_clip) + # Extract three hard-coded scenes for testing, each 60 frames. + scenes = [ + (video.base_timecode + 60, video.base_timecode + 120), + (video.base_timecode + 120, video.base_timecode + 180), + (video.base_timecode + 180, video.base_timecode + 240), + ] + + # Custom filename formatter: + def name_formatter(video: VideoMetadata, scene: SceneMetadata): + return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" + + assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) == 0 + video_name = Path(test_movie_clip).stem + entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) + assert (len(entries) == len(scenes)) + + +# TODO: Add tests for `split_video_mkvmerge`. From 45b9bb44ae36fd20fe7950dfec59eb7619340769 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 26 Jan 2024 22:22:58 -0500 Subject: [PATCH 044/407] [stats_manager] Simplify metric key registration Fixes #334 --- scenedetect/scene_manager.py | 9 +--- scenedetect/stats_manager.py | 83 +++++++++++++--------------------- tests/test_backwards_compat.py | 4 +- tests/test_stats_manager.py | 45 +++++------------- website/pages/changelog.md | 7 ++- 5 files changed, 52 insertions(+), 96 deletions(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 38e32736..0b94a354 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -647,14 +647,7 @@ def add_detector(self, detector: SceneDetector) -> None: detector.stats_manager = self._stats_manager if self._stats_manager is not None: - try: - self._stats_manager.register_metrics(detector.get_metrics()) - except FrameMetricRegistered: - # Allow multiple detection algorithms of the same type to be added - # by suppressing any FrameMetricRegistered exceptions due to attempts - # to re-register the same frame metric keys. - # TODO(#334): Fix this, this should not be part of regular control flow. - pass + self._stats_manager.register_metrics(detector.get_metrics()) if not issubclass(type(detector), SparseSceneDetector): self._detector_list.append(detector) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 4cf4453c..8a7a45ec 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -24,6 +24,8 @@ import csv from logging import getLogger +import typing as ty +# TODO: Replace below imports with `ty.` prefix. from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union import os.path @@ -47,25 +49,13 @@ class FrameMetricRegistered(Exception): - """ Raised when attempting to register a frame metric key which has - already been registered. """ - - def __init__(self, - metric_key: str, - message: str = "Attempted to re-register frame metric key."): - super().__init__(message) - self.metric_key = metric_key + """[DEPRECATED - DO NOT USE] No longer used.""" + pass class FrameMetricNotRegistered(Exception): - """ Raised when attempting to call get_metrics(...)/set_metrics(...) with a - frame metric that does not exist, or has not been registered. """ - - def __init__(self, - metric_key: str, - message: str = "Attempted to get/set frame metrics for unregistered metric key."): - super().__init__(message) - self.metric_key = metric_key + """[DEPRECATED - DO NOT USE] No longer used.""" + pass class StatsFileCorrupt(Exception): @@ -107,30 +97,21 @@ def __init__(self, base_timecode: FrameTimecode = None): # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) # of each frame metric key and the value it represents (usually float). self._frame_metrics: Dict[FrameTimecode, Dict[str, float]] = dict() - self._registered_metrics: Set[str] = set() # Set of frame metric keys. - self._loaded_metrics: Set[str] = set() # Metric keys loaded from stats file. + self._metric_keys: Set[str] = set() self._metrics_updated: bool = False # Flag indicating if metrics require saving. self._base_timecode: Optional[FrameTimecode] = base_timecode # Used for timing calculations. - def register_metrics(self, metric_keys: Iterable[str]) -> None: - """Register a list of metric keys that will be used by the detector. - - Used to ensure that multiple detector keys don't overlap. + @property + def metric_keys(self) -> ty.Iterable[str]: + return self._metric_keys - Raises: - FrameMetricRegistered: A particular metric_key has already been registered/added - to the StatsManager. Only if the StatsManager is being used for read-only - access (i.e. all frames in the video have already been processed for the given - metric_key in the exception) is this behavior desirable. - """ - for metric_key in metric_keys: - if metric_key not in self._registered_metrics: - self._registered_metrics.add(metric_key) - else: - raise FrameMetricRegistered(metric_key) + def register_metrics(self, metric_keys: Iterable[str]) -> None: + """Register a list of metric keys that will be used by the detector.""" + self._metric_keys = self._metric_keys.union(set(metric_keys)) # TODO(v1.0): Change frame_number to a FrameTimecode now that it is just a hash and will - # be required for VFR support. + # be required for VFR support. This API is also really difficult to use, this type should just + # function like a dictionary. def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any]: """Return the requested statistics/metrics for a given frame. @@ -189,16 +170,12 @@ def save_to_csv(self, """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error('base_timecode is deprecated.') + logger.error('base_timecode is deprecated and has no effect.') - # Ensure we need to write to the file, and that we have data to do so with. - if not ((self.is_save_required() or force_save) and self._registered_metrics - and self._frame_metrics): - logger.info("No metrics to save.") + if not (force_save or self.is_save_required()): + logger.info("No metrics to write.") return - assert self._base_timecode is not None - # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. if isinstance(csv_file, (str, bytes)): @@ -207,7 +184,7 @@ def save_to_csv(self, return csv_writer = csv.writer(csv_file, lineterminator='\n') - metric_keys = sorted(list(self._registered_metrics.union(self._loaded_metrics))) + metric_keys = sorted(list(self._metric_keys)) csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) @@ -234,7 +211,8 @@ def valid_header(row: List[str]) -> bool: return False return True - # TODO(v1.0): Remove. + # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv + # did, but is better integrated with detectors for cached calculations instead of statistics. def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: """[DEPRECATED] DO NOT USE @@ -285,29 +263,32 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: num_metrics = num_cols - 2 if not num_metrics > 0: raise StatsFileCorrupt('No metrics defined in CSV file.') - self._loaded_metrics = row[2:] + loaded_metrics = list(row[2:]) num_frames = 0 for row in csv_reader: metric_dict = {} if not len(row) == num_cols: raise StatsFileCorrupt('Wrong number of columns detected in stats file row.') - for i, metric_str in enumerate(row[2:]): - if metric_str and metric_str != 'None': - try: - metric_dict[self._loaded_metrics[i]] = float(metric_str) - except ValueError: - raise StatsFileCorrupt('Corrupted value in stats file: %s' % - metric_str) from ValueError frame_number = int(row[0]) # Switch from 1-based to 0-based frame numbers. if frame_number > 0: frame_number -= 1 self.set_metrics(frame_number, metric_dict) + for i, metric in enumerate(row[2:]): + if metric and metric != 'None': + try: + self._set_metric(frame_number, loaded_metrics[i], float(metric)) + except ValueError: + raise StatsFileCorrupt('Corrupted value in stats file: %s' % + metric) from ValueError num_frames += 1 + self._metric_keys = self._metric_keys.union(set(loaded_metrics)) logger.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) self._metrics_updated = False return num_frames + # TODO: Get rid of these functions and simplify the implementation of this class. + def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]: if self._metric_exists(frame_number, metric_key): return self._frame_metrics[frame_number][metric_key] diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py index b7049ad8..d57db56b 100644 --- a/tests/test_backwards_compat.py +++ b/tests/test_backwards_compat.py @@ -88,10 +88,8 @@ def test_backwards_compatibility_with_stats(test_video_file: str): """Runs equivalent code to `tests/api_test.py` from v0.5 twice to also exercise loading a statsfile from disk.""" stats_file_path = test_video_file + '.csv' - try: + if os.path.exists(stats_file_path): os.remove(stats_file_path) - except FileNotFoundError: - pass scenes = validate_backwards_compatibility(test_video_file, stats_file_path) assert scenes assert os.path.exists(stats_file_path) diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 2ddd8b65..f8e1a0cb 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -41,14 +41,11 @@ from scenedetect.detectors import ContentDetector from scenedetect.stats_manager import StatsManager -from scenedetect.stats_manager import FrameMetricRegistered from scenedetect.stats_manager import StatsFileCorrupt from scenedetect.stats_manager import COLUMN_NAME_FRAME_NUMBER from scenedetect.stats_manager import COLUMN_NAME_TIMECODE -# TODO(v1.0): Need to add test case which raises scenedetect.stats_manager.FrameMetricNotRegistered. - # TODO(v1.0): use https://docs.pytest.org/en/6.2.x/tmpdir.html TEST_STATS_FILES = ['TEST_STATS_FILE'] * 4 TEST_STATS_FILES = [ @@ -77,8 +74,6 @@ def test_metrics(): stats.register_metrics(metric_keys) assert not stats.is_save_required() - with pytest.raises(FrameMetricRegistered): - stats.register_metrics(metric_keys) assert not stats.metrics_exist(frame_key, metric_keys) assert stats.get_metrics(frame_key, metric_keys) == [None] * len(metric_keys) @@ -101,28 +96,13 @@ def test_detector_metrics(test_video_file): video = VideoStreamCv2(test_video_file) stats_manager = StatsManager() scene_manager = SceneManager(stats_manager) - - assert not stats_manager._registered_metrics scene_manager.add_detector(ContentDetector()) - # add_detector should trigger register_metrics in the StatsManager. - assert stats_manager._registered_metrics - video_fps = video.frame_rate - duration = FrameTimecode('00:00:20', video_fps) - + duration = FrameTimecode('00:00:05', video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video=video, duration=duration) - # Check that metrics were written to the StatsManager. - assert stats_manager._frame_metrics - frame_key = min(stats_manager._frame_metrics.keys()) - assert stats_manager._frame_metrics[frame_key] - assert stats_manager.metrics_exist(frame_key, list(stats_manager._registered_metrics)) - - # Since we only added 1 detector, the number of metrics from get_metrics - # should equal the number of metric keys in _registered_metrics. - assert len(stats_manager.get_metrics(frame_key, list( - stats_manager._registered_metrics))) == len(stats_manager._registered_metrics) + assert stats_manager.get_metrics(0, ContentDetector.METRIC_KEYS) def test_load_empty_stats(): @@ -178,27 +158,26 @@ def test_save_load_from_video(test_video_file): scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode('00:00:20', video_fps) + duration = FrameTimecode('00:00:05', video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video, duration=duration) stats_manager.save_to_csv(csv_file=TEST_STATS_FILES[0]) + metrics = stats_manager.metric_keys + stats_manager_new = StatsManager() stats_manager_new.load_from_csv(TEST_STATS_FILES[0]) - # Choose the first available frame key and compare all metrics in both. - frame_key = min(stats_manager._frame_metrics.keys()) - metric_keys = list(stats_manager._registered_metrics) - - assert stats_manager.metrics_exist(frame_key, metric_keys) - orig_metrics = stats_manager.get_metrics(frame_key, metric_keys) - new_metrics = stats_manager_new.get_metrics(frame_key, metric_keys) - - for i, metric_val in enumerate(orig_metrics): - assert metric_val == pytest.approx(new_metrics[i]) + # Compare the first 5 frames. Frame 0 won't have any metrics for this detector. + for frame in range(1, 5 + 1): + assert stats_manager.metrics_exist(frame, metrics) + orig_metrics = stats_manager.get_metrics(frame, metrics) + new_metrics = stats_manager_new.get_metrics(frame, metrics) + for i, metric_val in enumerate(orig_metrics): + assert metric_val == pytest.approx(new_metrics[i]) def test_load_corrupt_stats(): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index fd3bdc28..f8a7bbae 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -22,7 +22,12 @@ Releases - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) - [feature] Add `output_dir` argument to split_video_* functions to customize output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) - - [feature] Add `formatter` argument to split_video_ffmpeg to customize filename generation [#359](https://github.com/Breakthrough/PySceneDetect/issues/359) + - [feature] Add `formatter` argument to split_video_ffmpeg to customize filename generation [#359](https://github.com/ + Breakthrough/PySceneDetect/issues/359) + - [improvement] `scenedetect.stats_manager` module improvements: + - The `StatsManager.register_metrics()` method no longer throws any exceptions + - Add `StatsManager.metric_keys` property to query registered metric keys + - Deprecate `FrameMetricRegistered` and `FrameMetricNotRegistered` exceptions (no longer used) ### 0.6.2 (July 23, 2023) From e5bbe5547e423412151a3a13a5cc60b7a340c284 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Jan 2024 17:08:14 -0500 Subject: [PATCH 045/407] [build] Ensure builds run when expected. --- .github/workflows/build-windows.yml | 4 ++++ .github/workflows/build.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 952005e2..00f8b747 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -15,6 +15,10 @@ on: - dist/** - scenedetect/** - tests/** + branches: + - main + - develop + - 'releases/**' tags: - v*-release workflow_dispatch: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0a32586..39005c05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,10 @@ on: - dist/** - scenedetect/** - tests/** + branches: + - main + - develop + - 'releases/**' tags: - v*-release workflow_dispatch: From 78d022c21240366be601e09bcb032b5d13ea91f9 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Jan 2024 17:09:51 -0500 Subject: [PATCH 046/407] Test build. --- scenedetect/video_stream.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 34116be0..df642d1d 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -33,7 +33,6 @@ """ from abc import ABC, abstractmethod -from logging import getLogger from typing import Tuple, Optional, Union import numpy as np From 9883fa5078d0feb45218b83b4af168553a05488c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 28 Jan 2024 18:54:57 -0500 Subject: [PATCH 047/407] [list-scenes] Add ability to customize cut format Fixes #349 --- scenedetect.cfg | 10 ++++---- scenedetect/_cli/config.py | 42 +++++++++++++++++++++++++++------- scenedetect/_cli/context.py | 4 +++- scenedetect/_cli/controller.py | 2 +- website/pages/changelog.md | 4 ++-- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index c60db3a7..585c6cb4 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -209,9 +209,6 @@ [list-scenes] -# Save scene/cut list as a CSV file. -#save = yes - # Folder to output scene list. Overrides [global] output option. #output = /usr/tmp/images @@ -225,12 +222,15 @@ # Display list of cut points generated from scene boundaries (yes/no). #display-cuts = yes +# Format to use for list of cut points (frames, seconds, timecode). +#cut-format = timecode + # Skip writing cut points as the first row in the CSV file (yes/no). # Set for RFC 4180 compliance. #skip-cuts = no -# Format to use for list of cut points (frames, seconds, timecode). -#cut-format = timecode +# Output only to command-line, don't write file (yes/no). +#no-output-file = no # Suppress all display output of list-scenes command. # Overrides `display-scenes` and `display-cuts`. diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 1a4affb5..e28f8892 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -16,6 +16,7 @@ """ from abc import ABC, abstractmethod +from enum import Enum import logging import os import os.path @@ -213,6 +214,25 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal ) from ex +class TimecodeFormat(Enum): + """Format to display timecodes.""" + FRAMES = 0 + """Print timecodes as exact frame number.""" + TIMECODE = 1 + """Print timecodes in format HH:MM:SS.nnn.""" + SECONDS = 2 + """Print timecodes in seconds SSS.sss.""" + + def format(self, timecode: FrameTimecode) -> str: + if self == TimecodeFormat.FRAMES: + return str(timecode.get_frames()) + if self == TimecodeFormat.TIMECODE: + return timecode.get_timecode() + if self == TimecodeFormat.SECONDS: + return '%.3f' % timecode.get_seconds() + assert False + + ConfigValue = Union[bool, int, float, str] ConfigDict = Dict[str, Dict[str, ConfigValue]] @@ -263,11 +283,12 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal 'no-images': False, }, 'list-scenes': { + 'cut-format': 'timecode', 'display-cuts': True, 'display-scenes': True, 'filename': '$VIDEO_NAME-Scenes.csv', 'output': '', - 'no-output-file': False, # TODO(v0.6.3): Rename this to 'save'. + 'no-output-file': False, 'quiet': False, 'skip-cuts': False, }, @@ -313,30 +334,35 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal certain string options are stored in `CHOICE_MAP`.""" CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { + 'backend-pyav': { + 'threading_mode': [str(mode).lower() for mode in VALID_PYAV_THREAD_MODES], + }, 'global': { 'backend': ['opencv', 'pyav', 'moviepy'], 'default-detector': ['detect-adaptive', 'detect-content', 'detect-threshold'], 'downscale-method': [value.name.lower() for value in Interpolation], 'verbosity': ['debug', 'info', 'warning', 'error', 'none'], }, - 'split-video': { - 'preset': [ - 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', - 'veryslow' - ], + 'list-scenes': { + 'cut-format': [value.name.lower() for value in TimecodeFormat], }, 'save-images': { 'format': ['jpeg', 'png', 'webp'], 'scale-method': [value.name.lower() for value in Interpolation], }, - 'backend-pyav': { - 'threading_mode': [str(mode).lower() for mode in VALID_PYAV_THREAD_MODES], + 'split-video': { + 'preset': [ + 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', + 'veryslow' + ], }, } """Mapping of string options which can only be of a particular set of values. We use a list instead of a set to preserve order when generating error contexts. Values are case-insensitive, and must be in lowercase in this map.""" +# TODO: This isn't ideal for enums since this could be derived from the type directly, but it works. + def _validate_structure(config: ConfigParser) -> List[str]: """Validates the layout of the section/option mapping. diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index c9307132..cfe5d5bc 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -32,7 +32,7 @@ from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect._cli.config import ConfigRegistry, ConfigLoadFailure, CHOICE_MAP +from scenedetect._cli.config import ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP logger = logging.getLogger('pyscenedetect') @@ -159,6 +159,7 @@ def __init__(self): self.skip_cuts: bool = None # list-scenes -s/--skip-cuts self.display_cuts: bool = True # [list-scenes] display-cuts self.display_scenes: bool = True # [list-scenes] display-scenes + self.cut_format: TimecodeFormat = None # [list-scenes] cut-format # `export-html` Command Options self.export_html: bool = False @@ -480,6 +481,7 @@ def handle_list_scenes( self.display_cuts = self.config.get_value("list-scenes", "display-cuts") self.display_scenes = self.config.get_value("list-scenes", "display-scenes") self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts") + self.cut_format = TimecodeFormat[self.config.get_value("list-scenes", "cut-format").upper()] self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index fd13ebcc..bb5cfb3c 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -171,7 +171,7 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram if cut_list and context.display_cuts: logger.info("Comma-separated timecode list:\n %s", - ",".join([cut.get_timecode() for cut in cut_list])) + ",".join([context.cut_format.format(cut) for cut in cut_list])) def _save_images( diff --git a/website/pages/changelog.md b/website/pages/changelog.md index f8a7bbae..0e07361c 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -10,10 +10,10 @@ Releases - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) + - [general] Rename `list-scenes` flag `--no-output-file` to `--save` - [general] Several changes to `[list-scenes]` config file options: - Add `display-scenes` and `display-cuts` options to control output - - [TODO] Rename `no-output-file` to `save` - - [TODO] Add `cut-format` to control formatting of cut points [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) + - Add `cut-format` to control formatting of cut points [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) - Valid values: `frames`, `timecode`, `seconds` - [general] Increase progress bar indent to improve visibility and visual alignment From d20b1603337bd92a63d966a17d1147be9b635b2b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 28 Jan 2024 20:05:17 -0500 Subject: [PATCH 048/407] [cli] Cleanup some config defaults and ensure cut format is always set. --- scenedetect/_cli/config.py | 7 +++++-- scenedetect/_cli/context.py | 21 +++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index e28f8892..17920b07 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -238,9 +238,13 @@ def format(self, timecode: FrameTimecode) -> str: _CONFIG_FILE_NAME: AnyStr = 'scenedetect.cfg' _CONFIG_FILE_DIR: AnyStr = user_config_dir("PySceneDetect", False) +_PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format CONFIG_FILE_PATH: AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) +DEFAULT_JPG_QUALITY = 95 +DEFAULT_WEBP_QUALITY = 100 +# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv CONFIG_MAP: ConfigDict = { 'backend-opencv': { 'max-decode-attempts': 5, @@ -257,7 +261,6 @@ def format(self, timecode: FrameTimecode) -> str: 'min-scene-len': TimecodeValue(0), 'threshold': RangeValue(3.0, min_val=0.0, max_val=255.0), 'weights': ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), - # TODO(v0.7): Remove `min-delta-hsv``. 'min-delta-hsv': RangeValue(15.0, min_val=0.0, max_val=255.0), }, 'detect-content': { @@ -312,7 +315,7 @@ def format(self, timecode: FrameTimecode) -> str: 'height': 0, 'num-images': 3, 'output': '', - 'quality': RangeValue(0, min_val=0, max_val=100), # Default depends on format + 'quality': RangeValue(_PLACEHOLDER, min_val=0, max_val=100), 'scale': 1.0, 'scale-method': 'linear', 'width': 0, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index cfe5d5bc..6364a465 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -32,7 +32,8 @@ from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect._cli.config import ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP +from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP, + DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY) logger = logging.getLogger('pyscenedetect') @@ -152,14 +153,14 @@ def __init__(self): # `list-scenes` Command Options self.list_scenes: bool = False - self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_dir: str = None # list-scenes -o/--output - self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output - self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = True # [list-scenes] display-cuts - self.display_scenes: bool = True # [list-scenes] display-scenes - self.cut_format: TimecodeFormat = None # [list-scenes] cut-format + self.list_scenes_quiet: bool = None # list-scenes -q/--quiet + self.scene_list_dir: str = None # list-scenes -o/--output + self.scene_list_name_format: str = None # list-scenes -f/--filename + self.scene_list_output: bool = None # list-scenes -n/--no-output + self.skip_cuts: bool = None # list-scenes -s/--skip-cuts + self.display_cuts: bool = False # [list-scenes] display-cuts + self.display_scenes: bool = True # [list-scenes] display-scenes + self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format # `export-html` Command Options self.export_html: bool = False @@ -637,7 +638,7 @@ def handle_save_images( self.scale_method = Interpolation[self.config.get_value('save-images', 'scale-method').upper()] - default_quality = 100 if webp else 95 + default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY quality = ( default_quality if self.config.is_default('save-images', 'quality') else self.config.get_value('save-images', 'quality')) From e668b67adfbdfe55c39de6f2633bc6b286a4c029 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 28 Jan 2024 21:37:46 -0500 Subject: [PATCH 049/407] [scene_manager] Fix warning when using timecode strings for duration/end_time Fixes #346 --- scenedetect/scene_manager.py | 13 ++++++------- website/pages/changelog.md | 6 +++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 0b94a354..6cc1b702 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -810,28 +810,27 @@ def detect_scenes(self, video = frame_source if video is None: raise TypeError("detect_scenes() missing 1 required positional argument: 'video'") - if frame_skip > 0 and self.stats_manager is not None: raise ValueError('frame_skip must be 0 when using a StatsManager.') if duration is not None and end_time is not None: raise ValueError('duration and end_time cannot be set at the same time!') - if duration is not None and duration < 0: + if duration is not None and isinstance(duration, (int, float)) and duration < 0: raise ValueError('duration must be greater than or equal to 0!') - if end_time is not None and end_time < 0: + if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: raise ValueError('end_time must be greater than or equal to 0!') self._base_timecode = video.base_timecode + # TODO(v1.0): Fix this properly by making SceneManager create and own a StatsManager, # and requiring the framerate to be passed to the StatsManager the constructor. if self._stats_manager is not None: self._stats_manager._base_timecode = self._base_timecode start_frame_num: int = video.frame_number - if duration is not None: - end_time: Union[int, FrameTimecode] = duration + start_frame_num - if end_time is not None: - end_time: FrameTimecode = self._base_timecode + end_time + end_time = self._base_timecode + end_time + elif duration is not None: + end_time = (self._base_timecode + duration) + start_frame_num # Can only calculate total number of frames we expect to process if the duration of # the video is available. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0e07361c..fdf638da 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -6,6 +6,10 @@ Releases ### 0.6.3 (In Development) +#### Release Notes + +This release focuses on bugfixes and quality of life improvements. This has helped identify certain areas of focus for the next major release. Feedback is always welcome for command-line and API improvements. + **Program Changes:** - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) @@ -28,7 +32,7 @@ Releases - The `StatsManager.register_metrics()` method no longer throws any exceptions - Add `StatsManager.metric_keys` property to query registered metric keys - Deprecate `FrameMetricRegistered` and `FrameMetricNotRegistered` exceptions (no longer used) - + - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) ### 0.6.2 (July 23, 2023) From 5f49bcd2dd55b8dfac7875935218fa9d3d509b63 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 28 Jan 2024 21:55:36 -0500 Subject: [PATCH 050/407] [docs] Document empty exception. Fixes #334 --- scenedetect/platform.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 01dc15fa..45600ec6 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -245,12 +245,14 @@ def get_ffmpeg_path() -> Optional[str]: """Get path to ffmpeg if available on the current system. First looks at PATH, then checks if one is available from the `imageio_ffmpeg` package. Returns None if ffmpeg couldn't be found. """ + # Try invoking ffmpeg with the current environment. try: subprocess.call(['ffmpeg', '-v', 'quiet']) return 'ffmpeg' except OSError: - pass - # Failed to invoke ffmpeg from PATH, see if we have a copy from imageio_ffmpeg. + pass # Failed to invoke ffmpeg with current environment, try another possibility. + + # Try invoking ffmpeg using the one from `imageio_ffmpeg` if available. try: # pylint: disable=import-outside-toplevel from imageio_ffmpeg import get_ffmpeg_exe @@ -266,6 +268,7 @@ def get_ffmpeg_path() -> Optional[str]: # get_ffmpeg_exe may throw a RuntimeError if the executable is not available. except RuntimeError: pass + return None @@ -289,6 +292,7 @@ def get_mkvmerge_version() -> Optional[str]: try: output = subprocess.check_output(args=[tool_name, '--version'], text=True) except FileNotFoundError: + # mkvmerge doesn't exist on the system return None output_split = output.split() if len(output_split) >= 1 and output_split[0] == tool_name: From 21ef2b60dd4f5fce547c98713971771b5b86196f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 7 Feb 2024 22:12:20 -0500 Subject: [PATCH 051/407] [tests] Expand `time` command tests Demonstrates the issue outlined in #341 and will be used to verify the fix. --- tests/test_cli.py | 130 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 21 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4deb755a..dec9397e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,9 +11,10 @@ # included LICENSE file, or visit one of the above pages for details. # +from dataclasses import dataclass import glob import os -from typing import Optional +import typing as ty import subprocess import pytest from pathlib import Path @@ -57,8 +58,8 @@ def invoke_scenedetect( args: str = '', - output_dir: Optional[str] = None, - config_file: Optional[str] = DEFAULT_CONFIG_FILE, + output_dir: ty.Optional[str] = None, + config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, **kwargs, ): """Invokes the scenedetect CLI with the specified arguments and returns the exit code. @@ -114,7 +115,8 @@ def test_cli_info_command(info_command): def test_cli_frame_numbers(): """Validate frame numbers and timecodes align as expected for the scene list. - The end timecode must include the presentation time of the end frame itself. + The end timecode must include the presentation time of the end frame itself so it is the full + duration of the video. """ output = subprocess.check_output( SCENEDETECT_CMD.split(' ') + @@ -132,10 +134,111 @@ def test_cli_frame_numbers(): assert "00:01:19.913,00:01:21.999" in output +def test_cli_time_usage(): + """Validate behavior of setting parameters via the `time` command.""" + + # TODO: Add test for timecode formats. + base_command = '-i {VIDEO} time {TIME} {DETECTOR}' + + # Test setting start/end. + assert invoke_scenedetect(base_command, TIME='-s 2s -e 4s') == 0 + # Test setting start/duration. + assert invoke_scenedetect(base_command, TIME='-s 2s -d 2s') == 0 + + # Ensure cannot set end and duration at the same time. + assert invoke_scenedetect(base_command, TIME='-s 2s -d 6s -e 8s') != 0 + assert invoke_scenedetect(base_command, TIME='-s 2s -e 8s -d 6s ') != 0 + + +def test_cli_time_end(): + """Validate processed frames without start time being set.""" + EXPECTED = """[PySceneDetect] Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 1 | 00:00:00.000 | 10 | 00:00:00.417 | +----------------------------------------------------------------------- +""" + TEST_CASES = ["time --end 11"] + + for test_case in TEST_CASES: + output = subprocess.check_output( + SCENEDETECT_CMD.split(' ') + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True) + assert EXPECTED in output + + +def test_cli_time_start(): + """Validate processed frames without start time being set.""" + EXPECTED = """[PySceneDetect] Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 4 | 00:00:00.125 | 10 | 00:00:00.417 | +----------------------------------------------------------------------- +""" + TEST_CASES = ["time --start 4 --duration 8"] + + for test_case in TEST_CASES: + output = subprocess.check_output( + SCENEDETECT_CMD.split(' ') + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True) + assert EXPECTED in output + + +def test_cli_time_scene_boundary(): + """Validate frames that are processed when crossing a scene boundary.""" + # -------------------------------------------------------------------------------------------- + # | Scene | Frame | PTS | PTS + Duration | Annotation + # -------------------------------------------------------------------------------------------- + # | 1 | 86 | 00:00:03.545 | 00:00:03.587 | Start Frame + # | 1 | 87 | 00:00:03.587 | 00:00:03.629 | + # | 1 | 88 | 00:00:03.629 | 00:00:03.670 | + # | 1 | 89 | 00:00:03.670 | 00:00:03.712 | + # | 1 | 90 | 00:00:03.712 | 00:00:03.754 | Scene 1 End + # | 2 | 91 | 00:00:03.754 | 00:00:03.795 | Scene 2 Start + # | 2 | 92 | 00:00:03.795 | 00:00:03.837 | + # | 2 | 93 | 00:00:03.837 | 00:00:03.879 | + # | 2 | 94 | 00:00:03.879 | 00:00:03.921 | + # | 2 | 95 | 00:00:03.921 | 00:00:03.962 | + # | 2 | 96 | 00:00:03.962 | 00:00:04.004 | End Frame + # -------------------------------------------------------------------------------------------- + + EXPECTED = """ +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 86 | 00:00:03.545 | 90 | 00:00:03.754 | + | 2 | 91 | 00:00:03.754 | 96 | 00:00:04.004 | +----------------------------------------------------------------------- +""" + + TEST_CASES = [ + "time --start 86 --end 97", + "time --start 00:00:03.545 --end 00:00:04.004", + "time --start 3.545s --end 4.004s", + "time --start 86 --duration 12", + "time --start 00:00:03.545 --duration 00:00:00.459", + "time --start 3.545s --duration 0.459s", + ] + + for test_case in TEST_CASES: + output = subprocess.check_output( + SCENEDETECT_CMD.split(' ') + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True) + assert EXPECTED in output + + @pytest.mark.parametrize('detector_command', ALL_DETECTORS) -def test_cli_detector(detector_command: str): +def test_cli_detector(detector_command: str): # """Test each detection algorithm.""" - # Ensure all detectors work without a statsfile. + # Ensure all detectors work without a statsfile. assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR}', DETECTOR=detector_command) == 0 @@ -157,21 +260,6 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): # and ensuring that we got some frames. -def test_cli_time(): - """Test `time` command.""" - # TODO: Add test for timecode formats. - base_command = '-i {VIDEO} time {TIME} {DETECTOR}' - - # Test setting start/end. - assert invoke_scenedetect(base_command, TIME='-s 2s -e 4s') == 0 - # Test setting start/duration. - assert invoke_scenedetect(base_command, TIME='-s 2s -d 2s') == 0 - - # Ensure cannot set end and duration at the same time. - assert invoke_scenedetect(base_command, TIME='-s 2s -d 6s -e 8s') != 0 - assert invoke_scenedetect(base_command, TIME='-s 2s -e 8s -d 6s ') != 0 - - def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" # Regular invocation From 4db94c9a3afd4407055f7045297eb8bfae2d0e2f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 17 Feb 2024 17:29:02 -0500 Subject: [PATCH 052/407] [cli] Fix scene list always being printed Regressed in 46e170e02a2eea09808619a8d004852123a651c5 --- scenedetect/_cli/context.py | 4 ++-- scenedetect/_cli/controller.py | 34 ++++++++++++++++++++-------------- website/pages/changelog.md | 4 ++-- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 6364a465..583cfa1c 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -156,9 +156,9 @@ def __init__(self): self.list_scenes_quiet: bool = None # list-scenes -q/--quiet self.scene_list_dir: str = None # list-scenes -o/--output self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output + self.scene_list_output: bool = None # list-scenes -n/--no-output-file self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = False # [list-scenes] display-cuts + self.display_cuts: bool = True # [list-scenes] display-cuts self.display_scenes: bool = True # [list-scenes] display-scenes self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index bb5cfb3c..04a36fd3 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -139,6 +139,9 @@ def _save_stats(context: CliContext) -> None: def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], cut_list: List[FrameTimecode]) -> None: """Handles the `list-scenes` command.""" + if not context.list_scenes: + return + # Write scene list CSV to if required. if context.scene_list_output: scene_list_filename = Template( context.scene_list_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) @@ -154,24 +157,27 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram scene_list=scene_list, include_cut_list=not context.skip_cuts, cut_list=cut_list) - if not context.list_scenes_quiet: - if context.display_scenes: - logger.info( - """Scene List: + # Suppress output if requested. + if context.list_scenes_quiet: + return + # Print scene list. + if context.display_scenes: + logger.info( + """Scene List: ----------------------------------------------------------------------- - | Scene # | Start Frame | Start Time | End Frame | End Time | +| Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s -----------------------------------------------------------------------""", '\n'.join([ - " | %5d | %11d | %s | %11d | %s |" % - (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), - end_time.get_frames(), end_time.get_timecode()) - for i, (start_time, end_time) in enumerate(scene_list) - ])) - - if cut_list and context.display_cuts: - logger.info("Comma-separated timecode list:\n %s", - ",".join([context.cut_format.format(cut) for cut in cut_list])) + " | %5d | %11d | %s | %11d | %s |" % + (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), + end_time.get_frames(), end_time.get_timecode()) + for i, (start_time, end_time) in enumerate(scene_list) + ])) + # Print cut list. + if cut_list and context.display_cuts: + logger.info("Comma-separated timecode list:\n %s", + ",".join([context.cut_format.format(cut) for cut in cut_list])) def _save_images( diff --git a/website/pages/changelog.md b/website/pages/changelog.md index fdf638da..b419156d 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -25,8 +25,8 @@ This release focuses on bugfixes and quality of life improvements. This has help - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) - - [feature] Add `output_dir` argument to split_video_* functions to customize output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) - - [feature] Add `formatter` argument to split_video_ffmpeg to customize filename generation [#359](https://github.com/ + - [feature] Add `output_dir` argument to `split_video_ffmpeg` and `split_video_mkvmerge` functions to set output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) + - [feature] Add `formatter` argument to `split_video_ffmpeg` to allow formatting filenames via callback [#359](https://github.com/ Breakthrough/PySceneDetect/issues/359) - [improvement] `scenedetect.stats_manager` module improvements: - The `StatsManager.register_metrics()` method no longer throws any exceptions From c65b33b4bdd12993359166f92e0b78f72f97c777 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 17 Feb 2024 17:37:02 -0500 Subject: [PATCH 053/407] [cli] Fix incorrect list-scenes indentation --- scenedetect.cfg | 7 ++++--- scenedetect/_cli/controller.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 585c6cb4..73b7e671 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -209,6 +209,10 @@ [list-scenes] +# By default, list-scenes will create a CSV file. Enable this option +# to suppress creating the CSV file. +#no-output-file = no + # Folder to output scene list. Overrides [global] output option. #output = /usr/tmp/images @@ -229,9 +233,6 @@ # Set for RFC 4180 compliance. #skip-cuts = no -# Output only to command-line, don't write file (yes/no). -#no-output-file = no - # Suppress all display output of list-scenes command. # Overrides `display-scenes` and `display-cuts`. #quiet = no diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 04a36fd3..70cbb632 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -165,7 +165,7 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram logger.info( """Scene List: ----------------------------------------------------------------------- -| Scene # | Start Frame | Start Time | End Frame | End Time | + | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s -----------------------------------------------------------------------""", '\n'.join([ From 9e792566bcc3988f6403720c543e7b11c533265e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 17 Feb 2024 22:50:43 -0500 Subject: [PATCH 054/407] [frame_timecode] Relax requirement on `s` suffix on seconds as string Seconds are now inferred if a decimal place is present regardless of the suffix. Add test case to verify current behavior of the `time` command and its effect on scene boundaries. This is currently not the correct behavior when both the --start and --duration settings are used for the `time` command. This issue only applies to the command-line interface and does not affect the API. --- .github/workflows/build-windows.yml | 2 +- .github/workflows/build.yml | 2 +- docs/cli.rst | 4 +- scenedetect/_cli/__init__.py | 8 ++-- scenedetect/_cli/config.py | 5 +-- scenedetect/_cli/context.py | 3 +- scenedetect/frame_timecode.py | 60 ++++++++++++++--------------- tests/conftest.py | 20 ++++++++++ tests/test_cli.py | 44 +++++++++++++-------- tests/test_frame_timecode.py | 10 ++++- website/pages/changelog.md | 2 + website/pages/cli.md | 2 +- 12 files changed, 99 insertions(+), 63 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 00f8b747..ab94cd1a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -60,7 +60,7 @@ jobs: - name: Unit Test run: | 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r - python -m pytest tests/ + python -m pytest -vv - name: Build PySceneDetect run: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39005c05..eb710405 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,7 +68,7 @@ jobs: - name: Unit Tests run: | - python -m pytest tests/ + python -m pytest -vv - name: Smoke Test (Module) run: | diff --git a/docs/cli.rst b/docs/cli.rst index e329e65e..eae954c7 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -66,7 +66,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. TIMECODE can be specified as number of frames (:option:`-m=10 <-m>`), time in seconds followed by "s" (:option:`-m=2.5s <-m>`), or timecode (:option:`-m=00:02:53.633 <-m>`). + Minimum length of any scene. TIMECODE can be specified as number of frames (:option:`-m=10 <-m>`), time in seconds (:option:`-m=2.5 <-m>`), or timecode (:option:`-m=00:02:53.633 <-m>`). Default: ``0.6s`` @@ -617,7 +617,7 @@ Options .. option:: -s TIMECODE, --start TIMECODE - Time in video to start detection. TIMECODE can be specified as number of frames (:option:`--start=100 <--start>` for frame 100), time in seconds followed by "s" (:option:`--start=100s <--start>` for 100 seconds), or timecode (:option:`--start=00:01:40 <--start>` for 1m40s). + Time in video to start detection. TIMECODE can be specified as number of frames (:option:`--start=100 <--start>` for frame 100), time in seconds (:option:`--start=100.0 <--start>` for 100 seconds), or timecode (:option:`--start=00:01:40 <--start>` for 1m40s). .. option:: -d TIMECODE, --duration TIMECODE diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 66c750df..997345ac 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -203,7 +203,7 @@ def _print_command_help(ctx: click.Context, command: click.Command): metavar='TIMECODE', type=click.STRING, default=None, - help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds followed by "s" (-m=2.5s), or timecode (-m=00:02:53.633).%s' + help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s' % USER_CONFIG.get_help_string("global", "min-scene-len"), ) @click.option( @@ -398,7 +398,7 @@ def version_command(ctx: click.Context): metavar='TIMECODE', type=click.STRING, default=None, - help='Time in video to start detection. TIMECODE can be specified as number of frames (--start=100 for frame 100), time in seconds followed by "s" (--start=100s for 100 seconds), or timecode (--start=00:01:40 for 1m40s).', + help='Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).', ) @click.option( '--duration', @@ -425,11 +425,11 @@ def time_command( ): """Set start/end/duration of input video. -Values can be specified as frames (NNNN), seconds (NNNN.NNs), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: +Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: {scenedetect_with_video} time --end 00:01:00 - {scenedetect_with_video} time --duration 60s + {scenedetect_with_video} time --duration 60.0 Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 17920b07..3968d854 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -62,7 +62,7 @@ def from_config(config_value: str, default: 'ValidatedValue') -> 'ValidatedValue class TimecodeValue(ValidatedValue): - """Validator for timecode values in frames (1234), seconds (123.4s), or HH:MM:SS. + """Validator for timecode values in seconds (100.0), frames (100), or HH:MM:SS. Stores value in original representation.""" @@ -87,8 +87,7 @@ def from_config(config_value: str, default: 'TimecodeValue') -> 'TimecodeValue': return TimecodeValue(config_value) except ValueError as ex: raise OptionParseFailure( - 'Timecodes must be in frames (1234), seconds (123.4s), or HH:MM:SS (00:02:03.400).' - ) from ex + 'Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS.') from ex class RangeValue(ValidatedValue): diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 583cfa1c..95004ec9 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -60,8 +60,7 @@ def parse_timecode(value: str, return FrameTimecode(timecode=value, fps=frame_rate) except ValueError as ex: raise click.BadParameter( - 'timecode must be in frames (1234), seconds (123.4s), or HH:MM:SS (00:02:03.400)' - ) from ex + 'timecode must be in seconds (100.0), frames (100), or HH:MM:SS') from ex def contains_sequence_or_url(video_path: str) -> bool: diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 9487fde4..fbf4246f 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -77,9 +77,9 @@ TimecodeValue = Union[int, float, str] """Named type for values representing timecodes. Must be in one of the following forms: - 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'`) + 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 "SSSS.nnnn" (`"45.678"`) + 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) """ @@ -88,10 +88,9 @@ class FrameTimecode: 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'`) + 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 "SSSS.nnnn" (`"45.678"`) + 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) """ def __init__(self, @@ -262,47 +261,44 @@ def _parse_timecode_number(self, timecode: Union[int, float]) -> int: else: raise TypeError('Timecode format/type unrecognized.') - def _parse_timecode_string(self, timecode_string: str) -> int: + def _parse_timecode_string(self, input: 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 + '9000', '300s', and '300.0' 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 + ValueError: Value could not be parsed correctly. """ - 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) + assert not self.framerate is None + input = input.strip() # Exact number of frames N - elif timecode_string.isdigit(): - timecode = int(timecode_string) + if input.isdigit(): + timecode = int(input) 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]) + # Timecode in string format 'HH:MM:SS[.nnn]' + elif input.find(":") >= 0: + values = input.split(":") + hrs, mins = int(values[0]), int(values[1]) + secs = float(values[2]) if '.' in values[2] else int(values[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) + secs += (hrs * 60 * 60) + (mins * 60) return self._seconds_to_frames(secs) + # Try to parse the number as seconds in the format 1234.5 or 1234s + if input.endswith('s'): + input = input[:-1] + if not input.replace('.', '').isdigit(): + raise ValueError('All characters in timecode seconds string must be digits.') + as_float = float(input) + if as_float < 0.0: + raise ValueError('Timecode seconds value must be positive.') + return self._seconds_to_frames(as_float) def __iadd__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode': if isinstance(other, int): diff --git a/tests/conftest.py b/tests/conftest.py index b7a17b8f..e9619277 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,26 @@ def check_exists(path: AnyStr) -> AnyStr: return path +# +# Pytest Hooks +# + + +def pytest_assertrepr_compare(op, left, right): + if isinstance(left, str) and isinstance(right, str) and op == "in": + return [ + "Did not find expected output in test.", + "", + "Expected to find:", + "", + *left.splitlines(), + "", + "Actual output:", + "", + *right.splitlines(), + ] + + # # Test Case Fixtures # diff --git a/tests/test_cli.py b/tests/test_cli.py index dec9397e..8c1825eb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,7 +11,6 @@ # included LICENSE file, or visit one of the above pages for details. # -from dataclasses import dataclass import glob import os import typing as ty @@ -137,17 +136,19 @@ def test_cli_frame_numbers(): def test_cli_time_usage(): """Validate behavior of setting parameters via the `time` command.""" - # TODO: Add test for timecode formats. + # TODO: Add tests for more timecode formats. base_command = '-i {VIDEO} time {TIME} {DETECTOR}' # Test setting start/end. - assert invoke_scenedetect(base_command, TIME='-s 2s -e 4s') == 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 4.0') == 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0s -e 4.0s') == 0 # Test setting start/duration. - assert invoke_scenedetect(base_command, TIME='-s 2s -d 2s') == 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 2.0') == 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0s -d 2.0s') == 0 # Ensure cannot set end and duration at the same time. - assert invoke_scenedetect(base_command, TIME='-s 2s -d 6s -e 8s') != 0 - assert invoke_scenedetect(base_command, TIME='-s 2s -e 8s -d 6s ') != 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 6.0 -e 8.0') != 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 8.0 -d 6.0 ') != 0 def test_cli_time_end(): @@ -159,7 +160,14 @@ def test_cli_time_end(): | 1 | 1 | 00:00:00.000 | 10 | 00:00:00.417 | ----------------------------------------------------------------------- """ - TEST_CASES = ["time --end 11"] + TEST_CASES = [ + "time --end 11", + "time --end 00:00:00.417", + "time --end 0.417", + "time --duration 11", + "time --duration 00:00:00.417", + "time --duration 0.417", + ] for test_case in TEST_CASES: output = subprocess.check_output( @@ -167,7 +175,7 @@ def test_cli_time_end(): ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + test_case.split(), text=True) - assert EXPECTED in output + assert EXPECTED in output, test_case def test_cli_time_start(): @@ -179,15 +187,22 @@ def test_cli_time_start(): | 1 | 4 | 00:00:00.125 | 10 | 00:00:00.417 | ----------------------------------------------------------------------- """ - TEST_CASES = ["time --start 4 --duration 8"] - + # TODO(v0.6.3): Duration is incorrectly applied when used with start time. + TEST_CASES = [ + "time --start 4 --duration 8", + "time --start 4 --duration 0.292", + "time --start 4 --duration 00:00:00.292", + "time --start 4 --end 11", + "time --start 4 --end 00:00:00.417", + "time --start 4 --end 0.417", + ] for test_case in TEST_CASES: output = subprocess.check_output( SCENEDETECT_CMD.split(' ') + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + test_case.split(), text=True) - assert EXPECTED in output + assert EXPECTED in output, test_case def test_cli_time_scene_boundary(): @@ -220,10 +235,10 @@ def test_cli_time_scene_boundary(): TEST_CASES = [ "time --start 86 --end 97", "time --start 00:00:03.545 --end 00:00:04.004", - "time --start 3.545s --end 4.004s", + "time --start 3.545 --end 4.004", "time --start 86 --duration 12", "time --start 00:00:03.545 --duration 00:00:00.459", - "time --start 3.545s --duration 0.459s", + "time --start 3.545 --duration 0.459", ] for test_case in TEST_CASES: @@ -232,7 +247,7 @@ def test_cli_time_scene_boundary(): ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + test_case.split(), text=True) - assert EXPECTED in output + assert EXPECTED in output, test_case @pytest.mark.parametrize('detector_command', ALL_DETECTORS) @@ -409,7 +424,6 @@ def test_cli_load_scenes_with_time_frames(): 'list-scenes', ], text=True) - print(output) assert """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 30f62755..207ea609 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -89,8 +89,6 @@ def test_timecode_string(): FrameTimecode(timecode='-1.0', fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode='-0.1', fps=1.0) - with pytest.raises(ValueError): - FrameTimecode(timecode='1.0', fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode='1.9x', fps=1) with pytest.raises(ValueError): @@ -105,6 +103,14 @@ def test_timecode_string(): assert FrameTimecode(timecode='1', fps=1).frame_num == 1 assert FrameTimecode(timecode='10', fps=1.0).frame_num == 10 + # Seconds format [float->str] ('%f', number as string) + assert FrameTimecode(timecode='0.0', fps=1).frame_num == 0 + assert FrameTimecode(timecode='1.0', fps=1).frame_num == 1 + assert FrameTimecode(timecode='10.0', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.0000000000', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.100', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='1.100', fps=10.0).frame_num == 11 + # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) assert FrameTimecode(timecode='0s', fps=1).frame_num == 0 assert FrameTimecode(timecode='1s', fps=1).frame_num == 1 diff --git a/website/pages/changelog.md b/website/pages/changelog.md index b419156d..f1da2cba 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -20,6 +20,7 @@ This release focuses on bugfixes and quality of life improvements. This has help - Add `cut-format` to control formatting of cut points [#349](https://github.com/Breakthrough/PySceneDetect/issues/349) - Valid values: `frames`, `timecode`, `seconds` - [general] Increase progress bar indent to improve visibility and visual alignment + - [improvement] The `s` suffix for setting timecode values in seconds is no longer required (values without decimal places are still interpreted as frame numbers) **API Changes:** @@ -33,6 +34,7 @@ This release focuses on bugfixes and quality of life improvements. This has help - Add `StatsManager.metric_keys` property to query registered metric keys - Deprecate `FrameMetricRegistered` and `FrameMetricNotRegistered` exceptions (no longer used) - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) + - [improvement] When converting strings representing seconds to `FrameTimecode`, the `s` suffix is now optional, and whitespace is ignored (note that values without decimal places are still interpreted as frame numbers) ### 0.6.2 (July 23, 2023) diff --git a/website/pages/cli.md b/website/pages/cli.md index df0c5eb2..c35f48ac 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -155,7 +155,7 @@ This makes the two harder to distinguish, and can cause additional false scene c ## Seeking, Duration, and Setting Start / Stop Times -Specifying the `time` command allows control over what portion of the video PySceneDetect processes. The `time` command accepts three options: start time (`-s` / `-start`), end time (`-e` / `-end`), and duration (`-d` / `--duration`). Specifying both end time and duration is redundant, and in this case, duration overrides end time. Timecodes can be given in three formats: exact frame number (e.g. `12345`), number of seconds followed by `s` (e.g. `123s`, `123.45s`), or standard format (HH:MM:SS[.nnn], e.g. `12:34:56`, `12:34:56.789`). +Specifying the `time` command allows control over what portion of the video PySceneDetect processes. The `time` command accepts three options: start time (`-s` / `-start`), end time (`-e` / `-end`), and duration (`-d` / `--duration`). Specifying both end time and duration is redundant, and in this case, duration overrides end time. Timecodes can be given in seconds (`100.0`), frames (no decimal place, `100`), or timecode as `HH:MM:SS[.nnn]` (`12:34:56.789`). For example, let's say we have a video shot at 30 FPS, and want to analyze only the segment from the 5 to the 6.5 minute mark in the video (we want to analyze the 90 seconds [2700 frames] between 00:05:00 and 00:06:30). The following commands are all thus equivalent in this regard (assuming we are using the content detector): From 632a415b8ff794ec7e24ea4c3799a2b4934f0182 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 19 Feb 2024 18:53:43 -0500 Subject: [PATCH 055/407] [cli] Correct duration/end times for presentation time when set as frames Fix incorrect progress bar duration. Fixes #341. --- scenedetect/_cli/context.py | 18 ++--- scenedetect/_cli/controller.py | 2 + scenedetect/backends/moviepy.py | 4 ++ scenedetect/scene_manager.py | 12 ++-- scenedetect/video_stream.py | 3 - tests/test_cli.py | 116 ++++++++++++++------------------ website/pages/changelog.md | 2 + 7 files changed, 74 insertions(+), 83 deletions(-) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 95004ec9..5198ba06 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -14,6 +14,7 @@ import logging import os +import typing as ty from typing import Any, AnyStr, Dict, Optional, Tuple, Type import click @@ -40,9 +41,9 @@ USER_CONFIG = ConfigRegistry(throw_exception=False) -def parse_timecode(value: str, +def parse_timecode(value: ty.Optional[str], frame_rate: float, - first_index_is_one: bool = False) -> FrameTimecode: + correct_pts: bool = False) -> FrameTimecode: """Parses a user input string into a FrameTimecode assuming the given framerate. If value is None, None will be returned instead of processing the value. @@ -53,7 +54,7 @@ def parse_timecode(value: str, if value is None: return None try: - if first_index_is_one and value.isdigit(): + if correct_pts and value.isdigit(): value = int(value) if value >= 1: value -= 1 @@ -686,11 +687,12 @@ def handle_time(self, start, duration, end): logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration, end) - self.start_time = parse_timecode( - start, self.video_stream.frame_rate, first_index_is_one=True) - self.end_time = parse_timecode(end, self.video_stream.frame_rate, first_index_is_one=True) - self.duration = parse_timecode( - duration, self.video_stream.frame_rate, first_index_is_one=True) + # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to + # match the default start number used by `ffmpeg` when saving frames as images. As such, + # we must correct start time if set as frames. See the test_cli_time* tests for for details. + self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True) + self.end_time = parse_timecode(end, self.video_stream.frame_rate) + self.duration = parse_timecode(duration, self.video_stream.frame_rate) self.time = True # diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 70cbb632..e4a84bad 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -55,6 +55,7 @@ def run_scenedetect(context: CliContext): try: context.video_stream.seek(target=context.start_time) except SeekError as ex: + # TODO(#380): Use `logger` instead of `logger`. logging.critical('Failed to seek to %s / frame %d: %s', context.start_time.get_timecode(), context.start_time.get_frames(), str(ex)) @@ -68,6 +69,7 @@ def run_scenedetect(context: CliContext): show_progress=not context.quiet_mode) # Handle case where video failure is most likely due to multiple audio tracks (#179). + # TODO(#380): Ensure this does not erroneusly fire. if num_frames <= 0 and context.video_stream.BACKEND_NAME == 'opencv': logger.critical( 'Failed to read any frames from video file. This could be caused by the video' diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 36473bb8..934f8055 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -180,6 +180,10 @@ def seek(self, target: Union[FrameTimecode, float, int]): except IOError as ex: # Leave the object in a valid state. self.reset() + # TODO(#380): Other backends do not currently throw an exception if attempting to seek + # past EOF. We need to ensure consistency for seeking past end of video with respect to + # errors and behaviour, and should probably gracefully stop at the last frame instead + # of throwing an exception. if target >= self.duration: raise SeekError("Target frame is beyond end of video!") from ex raise diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 6cc1b702..969e73c6 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -805,15 +805,16 @@ def detect_scenes(self, was constructed with a StatsManager object. """ # TODO(v0.7): Add DeprecationWarning that `frame_source` will be removed in v0.8. - # TODO(v0.8): Remove default value for `video`` when removing `frame_source`. if frame_source is not None: video = frame_source + # TODO(v0.8): Remove default value for `video` after `frame_source` is removed. if video is None: raise TypeError("detect_scenes() missing 1 required positional argument: 'video'") if frame_skip > 0 and self.stats_manager is not None: raise ValueError('frame_skip must be 0 when using a StatsManager.') if duration is not None and end_time is not None: raise ValueError('duration and end_time cannot be set at the same time!') + # TODO: These checks should be handled by the FrameTimecode constructor. if duration is not None and isinstance(duration, (int, float)) and duration < 0: raise ValueError('duration must be greater than or equal to 0!') if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: @@ -821,23 +822,20 @@ def detect_scenes(self, self._base_timecode = video.base_timecode - # TODO(v1.0): Fix this properly by making SceneManager create and own a StatsManager, - # and requiring the framerate to be passed to the StatsManager the constructor. + # TODO: Figure out a better solution for communicating framerate to StatsManager. if self._stats_manager is not None: self._stats_manager._base_timecode = self._base_timecode - start_frame_num: int = video.frame_number + start_frame_num: int = video.frame_number if end_time is not None: end_time = self._base_timecode + end_time elif duration is not None: end_time = (self._base_timecode + duration) + start_frame_num - # Can only calculate total number of frames we expect to process if the duration of - # the video is available. total_frames = 0 if video.duration is not None: if end_time is not None and end_time < video.duration: - total_frames = (end_time - start_frame_num) + 1 + total_frames = (end_time - start_frame_num) else: total_frames = (video.duration.get_frames() - start_frame_num) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index df642d1d..8fda85a7 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -216,6 +216,3 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: ValueError: `target` is not a valid value (i.e. it is negative). """ raise NotImplementedError - - -# TODO(0.6.3): Add a StreamJoiner class to concatenate multiple videos using a specified backend. diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c1825eb..96c5a36c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -111,48 +111,17 @@ def test_cli_info_command(info_command): assert invoke_scenedetect(info_command) == 0 -def test_cli_frame_numbers(): - """Validate frame numbers and timecodes align as expected for the scene list. - - The end timecode must include the presentation time of the end frame itself so it is the full - duration of the video. - """ - output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ['-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], - text=True) - assert """ ------------------------------------------------------------------------ - | Scene # | Start Frame | Start Time | End Frame | End Time | ------------------------------------------------------------------------ - | 1 | 1872 | 00:01:18.036 | 1916 | 00:01:19.913 | - | 2 | 1917 | 00:01:19.913 | 1966 | 00:01:21.999 | - | 3 | 1967 | 00:01:21.999 | 1980 | 00:01:22.582 | ------------------------------------------------------------------------ -""" in output - assert "00:01:19.913,00:01:21.999" in output - - -def test_cli_time_usage(): +def test_cli_time_validate_options(): """Validate behavior of setting parameters via the `time` command.""" - - # TODO: Add tests for more timecode formats. base_command = '-i {VIDEO} time {TIME} {DETECTOR}' - - # Test setting start/end. - assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 4.0') == 0 - assert invoke_scenedetect(base_command, TIME='-s 2.0s -e 4.0s') == 0 - # Test setting start/duration. - assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 2.0') == 0 - assert invoke_scenedetect(base_command, TIME='-s 2.0s -d 2.0s') == 0 - - # Ensure cannot set end and duration at the same time. + # Ensure cannot set end and duration together. assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 6.0 -e 8.0') != 0 assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 8.0 -d 6.0 ') != 0 def test_cli_time_end(): - """Validate processed frames without start time being set.""" + """Validate processed frames without start time being set. End time is the end frame to stop at, + but with duration, we stop at start + duration - 1.""" EXPECTED = """[PySceneDetect] Scene List: ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | @@ -161,14 +130,13 @@ def test_cli_time_end(): ----------------------------------------------------------------------- """ TEST_CASES = [ - "time --end 11", + "time --end 10", "time --end 00:00:00.417", "time --end 0.417", - "time --duration 11", + "time --duration 10", "time --duration 00:00:00.417", "time --duration 0.417", ] - for test_case in TEST_CASES: output = subprocess.check_output( SCENEDETECT_CMD.split(' ') + @@ -179,7 +147,8 @@ def test_cli_time_end(): def test_cli_time_start(): - """Validate processed frames without start time being set.""" + """Validate processed frames with both start and end/duration set. End time is the end frame to + stop at, but with duration, we stop at start + duration - 1.""" EXPECTED = """[PySceneDetect] Scene List: ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | @@ -187,14 +156,13 @@ def test_cli_time_start(): | 1 | 4 | 00:00:00.125 | 10 | 00:00:00.417 | ----------------------------------------------------------------------- """ - # TODO(v0.6.3): Duration is incorrectly applied when used with start time. TEST_CASES = [ - "time --start 4 --duration 8", - "time --start 4 --duration 0.292", - "time --start 4 --duration 00:00:00.292", - "time --start 4 --end 11", + "time --start 4 --end 10", "time --start 4 --end 00:00:00.417", "time --start 4 --end 0.417", + "time --start 4 --duration 7", + "time --start 4 --duration 0.292", + "time --start 4 --duration 00:00:00.292", ] for test_case in TEST_CASES: output = subprocess.check_output( @@ -206,23 +174,23 @@ def test_cli_time_start(): def test_cli_time_scene_boundary(): - """Validate frames that are processed when crossing a scene boundary.""" - # -------------------------------------------------------------------------------------------- - # | Scene | Frame | PTS | PTS + Duration | Annotation - # -------------------------------------------------------------------------------------------- - # | 1 | 86 | 00:00:03.545 | 00:00:03.587 | Start Frame - # | 1 | 87 | 00:00:03.587 | 00:00:03.629 | - # | 1 | 88 | 00:00:03.629 | 00:00:03.670 | - # | 1 | 89 | 00:00:03.670 | 00:00:03.712 | - # | 1 | 90 | 00:00:03.712 | 00:00:03.754 | Scene 1 End - # | 2 | 91 | 00:00:03.754 | 00:00:03.795 | Scene 2 Start - # | 2 | 92 | 00:00:03.795 | 00:00:03.837 | - # | 2 | 93 | 00:00:03.837 | 00:00:03.879 | - # | 2 | 94 | 00:00:03.879 | 00:00:03.921 | - # | 2 | 95 | 00:00:03.921 | 00:00:03.962 | - # | 2 | 96 | 00:00:03.962 | 00:00:04.004 | End Frame - # -------------------------------------------------------------------------------------------- - + """Validate frames that are processed when crossing a scene boundary. End time is the end frame + to stop at, but with duration, we stop at start + duration - 1.""" + # ------------------------------------------------------------------------------------- + # | Scene | Frame | PTS | PTS + Duration | Annotation | + # ------------------------------------------------------------------------------------- + # | 1 | 86 | 00:00:03.545 | 00:00:03.587 | Start Frame | + # | 1 | 87 | 00:00:03.587 | 00:00:03.629 | | + # | 1 | 88 | 00:00:03.629 | 00:00:03.670 | | + # | 1 | 89 | 00:00:03.670 | 00:00:03.712 | | + # | 1 | 90 | 00:00:03.712 | 00:00:03.754 | Scene 1 End | + # | 2 | 91 | 00:00:03.754 | 00:00:03.795 | Scene 2 Start | + # | 2 | 92 | 00:00:03.795 | 00:00:03.837 | | + # | 2 | 93 | 00:00:03.837 | 00:00:03.879 | | + # | 2 | 94 | 00:00:03.879 | 00:00:03.921 | | + # | 2 | 95 | 00:00:03.921 | 00:00:03.962 | | + # | 2 | 96 | 00:00:03.962 | 00:00:04.004 | End Frame | + # ------------------------------------------------------------------------------------- EXPECTED = """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | @@ -231,16 +199,15 @@ def test_cli_time_scene_boundary(): | 2 | 91 | 00:00:03.754 | 96 | 00:00:04.004 | ----------------------------------------------------------------------- """ - + # End time is the end frame to stop at, but with duration, we stop at start + duration - 1. TEST_CASES = [ - "time --start 86 --end 97", + "time --start 86 --end 96", "time --start 00:00:03.545 --end 00:00:04.004", "time --start 3.545 --end 4.004", - "time --start 86 --duration 12", + "time --start 86 --duration 11", "time --start 00:00:03.545 --duration 00:00:00.459", "time --start 3.545 --duration 0.459", ] - for test_case in TEST_CASES: output = subprocess.check_output( SCENEDETECT_CMD.split(' ') + @@ -250,6 +217,25 @@ def test_cli_time_scene_boundary(): assert EXPECTED in output, test_case +def test_cli_time_end_of_video(): + """Validate frame number/timecode alignment at the end of the video. The end timecode includes + presentation time and therefore should represent the full length of the video.""" + output = subprocess.check_output( + SCENEDETECT_CMD.split(' ') + + ['-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], + text=True) + assert """ +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- + | 1 | 1872 | 00:01:18.036 | 1916 | 00:01:19.913 | + | 2 | 1917 | 00:01:19.913 | 1966 | 00:01:21.999 | + | 3 | 1967 | 00:01:21.999 | 1980 | 00:01:22.582 | +----------------------------------------------------------------------- +""" in output + assert "00:01:19.913,00:01:21.999" in output + + @pytest.mark.parametrize('detector_command', ALL_DETECTORS) def test_cli_detector(detector_command: str): # """Test each detection algorithm.""" diff --git a/website/pages/changelog.md b/website/pages/changelog.md index f1da2cba..cd20ede7 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -13,6 +13,8 @@ This release focuses on bugfixes and quality of life improvements. This has help **Program Changes:** - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) + - [bugfix] Correct `--duration` and `--end` for presentation time when specified as frame numbers [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) + - [bugfix] Progress bar now has correct frame accounting when `--duration` or `--end` are set [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) - [general] Rename `list-scenes` flag `--no-output-file` to `--save` - [general] Several changes to `[list-scenes]` config file options: From e7cfb36aab2918658da4930ba79be4c16c59be99 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 3 Mar 2024 21:18:49 -0500 Subject: [PATCH 056/407] [backends] Add duration to `VideoCaptureAdapter` Fixes #335 --- scenedetect/backends/opencv.py | 7 +++++-- website/pages/changelog.md | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 464a5512..26780a1b 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -449,8 +449,11 @@ def frame_size(self) -> Tuple[int, int]: @property def duration(self) -> Optional[FrameTimecode]: - """Always None, as the underlying VideoCapture is assumed to not have a known duration.""" - None + """Duration of the stream as a FrameTimecode, or None if non terminating.""" + frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) + if frame_count > 0: + return self.base_timecode + frame_count + return None @property def aspect_ratio(self) -> float: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index cd20ede7..816ca1f8 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -37,6 +37,7 @@ This release focuses on bugfixes and quality of life improvements. This has help - Deprecate `FrameMetricRegistered` and `FrameMetricNotRegistered` exceptions (no longer used) - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) - [improvement] When converting strings representing seconds to `FrameTimecode`, the `s` suffix is now optional, and whitespace is ignored (note that values without decimal places are still interpreted as frame numbers) + - [improvement] The `VideoCaptureAdapter` in `scenedetect.backends.opencv` now attempts to report duration if known ### 0.6.2 (July 23, 2023) From 8f0f7d5c6eb0f4eba39f69e6b39d7d47d745c5a2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 4 Mar 2024 22:16:04 -0500 Subject: [PATCH 057/407] [frame_timecode] Fix and validate rounding behavior when converting to string #354 --- scenedetect/frame_timecode.py | 39 +++++++++++++++++++++-------------- tests/test_frame_timecode.py | 20 ++++++++++++++++++ website/pages/changelog.md | 1 + 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index fbf4246f..5dbb5593 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -73,6 +73,10 @@ MAX_FPS_DELTA: float = 1.0 / 100000 """Maximum amount two framerates can differ by for equality testing.""" +_SECONDS_PER_MINUTE = 60.0 +_SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE +_MINUTES_PER_HOUR = 60.0 + # TODO(0.6.3): Replace uses of Union[int, float, str] with TimecodeValue. TimecodeValue = Union[int, float, str] """Named type for values representing timecodes. Must be in one of the following forms: @@ -201,22 +205,27 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: """ # 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) + hrs = int(secs / _SECONDS_PER_HOUR) + secs -= (hrs * _SECONDS_PER_HOUR) + mins = int(secs / _SECONDS_PER_MINUTE) + secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) + if use_rounding: + secs = round(secs, precision) + secs = min(_SECONDS_PER_MINUTE, secs) + # Guard against emitting timecodes with 60 seconds after rounding/floating point errors. + if int(secs) == _SECONDS_PER_MINUTE: + secs = 0.0 + mins += 1 + if mins >= _MINUTES_PER_HOUR: + mins = 0 + hrs += 1 + # We have to extend the precision by 1 here, since `format` will round up. + msec = format(secs, '.%df' % (precision + 1)) if precision else '' + # Need to include decimal place in `msec_str`. + msec_str = msec[-(2 + precision):-1] + secs_str = f"{int(secs):02d}{msec_str}" # Return hours, minutes, and seconds as a formatted timecode string. - return '%02d:%02d:%s' % (hrs, mins, secs) + return '%02d:%02d:%s' % (hrs, mins, secs_str) # TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter. def previous_frame(self) -> 'FrameTimecode': diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 207ea609..1e5ecbd2 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -275,3 +275,23 @@ def test_identity(frame_num, fps): assert FrameTimecode(frame_time_code.get_frames(), fps=fps) == frame_time_code assert FrameTimecode(frame_time_code.get_seconds(), fps=fps) == frame_time_code assert FrameTimecode(frame_time_code.get_timecode(), fps=fps) == frame_time_code + + +def test_precision(): + """Test rounding and precision, which has implications for rounding behavior.""" + + fps = 1000.0 + + assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.11" + assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.11" + assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) == "00:00:00.1" + assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.1" + assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) == "00:00:00" + assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + + assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.99" + assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.99" + assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) == "00:00:01.0" + assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" + assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" + assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 816ca1f8..d5f35916 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -38,6 +38,7 @@ This release focuses on bugfixes and quality of life improvements. This has help - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) - [improvement] When converting strings representing seconds to `FrameTimecode`, the `s` suffix is now optional, and whitespace is ignored (note that values without decimal places are still interpreted as frame numbers) - [improvement] The `VideoCaptureAdapter` in `scenedetect.backends.opencv` now attempts to report duration if known + - [bugfix] Ensure correct string conversion behavior for `FrameTimecode` when rounding is enabled [#354](https://github.com/Breakthrough/PySceneDetect/issues/354) ### 0.6.2 (July 23, 2023) From 2299259647805799dbb1c2d719d9114311483dba Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 5 Mar 2024 19:44:12 -0500 Subject: [PATCH 058/407] [cli] Ensure `load-scenes` can only be specified once Disallow combining `load-scenes` with detectors to avoid confusion. #347 --- scenedetect/_cli/context.py | 14 ++++++-------- tests/test_cli.py | 10 ++++++++++ website/pages/changelog.md | 1 + 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 5198ba06..f0ee6b94 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -418,9 +418,8 @@ def get_detect_threshold_params( else: min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - + # TODO(v1.0): add_last_scene cannot be disabled right now. return { - # TODO(v1.0): add_last_scene cannot be disabled right now. 'add_final_scene': add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"), 'fade_bias': @@ -434,6 +433,10 @@ def get_detect_threshold_params( def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): """Handle `load-scenes` command options.""" self._ensure_input_open() + if self.scene_manager._detector_list: + raise click.ClickException( + "The load-scenes command cannot be used with other detectors, and may only be " + "specified once.") start_col_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) self.add_detector( SceneLoader( @@ -735,12 +738,7 @@ def _initialize_logging( def add_detector(self, detector): """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """ self._ensure_input_open() - try: - self.scene_manager.add_detector(detector) - except scenedetect.stats_manager.FrameMetricRegistered as ex: - raise click.BadParameter( - message='Cannot specify detection algorithm twice.', - param_hint=detector.cli_name) from ex + self.scene_manager.add_detector(detector) def _ensure_input_open(self) -> None: """Ensure self.video_stream was initialized (i.e. -i/--input was specified), diff --git a/tests/test_cli.py b/tests/test_cli.py index 96c5a36c..3c8d3de6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -382,7 +382,17 @@ def test_cli_load_scenes(): """Ensure we can load scenes both with and without the cut row.""" assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes') == 0 assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 + # Specifying a detector with load-scenes should be disallowed. + assert invoke_scenedetect( + '-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv') + # Specifying load-scenes several times should be disallowed. + assert invoke_scenedetect( + '-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv' + ) + # If `-s`/`--skip-cuts` is specified, the resulting scene list should still be compatible with + # the `load-scenes` command. assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s') == 0 + assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 def test_cli_load_scenes_with_time_frames(): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index d5f35916..bebf9a7b 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -23,6 +23,7 @@ This release focuses on bugfixes and quality of life improvements. This has help - Valid values: `frames`, `timecode`, `seconds` - [general] Increase progress bar indent to improve visibility and visual alignment - [improvement] The `s` suffix for setting timecode values in seconds is no longer required (values without decimal places are still interpreted as frame numbers) + - [general] The `load-scenes` command may only be specified once, and is now disallowed with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) **API Changes:** From 9ea1ddba1db1a6521c7ba2fdb1db31dcc5ae9c4a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 6 Mar 2024 00:57:27 -0500 Subject: [PATCH 059/407] [cli] Rework `load-scenes` implementation Remove `SceneLoader` and instead use the loaded scenes to bypass detection entirely. This provides a major performance boost while still supporting all required output options. Fixes #347 --- scenedetect/_cli/context.py | 35 ++++--- scenedetect/_cli/controller.py | 163 +++++++++++++++++++++++---------- scenedetect/_scene_loader.py | 107 ---------------------- tests/test_scene_manager.py | 44 +-------- website/pages/changelog.md | 4 +- 5 files changed, 141 insertions(+), 212 deletions(-) delete mode 100644 scenedetect/_scene_loader.py diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index f0ee6b94..ad98677b 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -22,7 +22,6 @@ import scenedetect from scenedetect import open_video, AVAILABLE_BACKENDS -from scenedetect._scene_loader import SceneLoader from scenedetect.scene_detector import SceneDetector from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger @@ -111,6 +110,7 @@ def __init__(self): self.video_stream: VideoStream = None self.scene_manager: SceneManager = None self.stats_manager: StatsManager = None + self.added_detector: bool = False # Global `scenedetect` Options self.output_dir: str = None # -o/--output @@ -169,6 +169,10 @@ def __init__(self): self.image_width: int = None # export-html -w/--image-width self.image_height: int = None # export-html -h/--image-height + # `load-scenes` Command Options + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + # # Command Handlers # @@ -273,7 +277,7 @@ def handle_options( # Create StatsManager if --stats is specified. if stats_file: - self.stats_file_path = get_and_create_path(stats_file, self.output_dir) + self.stats_file_path = stats_file self.stats_manager = StatsManager() # Initialize default detector with values in the config file. @@ -433,14 +437,17 @@ def get_detect_threshold_params( def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): """Handle `load-scenes` command options.""" self._ensure_input_open() - if self.scene_manager._detector_list: - raise click.ClickException( - "The load-scenes command cannot be used with other detectors, and may only be " - "specified once.") - start_col_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) - self.add_detector( - SceneLoader( - file=input, framerate=self.video_stream.frame_rate, start_col_name=start_col_name)) + if self.added_detector: + raise click.ClickException("The load-scenes command cannot be used with detectors.") + if self.load_scenes_input: + raise click.ClickException("The load-scenes command must only be specified once.") + input = os.path.abspath(input) + if not os.path.exists(input): + raise click.BadParameter( + f'Could not load scenes, file does not exist: {input}', param_hint='-i/--input') + self.load_scenes_input = input + self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", + start_col_name) def handle_export_html( self, @@ -681,21 +688,20 @@ def handle_time(self, start, duration, end): self._ensure_input_open() if self.time: self._on_duplicate_command('time') - if duration is not None and end is not None: raise click.BadParameter( 'Only one of --duration/-d or --end/-e can be specified, not both.', param_hint='time') - logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration, end) - # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to # match the default start number used by `ffmpeg` when saving frames as images. As such, # we must correct start time if set as frames. See the test_cli_time* tests for for details. self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True) self.end_time = parse_timecode(end, self.video_stream.frame_rate) self.duration = parse_timecode(duration, self.video_stream.frame_rate) + if self.start_time and self.end_time and (self.start_time + 1) > self.end_time: + raise click.BadParameter("-e/--end time must be greater than -s/--start") self.time = True # @@ -737,8 +743,11 @@ def _initialize_logging( def add_detector(self, detector): """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """ + if self.load_scenes_input: + raise click.ClickException("The load-scenes command cannot be used with detectors.") self._ensure_input_open() self.scene_manager.add_detector(detector) + self.added_detector = True def _ensure_input_open(self) -> None: """Ensure self.video_stream was initialized (i.e. -i/--input was specified), diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index e4a84bad..d8f217e4 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -12,17 +12,18 @@ # """Logic for the PySceneDetect command.""" +import csv import logging import os from string import Template import time +import typing as ty from typing import Dict, List, Tuple, Optional from string import Template -from scenedetect.detectors import AdaptiveDetector from scenedetect.frame_timecode import FrameTimecode -from scenedetect.platform import get_and_create_path, get_file_name -from scenedetect.scene_manager import save_images, write_scene_list, write_scene_list_html +from scenedetect.platform import get_and_create_path +from scenedetect.scene_manager import get_scenes_from_cuts, save_images, write_scene_list, write_scene_list_html from scenedetect.video_splitter import split_video_mkvmerge, split_video_ffmpeg from scenedetect.video_stream import SeekError @@ -43,6 +44,43 @@ def run_scenedetect(context: CliContext): if context.scene_manager is None: logger.debug("No input specified.") return + + if context.load_scenes_input: + # Skip detection if load-scenes was used. + logger.info("Loading scenes from file: %s", context.load_scenes_input) + if context.stats_file_path: + logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") + scene_list, cut_list = _load_scenes(context) + _postprocess_scene_list(context, scene_list) + logger.info("Loaded %d scenes.", len(scene_list)) + else: + # Perform scene detection on input. + scene_list, cut_list = _detect(context) + _postprocess_scene_list(context, scene_list) + # Handle -s/--stats option. + _save_stats(context) + if scene_list: + logger.info( + 'Detected %d scenes, average shot length %.1f seconds.', len(scene_list), + sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) + / float(len(scene_list))) + else: + logger.info('No scenes detected.') + + # Handle list-scenes command. + _list_scenes(context, scene_list, cut_list) + + # Handle save-images command. + image_filenames = _save_images(context, scene_list) + + # Handle export-html command. + _export_html(context, scene_list, cut_list, image_filenames) + + # Handle split-video command. + _split_video(context, scene_list) + + +def _detect(context: CliContext): # Use default detector if one was not specified. if context.scene_manager.get_num_detectors() == 0: detector_type, detector_args = context.default_detector @@ -55,10 +93,9 @@ def run_scenedetect(context: CliContext): try: context.video_stream.seek(target=context.start_time) except SeekError as ex: - # TODO(#380): Use `logger` instead of `logger`. - logging.critical('Failed to seek to %s / frame %d: %s', - context.start_time.get_timecode(), context.start_time.get_frames(), - str(ex)) + logger.critical('Failed to seek to %s / frame %d: %s', + context.start_time.get_timecode(), context.start_time.get_frames(), + str(ex)) return num_frames = context.scene_manager.detect_scenes( @@ -86,56 +123,25 @@ def run_scenedetect(context: CliContext): perf_duration, float(num_frames) / perf_duration) - # Handle -s/--stats option. - _save_stats(context) - # Get list of detected cuts/scenes from the SceneManager to generate the required output # files, based on the given commands (list-scenes, split-video, save-images, etc...). cut_list = context.scene_manager.get_cut_list(show_warning=False) scene_list = context.scene_manager.get_scene_list(start_in_scene=True) - # Handle --merge-last-scene. - if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: - if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: - new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] - - # Handle --drop-short-scenes. - if context.drop_short_scenes and context.min_scene_len > 0: - scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] - - # Ensure we don't divide by zero. - if scene_list: - logger.info( - 'Detected %d scenes, average shot length %.1f seconds.', len(scene_list), - sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) / - float(len(scene_list))) - else: - logger.info('No scenes detected.') - - # Handle list-scenes command. - _list_scenes(context, scene_list, cut_list) - - # Handle save-images command. - image_filenames = _save_images(context, scene_list) - - # Handle export-html command. - _export_html(context, scene_list, cut_list, image_filenames) - - # Handle split-video command. - _split_video(context, scene_list) + return scene_list, cut_list def _save_stats(context: CliContext) -> None: """Handles saving the statsfile if -s/--stats was specified.""" - if context.stats_file_path is not None: - # We check if the save is required in order to reduce unnecessary log messages. - if context.stats_manager.is_save_required(): - logger.info('Saving frame metrics to stats file: %s', - os.path.basename(context.stats_file_path)) - context.stats_manager.save_to_csv(csv_file=context.stats_file_path) - else: - logger.debug('No frame metrics updated, skipping update of the stats file.') + if not context.stats_file_path: + return + if context.stats_manager.is_save_required(): + path = get_and_create_path(context.stats_file_path, context.output_dir) + logger.info('Saving frame metrics to stats file: %s', path) + with open(path, mode="w") as file: + context.stats_manager.save_to_csv(csv_file=file) + else: + logger.debug('No frame metrics updated, skipping update of the stats file.') def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], @@ -270,3 +276,64 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, ) if scene_list: logger.info('Video splitting completed, scenes written to disk.') + + +def _load_scenes( + context: CliContext +) -> ty.Tuple[ty.Iterable[ty.Tuple[FrameTimecode, FrameTimecode]], ty.Iterable[FrameTimecode]]: + assert context.load_scenes_input + assert os.path.exists(context.load_scenes_input) + + with open(context.load_scenes_input, 'r') as input_file: + file_reader = csv.reader(input_file) + csv_headers = next(file_reader) + if not context.load_scenes_column_name in csv_headers: + csv_headers = next(file_reader) + # Check to make sure column headers are present + if context.load_scenes_column_name not in csv_headers: + raise ValueError('specified column header for scene start is not present') + + col_idx = csv_headers.index(context.load_scenes_column_name) + + cut_list = sorted( + FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 + for row in file_reader) + # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame + # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` + # but this part of the API is being reworked and hasn't been used by any detectors yet. + if cut_list: + cut_list = cut_list[1:] + + start_time = context.video_stream.base_timecode + if context.start_time is not None: + start_time = context.start_time + cut_list = [cut for cut in cut_list if cut > context.start_time] + + end_time = context.video_stream.duration + if context.end_time is not None or context.duration is not None: + if context.end_time is not None: + end_time = context.end_time + elif context.duration is not None: + end_time = start_time + context.duration + end_time = min(end_time, context.video_stream.duration) + cut_list = [cut for cut in cut_list if cut < end_time] + + return get_scenes_from_cuts( + cut_list=cut_list, start_pos=start_time, end_pos=end_time), cut_list + + +def _postprocess_scene_list( + context: CliContext, scene_list: ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] +) -> ty.List[ty.Tuple[FrameTimecode, FrameTimecode]]: + + # Handle --merge-last-scene. + if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: + if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: + new_last_scene = (scene_list[-2][0], scene_list[-1][1]) + scene_list = scene_list[:-2] + [new_last_scene] + + # Handle --drop-short-scenes. + if context.drop_short_scenes and context.min_scene_len > 0: + scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] + + return scene_list diff --git a/scenedetect/_scene_loader.py b/scenedetect/_scene_loader.py deleted file mode 100644 index fc0d70db..00000000 --- a/scenedetect/_scene_loader.py +++ /dev/null @@ -1,107 +0,0 @@ -# -*- 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-2023 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -""":class:`SceneLoader` is a class designed for use cases in which a list of -scenes is read from a csv file and actual detection of scene boundaries does not -need to occur. - -This is available from the command-line as the `load-scenes` command. -""" - -import os -import csv - -import typing as ty - -import numpy - -from scenedetect.scene_detector import SceneDetector -from scenedetect.frame_timecode import FrameTimecode - - -class SceneLoader(SceneDetector): - """Detector which load a list of predefined cuts from a CSV file. Used by the CLI to implement - the `load-scenes` functionality. Incompatible with other detectors. - """ - - def __init__(self, file: ty.TextIO, framerate: float, start_col_name: str = "Start Frame"): - """ - Arguments: - file: Path to csv file containing scene data for video - framerate: Framerate used to construct `FrameTimecode` for parsing input. - start_col_name: Header for column containing the frame/timecode where cuts occur. - """ - super().__init__() - - # Check to make specified csv file exists - if not file: - raise ValueError('file path to csv file must be specified') - if not os.path.exists(file): - raise ValueError('specified csv file does not exist') - - self.csv_file = file - - # Open csv and check and read first row for column headers - (self.file_reader, csv_headers) = self._open_csv(self.csv_file, start_col_name) - - # Check to make sure column headers are present - if start_col_name not in csv_headers: - raise ValueError('specified column header for scene start is not present') - - self._col_idx = csv_headers.index(start_col_name) - self._last_scene_row = None - self._scene_start = None - - # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame - # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` - # but this part of the API is being reworked and hasn't been used by any detectors yet. - self._cut_list = sorted( - FrameTimecode(row[self._col_idx], fps=framerate).frame_num - 1 - for row in self.file_reader) - if self._cut_list: - self._cut_list = self._cut_list[1:] - - def _open_csv(self, csv_file, start_col_name): - """Opens the specified csv file for reading. - - Arguments: - csv_file: Path to csv file containing scene data for video - - Returns: - (reader, headers): csv.reader object and headers - """ - input_file = open(csv_file, 'r') - file_reader = csv.reader(input_file) - csv_headers = next(file_reader) - if not start_col_name in csv_headers: - csv_headers = next(file_reader) - return (file_reader, csv_headers) - - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: - """Simply reads cut data from a given csv file. Video is not analyzed. Therefore this - detector is incompatible with other detectors or a StatsManager. - - Arguments: - frame_num: Frame number of frame that is being passed. - frame_img: Decoded frame image (numpy.ndarray) to perform scene detection on. This is - unused for this detector as the video is not analyzed, but is allowed for - compatibility. - - Returns: - cut_list: List of cuts (as provided by input csv file) - """ - if frame_num in self._cut_list: - return [frame_num] - return [] - - def is_processing_required(self, frame_num): - return False diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 9a7faf58..12ead8e0 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -23,11 +23,10 @@ import os.path from typing import List -from scenedetect._scene_loader import SceneLoader from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.detectors import AdaptiveDetector, ContentDetector from scenedetect.frame_timecode import FrameTimecode -from scenedetect.scene_manager import SceneManager, save_images, write_scene_list +from scenedetect.scene_manager import SceneManager, save_images TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] @@ -247,44 +246,3 @@ def test_detect_scenes_callback_adaptive(test_video_file): scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] - - -def test_scene_loader(tmp_path, test_movie_clip): - """Test `SceneLoader` loading from CSV written by `write_scene_list`.""" - - def _detect(video, detector, start, end): - """Helper function similar to `scenedetect.detect()` but using an existing VideoStream.""" - scene_manager = SceneManager() - scene_manager.add_detector(detector) - scene_manager.auto_downscale = True - video.seek(start) - scene_manager.detect_scenes(video=video, end_time=end) - return scene_manager.get_scene_list() - - # Generate scene list. - video = VideoStreamCv2(test_movie_clip) - scene_list = _detect( - video=video, - detector=ContentDetector(), - start=FrameTimecode('00:00:50', video.frame_rate), - end=FrameTimecode('00:01:19', video.frame_rate)) - - # Save and see if we get the same result. - with open(tmp_path / "scenes.csv", "w") as csv_file: - write_scene_list(csv_file, scene_list, include_cut_list=True) - from_csv = _detect( - video=VideoStreamCv2(test_movie_clip), - detector=SceneLoader(tmp_path / "scenes.csv", framerate=video.frame_rate), - start=FrameTimecode('00:00:50', video.frame_rate), - end=FrameTimecode('00:01:19', video.frame_rate)) - assert from_csv == scene_list - - # Test without the cut list as a header as well. - with open(tmp_path / "scenes-nocuts.csv", "w") as csv_file: - write_scene_list(csv_file, scene_list, include_cut_list=False) - from_csv = _detect( - video=VideoStreamCv2(test_movie_clip), - detector=SceneLoader(tmp_path / "scenes-nocuts.csv", framerate=video.frame_rate), - start=FrameTimecode('00:00:50', video.frame_rate), - end=FrameTimecode('00:01:19', video.frame_rate)) - assert from_csv == scene_list diff --git a/website/pages/changelog.md b/website/pages/changelog.md index bebf9a7b..614ac61e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -23,7 +23,9 @@ This release focuses on bugfixes and quality of life improvements. This has help - Valid values: `frames`, `timecode`, `seconds` - [general] Increase progress bar indent to improve visibility and visual alignment - [improvement] The `s` suffix for setting timecode values in seconds is no longer required (values without decimal places are still interpreted as frame numbers) - - [general] The `load-scenes` command may only be specified once, and is now disallowed with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) + - [improvement] `load-scenes` now skips detection, generating output much faster [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) + - [bugfix] Only allow `load-scenes` to be specified once, and disallow with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) + - [bugfix] `-s`/`--start` must now be greater than `-e`/`--end` for the `time` command **API Changes:** From 19faaa70d4ec6bbec5fce94f9f713ce9b057f8d8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 6 Mar 2024 23:55:18 -0500 Subject: [PATCH 060/407] [release] Prepare for v0.6.3 release. --- LICENSE | 2 +- docs/api.rst | 13 +-- docs/generate_cli_docs.py | 2 +- scenedetect/__init__.py | 2 +- scenedetect/__main__.py | 2 +- scenedetect/_cli/__init__.py | 4 +- scenedetect/_cli/config.py | 2 +- scenedetect/_cli/context.py | 2 +- scenedetect/_cli/controller.py | 12 ++- scenedetect/_thirdparty/__init__.py | 2 +- scenedetect/backends/__init__.py | 5 +- scenedetect/backends/moviepy.py | 11 +-- scenedetect/backends/opencv.py | 2 +- scenedetect/backends/pyav.py | 2 +- scenedetect/detectors/__init__.py | 50 ++++++----- scenedetect/detectors/adaptive_detector.py | 22 ++--- scenedetect/detectors/content_detector.py | 38 +++------ scenedetect/detectors/motion_detector.py | 92 --------------------- scenedetect/detectors/threshold_detector.py | 15 ++-- scenedetect/frame_timecode.py | 11 +-- scenedetect/platform.py | 2 +- scenedetect/scene_detector.py | 15 +++- scenedetect/scene_manager.py | 4 +- scenedetect/stats_manager.py | 2 +- scenedetect/video_manager.py | 2 +- scenedetect/video_splitter.py | 2 +- scenedetect/video_stream.py | 2 +- setup.py | 2 +- tests/__init__.py | 2 +- tests/conftest.py | 2 +- tests/test_api.py | 2 +- tests/test_backend_opencv.py | 2 +- tests/test_backend_pyav.py | 2 +- tests/test_backwards_compat.py | 2 +- tests/test_cli.py | 4 +- tests/test_detectors.py | 10 +-- tests/test_frame_timecode.py | 2 +- tests/test_platform.py | 2 +- tests/test_stats_manager.py | 2 +- tests/test_video_stream.py | 2 +- website/mkdocs.yml | 4 +- website/pages/changelog.md | 15 ++-- website/pages/copyright.md | 2 +- 43 files changed, 138 insertions(+), 239 deletions(-) delete mode 100644 scenedetect/detectors/motion_detector.py diff --git a/LICENSE b/LICENSE index fa894ac7..317bd112 100644 --- a/LICENSE +++ b/LICENSE @@ -9,7 +9,7 @@ or any other material included in in distribution. PySceneDetect License (BSD 3-Clause) < http://www.bcastell.com/projects/PySceneDetect > -Copyright (C) 2014-2023, Brandon Castellano. +Copyright (C) 2014-2024, Brandon Castellano. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/docs/api.rst b/docs/api.rst index c2f66bb2..d6a94a33 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -7,11 +7,11 @@ Overview ======================================================================= -The `scenedetect` API is designed to be extensible and easy to integrate with most application workflows. Many use cases are covered by the `Quickstart`_ and `Example`_ sections below. The `scenedetect` package provides: +The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Quickstart`_ and `Example`_ sections below for some common use cases and integrations. The `scenedetect` package contains several modules: - * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` class applies `SceneDetector` objects on video frames from a :ref:`VideoStream `. Also contains the :func:`save_images ` and :func:`write_scene_list ` / :func:`write_scene_list_html ` functions to export information about the detected scenes in various formats. + * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). This module also contains functionality to export information about scenes in various formats: :func:`save_images ` to save images for each scene, :func:`write_scene_list ` to save scene/cut info as CSV, and :func:`write_scene_list_html ` to export scenes in viewable HTML format. - * :ref:`scenedetect.detectors 🕵️ `: Scene/shot detection algorithms: + * :ref:`scenedetect.detectors 🕵️ `: Detection algorithms: * :mod:`ContentDetector `: detects fast changes/cuts in video content. @@ -19,10 +19,11 @@ The `scenedetect` API is designed to be extensible and easy to integrate with mo * :mod:`AdaptiveDetector `: similar to `ContentDetector` but may result in less false negatives during rapid camera movement. - * :ref:`scenedetect.video_stream 🎥 `: Contains :class:`VideoStream ` interface for video decoding using different backends (:mod:`scenedetect.backends`). Current supported backends: + * :ref:`scenedetect.video_stream 🎥 `: Video input is handled through the :class:`VideoStream ` interface. Implementations for common video libraries are provided in :mod:`scenedetect.backends`: * OpenCV: :class:`VideoStreamCv2 ` - * PyAV: In Development + * PyAV: :class:`VideoStreamAv ` + * MoviePy: :class:`VideoStreamMoviePy ` * :ref:`scenedetect.video_splitter ✂️ `: Contains :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` to split a video based on the detected scenes. @@ -31,7 +32,7 @@ The `scenedetect` API is designed to be extensible and easy to integrate with mo class for storing, converting, and performing arithmetic on timecodes with frame-accurate precision. - * :ref:`scenedetect.scene_detector 🌐 `: Contains :class:`SceneDetector ` base class for implementing scene detection algorithms. + * :ref:`scenedetect.scene_detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. * :ref:`scenedetect.stats_manager 🧮 `: Contains :class:`StatsManager ` class for caching frame metrics and loading/saving them to disk in CSV format for analysis. Also used as a persistent cache to make multiple passes on the same video significantly faster. diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index a5e761e7..cd5c6f6f 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -3,7 +3,7 @@ # # Inspired by sphinx-click: https://github.com/click-contrib/sphinx-click # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. """Generates CLI reference documentation file docs/cli.rst. diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 26a89150..5029094c 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 7de7396d..7a8cfb9a 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 997345ac..33ad89e3 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -47,7 +47,7 @@ Docs: http://manual.scenedetect.com/ Code: https://github.com/Breakthrough/PySceneDetect/ -Copyright (C) 2014-2023 Brandon Castellano. All rights reserved. +Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. PySceneDetect is released under the BSD 3-Clause license. See the included LICENSE file or visit the PySceneDetect website for details. diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 3968d854..6588d909 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index ad98677b..6f0e1386 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index d8f217e4..619cf79d 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -51,12 +51,12 @@ def run_scenedetect(context: CliContext): if context.stats_file_path: logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") scene_list, cut_list = _load_scenes(context) - _postprocess_scene_list(context, scene_list) + scene_list = _postprocess_scene_list(context, scene_list) logger.info("Loaded %d scenes.", len(scene_list)) else: # Perform scene detection on input. scene_list, cut_list = _detect(context) - _postprocess_scene_list(context, scene_list) + scene_list = _postprocess_scene_list(context, scene_list) # Handle -s/--stats option. _save_stats(context) if scene_list: @@ -326,7 +326,8 @@ def _postprocess_scene_list( context: CliContext, scene_list: ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] ) -> ty.List[ty.Tuple[FrameTimecode, FrameTimecode]]: - # Handle --merge-last-scene. + # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, + # it will be merged with the previous one. if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: new_last_scene = (scene_list[-2][0], scene_list[-1][1]) @@ -334,6 +335,9 @@ def _postprocess_scene_list( # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: + print([str(s[1] - s[0]) for s in scene_list].__str__()) + print(context.min_scene_len) scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] + print([str(s[1] - s[0]) for s in scene_list].__str__()) return scene_list diff --git a/scenedetect/_thirdparty/__init__.py b/scenedetect/_thirdparty/__init__.py index c56fdb77..83987ca8 100644 --- a/scenedetect/_thirdparty/__init__.py +++ b/scenedetect/_thirdparty/__init__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 70fd6b52..6296bd31 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -99,7 +99,8 @@ except ImportError: VideoStreamMoviePy = None -# TODO(0.6.3): Replace this with a function named `get_available_backends`. +# TODO: Lazy-loading backends would improve startup performance. However, this requires removing +# some of the re-exported types above from the public API. AVAILABLE_BACKENDS: Dict[str, Type] = { backend.BACKEND_NAME: backend for backend in filter(None, [ VideoStreamCv2, diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 934f8055..e0c4a92b 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -49,13 +49,14 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: """ super().__init__() - # TODO(0.6.3) - Investigate how MoviePy handles ffmpeg not being on PATH. - # TODO(0.6.3): Add framerate override. + # TODO: Investigate how MoviePy handles ffmpeg not being on PATH. + # TODO: Add framerate override. if framerate is not None: - raise NotImplementedError("TODO(0.6.3)") + raise NotImplementedError( + "VideoStreamMoviePy does not support the `framerate` argument yet.") self._path = path - # TODO(0.6.3): Need to map errors based on the strings, since several failure + # TODO: Need to map errors based on the strings, since several failure # cases return IOErrors (e.g. could not read duration/video resolution). These # should be mapped to specific errors, e.g. write a function to map MoviePy # exceptions to a new set of equivalents. diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 26780a1b..4ab9a897 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index fadace09..07647818 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index d77e495b..c39a9cbb 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -29,24 +29,17 @@ processing videos, however they can also be used to process frames directly. """ +from scenedetect.detectors.content_detector import ContentDetector +from scenedetect.detectors.threshold_detector import ThresholdDetector +from scenedetect.detectors.adaptive_detector import AdaptiveDetector +from scenedetect.detectors.hash_detector import HashDetector + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # Detection Methods & Algorithms Planned or In Development # # # +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# class EdgeDetector(SceneDetector): -# """Detects fast cuts/slow fades by using edge detection on adjacent frames. -# -# Computes the difference image between subsequent frames after applying a -# Sobel filter (can also use a high-pass or other edge detection filters) and -# comparing the result with a set threshold (may be found using -stats mode). -# Detects both fast cuts and slow fades, although some parameters may need to -# be modified for accurate slow fade detection. -# """ -# def __init__(self): -# super(EdgeDetector, self).__init__() -# # -# # # class DissolveDetector(SceneDetector): # """Detects slow fades (dissolve cuts) via changes in the HSV colour space. # @@ -56,8 +49,9 @@ # # def __init__(self): # super(DissolveDetector, self).__init__() -# # -# # +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# # class HistogramDetector(SceneDetector): # """Detects fast cuts via histogram changes between sequential frames. # @@ -68,15 +62,17 @@ # # def __init__(self): # super(DissolveDetector, self).__init__() -# # -# # +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# +# class MotionDetector(SceneDetector): +# """Detects motion events in scenes containing a static background. +# +# Uses background subtraction followed by noise removal (via morphological +# opening) to generate a frame score compared against the set threshold. +# """ +# +# def __init__(self): +# super(MotionDetector, self).__init__() +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # - -# PySceneDetect Detection Algorithm Imports -from scenedetect.detectors.content_detector import ContentDetector -from scenedetect.detectors.threshold_detector import ThresholdDetector -from scenedetect.detectors.adaptive_detector import AdaptiveDetector -from scenedetect.detectors.hash_detector import HashDetector - -# Algorithms being ported: -#from scenedetect.detectors.motion_detector import MotionDetector diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index bc8dac31..61d1648d 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -115,18 +115,16 @@ def stats_manager_required(self) -> bool: return False def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: - """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead - of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). + """Process the next frame. `frame_num` is assumed to be sequential. - Arguments: - frame_num: Frame number of frame that is being passed. - - frame_img: Decoded frame image (np.ndarray) to perform scene - detection on. Can be None *only* if the self.is_processing_required() method - (inhereted from the base SceneDetector class) returns True. + Args: + frame_num (int): Frame number of frame that is being passed. Can start from any value + but must remain sequential. + frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - Empty list + List[int]: List of frames where scene cuts have been detected. There may be 0 + or more frames in the list, and not necessarily the same as frame_num. """ # TODO(#283): Merge this with ContentDetector and turn it on by default. @@ -172,9 +170,11 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List return cut_list - # TODO(0.6.3): Deprecate & remove this method. def get_content_val(self, frame_num: int) -> Optional[float]: """Returns the average content change for a frame.""" + # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. + logger.error("get_content_val is deprecated and will be removed. Lookup the value" + " using a StatsManager with ContentDetector.FRAME_SCORE_KEY.") if self.stats_manager is not None: return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] return 0.0 diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 857fcee8..1136609c 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -48,9 +48,8 @@ def _estimated_kernel_size(frame_width: int, frame_height: int) -> int: class ContentDetector(SceneDetector): """Detects fast cuts using changes in colour and intensity between frames. - Since the difference between frames is used, unlike the ThresholdDetector, - only fast cuts are detected with this method. To detect slow fades between - content scenes still using HSV information, use the DissolveDetector. + The difference is calculated in the HSV color space, and compared against a set threshold to + determine when a fast cut has occurred. """ # TODO: Come up with some good weights for a new default if there is one that can pass @@ -185,24 +184,17 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl return frame_score def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: - """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead - of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). + """Process the next frame. `frame_num` is assumed to be sequential. - Arguments: - frame_num: Frame number of frame that is being passed. - frame_img: Decoded frame image (numpy.ndarray) to perform scene - detection on. Can be None *only* if the self.is_processing_required() method - (inhereted from the base SceneDetector class) returns True. + Args: + frame_num (int): Frame number of frame that is being passed. Can start from any value + but must remain sequential. + frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List of frames where scene cuts have been detected. There may be 0 + List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - if frame_img is None: - # TODO(0.6.3): Make frame_img a required argument in the interface. Log a warning - # that passing None is deprecated and results will be incorrect if this is the case. - return [] - # Initialize last scene cut point at the beginning of the frames of interest. if self._last_scene_cut is None: self._last_scene_cut = frame_num @@ -220,16 +212,6 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return [] - # TODO(#250): Based on the parameters passed to the ContentDetector constructor, - # ensure that the last scene meets the minimum length requirement, otherwise it - # should be merged with the previous scene. This can be done by caching the cuts - # for the amount of time the minimum length is set to, returning any outstanding - # final cuts in post_process. - - #def post_process(self, frame_num): - # """ - # return [] - def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. @@ -246,7 +228,7 @@ def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) # Estimate levels for thresholding. - # TODO(0.6.3): Add config file entries for sigma, aperture/kernel size, etc. + # TODO: Add config file entries for sigma, aperture/kernel size, etc. sigma: float = 1.0 / 3.0 median = numpy.median(lum) low = int(max(0, (1.0 - sigma) * median)) diff --git a/scenedetect/detectors/motion_detector.py b/scenedetect/detectors/motion_detector.py deleted file mode 100644 index 66367275..00000000 --- a/scenedetect/detectors/motion_detector.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2023 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -""":class:`MotionDetector`, detects motion events using background subtraction, morphological -transforms, and thresholding.""" - -# Third-Party Library Imports -import cv2 - -# PySceneDetect Library Imports -from scenedetect.scene_detector import SparseSceneDetector - - -class MotionDetector(SparseSceneDetector): - """Detects motion events in scenes containing a static background. - - Uses background subtraction followed by noise removal (via morphological - opening) to generate a frame score compared against the set threshold. - - Attributes: - threshold: floating point value compared to each frame's score, which - represents average intensity change per pixel (lower values are - more sensitive to motion changes). Default 0.5, must be > 0.0. - num_frames_post_scene: Number of frames to include in each motion - event after the frame score falls below the threshold, adding any - subsequent motion events to the same scene. - kernel_size: Size of morphological opening kernel for noise removal. - Setting to -1 (default) will auto-compute based on video resolution - (typically 3 for SD, 5-7 for HD). Must be an odd integer > 1. - """ - - def __init__(self, threshold=0.50, num_frames_post_scene=30, kernel_size=-1): - """Initializes motion-based scene detector object.""" - # TODO: Requires porting to v0.5 API. - raise NotImplementedError() - """ - self.threshold = float(threshold) - self.num_frames_post_scene = int(num_frames_post_scene) - - self.kernel_size = int(kernel_size) - if self.kernel_size < 0: - # Set kernel size when process_frame first runs based on - # video resolution (480p = 3x3, 720p = 5x5, 1080p = 7x7). - pass - - self.bg_subtractor = cv2.createBackgroundSubtractorMOG2( - detectShadows = False ) - - self.last_frame_score = 0.0 - - self.in_motion_event = False - self.first_motion_frame_index = -1 - self.last_motion_frame_index = -1 - """ - - def process_frame(self, frame_num, frame_img): - # TODO. - """ - frame_grayscale = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - masked_frame = self.bg_subtractor.apply(frame_grayscale) - - kernel = numpy.ones((self.kernel_size, self.kernel_size), numpy.uint8) - filtered_frame = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel) - - frame_score = numpy.sum(filtered_frame) / float( - filtered_frame.shape[0] * filtered_frame.shape[1] ) - """ - return [] - - def post_process(self, frame_num): - """Writes the last scene if the video ends while in a motion event. - """ - - # If the last fade detected was a fade out, we add a corresponding new - # scene break to indicate the end of the scene. This is only done for - # fade-outs, as a scene cut is already added when a fade-in is found. - """ - if self.in_motion_event: - # Write new scene based on first and last motion event frames. - pass - return self.in_motion_event - """ - return [] diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 0f61fb10..784bd1f9 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -117,13 +117,14 @@ def __init__( def get_metrics(self) -> List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: Optional[numpy.ndarray]) -> List[int]: - """ + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + """Process the next frame. `frame_num` is assumed to be sequential. + Args: - frame_num (int): Frame number of frame that is being passed. - frame_img (numpy.ndarray or None): Decoded frame image (numpy.ndarray) to perform - scene detection with. Can be None *only* if the self.is_processing_required() - method (inhereted from the base SceneDetector class) returns True. + frame_num (int): Frame number of frame that is being passed. Can start from any value + but must remain sequential. + frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. + Returns: List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 5dbb5593..5c009f52 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -77,15 +77,6 @@ _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE _MINUTES_PER_HOUR = 60.0 -# TODO(0.6.3): Replace uses of Union[int, float, str] with TimecodeValue. -TimecodeValue = Union[int, float, str] -"""Named type for values representing timecodes. Must be in one of the following forms: - - 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 "SSSS.nnnn" (`"45.678"`) - 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) -""" - class FrameTimecode: """Object for frame-based timecodes, using the video framerate to compute back and diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 45600ec6..38c86bf3 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 6beddca6..a5d5dc8b 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -86,10 +86,17 @@ def get_metrics(self) -> List[str]: """ return [] - def process_frame(self, frame_num: int, frame_img: Optional[numpy.ndarray]) -> List[int]: - """Process Frame: Computes/stores metrics and detects any scene changes. + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + """Process the next frame. `frame_num` is assumed to be sequential. - Prototype method, no actual detection. + Args: + frame_num (int): Frame number of frame that is being passed. Can start from any value + but must remain sequential. + frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. + + Returns: + List[int]: List of frames where scene cuts have been detected. There may be 0 + or more frames in the list, and not necessarily the same as frame_num. Returns: List of frame numbers of cuts to be added to the cutting list. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 969e73c6..3d3bd435 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -495,7 +495,7 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], IMAGE_NUMBER=image_num_format % (j + 1), FRAME_NUMBER=image_timecode.get_frames()), image_extension) image_filenames[i].append(file_path) - # TODO(0.6.3): Combine this resize with the ones below. + # TODO: Combine this resize with the ones below. if aspect_ratio is not None: frame_im = cv2.resize( frame_im, (0, 0), diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 8a7a45ec..8bb8b9ec 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index 626bfa69..a927bc95 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 50f88a5c..bcb377f2 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 8fda85a7..bfdcbbf0 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/setup.py b/setup.py index 0f8a461d..2d8b2415 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # [ Documentation: http://www.scenedetect.com/docs/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # """ PySceneDetect setup.py - DEPRECATED. diff --git a/tests/__init__.py b/tests/__init__.py index f9e9787d..5a618310 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/conftest.py b/tests/conftest.py index e9619277..f7e8a25a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_api.py b/tests/test_api.py index 613cc58d..1ddb5596 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index ebe54db8..eeae7620 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index c680df0a..bfcc4bfb 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py index d57db56b..2c7b5064 100644 --- a/tests/test_backwards_compat.py +++ b/tests/test_backwards_compat.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_cli.py b/tests/test_cli.py index 3c8d3de6..460e6824 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -40,6 +40,8 @@ # That will also allow splitting up the validation of argument parsing logic from the controller # logic by creating a CLI context with the desired parameters. +# TODO: Missing tests for --min-scene-len and --drop-short-scenes. + SCENEDETECT_CMD = 'python -m scenedetect' ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] ALL_BACKENDS = ['opencv', 'pyav'] diff --git a/tests/test_detectors.py b/tests/test_detectors.py index da4edb4f..aac929ce 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -23,10 +23,10 @@ from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HashDetector from scenedetect.backends.opencv import VideoStreamCv2 -# TODO(v1.0): Parameterize these tests like VideoStreams are. -# Current test output cannot be used for profiling cases which iterate over multiple detectors. - -# TODO(v1.0): Add new test video. +# TODO: Test more parameters and add more videos. Parameterize the tests below such that +# a detector instance is combined with the other parameters like ground truth that go along +# with a specific video and detector values. E.g. Use Video-000, Video-001, etc..., and map +# that to a particular filename. TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] """Ground truth of start frame for each fast cut in `test_movie_clip`.""" diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 1e5ecbd2..aa5c5386 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_platform.py b/tests/test_platform.py index 30e2673d..4f90ff1e 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index f8e1a0cb..9c2f0af6 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 9948b568..7e952881 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -6,7 +6,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/website/mkdocs.yml b/website/mkdocs.yml index d63f7a0b..501fc51e 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -1,5 +1,5 @@ # PySceneDetect Website (https://www.scenedetect.com) -# Copyright (C) 2014-2023 Brandon Castellano . +# Copyright (C) 2014-2024 Brandon Castellano . site_name: PySceneDetect site_description: "Website and documentation for PySceneDetect, a program to automatically detect scene cuts and split videos. Written in Python, and also provides Python API in addition to command-line interface for use within other programs." site_author: "Brandon Castellano" @@ -8,7 +8,7 @@ site_dir: "build" repo_url: https://github.com/Breakthrough/PySceneDetect edit_uri: 'blob/main/website/pages/' repo_name: "PySceneDetect on Github" -copyright: 'Copyright © 2014-2023 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' +copyright: 'Copyright © 2014-2024 Brandon Castellano. All rights reserved.
Licensed under BSD 3-Clause (see the LICENSE file for details).' theme: name: readthedocs logo: img/pyscenedetect_logo_small.png diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 614ac61e..253ba72f 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -8,13 +8,15 @@ Releases #### Release Notes -This release focuses on bugfixes and quality of life improvements. This has helped identify certain areas of focus for the next major release. Feedback is always welcome for command-line and API improvements. +This release of PySceneDetect includes quite a few bugfixes, as well as some performance improvements with the `load-scenes` command. Thanks for everyone who contributed to the release. **Program Changes:** - [bugfix] Fix crash for some WebM videos when using `save-images` with `--backend pyav` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [bugfix] Correct `--duration` and `--end` for presentation time when specified as frame numbers [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) - [bugfix] Progress bar now has correct frame accounting when `--duration` or `--end` are set [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) + - [bugfix] Only allow `load-scenes` to be specified once, and disallow with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) + - [bugfix] Disallow `-s`/`--start` being larger than `-e`/`--end` for the `time` command - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) - [general] Rename `list-scenes` flag `--no-output-file` to `--save` - [general] Several changes to `[list-scenes]` config file options: @@ -24,24 +26,27 @@ This release focuses on bugfixes and quality of life improvements. This has help - [general] Increase progress bar indent to improve visibility and visual alignment - [improvement] The `s` suffix for setting timecode values in seconds is no longer required (values without decimal places are still interpreted as frame numbers) - [improvement] `load-scenes` now skips detection, generating output much faster [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) - - [bugfix] Only allow `load-scenes` to be specified once, and disallow with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) - - [bugfix] `-s`/`--start` must now be greater than `-e`/`--end` for the `time` command **API Changes:** - [bugfix] Fix `AttributeError` thrown when accessing `aspect_ratio` on certain videos using `VideoStreamAv` [#355](https://github.com/Breakthrough/PySceneDetect/issues/355) - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) + - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) + - [bugfix] Ensure correct string conversion behavior for `FrameTimecode` when rounding is enabled [#354](https://github.com/Breakthrough/PySceneDetect/issues/354) - [feature] Add `output_dir` argument to `split_video_ffmpeg` and `split_video_mkvmerge` functions to set output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) - [feature] Add `formatter` argument to `split_video_ffmpeg` to allow formatting filenames via callback [#359](https://github.com/ Breakthrough/PySceneDetect/issues/359) + - [general] The `frame_img` argument to `SceneDetector.process_frame()` is now required + - [general] Remove some unused or unimplemented APIs: + - Remove `TimecodeValue` from `scenedetect.frame_timecode` (use `typing.Union[int, float, str]`) + - Remove `MotionDetector` and `scenedetect.detectors.motion_detector` module (will be reintroduced after `SceneDetector` interface is stable) - [improvement] `scenedetect.stats_manager` module improvements: - The `StatsManager.register_metrics()` method no longer throws any exceptions - Add `StatsManager.metric_keys` property to query registered metric keys - Deprecate `FrameMetricRegistered` and `FrameMetricNotRegistered` exceptions (no longer used) - - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) - [improvement] When converting strings representing seconds to `FrameTimecode`, the `s` suffix is now optional, and whitespace is ignored (note that values without decimal places are still interpreted as frame numbers) - [improvement] The `VideoCaptureAdapter` in `scenedetect.backends.opencv` now attempts to report duration if known - - [bugfix] Ensure correct string conversion behavior for `FrameTimecode` when rounding is enabled [#354](https://github.com/Breakthrough/PySceneDetect/issues/354) + ### 0.6.2 (July 23, 2023) diff --git a/website/pages/copyright.md b/website/pages/copyright.md index e3da8749..b88f7f22 100644 --- a/website/pages/copyright.md +++ b/website/pages/copyright.md @@ -6,7 +6,7 @@ PySceneDetect License (BSD 3-Clause) < http://www.bcastell.com/projects/PySceneDetect > -Copyright (C) 2014-2023, Brandon Castellano. +Copyright (C) 2014-2024, Brandon Castellano. All rights reserved. Redistribution and use in source and binary forms, with or without From f401d91270358d9bed02aecf5ccfb4b5e67cba8e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 7 Mar 2024 22:05:27 -0500 Subject: [PATCH 061/407] [build] Build scenedetect.com/docs/latest from release branch --- .github/workflows/generate-docs.yml | 34 ++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 2dcb69f4..9b410988 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -15,7 +15,8 @@ jobs: update_docs: runs-on: ubuntu-latest env: - scenedetect_docs_dest: ${{ github.ref_name == 'refs/heads/main' && 'latest' || 'develop' }} + scenedetect_docs_latest: '0.6.2' + scenedetect_docs_dest: '' steps: - uses: actions/checkout@v3 @@ -26,21 +27,28 @@ jobs: python-version: '3.11' cache: 'pip' - - name: Install Dependencies - run: | - python -m pip install --upgrade pip build wheel virtualenv - pip install -r docs/requirements.txt - - name: Set Destination (Releases) if: ${{ contains(github.ref_name, 'releases') }} run: | echo "scenedetect_docs_dest=$(echo ${{ github.ref_name }} | cut -b 10-)" >> "$GITHUB_ENV" + - name: Set Destination (Develop) + if: ${{ contains(github.ref_name, 'develop') }} + run: | + echo "scenedetect_docs_dest=develop" >> "$GITHUB_ENV" + - name: Check Destination if: ${{ env.scenedetect_docs_dest == '' }} run: | echo "Failing build: destination must be set!" + - name: Setup Environment + run: | + python -m pip install --upgrade pip build wheel virtualenv + pip install -r docs/requirements.txt + git config --global user.name github-actions + git config --global user.email github-actions@github.com + - name: Generate Docs run: | sphinx-build -b html docs build @@ -52,8 +60,18 @@ jobs: git rm "docs/${{ env.scenedetect_docs_dest }}" -r -f --ignore-unmatch git add build/ git mv build "docs/${{ env.scenedetect_docs_dest }}" - git config --global user.name github-actions - git config --global user.email github-actions@github.com + + - name: Update Latest + if: ${{ env.scenedetect_docs_dest == env.scenedetect_docs_latest }} + run: | + git rm "docs/latest" -r -f --ignore-unmatch + mkdir -p latest + cp -r -f "docs/${{ env.scenedetect_docs_dest }}/*" docs/latest + git add docs/latest + echo "scenedetect_docs_dest='${{ env.scenedetect_docs_dest }} (latest)'" >> "$GITHUB_ENV" + + - name: Commit and Push + run: | git commit -a -m "[docs] @${{ github.triggering_actor }}: Generate Documentation" \ -m "Source: ${{ github.ref_name }} (${{ github.sha }})" \ -m "Destination: ${{ env.scenedetect_docs_dest }}" From 503be674c68b5bd0e2c541b011fa8c889ca64611 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 7 Mar 2024 22:20:42 -0500 Subject: [PATCH 062/407] [build] Fix incorrect expression in docs generation for latest. --- .github/workflows/generate-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 9b410988..7a2d7b70 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -66,7 +66,7 @@ jobs: run: | git rm "docs/latest" -r -f --ignore-unmatch mkdir -p latest - cp -r -f "docs/${{ env.scenedetect_docs_dest }}/*" docs/latest + cp -r -f "docs/${{ env.scenedetect_docs_dest }}" docs/latest git add docs/latest echo "scenedetect_docs_dest='${{ env.scenedetect_docs_dest }} (latest)'" >> "$GITHUB_ENV" From dd442b5a107af03aafc1a3dd478d2577f59862f2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 7 Mar 2024 22:55:25 -0500 Subject: [PATCH 063/407] [build] Cleanup Windows build workflow. --- .github/workflows/build-windows.yml | 5 ++- LICENSE | 27 ++++++++-------- appveyor.yml | 4 +-- dist/cleanup_dependencies.py | 49 ----------------------------- 4 files changed, 19 insertions(+), 66 deletions(-) delete mode 100644 dist/cleanup_dependencies.py diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index ab94cd1a..cf485d74 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -72,7 +72,10 @@ jobs: - name: Assemble Portable Distribution run: | - python dist/cleanup_dependencies.py + Move-Item -Path dist/windows/README.txt -Destination dist/scenedetect/README.txt -Force + Move-Item -Path dist/windows/LICENSE-PYTHON -Destination dist/scenedetect/LICENSE-PYTHON -Force + Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect -Force + Move-Item -Path LICENSE -Destination dist/scenedetect/LICENSE -Force 7z e -odist/ffmpeg ffmpeg-6.0-full_build.7z LICENSE -r Move-Item -Path ffmpeg.exe -Destination dist/scenedetect/ffmpeg.exe Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/LICENSE-FFMPEG -Force diff --git a/LICENSE b/LICENSE index 317bd112..59b46506 100644 --- a/LICENSE +++ b/LICENSE @@ -52,33 +52,32 @@ installing or using this software/tutorial, you agree to these terms. > click [Copyright (C) 2017, Armin Ronacher]: - This software uses OpenCV; see the thirdparty/LICENSE-CLICK - file or visit [ http://click.pocoo.org/license/ ] + This software uses OpenCV; see thirdparty/LICENSE-CLICK or visit: + [ http://click.pocoo.org/license/ ] > NumPy [Copyright (C) 2005-2016, Numpy Developers]: - This software uses Numpy; see the thirdparty/LICENSE-NUMPY - file or visit [ http://www.numpy.org/license.html ] + This software uses Numpy; see thirdparty/LICENSE-NUMPY or visit: + [ http://www.numpy.org/license.html ] > OpenCV [Copyright (C) 2017, Itseez]: - This software uses OpenCV; see the thirdparty/LICENSE-OPENCV - file or visit [ http://opencv.org/license.html ] + This software uses OpenCV; see thirdparty/LICENSE-OPENCV or visit: + [ http://opencv.org/license.html ] > PyAV [Copyright (C) 2017, Mike Boers and others]: - This software uses PyAV; see the thirdparty/LICENSE-PYAV - file or visit [ https://github.com/PyAV-Org/PyAV/blob/main/LICENSE.txt ] + This software uses PyAV; see thirdparty/LICENSE-PYAV or visit: + [ https://github.com/PyAV-Org/PyAV/blob/main/LICENSE.txt ] > pytest [Copyright (C) 2004-2017, Holger Krekel and others]: - This software uses pytest; see the thirdparty/LICENSE-PYTEST - file or visit [ https://docs.pytest.org/en/latest/license.html ] + This software uses pytest; see thirdparty/LICENSE-PYTEST or visit: + [ https://docs.pytest.org/en/latest/license.html ] > simpletable [Copyright (C) 2014-2019, Matheus Vieira Portela and others]: - This software uses simpletable; see the thirdparty/simpletable.py or visit the following URL: + This software uses simpletable; see thirdparty/LICENSE-SIMPLETABLE or visit: [ https://github.com/matheusportela/simpletable/blob/master/LICENSE ] > tqdm [Copyright (C) 2013-2018, Casper da Costa-Luis, Google Inc., and Noam Yorav-Raphael]: - This software uses tqdm; see the thirdparty/LICENSE-TQDM - file or visit the following URL for details: + This software uses tqdm; see thirdparty/LICENSE-TQDM or visit: [ https://github.com/tqdm/tqdm/blob/master/LICENCE ] @@ -93,7 +92,7 @@ these programs can be obtained from following URLs: Once installed, ensure the program is in your PATH variable (i.e. you can run the `ffmpeg` or `mkvmerge` command from any location). -Certain distributions of PySceneDetect may include ffmpeg. See the +Certain distributions of PySceneDetect may include ffmpeg. See thirdparty/LICENSE-FFMPEG file or visit [ https://ffmpeg.org ] FFmpeg is a trademark of Fabrice Bellard diff --git a/appveyor.yml b/appveyor.yml index 1ffaaf28..c25c4310 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -48,9 +48,9 @@ install: # Build Windows .EXE and create portable .ZIP - pyinstaller dist/scenedetect.spec - sphinx-build -b singlehtml docs dist/scenedetect/docs - - python dist/cleanup_dependencies.py - move dist\ffmpeg\ffmpeg.exe dist\scenedetect\ - move dist\ffmpeg\LICENSE dist\scenedetect\LICENSE-FFMPEG + - move dist\windows\* dist\scenedetect\ - copy scenedetect\_thirdparty\LICENSE* dist\scenedetect\ - cd dist/scenedetect - 7z a ../scenedetect-win64.zip * @@ -65,7 +65,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 20.8\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 21.5\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer diff --git a/dist/cleanup_dependencies.py b/dist/cleanup_dependencies.py deleted file mode 100644 index 69e38c18..00000000 --- a/dist/cleanup_dependencies.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -import glob -import os -import shutil - -BASE_PATH = 'dist/scenedetect' - -DIRECTORY_GLOBS = [ - 'altgraph-*.dist-info', - 'certifi', - 'importlib_metadata-*.dist-info', - 'matplotlib', - 'PIL', - 'PyQt5', - 'pip-*.dist-info', - 'psutil', - 'pyinstaller-*.dist-info', - 'setuptools-*.dist-info', - 'tcl8', - 'wheel-*.dist-info', - 'wx', -] - -FILE_GLOBS = [ - '_asyncio.pyd', - '_bz2.pyd', - '_decimal.pyd', - '_elementtree.pyd', - '_hashlib.pyd', - '_lzma.pyd', - '_multiprocessing.pyd', - '_tkinter.pyd', - 'd3dcompiler*.dll', - 'kiwisolver.*.pyd', - 'libEGL.dll', - 'libGLESv2.dll', - 'opengl32sw.dll', - 'Qt5*.dll', - 'wxbase*.dll', - 'wxmsw315u*.dll', -] - -for dir_glob in DIRECTORY_GLOBS: - for dir_path in glob.glob(os.path.join(BASE_PATH, dir_glob)): - shutil.rmtree(dir_path) - -for file_glob in FILE_GLOBS: - for file_path in glob.glob(os.path.join(BASE_PATH, file_glob)): - os.remove(file_path) From fa86a18d7464f16bbdc8e4a1e68a8ea7692c2dbf Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 7 Mar 2024 23:29:53 -0500 Subject: [PATCH 064/407] [build] Prepare v0.6.3 release. --- .github/workflows/build-windows.yml | 8 +- .github/workflows/generate-docs.yml | 2 + README.md | 4 +- appveyor.yml | 7 +- dist/installer/PySceneDetect.aip | 877 +++++++++++++++------------- docs/api.rst | 6 +- docs/api/migration_guide.rst | 2 +- docs/cli/config_file.rst | 2 +- scenedetect/__init__.py | 2 +- scenedetect/video_splitter.py | 4 +- website/pages/changelog.md | 2 +- website/pages/cli.md | 4 +- website/pages/download.md | 8 +- website/pages/index.md | 2 +- 14 files changed, 504 insertions(+), 426 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index cf485d74..c6299cc5 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -72,13 +72,13 @@ jobs: - name: Assemble Portable Distribution run: | - Move-Item -Path dist/windows/README.txt -Destination dist/scenedetect/README.txt -Force - Move-Item -Path dist/windows/LICENSE-PYTHON -Destination dist/scenedetect/LICENSE-PYTHON -Force - Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect -Force + New-Item -Path dist/scenedetect/ -Name thirdparty -ItemType Directory Move-Item -Path LICENSE -Destination dist/scenedetect/LICENSE -Force + Move-Item -Path dist/windows/* -Destination dist/scenedetect/thirdparty/ -Force + Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ -Force 7z e -odist/ffmpeg ffmpeg-6.0-full_build.7z LICENSE -r Move-Item -Path ffmpeg.exe -Destination dist/scenedetect/ffmpeg.exe - Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/LICENSE-FFMPEG -Force + Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/thirdparty/LICENSE-FFMPEG -Force - name: Test Portable Distribution run: | diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 7a2d7b70..a7f5a62c 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -15,6 +15,8 @@ jobs: update_docs: runs-on: ubuntu-latest env: + # TODO: Figure out a better way to handle figuring out what version /latest should be, + # e.g. add a latest version file in main. scenedetect_docs_latest: '0.6.2' scenedetect_docs_dest: '' diff --git a/README.md b/README.md index 9ab6e282..dfb62551 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Scene Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.2 (July 23, 2023) +### Latest Release: v0.6.3 (March 8, 2024) **Website**: [scenedetect.com](https://www.scenedetect.com) @@ -102,7 +102,7 @@ See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for mo - [Documentation](https://www.scenedetect.com/docs/) (covers application and Python API) - [CLI Example](https://www.scenedetect.com/cli/) - - [Config File](https://www.scenedetect.com/docs/0.6.2/cli/config_file.html) + - [Config File](https://www.scenedetect.com/docs/0.6.3/cli/config_file.html) ## Help & Contributing diff --git a/appveyor.yml b/appveyor.yml index c25c4310..64ac0c09 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -48,10 +48,11 @@ install: # Build Windows .EXE and create portable .ZIP - pyinstaller dist/scenedetect.spec - sphinx-build -b singlehtml docs dist/scenedetect/docs + - mkdir dist\scenedetect\thirdparty + - move dist\windows\* dist\scenedetect\thirdparty\ + - move scenedetect\_thirdparty\LICENSE* dist\scenedetect\thirdparty\ - move dist\ffmpeg\ffmpeg.exe dist\scenedetect\ - - move dist\ffmpeg\LICENSE dist\scenedetect\LICENSE-FFMPEG - - move dist\windows\* dist\scenedetect\ - - copy scenedetect\_thirdparty\LICENSE* dist\scenedetect\ + - move dist\ffmpeg\LICENSE dist\scenedetect\thirdparty\LICENSE-FFMPEG - cd dist/scenedetect - 7z a ../scenedetect-win64.zip * - cd ../.. diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 02200a9f..30ff6240 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -1,5 +1,5 @@ - + @@ -29,7 +29,7 @@ - + @@ -41,163 +41,175 @@ + + + - - - + + + - - - - - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + - - + + + + - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + @@ -207,191 +219,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -426,13 +254,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -455,10 +493,15 @@ + + + + + @@ -472,6 +515,7 @@ + @@ -501,6 +545,10 @@ + + + + @@ -508,16 +556,25 @@ + + + + + + + + + @@ -525,9 +582,11 @@ + + @@ -541,40 +600,20 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - @@ -603,63 +642,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -673,10 +737,19 @@ - + + + + + + + + + + @@ -684,21 +757,23 @@ - - - + + + + + @@ -755,7 +830,7 @@ - + diff --git a/docs/api.rst b/docs/api.rst index d6a94a33..b08d1c7c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -85,7 +85,7 @@ Now that we know where each scene is, we can also :ref:`split the input video `_ file. +In the next example, we show how the library components can be used to create a more customizable scene cut/shot detection pipeline. Additional demonstrations/recipes can be found in the `tests/test_api.py `_ file. .. _scenedetect-detailed_example: @@ -115,7 +115,7 @@ Using a :class:`SceneManager ` directly For a more advanced example of using the PySceneDetect API to with a stats file (to save per-frame metrics to disk and/or speed up multiple passes of the same video), take a look at the :ref:`example in the SceneManager reference`. -In addition to module-level examples, demonstrations of some common use cases can be found in the `tests/test_api.py `_ file. +In addition to module-level examples, demonstrations of some common use cases can be found in the `tests/test_api.py `_ file. ======================================================================= @@ -156,4 +156,4 @@ PySceneDetect outputs messages to a logger named ``pyscenedetect`` which does no Migrating From 0.5 ======================================================================= -PySceneDetect 0.6 introduces several breaking changes which are incompatible with 0.5. See :ref:`Migration Guide ` for details on how to update your application. In addition, demonstrations of common use cases can be found in the `tests/test_api.py `_ file. +PySceneDetect 0.6 introduces several breaking changes which are incompatible with 0.5. See :ref:`Migration Guide ` for details on how to update your application. In addition, demonstrations of common use cases can be found in the `tests/test_api.py `_ file. diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index f0fc9a3c..c4c51b0b 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -5,7 +5,7 @@ Migration Guide --------------------------------------------------------------- -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Quickstart ` and :ref:`Example ` sections first, as they should cover the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. +This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Quickstart ` and :ref:`Example ` sections first, as they should cover the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. diff --git a/docs/cli/config_file.rst b/docs/cli/config_file.rst index 664cc342..ff354687 100644 --- a/docs/cli/config_file.rst +++ b/docs/cli/config_file.rst @@ -60,7 +60,7 @@ Example Template ======================================================================= -This template shows every possible configuration option and default values. It can be used as a ``scenedetect.cfg`` file. You can also `download it from Github `_. +This template shows every possible configuration option and default values. It can be used as a ``scenedetect.cfg`` file. You can also `download it from Github `_. .. literalinclude:: ../../scenedetect.cfg :language: ini diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 5029094c..ae0e36ab 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.3.dev0' +__version__ = '0.6.3' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index bcb377f2..4b92e21c 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -219,7 +219,7 @@ def split_video_mkvmerge( ] total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() processing_start_time = time.time() - # TODO(v0.6.2): Capture stdout/stderr and show that if the command fails. + # TODO: Capture stdout/stderr and show that if the command fails. ret_val = invoke_command(call_list) if show_output: logger.info('Average processing speed %.2f frames/sec.', @@ -341,7 +341,7 @@ def split_video_ffmpeg( logger.info( 'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...') if ret_val != 0: - # TODO(v0.6.2): Capture stdout/stderr and display it on any failed calls. + # TODO: Capture stdout/stderr and display it on any failed calls. logger.error('Error splitting video (ffmpeg returned %d).', ret_val) break if progress_bar: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 253ba72f..b3d08d9b 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,7 +4,7 @@ Releases ## PySceneDetect 0.6 -### 0.6.3 (In Development) +### 0.6.3 (March 8, 2024) #### Release Notes diff --git a/website/pages/cli.md b/website/pages/cli.md index c35f48ac..18d2ea7d 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -192,7 +192,7 @@ A configuration file path can be specified using the `-c`/`--config` argument. P * Mac: * `~/Library/Preferences/PySceneDetect/scenedetect.cfg` -Run `scenedetect --help` to see the exact path on your system which will be used (it will be listed under the help text for the -c/--config option). You can [click here to download a `scenedetect.cfg` config file](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.2-release/scenedetect.cfg) to use as a template. Note that lines starting with a `#` are comments and will be ignored. The `scenedetect.cfg` template file is also available in the folder where PySceneDetect is installed. +Run `scenedetect --help` to see the exact path on your system which will be used (it will be listed under the help text for the -c/--config option). You can [click here to download a `scenedetect.cfg` config file](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.3-release/scenedetect.cfg) to use as a template. Note that lines starting with a `#` are comments and will be ignored. The `scenedetect.cfg` template file is also available in the folder where PySceneDetect is installed. Specifying a config file path using -c/--config overrides the user config file. Specifying values on the command line will override those values in the config file. @@ -228,7 +228,7 @@ quality = 80 num-images = 3 ``` -See the `scenedetect.cfg` file in the location you installed PySceneDetect or [download it from Github](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.2-release/scenedetect.cfg) for a complete listing of all configuration options. +See the `scenedetect.cfg` file in the location you installed PySceneDetect or [download it from Github](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.3-release/scenedetect.cfg) for a complete listing of all configuration options. ##   Video Splitting Requirements diff --git a/website/pages/download.md b/website/pages/download.md index a192f541..c5e7af15 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi ## Windows Build (64-bit Only)  
-

Latest Release: v0.6.2

-

  Release Date:  July 23, 2023

-  Installer  (recommended)      -  Portable .zip      +

Latest Release: v0.6.3

+

  Release Date:  March 8, 2024

+  Installer  (recommended)      +  Portable .zip        Getting Started
diff --git a/website/pages/index.md b/website/pages/index.md index a055c374..e4f30718 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
-

  Latest Release: v0.6.2 (July 23, 2023)

+

  Latest Release: v0.6.3 (March 8, 2024)

  Download        Changelog        Documentation        Getting Started
See the changelog for the latest release notes and known issues. From c9f70af344a083f9ca5a25cfc4d1942950412483 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 8 Mar 2024 10:12:59 -0500 Subject: [PATCH 065/407] [build] Add missing installer artifacts. --- .github/workflows/build-windows.yml | 9 +++++---- appveyor.yml | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index c6299cc5..febc4f6c 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -72,13 +72,14 @@ jobs: - name: Assemble Portable Distribution run: | + Move-Item -Path LICENSE -Destination dist/scenedetect/ New-Item -Path dist/scenedetect/ -Name thirdparty -ItemType Directory - Move-Item -Path LICENSE -Destination dist/scenedetect/LICENSE -Force - Move-Item -Path dist/windows/* -Destination dist/scenedetect/thirdparty/ -Force - Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ -Force + Move-Item -Path dist/windows/README* -Destination dist/scenedetect/ + Move-Item -Path dist/windows/LICENSE* -Destination dist/scenedetect/thirdparty/ + Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ 7z e -odist/ffmpeg ffmpeg-6.0-full_build.7z LICENSE -r Move-Item -Path ffmpeg.exe -Destination dist/scenedetect/ffmpeg.exe - Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/thirdparty/LICENSE-FFMPEG -Force + Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/thirdparty/LICENSE-FFMPEG - name: Test Portable Distribution run: | diff --git a/appveyor.yml b/appveyor.yml index 64ac0c09..5df5b383 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -49,7 +49,9 @@ install: - pyinstaller dist/scenedetect.spec - sphinx-build -b singlehtml docs dist/scenedetect/docs - mkdir dist\scenedetect\thirdparty - - move dist\windows\* dist\scenedetect\thirdparty\ + - move LICENSE dist\scenedetect\ + - move dist\windows\README* dist\scenedetect\ + - move dist\windows\LICENSE* dist\scenedetect\thirdparty\ - move scenedetect\_thirdparty\LICENSE* dist\scenedetect\thirdparty\ - move dist\ffmpeg\ffmpeg.exe dist\scenedetect\ - move dist\ffmpeg\LICENSE dist\scenedetect\thirdparty\LICENSE-FFMPEG From 4d354acc4c72edcacfccb1a3a3e787bef43d5111 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 8 Mar 2024 10:45:50 -0500 Subject: [PATCH 066/407] [build] Update Signpath token. --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 5df5b383..cb1e5bff 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -26,7 +26,7 @@ deploy: - provider: Webhook url: https://app.signpath.io/API/v1/f2efa44c-5b5c-45f2-b44f-8f9dde708313/Integrations/AppVeyor?ProjectSlug=PySceneDetect&SigningPolicySlug=release-signing authorization: - secure: NPMogMcEb5S/ASMEiL275H79D+Pj9cgUqx8kjTPGXtF9drZW41nljczuhF1XvcxFgI0q9TA1BUX9YuCoTQ3mEQ== + secure: FBgWCaxCCKOqc2spYf5NGWSNUGLbT5WeuC5U0k4Of1Ids9n51YWxhGlMyzLbdNBFe64RUcOSzk/N3emlQzbsJg== install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * From 93bf313a204707b0d2d773f09e8131a0d3827531 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 8 Mar 2024 16:43:52 -0500 Subject: [PATCH 067/407] [build] Update AI license file and finalize release. --- README.md | 2 +- appveyor.yml | 4 ++-- dist/.version_info | 10 +++++----- dist/installer/license65.dat.enc | Bin 416 -> 416 bytes dist/package-info.rst | 2 +- website/pages/changelog.md | 8 +++----- 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dfb62551..32e3e423 100644 --- a/README.md +++ b/README.md @@ -119,5 +119,5 @@ This program uses free code signing provided by [SignPath.io](https://signpath.i Licensed under BSD 3-Clause (see the `LICENSE` file for details). -Copyright (C) 2014-2023 Brandon Castellano. +Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. diff --git a/appveyor.yml b/appveyor.yml index cb1e5bff..e7eb4c07 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,9 +17,9 @@ environment: - PYTHON: "C:\\Python39-x64" # Encrypted AdvancedInstaller License ai_license_secret: - secure: lulTujjpNX3A1RKIvj834/Czn6etzevma6oqlA5Xia5tgrg75SPXcs1lPNlu5YPU + secure: MOkULlGPSi0C1Hg2PU1h2SZg/eyQnPQhRJ1XFlavfMKMOoX9hY4pSjpdgW3psSau ai_license_salt: - secure: kMv/7J3wqaRGUJYwnfaY6edw0VW39uX7oM9Od9PQ2wwlCmTZcwAh4kEUxhB5u91QbXmp4McMuwcXAO4UdVoSGg== + secure: /LlGOUGZk8HQgrW6txtssTt8I6Z6pU7K3XOcqTqr2iKX4vLO3ZTdILgL/6M6u7gWVdRoUYfbxm4JVYjs4hfcmQ== # SignPath Config for Code Signing deploy: diff --git a/dist/.version_info b/dist/.version_info index 170ad053..03778c42 100644 --- a/dist/.version_info +++ b/dist/.version_info @@ -8,8 +8,8 @@ VSVersionInfo( ffi=FixedFileInfo( # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # Set not needed items to zero 0. -filevers=(0, 6, 2, 0), -prodvers=(0, 6, 2, 0), +filevers=(0, 6, 3, 0), +prodvers=(0, 6, 3, 0), # Contains a bitmask that specifies the valid bits 'flags'r mask=0x3f, # Contains a bitmask that specifies the Boolean attributes of the file. @@ -33,12 +33,12 @@ StringFileInfo( u'040904B0', [StringStruct(u'CompanyName', u'github.com/Breakthrough'), StringStruct(u'FileDescription', u'www.scenedetect.com'), - StringStruct(u'FileVersion', u'v0.6.2'), + StringStruct(u'FileVersion', u'v0.6.3'), StringStruct(u'InternalName', u'PySceneDetect'), - StringStruct(u'LegalCopyright', u'Copyright © 2023 Brandon Castellano'), + StringStruct(u'LegalCopyright', u'Copyright © 2024 Brandon Castellano'), StringStruct(u'OriginalFilename', u'scenedetect.exe'), StringStruct(u'ProductName', u'PySceneDetect'), - StringStruct(u'ProductVersion', u'v0.6.2')]) + StringStruct(u'ProductVersion', u'v0.6.3')]) ]), VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) ] diff --git a/dist/installer/license65.dat.enc b/dist/installer/license65.dat.enc index 6158d992af601b0b4560726a1242072f9d0f8a56..380139d5c48307f9ad95ce5b0f4dc5c25bb83cea 100644 GIT binary patch literal 416 zcmV;R0bl;+0`C4e9W;hW1ZfKnSQPjY-25=@T8gj^%WGsHhh;yDFOrsKk37++1s9fm z)}I@jj(J71?fxoU>L*)blV?A!+Lqes@@IImVB;Ot*IW)<3(i|L2hkH#yjH0?W zr{MPio$+oFt2)Prl^OjP4cu)~Lg}h4F8hc|XDX@XJfz^g0PzxGOR}&^Sw6;=%MPnC z>4YM7qiM*q?-vt~6-riv_`89qrX3)eCj?QVU@N{V1@W)UeI$=YnGw>%q2t)d2)q~m zs+)6Rej=8q&lmgNG?Y0Z^PYf%L!e}SLex`h6uy zL%yf1@DZpyS+sIoR%%D$US*TC13)mX4?PIXKG0to#+YZ6MVTvH92{N&lu*0u*4FIo zVRrO|?fPD6Sao}PrFTn!y1#;F(`ts;feOq17HgWYJckbqthG(Lt-bICCU(OGSO zZtQgzaXgfroyyAS=D20BP_TtQ$f+#)z4knST$BR+IV8M`&V0m_$z^L(6^3cT5hlEV zq~+_jxIJLC1b1XFuUnh0++sKArcGC)xqdV?_l8hjZSJ1Vw7U1u^W(ONei3I%R2xP4 z Date: Fri, 8 Mar 2024 17:00:17 -0500 Subject: [PATCH 068/407] [installer] Re-generate product code for update. --- dist/installer/PySceneDetect.aip | 393 ++++++++++++++++++++----------- 1 file changed, 262 insertions(+), 131 deletions(-) diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 30ff6240..12a00714 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -12,7 +12,7 @@ - + @@ -26,7 +26,7 @@ - + @@ -80,136 +80,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f946f8f62f3231239c2b3a29021771dc562e42b7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 8 Mar 2024 17:08:52 -0500 Subject: [PATCH 069/407] [website] Add link to v0.6.3 docs. --- website/pages/docs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/pages/docs.md b/website/pages/docs.md index dd3cbcd1..b270cafa 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,7 @@ ## Stable * [latest](latest/) + * [v0.6.3](0.6.3/) * [v0.6.2](0.6.2/) * [v0.6.1](0.6.1/) From 474ff55974f2a661302a34292a8b624483716c64 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 9 Mar 2024 11:44:18 -0500 Subject: [PATCH 070/407] [adaptive_detector] Fix first scene not being checked against min_scene_len Refactor detector test cases to make it easier to add new ones. --- README.md | 2 +- scenedetect/_cli/controller.py | 2 +- scenedetect/detectors/adaptive_detector.py | 28 +- scenedetect/detectors/content_detector.py | 2 +- tests/test_cli.py | 4 +- tests/test_detectors.py | 310 +++++++++++---------- tests/test_scene_manager.py | 4 + website/pages/changelog.md | 6 +- website/pages/download.md | 2 +- website/pages/index.md | 2 +- 10 files changed, 192 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 32e3e423..7c928677 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Scene Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.3 (March 8, 2024) +### Latest Release: v0.6.3 (March 9, 2024) **Website**: [scenedetect.com](https://www.scenedetect.com) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 619cf79d..4350de32 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -47,7 +47,7 @@ def run_scenedetect(context: CliContext): if context.load_scenes_input: # Skip detection if load-scenes was used. - logger.info("Loading scenes from file: %s", context.load_scenes_input) + logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) if context.stats_file_path: logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") scene_list, cut_list = _load_scenes(context) diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 61d1648d..85778158 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -95,8 +95,8 @@ def __init__( self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( window_width=window_width, luma_only='' if not luma_only else '_lum') self._first_frame_num = None - self._last_frame_num = None + # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. self._last_cut: Optional[int] = None self._buffer = [] @@ -131,6 +131,10 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List super().process_frame(frame_num=frame_num, frame_img=frame_img) + # Initialize last scene cut point at the beginning of the frames of interest. + if self._last_cut is None: + self._last_cut = frame_num + required_frames = 1 + (2 * self.window_width) self._buffer.append((frame_num, self._frame_score)) if not len(self._buffer) >= required_frames: @@ -152,23 +156,15 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List if self.stats_manager is not None: self.stats_manager.set_metrics(target[0], {self._adaptive_ratio_key: adaptive_ratio}) - cut_list = [] # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut - if (adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val): - - if self._last_cut is None: - # No previously detected cuts - cut_list.append(target[0]) - self._last_cut = target[0] - elif (target[0] - self._last_cut) >= self.min_scene_len: - # Respect the min_scene_len parameter - cut_list.append(target[0]) - # TODO: Should this be updated every time the threshold is exceeded? - # It might help with flash suppression for example. - self._last_cut = target[0] - - return cut_list + threshold_met: bool = ( + adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val) + min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len + if threshold_met and min_length_met: + self._last_cut = target[0] + return [target[0]] + return [] def get_content_val(self, frame_num: int) -> Optional[float]: """Returns the average content change for a frame.""" diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 1136609c..c209bb78 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -205,7 +205,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - min_length_met = (frame_num - self._last_scene_cut) >= self._min_scene_len + min_length_met: bool = (frame_num - self._last_scene_cut) >= self._min_scene_len if self._frame_score >= self._threshold and min_length_met: self._last_scene_cut = frame_num return [frame_num] diff --git a/tests/test_cli.py b/tests/test_cli.py index 460e6824..f3ab39d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -239,9 +239,9 @@ def test_cli_time_end_of_video(): @pytest.mark.parametrize('detector_command', ALL_DETECTORS) -def test_cli_detector(detector_command: str): # +def test_cli_detector(detector_command: str): """Test each detection algorithm.""" - # Ensure all detectors work without a statsfile. + # Ensure all detectors work without a statsfile. assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR}', DETECTOR=detector_command) == 0 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index aac929ce..690760a4 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -17,115 +17,176 @@ test case material. """ -import time +from dataclasses import dataclass +import os +import typing as ty -from scenedetect import detect, SceneManager, FrameTimecode, StatsManager +import pytest + +from scenedetect import detect, SceneManager, FrameTimecode, StatsManager, SceneDetector from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HashDetector from scenedetect.backends.opencv import VideoStreamCv2 -# TODO: Test more parameters and add more videos. Parameterize the tests below such that -# a detector instance is combined with the other parameters like ground truth that go along -# with a specific video and detector values. E.g. Use Video-000, Video-001, etc..., and map -# that to a particular filename. - -TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] -"""Ground truth of start frame for each fast cut in `test_movie_clip`.""" - -TEST_VIDEO_FILE_START_FRAMES_ACTUAL = [0, 15, 198, 376] -"""Results for `test_video_file` with default ThresholdDetector values.""" - -FADES_FLOOR_START_FRAMES = [0, 84, 167, 245] -"""Results for `test_fades_clip` with default ThresholdDetector values.""" - -FADES_CEILING_START_FRAMES = [0, 42, 125, 209] -"""Results for `test_fades_clip` with ThresholdDetector fade to light with threshold 243.""" - - -def test_detect(test_video_file): - """ Test scenedetect.detect and ThresholdDetector. """ - scene_list = detect(video_path=test_video_file, detector=ThresholdDetector()) - assert len(scene_list) == len(TEST_VIDEO_FILE_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert all(x == y for (x, y) in zip(TEST_VIDEO_FILE_START_FRAMES_ACTUAL, detected_start_frames)) - - -def test_content_detector(test_movie_clip): - """ Test SceneManager with VideoStreamCv2 and ContentDetector. """ - video = VideoStreamCv2(test_movie_clip) - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector()) - - video_fps = video.frame_rate - start_time = FrameTimecode('00:00:50', video_fps) - end_time = FrameTimecode('00:01:19', video_fps) - - video.seek(start_time) - scene_manager.auto_downscale = True - - scene_manager.detect_scenes(video=video, end_time=end_time) - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames - # Ensure last scene's end timecode matches the end time we set. - assert scene_list[-1][1] == end_time - -def test_adaptive_detector(test_movie_clip): - """ Test SceneManager with VideoStreamCv2 and AdaptiveDetector. """ - video = VideoStreamCv2(test_movie_clip) - scene_manager = SceneManager() - scene_manager.add_detector(AdaptiveDetector()) - scene_manager.auto_downscale = True - - video_fps = video.frame_rate - start_time = FrameTimecode('00:00:50', video_fps) - end_time = FrameTimecode('00:01:19', video_fps) - - video.seek(start_time) - scene_manager.detect_scenes(video=video, end_time=end_time) - - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames - # Ensure last scene's end timecode matches the end time we set. - assert scene_list[-1][1] == end_time - - -def test_hash_detector(test_movie_clip): - """ Test SceneManager with VideoStreamCv2 and HashDetector. """ - video = VideoStreamCv2(test_movie_clip) - scene_manager = SceneManager() - scene_manager.add_detector(HashDetector()) - scene_manager.auto_downscale = True - - video_fps = video.frame_rate - start_time = FrameTimecode('00:00:50', video_fps) - end_time = FrameTimecode('00:01:19', video_fps) - - video.seek(start_time) - scene_manager.detect_scenes(video=video, end_time=end_time) - - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames - # Ensure last scene's end timecode matches the end time we set. - assert scene_list[-1][1] == end_time - - -def test_threshold_detector(test_video_file): - """ Test SceneManager with VideoStreamCv2 and ThresholdDetector. """ - video = VideoStreamCv2(test_video_file) - scene_manager = SceneManager() - scene_manager.add_detector(ThresholdDetector()) - scene_manager.auto_downscale = True - scene_manager.detect_scenes(video) - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(TEST_VIDEO_FILE_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert all(x == y for (x, y) in zip(TEST_VIDEO_FILE_START_FRAMES_ACTUAL, detected_start_frames)) +# TODO: Reduce code duplication here and in `conftest.py` +def get_absolute_path(relative_path: str) -> str: + """ Returns the absolute path to a (relative) path of a file that + should exist within the tests/ directory. + + Throws FileNotFoundError if the file could not be found. + """ + abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(""" +Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + +git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources +git checkout refs/remotes/origin/resources -- tests/resources/ +git reset +""" % relative_path) + return abs_path + + +@dataclass +class TestCase: + """Properties for detector test cases.""" + path: str + """Path to video for test case.""" + detector: SceneDetector + """Detector instance to use.""" + start_time: int + """Start time as frames.""" + end_time: int + """End time as frames.""" + scene_boundaries: ty.List[int] + """Scene boundaries.""" + + def detect(self): + """Run scene detection for test case. Should only be called once.""" + return detect( + video_path=self.path, + detector=self.detector, + start_time=self.start_time, + end_time=self.end_time) + + +def get_fast_cut_test_cases(): + """Fixture for parameterized test cases that detect fast cuts.""" + return [ + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=ContentDetector(), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), + id="content_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=AdaptiveDetector(), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), + id="adaptive_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=HashDetector(), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), + id="hash_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=ContentDetector(min_scene_len=30), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365]), + id="content_min_scene_len"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=AdaptiveDetector(min_scene_len=30), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365]), + id="adaptive_min_scene_len"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=HashDetector(min_scene_len=30), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365]), + id="hash_min_scene_len"), + ] + + +def get_fade_in_out_test_cases(): + """Fixture for parameterized test cases that detect fades.""" + # TODO: min_scene_len doesn't seem to be working as intended for ThresholdDetector. + # Possibly related to #278: https://github.com/Breakthrough/PySceneDetect/issues/278 + return [ + pytest.param( + TestCase( + path=get_absolute_path("resources/testvideo.mp4"), + detector=ThresholdDetector(), + start_time=0, + end_time=500, + scene_boundaries=[0, 15, 198, 376]), + id="threshold_testvideo_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/fades.mp4"), + detector=ThresholdDetector(), + start_time=0, + end_time=250, + scene_boundaries=[0, 84, 167]), + id="threshold_fades_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/fades.mp4"), + detector=ThresholdDetector( + threshold=12.0, + method=ThresholdDetector.Method.FLOOR, + add_final_scene=True, + ), + start_time=0, + end_time=250, + scene_boundaries=[0, 84, 167, 245]), + id="threshold_fades_floor"), + pytest.param( + TestCase( + path=get_absolute_path("resources/fades.mp4"), + detector=ThresholdDetector( + threshold=243.0, + method=ThresholdDetector.Method.CEILING, + add_final_scene=True, + ), + start_time=0, + end_time=250, + scene_boundaries=[0, 42, 125, 209]), + id="threshold_fades_ceil"), + ] + +@pytest.mark.parametrize("test_case", get_fast_cut_test_cases()) +def test_detect_fast_cuts(test_case: TestCase): + scene_list = test_case.detect() + start_frames = [timecode.get_frames() for timecode, _ in scene_list] + assert test_case.scene_boundaries == start_frames + assert scene_list[0][0] == test_case.start_time + assert scene_list[-1][1] == test_case.end_time + + +@pytest.mark.parametrize("test_case", get_fade_in_out_test_cases()) +def test_detect_fades(test_case: TestCase): + scene_list = test_case.detect() + start_frames = [timecode.get_frames() for timecode, _ in scene_list] + assert test_case.scene_boundaries == start_frames + assert scene_list[0][0] == test_case.start_time + assert scene_list[-1][1] == test_case.end_time def test_detectors_with_stats(test_video_file): @@ -138,10 +199,7 @@ def test_detectors_with_stats(test_video_file): scene_manager.add_detector(detector()) scene_manager.auto_downscale = True end_time = FrameTimecode('00:00:08', video.frame_rate) - benchmark_start = time.time() scene_manager.detect_scenes(video=video, end_time=end_time) - benchmark_end = time.time() - time_no_stats = benchmark_end - benchmark_start initial_scene_len = len(scene_manager.get_scene_list()) assert initial_scene_len > 0 # test case must have at least one scene! # Re-analyze using existing stats manager. @@ -151,44 +209,6 @@ def test_detectors_with_stats(test_video_file): video.reset() scene_manager.auto_downscale = True - benchmark_start = time.time() scene_manager.detect_scenes(video=video, end_time=end_time) - benchmark_end = time.time() - time_with_stats = benchmark_end - benchmark_start scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len - - print("--------------------------------------------------------------------") - print("StatsManager Benchmark For %s" % (detector.__name__)) - print("--------------------------------------------------------------------") - print("No Stats:\t%2.1fs" % time_no_stats) - print("With Stats:\t%2.1fs" % time_with_stats) - print("--------------------------------------------------------------------") - - -def test_threshold_detector_fade_out(test_fades_clip): - """Test ThresholdDetector handles fading out to black.""" - video = VideoStreamCv2(test_fades_clip) - scene_manager = SceneManager() - scene_manager.add_detector(ThresholdDetector(add_final_scene=True)) - scene_manager.auto_downscale = True - scene_manager.detect_scenes(video) - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(FADES_FLOOR_START_FRAMES) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert all(x == y for (x, y) in zip(FADES_FLOOR_START_FRAMES, detected_start_frames)) - - -def test_threshold_detector_fade_in(test_fades_clip): - """Test ThresholdDetector handles fading in from white.""" - video = VideoStreamCv2(test_fades_clip) - scene_manager = SceneManager() - scene_manager.add_detector( - ThresholdDetector( - threshold=243, method=ThresholdDetector.Method.CEILING, add_final_scene=True)) - scene_manager.auto_downscale = True - scene_manager.detect_scenes(video) - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(FADES_CEILING_START_FRAMES) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert all(x == y for (x, y) in zip(FADES_CEILING_START_FRAMES, detected_start_frames)) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 12ead8e0..20ef4677 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -55,6 +55,10 @@ def test_scene_list(test_video_file): # Each scene is in the format (Start Timecode, End Timecode) assert len(scene_list[0]) == 2 + # First scene should start at start_time and last scene should end at end_time. + assert scene_list[0][0] == start_time + assert scene_list[-1][1] == end_time + for i, _ in enumerate(scene_list): assert scene_list[i][0].get_frames() < scene_list[i][1].get_frames() if i > 0: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 642c4db3..0b16ee03 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,11 +4,11 @@ Releases ## PySceneDetect 0.6 -### 0.6.3 (March 8, 2024) +### 0.6.3 (March 9, 2024) #### Release Notes -This release of PySceneDetect includes quite a few bugfixes, as well as some performance improvements with the `load-scenes` command. Thanks for everyone who contributed to the release. +In addition to some perfromance improvements with the `load-scenes` command, this release of PySceneDetect includes a significant amount of bugfixes. Thanks to everyone who contributed to the release, including those who filed bug reports and helped with debugging! **Program Changes:** @@ -17,6 +17,7 @@ This release of PySceneDetect includes quite a few bugfixes, as well as some per - [bugfix] Progress bar now has correct frame accounting when `--duration` or `--end` are set [#341](https://github.com/Breakthrough/PySceneDetect/issues/341) - [bugfix] Only allow `load-scenes` to be specified once, and disallow with other `detect-*` commands [#347](https://github.com/Breakthrough/PySceneDetect/issues/347) - [bugfix] Disallow `-s`/`--start` being larger than `-e`/`--end` for the `time` command + - [bugfix] Fix `detect-adaptive` not respecting `--min-scene-len` for the first scene - [general] Comma-separated timecode list is now only printed when the `list-scenes` command is specified [#356](https://github.com/Breakthrough/PySceneDetect/issues/356) - [general] Several changes to `[list-scenes]` config file options: - Add `display-scenes` and `display-cuts` options to control output @@ -32,6 +33,7 @@ This release of PySceneDetect includes quite a few bugfixes, as well as some per - [bugfix] Fix circular imports due to partially initialized module for some development environments [#350](https://github.com/Breakthrough/PySceneDetect/issues/350) - [bugfix] Fix `SceneManager.detect_scenes` warning when `duration` or `end_time` are specified as timecode strings [#346](https://github.com/Breakthrough/PySceneDetect/issues/346) - [bugfix] Ensure correct string conversion behavior for `FrameTimecode` when rounding is enabled [#354](https://github.com/Breakthrough/PySceneDetect/issues/354) + - [bugfix] Fix `AdaptiveDetector` not respecting `min_scene_len` for the first scene - [feature] Add `output_dir` argument to `split_video_ffmpeg` and `split_video_mkvmerge` functions to set output directory [#298](https://github.com/Breakthrough/PySceneDetect/issues/298) - [feature] Add `formatter` argument to `split_video_ffmpeg` to allow formatting filenames via callback [#359](https://github.com/ Breakthrough/PySceneDetect/issues/359) diff --git a/website/pages/download.md b/website/pages/download.md index c5e7af15..f05dddf2 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -21,7 +21,7 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi

Latest Release: v0.6.3

-

  Release Date:  March 8, 2024

+

  Release Date:  March 9, 2024

  Installer  (recommended)        Portable .zip        Getting Started diff --git a/website/pages/index.md b/website/pages/index.md index e4f30718..1ec0f316 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
-

  Latest Release: v0.6.3 (March 8, 2024)

+

  Latest Release: v0.6.3 (March 9, 2024)

  Download        Changelog        Documentation        Getting Started
See the changelog for the latest release notes and known issues. From 84e53ef25de670f2e207dc427e1866943a61571c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 9 Mar 2024 22:12:45 -0500 Subject: [PATCH 071/407] [build] Bump docs/latest to 0.6.3. --- .github/workflows/generate-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index a7f5a62c..4dbcec13 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -17,7 +17,7 @@ jobs: env: # TODO: Figure out a better way to handle figuring out what version /latest should be, # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.2' + scenedetect_docs_latest: '0.6.3' scenedetect_docs_dest: '' steps: From 2c79e196a5a389d62c36ad08790390f88dac56d2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 16 Apr 2024 21:10:24 -0400 Subject: [PATCH 072/407] [tests] Ensure Pytest does not treat TestCase as a unit test --- tests/test_detectors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 690760a4..078307c2 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -49,6 +49,7 @@ def get_absolute_path(relative_path: str) -> str: @dataclass class TestCase: + __test__ = False """Properties for detector test cases.""" path: str """Path to video for test case.""" From a1f4f80c9b649fe1b1e6fc3b5e94c8e7f605c2ee Mon Sep 17 00:00:00 2001 From: Walter Schwenger Date: Tue, 16 Apr 2024 21:54:07 -0400 Subject: [PATCH 073/407] Color Histogram Detector (#295) * Initial implementation of HistogramDetector. * Added check for color channels * Added tests for detect-hist. * Added documentation for detect-hist. * Add detect-hist to test_cli * Fix formatting * Fix test_histogram_detector * Move detect-hist to new location. * Delete scenedetect/cli/__init__.py Moved to scenedetect/_cli/__init__.py * Add config options for detect-hist * Update config.py * Update __init__.py * Update config.py --------- Co-authored-by: Brandon Castellano --- scenedetect/_cli/__init__.py | 46 +++++ scenedetect/_cli/config.py | 5 + scenedetect/_cli/context.py | 31 ++++ scenedetect/detectors/__init__.py | 14 +- scenedetect/detectors/histogram_detector.py | 189 ++++++++++++++++++++ tests/test_cli.py | 2 +- tests/test_detectors.py | 30 +++- website/pages/api.md | 5 + 8 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 scenedetect/detectors/histogram_detector.py diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 33ad89e3..ecdb1429 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -710,6 +710,52 @@ def detect_threshold_command( ctx.obj.add_detector(ThresholdDetector(**detector_args)) +@click.command('detect-hist', cls=_Command) +@click.option( + '--threshold', + '-t', + metavar='VAL', + type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, + CONFIG_MAP['detect-hist']['threshold'].max_val), + default=None, + help='Threshold value (float) that the rgb histogram difference must exceed to trigger' + ' a new scene. Refer to frame metric hist_diff in stats file.%s' % + (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) +@click.option( + '--bits', + '-b', + metavar='NUM', + type=click.INT, + default=None, + help='The number of most significant figures to keep when quantizing the RGB color channels.%s' + % (USER_CONFIG.get_help_string("detect-hist", "bits"))) +@click.option( + '--min-scene-len', + '-m', + metavar='TIMECODE', + type=click.STRING, + default=None, + help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' + ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' + ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % + ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( + 'detect-hist', 'min-scene-len'))) +@click.pass_context +def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Perform detection of scenes by comparing differences in the RGB histograms of adjacent + frames. + + Examples: + + detect-hist + + detect-hist --threshold 20000.0 + """ + assert isinstance(ctx.obj, CliContext) + ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) + + @click.command('load-scenes', cls=_Command) @click.option( '--input', diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 6588d909..2f72e9ca 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -275,6 +275,11 @@ def format(self, timecode: FrameTimecode) -> str: 'min-scene-len': TimecodeValue(0), 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, + 'detect-hist': { + 'bits': 4, + 'min-scene-len': TimecodeValue(0), + 'threshold': RangeValue(20000.0, min_val=0.0, max_val=10000000000.0), + }, 'load-scenes': { 'start-col-name': 'Start Frame', }, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 6f0e1386..36275744 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -449,6 +449,37 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) + def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Handle `detect-hist` command options.""" + self._check_input_open() + options_processed_orig = self.options_processed + self.options_processed = False + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-hist", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-hist", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + + threshold = self.config.get_value("detect-hist", "threshold", threshold) + bits = self.config.get_value("detect-hist", "bits", bits) + + # Log detector args for debugging before we construct it. + logger.debug( + 'Adding detector: HistogramDetector(threshold=%f, bits=%d,' + ' min_scene_len=%d)', threshold, bits, min_scene_len) + + self._add_detector( + scenedetect.detectors.HistogramDetector( + threshold=threshold, bits=bits, min_scene_len=min_scene_len)) + + self.options_processed = options_processed_orig + def handle_export_html( self, filename: Optional[AnyStr], diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index c39a9cbb..55ec8689 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -33,6 +33,7 @@ from scenedetect.detectors.threshold_detector import ThresholdDetector from scenedetect.detectors.adaptive_detector import AdaptiveDetector from scenedetect.detectors.hash_detector import HashDetector +from scenedetect.detectors.histogram_detector import HistogramDetector # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -52,19 +53,6 @@ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# class HistogramDetector(SceneDetector): -# """Detects fast cuts via histogram changes between sequential frames. -# -# Detects fast cuts between content (using histogram deltas, much like the -# ContentDetector uses HSV colourspace deltas), as well as both fades and -# cuts to/from black (using a threshold, much like the ThresholdDetector). -# """ -# -# def __init__(self): -# super(DissolveDetector, self).__init__() -# -# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# # class MotionDetector(SceneDetector): # """Detects motion events in scenes containing a static background. # diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py new file mode 100644 index 00000000..28d00eb5 --- /dev/null +++ b/scenedetect/detectors/histogram_detector.py @@ -0,0 +1,189 @@ +# -*- 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 . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +""":py:class:`HistogramDetector` compares the difference in the RGB histograms of subsequent +frames. If the difference exceeds a given threshold, a cut is detected. + +This detector is available from the command-line as the `detect-hist` command. +""" + +from typing import List + +import numpy + +# PySceneDetect Library Imports +from scenedetect.scene_detector import SceneDetector + + +class HistogramDetector(SceneDetector): + """Compares the difference in the RGB histograms of subsequent + frames. If the difference exceeds a given threshold, a cut is detected.""" + + METRIC_KEYS = ['hist_diff'] + + def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int = 15): + """ + Arguments: + threshold: Threshold value (float) that the calculated difference between subsequent + histograms must exceed to trigger a new scene. + bits: Number of most significant bits to keep of the pixel values. Most videos and + images are 8-bit rgb (0-255) and the default is to just keep the 4 most siginificant + bits. This compresses the 3*8bit (24bit) image down to 3*4bits (12bits). This makes + quantizing the rgb histogram a bit easier and comparisons more meaningful. + min_scene_len: Minimum length of any scene. + """ + super().__init__() + self.threshold = threshold + self.bits = bits + self.min_scene_len = min_scene_len + self._hist_bins = range(2**(3 * self.bits)) + self._last_hist = None + self._last_scene_cut = None + + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + """First, compress the image according to the self.bits value, then build a histogram for + the input frame. Afterward, compare against the previously analyzed frame and check if the + difference is large enough to trigger a cut. + + Arguments: + frame_num: Frame number of frame that is being passed. + frame_img: Decoded frame image (numpy.ndarray) to perform scene + detection on. + + Returns: + List of frames where scene cuts have been detected. There may be 0 + or more frames in the list, and not necessarily the same as frame_num. + """ + cut_list = [] + + np_data_type = frame_img.dtype + + if np_data_type != numpy.uint8: + raise ValueError('Image must be 8-bit rgb for HistogramDetector') + + if frame_img.shape[2] != 3: + raise ValueError('Image must have three color channels for HistogramDetector') + + # Initialize last scene cut point at the beginning of the frames of interest. + if not self._last_scene_cut: + self._last_scene_cut = frame_num + + # Quantize the image and separate the color channels + quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self.bits) + + # Perform bit shifting operations and bitwise combine color channels into one array + composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self.bits) + + # Create the histogram with a bin for every rgb value + hist, _ = numpy.histogram(composite_img, bins=self._hist_bins) + + # We can only start detecting once we have a frame to compare with. + if self._last_hist is not None: + # Compute histogram difference between frames + hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) + + # Check if a new scene should be triggered + if hist_diff >= self.threshold and ((frame_num - self._last_scene_cut) + >= self.min_scene_len): + cut_list.append(frame_num) + self._last_scene_cut = frame_num + + # Save stats to a StatsManager if it is being used + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, {self.METRIC_KEYS[0]: hist_diff}) + + self._last_hist = hist + + return cut_list + + def _quantize_frame(self, frame_img, bits): + """Quantizes the image based on the number of most significant figures to be preserved. + + Arguments: + frame_img: The 8-bit rgb image of the frame being analyzed. + bits: The number of most significant bits to keep during quantization. + + Returns: + [red_img, green_img, blue_img]: + The three separated color channels of the frame image that have been quantized. + """ + # First, find the value of the number of most significant bits, padding with zeroes + bit_value = int(bin(2**bits - 1).ljust(10, '0'), 2) + + # Separate R, G, and B color channels and cast to int for easier bitwise operations + red_img = frame_img[:, :, 0].astype(int) + green_img = frame_img[:, :, 1].astype(int) + blue_img = frame_img[:, :, 2].astype(int) + + # Quantize the frame images + red_img = red_img & bit_value + green_img = green_img & bit_value + blue_img = blue_img & bit_value + + return [red_img, green_img, blue_img] + + def _shift_bits(self, quantized_imgs, bits): + """Takes care of the bit shifting operations to combine the RGB color + channels into a single array. + + Arguments: + quantized_imgs: A list of the three quantized images of the RGB color channels + respectively. + bits: The number of most significant bits to use for quantizing the image. + + Returns: + composite_img: The resulting array after all bitwise operations. + """ + # First, figure out how much each shift needs to be + blue_shift = 8 - bits + green_shift = 8 - 2 * bits + red_shift = 8 - 3 * bits + + # Separate our color channels for ease + red_img = quantized_imgs[0] + green_img = quantized_imgs[1] + blue_img = quantized_imgs[2] + + # Perform the bit shifting for each color + red_img = self._shift_images(img=red_img, img_shift=red_shift) + green_img = self._shift_images(img=green_img, img_shift=green_shift) + blue_img = self._shift_images(img=blue_img, img_shift=blue_shift) + + # Join our rgb arrays together + composite_img = numpy.bitwise_or(red_img, numpy.bitwise_or(green_img, blue_img)) + + return composite_img + + def _shift_images(self, img, img_shift): + """Do bitwise shifting operations for a color channel image checking for shift direction. + + Arguments: + img: A quantized image of a single color channel + img_shift: How many bits to shift the values of img. If the value is negative, the shift + direction is to the left and 8 is added to make it a positive value. + + Returns: + shifted_img: The bitwise shifted image. + """ + if img_shift < 0: + img_shift += 8 + shifted_img = numpy.left_shift(img, img_shift) + else: + shifted_img = numpy.right_shift(img, img_shift) + + return shifted_img + + def is_processing_required(self, frame_num: int) -> bool: + return True + + def get_metrics(self) -> List[str]: + return HistogramDetector.METRIC_KEYS diff --git a/tests/test_cli.py b/tests/test_cli.py index f3ab39d2..6706af39 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. SCENEDETECT_CMD = 'python -m scenedetect' -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] +ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist'] ALL_BACKENDS = ['opencv', 'pyav'] DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 078307c2..714c9da7 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -24,9 +24,10 @@ import pytest from scenedetect import detect, SceneManager, FrameTimecode, StatsManager, SceneDetector -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HashDetector +from scenedetect.detectors import * from scenedetect.backends.opencv import VideoStreamCv2 +ALL_DETECTORS = (AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ThresholdDetector,) # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: @@ -47,6 +48,31 @@ def get_absolute_path(relative_path: str) -> str: return abs_path +# TODO: Add a test case for this in the fixtures defined below. +def test_histogram_detector(test_movie_clip): + """ Test SceneManager with VideoStreamCv2 and HistogramDetector. """ + TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] + """Ground truth of start frame for each fast cut in `test_movie_clip`.""" + video = VideoStreamCv2(test_movie_clip) + scene_manager = SceneManager() + scene_manager.add_detector(HistogramDetector()) + scene_manager.auto_downscale = True + + video_fps = video.frame_rate + start_time = FrameTimecode('00:00:50', video_fps) + end_time = FrameTimecode('00:01:19', video_fps) + + video.seek(start_time) + scene_manager.detect_scenes(video=video, end_time=end_time) + + scene_list = scene_manager.get_scene_list() + assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) + detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] + assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames + # Ensure last scene's end timecode matches the end time we set. + assert scene_list[-1][1] == end_time + + @dataclass class TestCase: __test__ = False @@ -193,7 +219,7 @@ def test_detect_fades(test_case: TestCase): def test_detectors_with_stats(test_video_file): """ Test all detectors functionality with a StatsManager. """ # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). - for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector, HashDetector]: + for detector in ALL_DETECTORS: video = VideoStreamCv2(test_video_file) stats = StatsManager() scene_manager = SceneManager(stats_manager=stats) diff --git a/website/pages/api.md b/website/pages/api.md index 17d5464c..3c95ac33 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -25,10 +25,15 @@ The adaptive content detector (`detect-adaptive`) compares the difference in con The threshold-based scene detector (`detect-threshold`) is how most traditional scene detection methods work (e.g. the `ffmpeg blackframe` filter), by comparing the intensity/brightness of the current frame with a set threshold, and triggering a scene cut/break when this value crosses the threshold. In PySceneDetect, this value is computed by averaging the R, G, and B values for every pixel in the frame, yielding a single floating point number representing the average pixel value (from 0.0 to 255.0). +## Histogram Detector + +The color histogram detector uses color information to detect fast cuts. The input video for this detector must be in 8-bit color. The detection algorithm consists of separating the three RGB color channels and then quantizing them by eliminating all but the given number of most significant bits (`--bits/-b`). The resulting quantized color channels are then bit shifted and joined together into a new, composite image. A histogram is then constructed from the pixel values in the new, composite image. This histogram is compared element-wise with the histogram from the previous frame and if the total difference between the two adjacent histograms exceeds the given threshold (`--threshold/-t`), then a new scene is triggered. + ## Perceptual Hash Detector The perceptual hash detector (`detect-hash`) calculates a hash for a frame and compares that hash to the previous frame's hash. If the hashes differ by more than the defined threshold, then a scene change is recorded. The hashing algorithm used for this detector is an implementation of `phash` from the [imagehash](https://github.com/JohannesBuchner/imagehash) library. In practice, this detector works similarly to `detect-content` in that it picks up large differences between adjacent frames. One important note is that the hashing algorithm converts the frames to grayscale, so this detector is insensitive to changes in colors if the brightness remains constant. In general, this algorithm is very computationally efficient compared to `detect-content` or `detect-adaptive`, especially if downscaling is not used. See [here](https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html) for an overview of how a perceptual hashing algorithm can be used for detecting similarity (or otherwise) of images and a visual depiction of the algorithm. + # Creating New Detection Algorithms All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). From 7379265d6290a2018a229455b83194a12d79909f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 16 Apr 2024 22:20:47 -0400 Subject: [PATCH 074/407] [detectors] Finalize initial implementation of detect-hist #53 Thank you @wjs018 for spearheading this work in PR #295 --- scenedetect.cfg | 19 ++++++++- scenedetect/_cli/__init__.py | 10 ++++- scenedetect/_cli/context.py | 34 ++++++--------- scenedetect/detectors/histogram_detector.py | 19 +++++---- tests/test_detectors.py | 47 ++++++++++----------- website/pages/changelog.md | 5 +++ 6 files changed, 75 insertions(+), 59 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 73b7e671..adc1e94e 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -26,7 +26,7 @@ #output = /usr/tmp/scenedetect/ # Default detector to use. -# Must be one of: detect-adaptive, detect-content, detect-threshold +# Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist #default-detector = detect-adaptive # Video backend interface, must be one of: opencv, pyav. @@ -87,7 +87,6 @@ #min-scene-len = 0.6s - [detect-threshold] # Average pixel intensity from 0-255 at which a fade event is triggered. #threshold = 12 @@ -126,6 +125,22 @@ #kernel-size = -1 +[detect-hist] +# +# IN DEVELOPMENT, SUBJECT TO CHANGE +# + +# Threshold value (float) that the calculated difference between subsequent +# histograms must exceed to trigger a new scene. +#threshold = 20000.0 + +# Number of bits to use for image quantization before binning. +#bits = 4 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + + # # COMMAND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ecdb1429..d5823703 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,7 +27,7 @@ import click import scenedetect -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.platform import get_system_version_info @@ -753,7 +753,12 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Op detect-hist --threshold 20000.0 """ assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) + + assert isinstance(ctx.obj, CliContext) + detector_args = ctx.obj.get_detect_hist_params( + threshold=threshold, bits=bits, min_scene_len=min_scene_len) + logger.debug('Adding detector: HistogramDetector(%s)', detector_args) + ctx.obj.add_detector(HistogramDetector(**detector_args)) @click.command('load-scenes', cls=_Command) @@ -1188,4 +1193,5 @@ def save_images_command( scenedetect.add_command(detect_content_command) scenedetect.add_command(detect_threshold_command) scenedetect.add_command(detect_adaptive_command) +scenedetect.add_command(detect_hist_command) scenedetect.add_command(load_scenes_command) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 36275744..c1ceb48d 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -28,7 +28,7 @@ from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation @@ -288,6 +288,8 @@ def handle_options( self.default_detector = (ContentDetector, self.get_detect_content_params()) elif default_detector == 'detect-threshold': self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) + elif default_detector == 'detect-hist': + self.default_detector = (HistogramDetector, self.get_detect_hist_params()) else: raise click.BadParameter("Unknown detector type!", param_hint='default-detector') @@ -449,13 +451,10 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) - def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], - min_scene_len: Optional[str]): - """Handle `detect-hist` command options.""" - self._check_input_open() - options_processed_orig = self.options_processed - self.options_processed = False - + def get_detect_hist_params(self, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]) -> Dict[str, Any]: + """Handle detect-hist command options and return dict to construct one with.""" + self._ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 else: @@ -465,20 +464,11 @@ def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], else: min_scene_len = self.config.get_value("detect-hist", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - - threshold = self.config.get_value("detect-hist", "threshold", threshold) - bits = self.config.get_value("detect-hist", "bits", bits) - - # Log detector args for debugging before we construct it. - logger.debug( - 'Adding detector: HistogramDetector(threshold=%f, bits=%d,' - ' min_scene_len=%d)', threshold, bits, min_scene_len) - - self._add_detector( - scenedetect.detectors.HistogramDetector( - threshold=threshold, bits=bits, min_scene_len=min_scene_len)) - - self.options_processed = options_processed_orig + return { + 'bits': self.config.get_value("detect-hist", "bits", bits), + 'min_scene_len': min_scene_len, + 'threshold': self.config.get_value("detect-hist", "threshold", threshold), + } def handle_export_html( self, diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 28d00eb5..937b7e13 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -42,10 +42,10 @@ def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int min_scene_len: Minimum length of any scene. """ super().__init__() - self.threshold = threshold - self.bits = bits - self.min_scene_len = min_scene_len - self._hist_bins = range(2**(3 * self.bits)) + self._threshold = threshold + self._bits = bits + self._min_scene_len = min_scene_len + self._hist_bins = range(2**(3 * self._bits)) self._last_hist = None self._last_scene_cut = None @@ -78,10 +78,10 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: self._last_scene_cut = frame_num # Quantize the image and separate the color channels - quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self.bits) + quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self._bits) # Perform bit shifting operations and bitwise combine color channels into one array - composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self.bits) + composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self._bits) # Create the histogram with a bin for every rgb value hist, _ = numpy.histogram(composite_img, bins=self._hist_bins) @@ -92,8 +92,11 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) # Check if a new scene should be triggered - if hist_diff >= self.threshold and ((frame_num - self._last_scene_cut) - >= self.min_scene_len): + + # TODO(#53): We should probably normalize the threshold based on the frame size, as + # larger images will have more pixels in each bin. + if hist_diff >= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 714c9da7..9a7332de 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -29,6 +29,12 @@ ALL_DETECTORS = (AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ThresholdDetector,) +# TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores +# regardless of resolution. This will ensure that threshold values will hold true for different +# input sources. Most detectors already provide this guarantee, so this is more to prevent any +# regressions in the future. + + # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: """ Returns the absolute path to a (relative) path of a file that @@ -48,31 +54,6 @@ def get_absolute_path(relative_path: str) -> str: return abs_path -# TODO: Add a test case for this in the fixtures defined below. -def test_histogram_detector(test_movie_clip): - """ Test SceneManager with VideoStreamCv2 and HistogramDetector. """ - TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] - """Ground truth of start frame for each fast cut in `test_movie_clip`.""" - video = VideoStreamCv2(test_movie_clip) - scene_manager = SceneManager() - scene_manager.add_detector(HistogramDetector()) - scene_manager.auto_downscale = True - - video_fps = video.frame_rate - start_time = FrameTimecode('00:00:50', video_fps) - end_time = FrameTimecode('00:01:19', video_fps) - - video.seek(start_time) - scene_manager.detect_scenes(video=video, end_time=end_time) - - scene_list = scene_manager.get_scene_list() - assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) - detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames - # Ensure last scene's end timecode matches the end time we set. - assert scene_list[-1][1] == end_time - - @dataclass class TestCase: __test__ = False @@ -124,6 +105,14 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), id="hash_default"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=HistogramDetector(), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), + id="histogram_default"), pytest.param( TestCase( path=get_absolute_path("resources/goldeneye.mp4"), @@ -148,6 +137,14 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1260, 1334, 1365]), id="hash_min_scene_len"), + pytest.param( + TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=HistogramDetector(min_scene_len=30), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365]), + id="histogram_min_scene_len"), ] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0b16ee03..1bdb8be6 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,11 @@ Releases ## PySceneDetect 0.6 +### 0.6.4 (In Development) + + - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + + ### 0.6.3 (March 9, 2024) #### Release Notes From 46c08da81536c96807f011adb6896622fcc2f613 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 17 Apr 2024 21:35:55 -0400 Subject: [PATCH 075/407] [detectors] Fix merge conflicts to land perceptual hash detector. --- scenedetect/__init__.py | 2 +- scenedetect/detectors/hash_detector.py | 75 +++++++++++++------------- tests/test_cli.py | 3 +- tests/test_detectors.py | 9 +++- 4 files changed, 48 insertions(+), 41 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index ae0e36ab..aa9fd929 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.3' +__version__ = '0.6.4-dev0' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index f6f2a453..7b94bed0 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -26,8 +26,8 @@ """ ``scenedetect.detectors.hash_detector`` Module This module implements the :py:class:`HashDetector`, which calculates a hash -value for each from of a video using a perceptual hashing algorithm. Then, the -differences in hash value between frames is calculated. If this difference +value for each from of a video using a perceptual hashing algorithm. Then, the +differences in hash value between frames is calculated. If this difference exceeds a set threshold, a scene cut is triggered. This detector is available from the command-line interface by using the @@ -87,28 +87,31 @@ class HashDetector(SceneDetector): Since the difference between frames is used, unlike the ThresholdDetector, only fast cuts are detected with this method. + + Arguments: + threshold: How much of a difference between subsequent hash values should trigger a cut + min_scene_len: Minimum length of any given scene, in frames (int) or FrameTimecode + hash_size: Size of square of low frequency data to include from the discrete cosine transform + highfreq_factor: How much high frequency information to filter from the DCT. A value of + 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... """ - def __init__(self, threshold=101.0, min_scene_len=15, hash_size=16, highfreq_factor=2): + def __init__( + self, + threshold: float = 101.0, + min_scene_len: int = 15, + hash_size: int = 16, + highfreq_factor: int = 2, + ): super(HashDetector, self).__init__() - # How much of a difference between subsequent hash values should trigger a cut - self.threshold = threshold - - # Minimum length of any given scene, in frames (int) or FrameTimecode - self.min_scene_len = min_scene_len - - # Size of square of low frequency data to include from the discrete cosine transform - self.hash_size = hash_size - - # How much high frequency data should be thrown out from the DCT - # A value of 2 means only keep 1/2 of the freq data, a value of 4 means only keep 1/4 - self.highfreq_factor = highfreq_factor - - self.last_frame = None - self.last_scene_cut = None - self.last_hash = numpy.array([]) + self._threshold = threshold + self._min_scene_len = min_scene_len + self._hash_size = hash_size + self._highfreq_factor = highfreq_factor + self._last_frame = None + self._last_scene_cut = None + self._last_hash = numpy.array([]) self._metric_keys = ['hash_dist'] - self.cli_name = 'detect-hash' def get_metrics(self): return self._metric_keys @@ -135,26 +138,27 @@ def process_frame(self, frame_num, frame_img): cut_list = [] metric_keys = self._metric_keys - _unused = '' # Initialize last scene cut point at the beginning of the frames of interest. - if self.last_scene_cut is None: - self.last_scene_cut = frame_num + if self._last_scene_cut is None: + self._last_scene_cut = frame_num # We can only start detecting once we have a frame to compare with. - if self.last_frame is not None: + if self._last_frame is not None: # We obtain the change in hash value between subsequent frames. curr_hash = calculate_frame_hash( - frame_img=frame_img, hash_size=self.hash_size, highfreq_factor=self.highfreq_factor) + frame_img=frame_img, + hash_size=self._hash_size, + highfreq_factor=self._highfreq_factor) - last_hash = self.last_hash + last_hash = self._last_hash if last_hash.size == 0: # Calculate hash of last frame last_hash = calculate_frame_hash( - frame_img=self.last_frame, - hash_size=self.hash_size, - highfreq_factor=self.highfreq_factor) + frame_img=self._last_frame, + hash_size=self._hash_size, + highfreq_factor=self._highfreq_factor) # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) @@ -162,18 +166,15 @@ def process_frame(self, frame_num, frame_img): if self.stats_manager is not None: self.stats_manager.set_metrics(frame_num, {metric_keys[0]: hash_dist}) - self.last_hash = curr_hash + self._last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - if hash_dist >= self.threshold and ( - (frame_num - self.last_scene_cut) >= self.min_scene_len): + if hash_dist >= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): cut_list.append(frame_num) - self.last_scene_cut = frame_num - - if self.last_frame is not None and self.last_frame is not _unused: - del self.last_frame + self._last_scene_cut = frame_num - self.last_frame = frame_img.copy() + self._last_frame = frame_img.copy() return cut_list diff --git a/tests/test_cli.py b/tests/test_cli.py index 6706af39..3a7ba4b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,8 +53,7 @@ DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. DEFAULT_DETECTOR = 'detect-content' DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hash'] -ALL_BACKENDS = ['opencv', 'pyav'] +DEFAULT_NUM_SCENES = 2 def invoke_scenedetect( diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 9a7332de..81dd4997 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -27,7 +27,13 @@ from scenedetect.detectors import * from scenedetect.backends.opencv import VideoStreamCv2 -ALL_DETECTORS = (AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ThresholdDetector,) +ALL_DETECTORS = ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) # TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores # regardless of resolution. This will ensure that threshold values will hold true for different @@ -195,6 +201,7 @@ def get_fade_in_out_test_cases(): id="threshold_fades_ceil"), ] + @pytest.mark.parametrize("test_case", get_fast_cut_test_cases()) def test_detect_fast_cuts(test_case: TestCase): scene_list = test_case.detect() From cd13adce1b87f5b5fb9665f8c3ee7d8f9bbed67a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 17 Apr 2024 22:00:06 -0400 Subject: [PATCH 076/407] [tests] Fix incorrect type hints in tests. --- tests/test_detectors.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index a79b66a1..e8c74719 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -27,18 +27,14 @@ from scenedetect.detectors import * from scenedetect.backends.opencv import VideoStreamCv2 - -FAST_CUT_DETECTORS: tuple[type[SceneDetector]] = ( +FAST_CUT_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ) -ALL_DETECTORS: tuple[type[SceneDetector]] = ( - *FAST_CUT_DETECTORS, - ThresholdDetector -) +ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) # TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores # regardless of resolution. This will ensure that threshold values will hold true for different @@ -93,7 +89,8 @@ def get_fast_cut_test_cases(): """Fixture for parameterized test cases that detect fast cuts.""" test_cases = [] # goldeneye.mp4 with min_scene_len = 15 (default) - test_cases += [pytest.param( + test_cases += [ + pytest.param( TestCase( path=get_absolute_path("resources/goldeneye.mp4"), detector=detector_type(min_scene_len=15), @@ -103,7 +100,8 @@ def get_fast_cut_test_cases(): id="%s/default" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS ] # goldeneye.mp4 with min_scene_len = 30 - test_cases += [pytest.param( + test_cases += [ + pytest.param( TestCase( path=get_absolute_path("resources/goldeneye.mp4"), detector=detector_type(min_scene_len=30), @@ -114,6 +112,7 @@ def get_fast_cut_test_cases(): ] return test_cases + def get_fade_in_out_test_cases(): """Fixture for parameterized test cases that detect fades.""" # TODO: min_scene_len doesn't seem to be working as intended for ThresholdDetector. From 8913d92945d618809f12a58c913b2b413cb48108 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 20 Apr 2024 12:25:09 -0400 Subject: [PATCH 077/407] [cli] Remove extraneous output with --drop-short-scenes --- scenedetect/_cli/controller.py | 3 --- website/pages/changelog.md | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 4350de32..d7180542 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -335,9 +335,6 @@ def _postprocess_scene_list( # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: - print([str(s[1] - s[0]) for s in scene_list].__str__()) - print(context.min_scene_len) scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] - print([str(s[1] - s[0]) for s in scene_list].__str__()) return scene_list diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1bdb8be6..c24934f5 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -7,7 +7,7 @@ Releases ### 0.6.4 (In Development) - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - + - [bugfix] Remove extraneous console output when using `--drop-short-scenes` ### 0.6.3 (March 9, 2024) From e1472bde4880d13534742a010e446e5a70910cd3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 20 Apr 2024 14:25:18 -0400 Subject: [PATCH 078/407] [detectors] Add new flash suppression method (#53) Add new FlashFilter to scenedetect.scene_detector. Integrates with ContentDetector and turn on by default. Add placeholder for config option and update changelog. --- scenedetect.cfg | 12 +++- scenedetect/__init__.py | 2 +- scenedetect/detectors/content_detector.py | 21 ++---- scenedetect/scene_detector.py | 79 +++++++++++++++++++++-- tests/test_detectors.py | 11 ++-- website/pages/changelog.md | 9 ++- 6 files changed, 101 insertions(+), 33 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index adc1e94e..23a86ab4 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -39,13 +39,19 @@ # Method to use for downscaling (nearest, linear, cubic, area, lanczos4). #downscale-method = linear -# Minimum length of a given scene (shorter scenes will be merged). +# Minimum length of a given scene. See filter-mode to control how this is enforced. #min-scene-len = 0.6s -# Merge last scene if it is shorter than min-scene-len (yes/no) +# Mode to use when filtering out scenes (merge or suppress): +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge + +# Merge last scene if it is shorter than min-scene-len (yes/no). This can occur +# when a cut is detected just before the video ends. #merge-last-scene = no -# Drop scenes shorter than min-scene-len instead of merging (yes/no) +# Drop scenes shorter than min-scene-len instead of merging (yes/no). #drop-short-scenes = no # Verbosity of console output (debug, info, warning, error, or none). diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index aa9fd929..f928ad4e 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.4-dev0' +__version__ = '0.7-dev0' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index c209bb78..954a91d7 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -22,7 +22,7 @@ import numpy import cv2 -from scenedetect.scene_detector import SceneDetector +from scenedetect.scene_detector import SceneDetector, FlashFilter def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: @@ -105,6 +105,7 @@ def __init__( weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: Optional[int] = None, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): """ Arguments: @@ -118,11 +119,12 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If None, automatically set using video resolution. + filter_mode: Mode to use when filtering cuts to meet `min_scene_len`. """ super().__init__() self._threshold: float = threshold self._min_scene_len: int = min_scene_len - self._last_scene_cut: Optional[int] = None + self._last_above_threshold: Optional[int] = None self._last_frame: Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: @@ -134,6 +136,7 @@ def __init__( raise ValueError('kernel_size must be odd integer >= 3') self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: Optional[float] = None + self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): return ContentDetector.METRIC_KEYS @@ -195,22 +198,12 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - # Initialize last scene cut point at the beginning of the frames of interest. - if self._last_scene_cut is None: - self._last_scene_cut = frame_num - self._frame_score = self._calculate_frame_score(frame_num, frame_img) if self._frame_score is None: return [] - # We consider any frame over the threshold a new scene, but only if - # the minimum scene length has been reached (otherwise it is ignored). - min_length_met: bool = (frame_num - self._last_scene_cut) >= self._min_scene_len - if self._frame_score >= self._threshold and min_length_met: - self._last_scene_cut = frame_num - return [frame_num] - - return [] + above_threshold: bool = self._frame_score >= self._threshold + return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index a5d5dc8b..ded5d35d 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -25,7 +25,8 @@ event (in, out, cut, etc...). """ -from typing import List, Optional, Tuple +from enum import Enum +import typing as ty import numpy @@ -46,7 +47,7 @@ class SceneDetector: """ # TODO(v0.7): Make this a proper abstract base class. - stats_manager: Optional[StatsManager] = None + stats_manager: ty.Optional[StatsManager] = None """Optional :class:`StatsManager ` to use for caching frame metrics to and from.""" @@ -77,7 +78,7 @@ def stats_manager_required(self) -> bool: """ return False - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: """Get Metrics: Get a list of all metric names/keys used by the detector. Returns: @@ -86,7 +87,7 @@ def get_metrics(self) -> List[str]: """ return [] - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -103,7 +104,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """ return [] - def post_process(self, frame_num: int) -> List[int]: + def post_process(self, frame_num: int) -> ty.List[int]: """Post Process: Performs any processing after the last frame has been read. Prototype method, no actual detection. @@ -132,7 +133,8 @@ class SparseSceneDetector(SceneDetector): An example of a SparseSceneDetector is the MotionDetector. """ - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[Tuple[int, int]]: + def process_frame(self, frame_num: int, + frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: """Process Frame: Computes/stores metrics and detects any scene changes. Prototype method, no actual detection. @@ -143,7 +145,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[Tuple[ """ return [] - def post_process(self, frame_num: int) -> List[Tuple[int, int]]: + def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: """Post Process: Performs any processing after the last frame has been read. Prototype method, no actual detection. @@ -153,3 +155,66 @@ def post_process(self, frame_num: int) -> List[Tuple[int, int]]: to be added to the output scene list directly. """ return [] + + +class FlashFilter: + + class Mode(Enum): + MERGE = 0 + """Merge consecutive cuts shorter than filter length.""" + SUPPRESS = 1 + """Suppress consecutive cuts until the filter length has passed.""" + + def __init__(self, mode: Mode, length: int): + self._mode = mode + self._filter_length = length # Number of frames to use for activating the filter. + self._last_above = None # Last frame above threshold. + self._merge_enabled = False # Used to disable merging until at least one cut was found. + self._merge_triggered = False # True when the merge filter is active. + self._merge_start = None # Frame number where we started the merge filte. + + def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + if not self._filter_length > 0: + return [frame_num] if above_threshold else [] + if self._last_above is None: + self._last_above = frame_num + if self._mode == FlashFilter.Mode.MERGE: + return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) + if self._mode == FlashFilter.Mode.SUPPRESS: + return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) + + def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + if not (above_threshold and min_length_met): + return [] + # Both length and threshold requirements were satisfied. Emit the cut, and wait until both + # requirements are met again. + self._last_above = frame_num + return [frame_num] + + def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + # Ensure last frame is always advanced to the most recent one that was above the threshold. + if above_threshold: + self._last_above = frame_num + if self._merge_triggered: + # This frame was under the threshold, see if enough frames passed to disable the filter. + num_merged_frames = self._last_above - self._merge_start + if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + self._merge_triggered = False + return [self._last_above] + # Keep merging until enough frames pass below the threshold. + return [] + # Wait for next frame above the threshold. + if not above_threshold: + return [] + # If we met the minimum length requirement, no merging is necessary. + if min_length_met: + # Only allow the merge filter once the first cut is emitted. + self._merge_enabled = True + return [frame_num] + # Start merging cuts until the length requirement is met. + if self._merge_enabled: + self._merge_triggered = True + self._merge_start = frame_num + return [] diff --git a/tests/test_detectors.py b/tests/test_detectors.py index e8c74719..42f46cbf 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -165,7 +165,8 @@ def get_fade_in_out_test_cases(): def test_detect_fast_cuts(test_case: TestCase): scene_list = test_case.detect() start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert test_case.scene_boundaries == start_frames + + assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time assert scene_list[-1][1] == test_case.end_time @@ -174,7 +175,7 @@ def test_detect_fast_cuts(test_case: TestCase): def test_detect_fades(test_case: TestCase): scene_list = test_case.detect() start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert test_case.scene_boundaries == start_frames + assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time assert scene_list[-1][1] == test_case.end_time @@ -191,14 +192,12 @@ def test_detectors_with_stats(test_video_file): end_time = FrameTimecode('00:00:08', video.frame_rate) scene_manager.detect_scenes(video=video, end_time=end_time) initial_scene_len = len(scene_manager.get_scene_list()) - assert initial_scene_len > 0 # test case must have at least one scene! - # Re-analyze using existing stats manager. + assert initial_scene_len > 0, "Test case must have at least one scene." + # Re-analyze using existing stats manager. scene_manager = SceneManager(stats_manager=stats) scene_manager.add_detector(detector()) - video.reset() scene_manager.auto_downscale = True - scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len diff --git a/website/pages/changelog.md b/website/pages/changelog.md index c24934f5..b0b78794 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -2,13 +2,18 @@ Releases ========================================================== -## PySceneDetect 0.6 +## PySceneDetect 0.7 -### 0.6.4 (In Development) +### 0.7 (In Development) - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) + - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` + - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - [bugfix] Remove extraneous console output when using `--drop-short-scenes` +## PySceneDetect 0.6 + ### 0.6.3 (March 9, 2024) #### Release Notes From 755c94152fce9254d198992ba06feac6ebd65a79 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 20 Apr 2024 19:15:50 -0400 Subject: [PATCH 079/407] [scene_detector] Make SceneDetector a proper interface --- scenedetect/detectors/adaptive_detector.py | 12 +-- scenedetect/detectors/content_detector.py | 10 ++- scenedetect/detectors/hash_detector.py | 12 ++- scenedetect/detectors/histogram_detector.py | 10 +-- scenedetect/detectors/threshold_detector.py | 7 +- scenedetect/scene_detector.py | 97 +++++++++------------ scenedetect/scene_manager.py | 40 ++------- website/pages/changelog.md | 4 + 8 files changed, 79 insertions(+), 113 deletions(-) diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 85778158..c26f592c 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -106,13 +106,10 @@ def event_buffer_length(self) -> int: """Number of frames any detected cuts will be behind the current frame due to buffering.""" return self.window_width - def get_metrics(self) -> List[str]: + @property + def metric_keys(self) -> List[str]: """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" - return super().get_metrics() + [self._adaptive_ratio_key] - - def stats_manager_required(self) -> bool: - """Not required for AdaptiveDetector.""" - return False + return super().metric_keys + [self._adaptive_ratio_key] def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. @@ -126,9 +123,6 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - - # TODO(#283): Merge this with ContentDetector and turn it on by default. - super().process_frame(frame_num=frame_num, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 954a91d7..fc5bdd27 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -138,11 +138,15 @@ def __init__( self._frame_score: Optional[float] = None self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) - def get_metrics(self): + @property + def metric_keys(self) -> List[str]: return ContentDetector.METRIC_KEYS - def is_processing_required(self, frame_num): - return True + @property + def event_buffer_length(self) -> int: + """Number of frames any detected cuts will be behind the current frame due to buffering.""" + # TODO(v0.7): Fixup private variables with properties. + return self._min_scene_len if self._flash_filter._mode == FlashFilter.Mode.MERGE else 0 def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> float: """Calculate score representing relative amount of motion in `frame_img` compared to diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 7b94bed0..c9b991aa 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -34,6 +34,8 @@ `detect-hash` command. """ +import typing as ty + # Third-Party Library Imports import numpy import cv2 @@ -113,12 +115,10 @@ def __init__( self._last_hash = numpy.array([]) self._metric_keys = ['hash_dist'] - def get_metrics(self): + @property + def metric_keys(self) -> ty.List[str]: return self._metric_keys - def is_processing_required(self, frame_num): - return True - def process_frame(self, frame_num, frame_img): """ Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference @@ -127,9 +127,7 @@ def process_frame(self, frame_num, frame_img): Arguments: frame_num (int): Frame number of frame that is being passed. - frame_img (Optional[int]): Decoded frame image (numpy.ndarray) to perform scene - detection on. Can be None *only* if the self.is_processing_required() method - (inhereted from the base SceneDetector class) returns True. + frame_img (numpy.ndarray): Decoded frame image (BGR) to perform scene detection on. Returns: List[int]: List of frames where scene cuts have been detected. There may be 0 diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 937b7e13..141f4ad0 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -16,7 +16,7 @@ This detector is available from the command-line as the `detect-hist` command. """ -from typing import List +import typing as ty import numpy @@ -49,7 +49,7 @@ def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int self._last_hist = None self._last_scene_cut = None - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """First, compress the image according to the self.bits value, then build a histogram for the input frame. Afterward, compare against the previously analyzed frame and check if the difference is large enough to trigger a cut. @@ -185,8 +185,6 @@ def _shift_images(self, img, img_shift): return shifted_img - def is_processing_required(self, frame_num: int) -> bool: - return True - - def get_metrics(self) -> List[str]: + @property + def metric_keys(self) -> ty.List[str]: return HistogramDetector.METRIC_KEYS diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 784bd1f9..ce00ec02 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -18,7 +18,7 @@ from enum import Enum from logging import getLogger -from typing import List, Optional +import typing as ty import numpy @@ -114,10 +114,11 @@ def __init__( } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] - def get_metrics(self) -> List[str]: + @property + def metric_keys(self) -> ty.List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index ded5d35d..5ba1c2e9 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -25,6 +25,7 @@ event (in, out, cut, etc...). """ +from abc import ABC, abstractmethod from enum import Enum import typing as ty @@ -33,60 +34,37 @@ from scenedetect.stats_manager import StatsManager -# pylint: disable=unused-argument, no-self-use -class SceneDetector: - """ Base class to inherit from when implementing a scene detection algorithm. +class SceneDetector(ABC): + """Base class to inherit from when implementing a scene detection algorithm. - This API is not yet stable and subject to change. + This API is not yet stable and subject to change. Currently has a very simple interface, where + on each frame, a detector emits a list of points where scene cuts are detected. - This represents a "dense" scene detector, which returns a list of frames where - the next scene/shot begins in a video. - - Also see the implemented scene detectors in the scenedetect.detectors module - to get an idea of how a particular detector can be created. + Also see the implemented scene detectors in the scenedetect.detectors module to get an idea of + how a particular detector can be created. In the future, this will be changed to support + different types of detections (e.g. fades versus cuts) and confidence scores of each event. """ - # TODO(v0.7): Make this a proper abstract base class. - - stats_manager: ty.Optional[StatsManager] = None - """Optional :class:`StatsManager ` to - use for caching frame metrics to and from.""" - - # TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain. - def is_processing_required(self, frame_num: int) -> bool: - """[DEPRECATED] DO NOT USE - Test if all calculations for a given frame are already done. - - Returns: - False if the SceneDetector has assigned _metric_keys, and the - stats_manager property is set to a valid StatsManager object containing - the required frame metrics/calculations for the given frame - thus, not - needing the frame to perform scene detection. - - True otherwise (i.e. the frame_img passed to process_frame is required - to be passed to process_frame for the given frame_num). - """ - metric_keys = self.get_metrics() - return not metric_keys or not (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys)) + def __init__(self): + self._stats_manager = None - def stats_manager_required(self) -> bool: - """Stats Manager Required: Prototype indicating if detector requires stats. - - Returns: - True if a StatsManager is required for the detector, False otherwise. - """ - return False + @property + def stats_manager(self) -> ty.Optional[StatsManager]: + """Optional :class:`StatsManager ` to + use for caching frame metrics to and from.""" + return self._stats_manager - def get_metrics(self) -> ty.List[str]: - """Get Metrics: Get a list of all metric names/keys used by the detector. + @stats_manager.setter + def stats_manager(self, new_manager): + self._stats_manager = new_manager - Returns: - List of strings of frame metric key names that will be used by - the detector when a StatsManager is passed to process_frame. - """ - return [] + @property + @abstractmethod + def metric_keys(self) -> ty.List[str]: + """List of all metric names/keys used by the detector.""" + raise NotImplementedError + @abstractmethod def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. @@ -102,12 +80,12 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int Returns: List of frame numbers of cuts to be added to the cutting list. """ - return [] + raise NotImplementedError def post_process(self, frame_num: int) -> ty.List[int]: """Post Process: Performs any processing after the last frame has been read. - Prototype method, no actual detection. + Default implementation is a no-op. Returns: List of frame numbers of cuts to be added to the cutting list. @@ -121,16 +99,26 @@ def event_buffer_length(self) -> int: """ return 0 + # DEPRECATED METHODS TO BE REMOVED IN v1.0 -class SparseSceneDetector(SceneDetector): - """Base class to inherit from when implementing a sparse scene detection algorithm. + def is_processing_required(self, frame_num: int) -> bool: + """[DEPRECATED] DO NOT USE""" + return True - This class will be removed in v1.0 and should not be used. + def stats_manager_required(self) -> bool: + """[DEPRECATED] DO NOT USE""" + return False - Unlike dense detectors, sparse detectors detect "events" and return a *pair* of frames, - as opposed to just a single cut. + def get_metrics(self) -> ty.List[str]: + """[DEPRECATED] USE `metric_keys` PROPERTY INSTEAD""" + return self.metric_keys + + +class SparseSceneDetector(SceneDetector): + """[DEPRECATED - DO NOT USE] - An example of a SparseSceneDetector is the MotionDetector. + This class will be removed in v1.0, with the goal being the SceneDetector interface will emit + event types and confidence scores rather than having different interfaces. """ def process_frame(self, frame_num: int, @@ -157,6 +145,7 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: return [] +# TODO(v0.7): Add documentation. class FlashFilter: class Mode(Enum): diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 3d3bd435..a138ec3b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -639,15 +639,9 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ - if self._stats_manager is None and detector.stats_manager_required(): - # Make sure the lists are empty so that the detectors don't get - # out of sync (require an explicit statsmanager instead) - assert not self._detector_list and not self._sparse_detector_list - self._stats_manager = StatsManager() - detector.stats_manager = self._stats_manager if self._stats_manager is not None: - self._stats_manager.register_metrics(detector.get_metrics()) + self._stats_manager.register_metrics(detector.metric_keys) if not issubclass(type(detector), SparseSceneDetector): self._detector_list.append(detector) @@ -908,24 +902,14 @@ def _decode_thread( ): try: while not self._stop.is_set(): - frame_im = None - # We don't do any kind of locking here since the worst-case of this being wrong - # is that we do some extra work, and this function should never mutate any data - # (all of which should be modified under the GIL). - # TODO(v1.0): This optimization should be removed as it is an uncommon use case and - # greatly increases the complexity of detection algorithms using it. - if self._is_processing_required(video.position.frame_num): - frame_im = video.read() - if frame_im is False: - break - if downscale_factor > 1: - frame_im = cv2.resize( - frame_im, (round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor)), - interpolation=self._interpolation.value) - else: - if video.read(decode=False) is False: - break + frame_im = video.read() + if frame_im is False: + break + if downscale_factor > 1: + frame_im = cv2.resize( + frame_im, (round(frame_im.shape[1] / downscale_factor), + round(frame_im.shape[0] / downscale_factor)), + interpolation=self._interpolation.value) # Set the start position now that we decoded at least the first frame. if self._start_pos is None: @@ -1018,9 +1002,3 @@ def get_event_list( return self._get_event_list() # pylint: enable=unused-argument - - def _is_processing_required(self, frame_num: int) -> bool: - """True if frame metrics not in StatsManager, False otherwise.""" - if self.stats_manager is None: - return True - return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index b0b78794..1fe3545a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -11,6 +11,10 @@ Releases - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - [bugfix] Remove extraneous console output when using `--drop-short-scenes` + - [api] Major changes to `SceneDetector` interface: + - Replace `get_metrics()` function with abstract property `metric_keys` to avoid confusion with `StatsManager.get_metrics()` function + - Deprecate `is_processing_required()` and `stats_manager_required()` functions + - Replace public `stats_manager` class variable with property including setter/getter ## PySceneDetect 0.6 From 9ad1f0547d1c940428e86da76031699e390edc68 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Apr 2024 10:16:04 -0400 Subject: [PATCH 080/407] Revert "[scene_detector] Make SceneDetector a proper interface" Need to create separate branch for breaking v0.7 API changes. This reverts commit 755c94152fce9254d198992ba06feac6ebd65a79. --- scenedetect/detectors/adaptive_detector.py | 12 ++- scenedetect/detectors/content_detector.py | 10 +-- scenedetect/detectors/hash_detector.py | 12 +-- scenedetect/detectors/histogram_detector.py | 10 ++- scenedetect/detectors/threshold_detector.py | 7 +- scenedetect/scene_detector.py | 97 ++++++++++++--------- scenedetect/scene_manager.py | 40 +++++++-- website/pages/changelog.md | 10 +-- 8 files changed, 115 insertions(+), 83 deletions(-) diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index c26f592c..85778158 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -106,10 +106,13 @@ def event_buffer_length(self) -> int: """Number of frames any detected cuts will be behind the current frame due to buffering.""" return self.window_width - @property - def metric_keys(self) -> List[str]: + def get_metrics(self) -> List[str]: """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" - return super().metric_keys + [self._adaptive_ratio_key] + return super().get_metrics() + [self._adaptive_ratio_key] + + def stats_manager_required(self) -> bool: + """Not required for AdaptiveDetector.""" + return False def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. @@ -123,6 +126,9 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ + + # TODO(#283): Merge this with ContentDetector and turn it on by default. + super().process_frame(frame_num=frame_num, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index fc5bdd27..954a91d7 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -138,15 +138,11 @@ def __init__( self._frame_score: Optional[float] = None self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) - @property - def metric_keys(self) -> List[str]: + def get_metrics(self): return ContentDetector.METRIC_KEYS - @property - def event_buffer_length(self) -> int: - """Number of frames any detected cuts will be behind the current frame due to buffering.""" - # TODO(v0.7): Fixup private variables with properties. - return self._min_scene_len if self._flash_filter._mode == FlashFilter.Mode.MERGE else 0 + def is_processing_required(self, frame_num): + return True def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> float: """Calculate score representing relative amount of motion in `frame_img` compared to diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index c9b991aa..7b94bed0 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -34,8 +34,6 @@ `detect-hash` command. """ -import typing as ty - # Third-Party Library Imports import numpy import cv2 @@ -115,10 +113,12 @@ def __init__( self._last_hash = numpy.array([]) self._metric_keys = ['hash_dist'] - @property - def metric_keys(self) -> ty.List[str]: + def get_metrics(self): return self._metric_keys + def is_processing_required(self, frame_num): + return True + def process_frame(self, frame_num, frame_img): """ Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference @@ -127,7 +127,9 @@ def process_frame(self, frame_num, frame_img): Arguments: frame_num (int): Frame number of frame that is being passed. - frame_img (numpy.ndarray): Decoded frame image (BGR) to perform scene detection on. + frame_img (Optional[int]): Decoded frame image (numpy.ndarray) to perform scene + detection on. Can be None *only* if the self.is_processing_required() method + (inhereted from the base SceneDetector class) returns True. Returns: List[int]: List of frames where scene cuts have been detected. There may be 0 diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 141f4ad0..937b7e13 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -16,7 +16,7 @@ This detector is available from the command-line as the `detect-hist` command. """ -import typing as ty +from typing import List import numpy @@ -49,7 +49,7 @@ def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int self._last_hist = None self._last_scene_cut = None - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """First, compress the image according to the self.bits value, then build a histogram for the input frame. Afterward, compare against the previously analyzed frame and check if the difference is large enough to trigger a cut. @@ -185,6 +185,8 @@ def _shift_images(self, img, img_shift): return shifted_img - @property - def metric_keys(self) -> ty.List[str]: + def is_processing_required(self, frame_num: int) -> bool: + return True + + def get_metrics(self) -> List[str]: return HistogramDetector.METRIC_KEYS diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index ce00ec02..784bd1f9 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -18,7 +18,7 @@ from enum import Enum from logging import getLogger -import typing as ty +from typing import List, Optional import numpy @@ -114,11 +114,10 @@ def __init__( } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] - @property - def metric_keys(self) -> ty.List[str]: + def get_metrics(self) -> List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 5ba1c2e9..ded5d35d 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -25,7 +25,6 @@ event (in, out, cut, etc...). """ -from abc import ABC, abstractmethod from enum import Enum import typing as ty @@ -34,37 +33,60 @@ from scenedetect.stats_manager import StatsManager -class SceneDetector(ABC): - """Base class to inherit from when implementing a scene detection algorithm. +# pylint: disable=unused-argument, no-self-use +class SceneDetector: + """ Base class to inherit from when implementing a scene detection algorithm. - This API is not yet stable and subject to change. Currently has a very simple interface, where - on each frame, a detector emits a list of points where scene cuts are detected. + This API is not yet stable and subject to change. - Also see the implemented scene detectors in the scenedetect.detectors module to get an idea of - how a particular detector can be created. In the future, this will be changed to support - different types of detections (e.g. fades versus cuts) and confidence scores of each event. + This represents a "dense" scene detector, which returns a list of frames where + the next scene/shot begins in a video. + + Also see the implemented scene detectors in the scenedetect.detectors module + to get an idea of how a particular detector can be created. """ + # TODO(v0.7): Make this a proper abstract base class. - def __init__(self): - self._stats_manager = None + stats_manager: ty.Optional[StatsManager] = None + """Optional :class:`StatsManager ` to + use for caching frame metrics to and from.""" - @property - def stats_manager(self) -> ty.Optional[StatsManager]: - """Optional :class:`StatsManager ` to - use for caching frame metrics to and from.""" - return self._stats_manager + # TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain. + def is_processing_required(self, frame_num: int) -> bool: + """[DEPRECATED] DO NOT USE - @stats_manager.setter - def stats_manager(self, new_manager): - self._stats_manager = new_manager + Test if all calculations for a given frame are already done. - @property - @abstractmethod - def metric_keys(self) -> ty.List[str]: - """List of all metric names/keys used by the detector.""" - raise NotImplementedError + Returns: + False if the SceneDetector has assigned _metric_keys, and the + stats_manager property is set to a valid StatsManager object containing + the required frame metrics/calculations for the given frame - thus, not + needing the frame to perform scene detection. + + True otherwise (i.e. the frame_img passed to process_frame is required + to be passed to process_frame for the given frame_num). + """ + metric_keys = self.get_metrics() + return not metric_keys or not (self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys)) + + def stats_manager_required(self) -> bool: + """Stats Manager Required: Prototype indicating if detector requires stats. + + Returns: + True if a StatsManager is required for the detector, False otherwise. + """ + return False + + def get_metrics(self) -> ty.List[str]: + """Get Metrics: Get a list of all metric names/keys used by the detector. + + Returns: + List of strings of frame metric key names that will be used by + the detector when a StatsManager is passed to process_frame. + """ + return [] - @abstractmethod def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. @@ -80,12 +102,12 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int Returns: List of frame numbers of cuts to be added to the cutting list. """ - raise NotImplementedError + return [] def post_process(self, frame_num: int) -> ty.List[int]: """Post Process: Performs any processing after the last frame has been read. - Default implementation is a no-op. + Prototype method, no actual detection. Returns: List of frame numbers of cuts to be added to the cutting list. @@ -99,26 +121,16 @@ def event_buffer_length(self) -> int: """ return 0 - # DEPRECATED METHODS TO BE REMOVED IN v1.0 - - def is_processing_required(self, frame_num: int) -> bool: - """[DEPRECATED] DO NOT USE""" - return True - - def stats_manager_required(self) -> bool: - """[DEPRECATED] DO NOT USE""" - return False - def get_metrics(self) -> ty.List[str]: - """[DEPRECATED] USE `metric_keys` PROPERTY INSTEAD""" - return self.metric_keys +class SparseSceneDetector(SceneDetector): + """Base class to inherit from when implementing a sparse scene detection algorithm. + This class will be removed in v1.0 and should not be used. -class SparseSceneDetector(SceneDetector): - """[DEPRECATED - DO NOT USE] + Unlike dense detectors, sparse detectors detect "events" and return a *pair* of frames, + as opposed to just a single cut. - This class will be removed in v1.0, with the goal being the SceneDetector interface will emit - event types and confidence scores rather than having different interfaces. + An example of a SparseSceneDetector is the MotionDetector. """ def process_frame(self, frame_num: int, @@ -145,7 +157,6 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: return [] -# TODO(v0.7): Add documentation. class FlashFilter: class Mode(Enum): diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index a138ec3b..3d3bd435 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -639,9 +639,15 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ + if self._stats_manager is None and detector.stats_manager_required(): + # Make sure the lists are empty so that the detectors don't get + # out of sync (require an explicit statsmanager instead) + assert not self._detector_list and not self._sparse_detector_list + self._stats_manager = StatsManager() + detector.stats_manager = self._stats_manager if self._stats_manager is not None: - self._stats_manager.register_metrics(detector.metric_keys) + self._stats_manager.register_metrics(detector.get_metrics()) if not issubclass(type(detector), SparseSceneDetector): self._detector_list.append(detector) @@ -902,14 +908,24 @@ def _decode_thread( ): try: while not self._stop.is_set(): - frame_im = video.read() - if frame_im is False: - break - if downscale_factor > 1: - frame_im = cv2.resize( - frame_im, (round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor)), - interpolation=self._interpolation.value) + frame_im = None + # We don't do any kind of locking here since the worst-case of this being wrong + # is that we do some extra work, and this function should never mutate any data + # (all of which should be modified under the GIL). + # TODO(v1.0): This optimization should be removed as it is an uncommon use case and + # greatly increases the complexity of detection algorithms using it. + if self._is_processing_required(video.position.frame_num): + frame_im = video.read() + if frame_im is False: + break + if downscale_factor > 1: + frame_im = cv2.resize( + frame_im, (round(frame_im.shape[1] / downscale_factor), + round(frame_im.shape[0] / downscale_factor)), + interpolation=self._interpolation.value) + else: + if video.read(decode=False) is False: + break # Set the start position now that we decoded at least the first frame. if self._start_pos is None: @@ -1002,3 +1018,9 @@ def get_event_list( return self._get_event_list() # pylint: enable=unused-argument + + def _is_processing_required(self, frame_num: int) -> bool: + """True if frame metrics not in StatsManager, False otherwise.""" + if self.stats_manager is None: + return True + return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1fe3545a..f304cf2f 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -2,21 +2,15 @@ Releases ========================================================== -## PySceneDetect 0.7 +## PySceneDetect 0.6 -### 0.7 (In Development) +### 0.6.4 (In Development) - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - [bugfix] Remove extraneous console output when using `--drop-short-scenes` - - [api] Major changes to `SceneDetector` interface: - - Replace `get_metrics()` function with abstract property `metric_keys` to avoid confusion with `StatsManager.get_metrics()` function - - Deprecate `is_processing_required()` and `stats_manager_required()` functions - - Replace public `stats_manager` class variable with property including setter/getter - -## PySceneDetect 0.6 ### 0.6.3 (March 9, 2024) From 894297d74a88621546fc183f971e7da3ba8e915a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Apr 2024 16:14:59 -0400 Subject: [PATCH 081/407] [cli] Add --filter-mode and replace --drop-short-scenes --- scenedetect.cfg | 22 +- scenedetect/__init__.py | 2 +- scenedetect/_cli/__init__.py | 11 + scenedetect/_cli/config.py | 13 +- scenedetect/_cli/context.py | 95 +- scenedetect/_cli/controller.py | 7 +- scenedetect/cli/__init__.py | 1189 ------------------- scenedetect/detectors/content_detector.py | 41 +- scenedetect/detectors/threshold_detector.py | 99 +- scenedetect/scene_detector.py | 89 +- scenedetect/scene_manager.py | 49 +- tests/test_api.py | 4 +- website/pages/changelog.md | 7 +- 13 files changed, 214 insertions(+), 1414 deletions(-) delete mode 100644 scenedetect/cli/__init__.py diff --git a/scenedetect.cfg b/scenedetect.cfg index 23a86ab4..b8bc3815 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -42,18 +42,16 @@ # Minimum length of a given scene. See filter-mode to control how this is enforced. #min-scene-len = 0.6s -# Mode to use when filtering out scenes (merge or suppress): +# Mode to use when filtering out scenes (merge, suppress, drop): # merge: Consecutive scenes shorter than min-scene-len are combined. # suppress: No new scenes can be generated until min-scene-len passes. +# drop: Drop all scenes shorter than global min-scene-len. #filter-mode = merge -# Merge last scene if it is shorter than min-scene-len (yes/no). This can occur -# when a cut is detected just before the video ends. +# If the video ends less than min-scene-len after the last cut, merge it with the +# previous scene (yes/no). #merge-last-scene = no -# Drop scenes shorter than min-scene-len instead of merging (yes/no). -#drop-short-scenes = no - # Verbosity of console output (debug, info, warning, error, or none). # Set to none for the same behavior as specifying -q/--quiet. #verbosity = debug @@ -89,7 +87,8 @@ # than or equal to 3. If None, automatically set using video resolution. #kernel-size = -1 -# Minimum length of a given scene (overrides [global] option). +# Minimum length of a given scene. No new cuts can be emitted after one is found +# until this length of time passes. #min-scene-len = 0.6s @@ -107,7 +106,8 @@ # Discard colour information and only use luminance (yes/no). #luma-only = no -# Minimum length of a given scene (overrides [global] option). +# Minimum length of a given scene. No new cuts can be emitted after one is found +# until this length of time passes. #min-scene-len = 0.6s @@ -121,7 +121,8 @@ # Window size (number of frames) before and after each frame to average together. #frame-window = 2 -# Minimum length of a given scene (overrides [global] option). +# Minimum length of a given scene. No new cuts can be emitted after one is found +# until this length of time passes. #min-scene-len = 0.6s # The following parameters are the those used to calculate `content_val`. @@ -143,7 +144,8 @@ # Number of bits to use for image quantization before binning. #bits = 4 -# Minimum length of a given scene (overrides [global] option). +# Minimum length of a given scene. No new cuts can be emitted after one is found +# until this length of time passes. #min-scene-len = 0.6s diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index f928ad4e..aa9fd929 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.7-dev0' +__version__ = '0.6.4-dev0' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index d5823703..c9eb8352 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -206,10 +206,19 @@ def _print_command_help(ctx: click.Context, command: click.Command): help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s' % USER_CONFIG.get_help_string("global", "min-scene-len"), ) +@click.option( + "--filter-mode", + metavar="MODE", + type=click.Choice(CHOICE_MAP["global"]["filter-mode"], False), + default=None, + help='Mode used when enforcing min-scene-len. MODE must be one of: %s. %s' % (', '.join( + CHOICE_MAP["global"]["filter-mode"]), USER_CONFIG.get_help_string("global", "filter-mode")), +) @click.option( '--drop-short-scenes', is_flag=True, flag_value=True, + hidden=True, help='Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s' % (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), ) @@ -281,6 +290,7 @@ def scenedetect( config: Optional[AnyStr], framerate: Optional[float], min_scene_len: Optional[str], + filter_mode: Optional[str], drop_short_scenes: bool, merge_last_scene: bool, backend: Optional[str], @@ -325,6 +335,7 @@ def scenedetect( downscale=downscale, frame_skip=frame_skip, min_scene_len=min_scene_len, + filter_mode=filter_mode, drop_short_scenes=drop_short_scenes, merge_last_scene=merge_last_scene, backend=backend, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 2f72e9ca..91767a07 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -27,6 +27,7 @@ from scenedetect.detectors import ContentDetector from scenedetect.frame_timecode import FrameTimecode +from scenedetect.scene_detector import FlashFilter from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS @@ -232,6 +233,13 @@ def format(self, timecode: FrameTimecode) -> str: assert False +class FlashFilterMode(Enum): + """Filter mode for the CLI. Has additional DROP mode which runs as a post-processing step.""" + MERGE = FlashFilter.Mode.MERGE + SUPPRESS = FlashFilter.Mode.SUPPRESS + DROP = -1 + + ConfigValue = Union[bool, int, float, str] ConfigDict = Dict[str, Dict[str, ConfigValue]] @@ -243,7 +251,8 @@ def format(self, timecode: FrameTimecode) -> str: DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 -# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv +# TODO(v0.6.4): Warn if [detect-adaptive] min-delta-hsv and [global] drop-short-scenes are used. +# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv and [global] drop-short-scenes CONFIG_MAP: ConfigDict = { 'backend-opencv': { 'max-decode-attempts': 5, @@ -305,6 +314,7 @@ def format(self, timecode: FrameTimecode) -> str: 'downscale': 0, 'downscale-method': 'linear', 'drop-short-scenes': False, + 'filter-mode': 'merge', 'frame-skip': 0, 'merge-last-scene': False, 'min-scene-len': TimecodeValue('0.6s'), @@ -348,6 +358,7 @@ def format(self, timecode: FrameTimecode) -> str: 'backend': ['opencv', 'pyav', 'moviepy'], 'default-detector': ['detect-adaptive', 'detect-content', 'detect-threshold'], 'downscale-method': [value.name.lower() for value in Interpolation], + 'filter-mode': [value.name.lower() for value in FlashFilterMode], 'verbosity': ['debug', 'info', 'warning', 'error', 'none'], }, 'list-scenes': { diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index c1ceb48d..f37d6806 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -23,8 +23,8 @@ from scenedetect import open_video, AVAILABLE_BACKENDS -from scenedetect.scene_detector import SceneDetector -from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger +from scenedetect.scene_detector import SceneDetector, FlashFilter +from scenedetect.platform import get_cv2_imwrite_params, init_logger from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available @@ -32,8 +32,9 @@ from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP, - DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY) +from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, + FlashFilterMode, CHOICE_MAP, DEFAULT_JPG_QUALITY, + DEFAULT_WEBP_QUALITY) logger = logging.getLogger('pyscenedetect') @@ -116,9 +117,9 @@ def __init__(self): self.output_dir: str = None # -o/--output self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet self.stats_file_path: str = None # -s/--stats - self.drop_short_scenes: bool = None # --drop-short-scenes - self.merge_last_scene: bool = None # --merge-last-scene self.min_scene_len: FrameTimecode = None # -m/--min-scene-len + self.filter_mode: FlashFilterMode = None # --filter-mode + self.merge_last_scene: bool = None # --merge-last-scene self.frame_skip: int = None # -fs/--frame-skip self.default_detector: Tuple[Type[SceneDetector], Dict[str, Any]] = None # [global] default-detector @@ -186,6 +187,7 @@ def handle_options( downscale: Optional[int], frame_skip: int, min_scene_len: str, + filter_mode: Optional[str], drop_short_scenes: bool, merge_last_scene: bool, backend: Optional[str], @@ -269,8 +271,15 @@ def handle_options( self.min_scene_len = parse_timecode( min_scene_len if min_scene_len is not None else self.config.get_value( "global", "min-scene-len"), self.video_stream.frame_rate) - self.drop_short_scenes = drop_short_scenes or self.config.get_value( - "global", "drop-short-scenes") + + if drop_short_scenes: + logger.warning( + "WARNING: --drop-short-scenes is deprecated, use --filter-mode=drop instead.") + if filter_mode is None: + self.filter_mode = FlashFilterMode.DROP + else: + self.filter_mode = FlashFilterMode[self.config.get_value("global", "filter-mode", + filter_mode).upper()] self.merge_last_scene = merge_last_scene or self.config.get_value( "global", "merge-last-scene") self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) @@ -281,6 +290,7 @@ def handle_options( self.stats_manager = StatsManager() # Initialize default detector with values in the config file. + # TODO(v0.6.4): Integrate perceptual hash detector. default_detector = self.config.get_value("global", "default-detector") if default_detector == 'detect-adaptive': self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) @@ -320,29 +330,18 @@ def get_detect_content_params( ) -> Dict[str, Any]: """Handle detect-content command options and return dict to construct one with.""" self._ensure_input_open() - - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default('detect-content', 'min-scene-len'): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value('detect-content', 'min-scene-len') - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='weights') + raise click.BadParameter(str(ex), param_hint="weights") return { - 'weights': self.config.get_value('detect-content', 'weights', weights), - 'kernel_size': self.config.get_value('detect-content', 'kernel-size', kernel_size), - 'luma_only': luma_only or self.config.get_value('detect-content', 'luma-only'), - 'min_scene_len': min_scene_len, - 'threshold': self.config.get_value('detect-content', 'threshold', threshold), + "weights": self.config.get_value("detect-content", "weights", weights), + "kernel_size": self.config.get_value("detect-content", "kernel-size", kernel_size), + "luma_only": luma_only or self.config.get_value("detect-content", "luma-only"), + "flash_filter": self._init_flash_filter("detect-content", min_scene_len), + "threshold": self.config.get_value("detect-content", "threshold", threshold), } def get_detect_adaptive_params( @@ -372,15 +371,8 @@ def get_detect_adaptive_params( self.config.config_dict["detect-adaptive"]["min-content-val"] = ( self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-adaptive", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + # TODO(v0.6.4): Integrate flash filter. + min_scene_len = self._init_flash_filter("detect-adaptive", min_scene_len)._filter_length if weights is not None: try: @@ -414,16 +406,8 @@ def get_detect_threshold_params( ) -> Dict[str, Any]: """Handle detect-threshold command options and return dict to construct one with.""" self._ensure_input_open() - - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-threshold", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + # TODO(v0.6.4): Integrate flash filter. + min_scene_len = self._init_flash_filter("detect-adaptive", min_scene_len)._filter_length # TODO(v1.0): add_last_scene cannot be disabled right now. return { 'add_final_scene': @@ -455,15 +439,8 @@ def get_detect_hist_params(self, threshold: Optional[float], bits: Optional[int] min_scene_len: Optional[str]) -> Dict[str, Any]: """Handle detect-hist command options and return dict to construct one with.""" self._ensure_input_open() - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hist", "min-scene-len"): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + # TODO(v0.6.4): Integrate flash filter. + min_scene_len = self._init_flash_filter("detect-adaptive", min_scene_len)._filter_length return { 'bits': self.config.get_value("detect-hist", "bits", bits), 'min_scene_len': min_scene_len, @@ -852,3 +829,15 @@ def _on_duplicate_command(self, command: str) -> None: raise click.BadParameter( '\n Command %s may only be specified once.' % command, param_hint='%s command' % command) + + def _init_flash_filter(self, detector_name: str, + min_scene_len: ty.Optional[int]) -> FlashFilter: + if self.filter_mode == FlashFilterMode.DROP: + return FlashFilter(length=0) + if min_scene_len is None: + if self.config.is_default(detector_name, 'min-scene-len'): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value(detector_name, 'min-scene-len') + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + return FlashFilter(length=min_scene_len, mode=self.filter_mode.value) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index d7180542..412227cb 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -28,6 +28,7 @@ from scenedetect.video_stream import SeekError from scenedetect._cli.context import CliContext, check_split_video_requirements +from scenedetect._cli.config import FlashFilterMode logger = logging.getLogger('pyscenedetect') @@ -330,11 +331,13 @@ def _postprocess_scene_list( # it will be merged with the previous one. if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: + logger.debug("Last scene is shorter than %d frames, merging with previous.", + context.min_scene_len.get_frames()) new_last_scene = (scene_list[-2][0], scene_list[-1][1]) scene_list = scene_list[:-2] + [new_last_scene] - # Handle --drop-short-scenes. - if context.drop_short_scenes and context.min_scene_len > 0: + if context.filter_mode == FlashFilterMode.DROP: + logger.debug("Dropping scenes shorter than %d frames.", context.min_scene_len.get_frames()) scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] return scene_list diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py deleted file mode 100644 index 22c76164..00000000 --- a/scenedetect/cli/__init__.py +++ /dev/null @@ -1,1189 +0,0 @@ -# -*- 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-2023 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""``scenedetect.cli`` Module - -This file contains the implementation of the PySceneDetect command-line interface (CLI) parser -logic for the PySceneDetect application ("business logic"), The main CLI entry-point function is -the function scenedetect_cli, which is a chained command group. - -The scenedetect.cli module coordinates first parsing all commands and their options using a -`CliContext`, finally performing scene detection by passing the `CliContext` to the -`run_scenedetect` run in `scenedetect.cli.controller`. -""" - -# Some parts of this file need word wrap to be displayed. -# pylint: disable=line-too-long - -import logging -from typing import AnyStr, Optional, Tuple - -import click - -import scenedetect -from scenedetect.backends import AVAILABLE_BACKENDS -from scenedetect.platform import get_system_version_info - -from scenedetect.cli.config import CHOICE_MAP, CONFIG_FILE_PATH, CONFIG_MAP -from scenedetect.cli.context import CliContext, USER_CONFIG -from scenedetect.cli.controller import run_scenedetect - -logger = logging.getLogger('pyscenedetect') - - -def _get_help_command_preface(command_name='scenedetect'): - """Preface/intro help message shown at the beginning of the help command.""" - return """ -The PySceneDetect command-line interface is grouped into commands which -can be combined together, each containing its own set of arguments: - - > {command_name} ([options]) [command] ([options]) ([...other command(s)...]) - -Where [command] is the name of the command, and ([options]) are the -arguments/options associated with the command, if any. Options -associated with the {command_name} command below (e.g. --input, ---framerate) must be specified before any commands. The order of -commands is not strict, but each command should only be specified once. - -Commands can also be combined, for example, running the 'detect-content' -and 'list-scenes' (specifying options for the latter): - - > {command_name} -i vid0001.mp4 detect-content list-scenes -n - -A list of all commands is printed below. Help for a particular command -can be printed by specifying 'help [command]', or 'help all' to print -the help information for every command. - -Lastly, there are several commands used for displaying application -version and copyright information (e.g. {command_name} about): - - help: Display help information (e.g. `help [command]`). - version: Display version of PySceneDetect being used. - about: Display license and copyright information. -""".format(command_name=command_name) - - -_COMMAND_DICT = [] -"""All commands registered with the CLI. Used for generating help contexts.""" - - -def _print_command_help(ctx: click.Context, command: click.Command): - """Print PySceneDetect help/usage for a given command.""" - ctx.help_option_names = [] - ctx_name = ctx.info_name - ctx.info_name = command.name - click.echo(click.style('`%s` Command' % command.name, fg='cyan')) - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(command.get_help(ctx)) - click.echo('') - ctx.info_name = ctx_name - - -def _print_command_list_header() -> None: - """Print header shown before the option/command list.""" - click.echo(click.style('PySceneDetect Options & Commands', fg='green')) - click.echo(click.style('----------------------------------------------------', fg='green')) - click.echo('') - - -def _print_help_header() -> None: - """Print header shown before the help command.""" - click.echo(click.style('----------------------------------------------------', fg='yellow')) - click.echo(click.style(' PySceneDetect %s Help' % scenedetect.__version__, fg='yellow')) - click.echo(click.style('----------------------------------------------------', fg='yellow')) - - -@click.group( - chain=True, - context_settings=dict(help_option_names=['-h', '--help']), -) -@click.option( - '--input', - '-i', - multiple=False, - required=False, - metavar='VIDEO', - type=click.STRING, - help='[Required] Input video file. Also supports image sequences and URLs.', -) -@click.option( - '--output', - '-o', - multiple=False, - required=False, - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help='Output directory for created files (stats file, output videos, images, etc...).' - ' If not set defaults to working directory. Some commands allow overriding this value.%s' % - (USER_CONFIG.get_help_string("global", "output", show_default=False)), -) -@click.option( - '--framerate', - '-f', - metavar='FPS', - type=click.FLOAT, - default=None, - help='Force framerate, in frames/sec (e.g. -f 29.97). Disables check to ensure that all' - ' input videos have the same framerates.', -) -@click.option( - '--downscale', - '-d', - metavar='N', - type=click.INT, - default=None, - help='Integer factor to downscale frames by (e.g. 2, 3, 4...), where the frame is scaled' - ' to width/N x height/N (thus -d 1 implies no downscaling). Leave unset for automatic' - ' downscaling based on source resolution.%s' % - (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), -) -@click.option( - '--frame-skip', - '-fs', - metavar='N', - type=click.INT, - default=None, - help='Skips N frames during processing (-fs 1 skips every other frame, processing 50%%' - ' of the video, -fs 2 processes 33%% of the frames, -fs 3 processes 25%%, etc...).' - ' Reduces processing speed at expense of accuracy.%s' % - USER_CONFIG.get_help_string("global", "frame-skip"), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. TIMECODE can be specified as exact' - ' number of frames, a time in seconds followed by s, or a timecode in the' - ' format HH:MM:SS or HH:MM:SS.nnn.%s' % USER_CONFIG.get_help_string("global", "min-scene-len"), -) -@click.option( - '--drop-short-scenes', - is_flag=True, - flag_value=True, - help='Drop scenes shorter than `min-scene-len` instead of combining them with neighbors.%s' % - (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), -) -@click.option( - '--merge-last-scene', - is_flag=True, - flag_value=True, - help='Merge last scene with previous if shorter than min-scene-len.%s' % - (USER_CONFIG.get_help_string('global', 'merge-last-scene')), -) -@click.option( - '--stats', - '-s', - metavar='CSV', - type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Path to stats file (.csv) for writing frame metrics to. If the file exists, any' - ' metrics will be processed, otherwise a new file will be created. Can be used to determine' - ' optimal values for various scene detector options, and to cache frame calculations in order' - ' to speed up multiple detection runs.', -) -@click.option( - '--verbosity', - '-v', - metavar='LEVEL', - type=click.Choice(CHOICE_MAP['global']['verbosity'], False), - default=None, - help='Level of debug/info/error information to show. Must be one of: %s.' - ' Overrides `-q`/`--quiet`. Use `-v debug` for bug reports.%s' % (', '.join( - CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string("global", "verbosity")), -) -@click.option( - '--logfile', - '-l', - metavar='LOG', - type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Path to log file for writing application logging information, mainly for debugging.' - ' Set `-v debug` as well if you are submitting a bug report. If verbosity is none, logfile' - ' is still be generated with info-level verbosity.', -) -@click.option( - '--quiet', - '-q', - is_flag=True, - flag_value=True, - help='Suppresses all output of PySceneDetect to the terminal/stdout. Equivalent to `-v none`.', -) -@click.option( - '--backend', - '-b', - metavar='BACKEND', - type=click.Choice(CHOICE_MAP["global"]["backend"]), - default=None, - help='Backend to use for video input. Backends can be configured using -c/--config. Backends' - ' available on this system: %s.%s.' % - (', '.join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), -) -@click.option( - '--config', - '-c', - metavar='FILE', - type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help='Path to config file. If not set, tries to load one from %s' % (CONFIG_FILE_PATH), -) -@click.pass_context -# pylint: disable=redefined-builtin -def scenedetect_cli( - ctx: click.Context, - input: Optional[AnyStr], - output: Optional[AnyStr], - framerate: Optional[float], - downscale: Optional[int], - frame_skip: Optional[int], - min_scene_len: Optional[str], - drop_short_scenes: bool, - merge_last_scene: bool, - stats: Optional[AnyStr], - verbosity: Optional[str], - logfile: Optional[AnyStr], - quiet: bool, - backend: Optional[str], - config: Optional[AnyStr], -): - """For example: - - scenedetect -i video.mp4 -s video.stats.csv detect-content list-scenes - - Note that the following options represent [OPTIONS] above. To list the optional - [ARGS] for a particular COMMAND, type `scenedetect help COMMAND`. You can also - combine commands (e.g. scenedetect [...] detect-content save-images --png split-video). - - - """ - assert isinstance(ctx.obj, CliContext) - ctx.call_on_close(lambda: run_scenedetect(ctx.obj)) - ctx.obj.handle_options( - input_path=input, - output=output, - framerate=framerate, - stats_file=stats, - downscale=downscale, - frame_skip=frame_skip, - min_scene_len=min_scene_len, - drop_short_scenes=drop_short_scenes, - merge_last_scene=merge_last_scene, - backend=backend, - quiet=quiet, - logfile=logfile, - config=config, - stats=stats, - verbosity=verbosity, - ) - - -# pylint: enable=redefined-builtin - - -@click.command('help') -@click.argument( - 'command_name', - required=False, - type=click.STRING, -) -@click.pass_context -def help_command(ctx: click.Context, command_name: str): - """Print help for command (`help [command]`) or all commands (`help all`).""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.process_input_flag = False - if command_name is not None: - if command_name.lower() == 'all': - _print_help_header() - click.echo(_get_help_command_preface(ctx.parent.info_name)) - _print_command_list_header() - click.echo(ctx.parent.get_help()) - click.echo('') - for command in _COMMAND_DICT: - _print_command_help(ctx, command) - else: - command = None - for command_ref in _COMMAND_DICT: - if command_name == command_ref.name: - command = command_ref - break - if command is None: - error_strs = [ - 'unknown command.', 'List of valid commands:', - ' %s' % ', '.join([command.name for command in _COMMAND_DICT]) - ] - raise click.BadParameter('\n'.join(error_strs), param_hint='command name') - click.echo('') - _print_command_help(ctx, command) - else: - _print_help_header() - click.echo(_get_help_command_preface(ctx.parent.info_name)) - _print_command_list_header() - click.echo(ctx.parent.get_help()) - click.echo("\nType '%s help [command]' for usage/help of [command], or" % - ctx.parent.info_name) - click.echo("'%s help all' to list usage information for every command." % - (ctx.parent.info_name)) - ctx.exit() - - -@click.command('about') -@click.pass_context -def about_command(ctx: click.Context): - """Print license/copyright info.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.process_input_flag = False - click.echo('') - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(click.style(' About PySceneDetect %s' % scenedetect.__version__, fg='yellow')) - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(scenedetect.ABOUT_STRING) - ctx.exit() - - -@click.command('version') -@click.option( - '-a', - '--all', - 'show_all', - is_flag=True, - flag_value=True, - help='Include system and package version information. Useful for troubleshooting.') -@click.pass_context -def version_command(ctx: click.Context, show_all: bool): - """Print PySceneDetect version.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.process_input_flag = False - click.echo('') - click.echo(click.style('PySceneDetect %s' % scenedetect.__version__, fg='yellow')) - if show_all: - click.echo('') - click.echo(get_system_version_info()) - ctx.exit() - - -@click.command('time') -@click.option( - '--start', - '-s', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Time in video to begin detecting scenes. TIMECODE can be specified as exact' - ' number of frames (-s 100 to start at frame 100), time in seconds followed by s' - ' (-s 100s to start at 100 seconds), or a timecode in the format HH:MM:SS or HH:MM:SS.nnn' - ' (-s 00:01:40 to start at 1m40s).', -) -@click.option( - '--duration', - '-d', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Maximum time in video to process. TIMECODE format is the same as other' - ' arguments. Mutually exclusive with --end / -e.', -) -@click.option( - '--end', - '-e', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Time in video to end detecting scenes. TIMECODE format is the same as other' - ' arguments. Mutually exclusive with --duration / -d.', -) -@click.pass_context -def time_command( - ctx: click.Context, - start: Optional[str], - duration: Optional[str], - end: Optional[str], -): - """Set start/end/duration of input video. - - Time values can be specified as frames (NNNN), seconds (NNNN.NNs), or as - a timecode (HH:MM:SS.nnn). For example, to start scene detection at 1 minute, - and stop after 100 seconds: - - time --start 00:01:00 --duration 100s - - Note that --end and --duration are mutually exclusive (i.e. only one of the two - can be set). Lastly, the following is an example using absolute frame numbers - to process frames 0 through 1000: - - time --start 0 --end 1000 - """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_time( - start=start, - duration=duration, - end=end, - ) - - -@click.command('detect-content') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-content']['threshold'].min_val, - CONFIG_MAP['detect-content']['threshold'].max_val), - default=None, - help='Threshold value that the content_val frame metric must exceed to trigger a new scene.' - ' Refers to frame metric content_val in stats file.%s' % - (USER_CONFIG.get_help_string("detect-content", "threshold")), -) -@click.option( - '--weights', - '-w', - type=(float, float, float, float), - default=None, - help='Weights of the 4 components used to calculate content_val in the form' - ' (delta_hue, delta_sat, delta_lum, delta_edges).%s' % - (USER_CONFIG.get_help_string("detect-content", "weights")), -) -@click.option( - '--luma-only', - '-l', - is_flag=True, - flag_value=True, - help='Only consider luma (brightness) channel. Useful for greyscale videos. Equivalent to' - 'setting -w/--weights to 0, 0, 1, 0.%s' % - (USER_CONFIG.get_help_string("detect-content", "luma-only")), -) -@click.option( - '--kernel-size', - '-k', - metavar='N', - type=click.INT, - default=None, - help='Size of kernel for expanding detected edges. Must be odd integer greater than or' - ' equal to 3. If unset, kernel size is estimated using video resolution.%s' % - (USER_CONFIG.get_help_string("detect-content", "kernel-size")), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-content', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-content', 'min-scene-len')), -) -@click.pass_context -def detect_content_command( - ctx: click.Context, - threshold: Optional[float], - weights: Optional[Tuple[float, float, float, float]], - luma_only: bool, - kernel_size: Optional[int], - min_scene_len: Optional[str], -): - """Perform content detection algorithm on input video. - -When processing each frame, a score (from 0 to 255.0) is calculated representing the difference in content from the previous frame (higher = more difference). A change in scene is triggered when this value exceeds the value set for `-t`/`--threshold`. This value is the *content_val* column in a statsfile. - -Frame scores are calculated from several components, which are used to generate a final weighted value with `-w`/`--weights`. These are also recorded in the statsfile if set. Currently there are four components: - - - *delta_hue*: Difference between pixel hue values of adjacent frames. - - - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. - -Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, `-w 1.0 0.5 1.0 0.2 -t 32` is a good starting point to use with edge detection. - -Edge detection is not enabled by default. Current default parameters are `-w 1.0 1.0 1.0 0.0 -t 27`. The final weighted sum is normalized based on the weight of the components, so they do not need to equal 100%. - -Examples: - - detect-content - - detect-content --threshold 27.5 - """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_detect_content( - threshold=threshold, - luma_only=luma_only, - min_scene_len=min_scene_len, - weights=weights, - kernel_size=kernel_size) - - -@click.command('detect-adaptive') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FLOAT, - default=None, - help='Threshold value (float) that the calculated frame score must exceed to' - ' trigger a new scene (see frame metric adaptive_ratio in stats file).%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'threshold')), -) -@click.option( - '--min-content-val', - '-c', - metavar='VAL', - type=click.FLOAT, - default=None, - help='Minimum threshold (float) that the content_val must exceed in order to register as a new' - ' scene. This is calculated the same way that `detect-content` calculates frame score.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-content-val')), -) -@click.option( - '--min-delta-hsv', - '-d', - metavar='VAL', - type=click.FLOAT, - default=None, - help='[DEPRECATED] Use -c/--min-content-val instead.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-delta-hsv')), - hidden=True, -) -@click.option( - '--frame-window', - '-f', - metavar='VAL', - type=click.INT, - default=None, - help='Size of window (number of frames) before and after each frame to average together in' - ' order to detect deviations from the mean.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'frame-window')), -) -@click.option( - '--weights', - '-w', - type=(float, float, float, float), - default=None, - help='Weights of the 4 components used to calculate content_val in the form' - ' (delta_hue, delta_sat, delta_lum, delta_edges).%s' % - (USER_CONFIG.get_help_string("detect-content", "weights")), -) -@click.option( - '--luma-only', - '-l', - is_flag=True, - flag_value=True, - help='Only consider luma (brightness) channel. Useful for greyscale videos. Equivalent to' - 'setting -w/--weights to 0, 0, 1, 0.%s' % - (USER_CONFIG.get_help_string("detect-content", "luma-only")), -) -@click.option( - '--kernel-size', - '-k', - metavar='N', - type=click.INT, - default=None, - help='Size of kernel for expanding detected edges. Must be odd integer greater than or' - ' equal to 3. If unset, kernel size is estimated using video resolution.%s' % - (USER_CONFIG.get_help_string("detect-content", "kernel-size")), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-adaptive', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-adaptive', 'min-scene-len')), -) -@click.pass_context -def detect_adaptive_command( - ctx: click.Context, - threshold: Optional[float], - min_content_val: Optional[float], - min_delta_hsv: Optional[float], - frame_window: Optional[int], - weights: Optional[Tuple[float, float, float, float]], - luma_only: bool, - kernel_size: Optional[int], - min_scene_len: Optional[str], -): - """Perform adaptive detection algorithm on input video. - -Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. - -Examples: - - detect-adaptive - - detect-adaptive --threshold 3.2 - """ - assert isinstance(ctx.obj, CliContext) - - ctx.obj.handle_detect_adaptive( - threshold=threshold, - min_content_val=min_content_val, - min_delta_hsv=min_delta_hsv, - frame_window=frame_window, - luma_only=luma_only, - min_scene_len=min_scene_len, - weights=weights, - kernel_size=kernel_size, - ) - - -@click.command('detect-threshold') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['threshold'].min_val, - CONFIG_MAP['detect-threshold']['threshold'].max_val), - default=None, - help='Threshold value (integer) that the delta_rgb frame metric must exceed to trigger' - ' a new scene. Refers to frame metric delta_rgb in stats file.%s' % - (USER_CONFIG.get_help_string('detect-threshold', 'threshold')), -) -@click.option( - '--fade-bias', - '-f', - metavar='PERCENT', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['fade-bias'].min_val, - CONFIG_MAP['detect-threshold']['fade-bias'].max_val), - default=None, - help='Percent (%%) from -100 to 100 of timecode skew for where cuts should be placed. -100' - ' indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s' % - (USER_CONFIG.get_help_string('detect-threshold', 'fade-bias')), -) -@click.option( - '--add-last-scene', - '-l', - is_flag=True, - flag_value=True, - help='If set, if the video ends on a fade-out, a final scene will be generated from the' - ' last fade-out position to the end of the video.%s' % - (USER_CONFIG.get_help_string('detect-threshold', 'add-last-scene')), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-threshold', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-threshold', 'min-scene-len')), -) -@click.pass_context -def detect_threshold_command( - ctx: click.Context, - threshold: Optional[float], - fade_bias: Optional[float], - add_last_scene: bool, - min_scene_len: Optional[str], -): - """Perform threshold detection algorithm on input video. - -Detects fades in/out based on average frame pixel value compared against `-t`/`--threshold`. - -Examples: - - detect-threshold - - detect-threshold --threshold 15 - """ - assert isinstance(ctx.obj, CliContext) - - ctx.obj.handle_detect_threshold( - threshold=threshold, - fade_bias=fade_bias, - add_last_scene=add_last_scene, - min_scene_len=min_scene_len, - ) - - -@click.command('detect-hash') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-hash']['threshold'].min_val, - CONFIG_MAP['detect-hash']['threshold'].max_val), - default=None, - help='Threshold value (float) that the hash_dist metric must exceed to trigger' - ' a new scene. Refers to frame metric hash_dist in the stats file.%s' % - (USER_CONFIG.get_help_string('detect-hash', 'threshold'))) -@click.option( - '--size', - '-s', - metavar='VAL', - type=click.IntRange(CONFIG_MAP['detect-hash']['size'].min_val, - CONFIG_MAP['detect-hash']['size'].max_val), - default=None, - help='Size of the hash used in the perceptual hasing algorithm. Must be an ' - 'integer >=2.%s' % (USER_CONFIG.get_help_string('detect-hash', 'size'))) -@click.option( - '--freq_factor', - '-f', - metavar='VAL', - type=click.IntRange(CONFIG_MAP['detect-hash']['freq_factor'].min_val, - CONFIG_MAP['detect-hash']['freq_factor'].max_val), - default=None, - help='Parameter used to specify the amount of high frequency image information ' - 'used for the perceptual hashing algorithm. A high value uses less high ' - 'frequency image information, meaning that the algorithm is less sensitive ' - 'to small changes. A low value causes the algorithm to be more sensitive to' - ' small changes. Must be an integer >0.%s' % - (USER_CONFIG.get_help_string('detect-hash', 'freq_factor'))) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-hash', 'min-scene-len') else USER_CONFIG.get_help_string( - 'detect-hash', 'min-scene-len'))) -@click.pass_context -def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], - freq_factor: Optional[int], min_scene_len: Optional[str]): - """ Perform perceptual hashing based scene detection on input video(s). - detect-hash - detect-hash --threshold 27.5 - detect-hash --threshold 100 --size 16 --freq_factor 2 - """ - assert isinstance(ctx.obj, CliContext) - - ctx.obj.handle_detect_hash( - threshold=threshold, - min_scene_len=min_scene_len, - hash_size=size, - highfreq_factor=freq_factor) - - -@click.command('export-html') -@click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.html', - type=click.STRING, - help='Filename format to use for the scene list HTML file. You can use the' - ' $VIDEO_NAME macro in the file name. Note that you may have to wrap' - ' the format name using single quotes.%s' % - (USER_CONFIG.get_help_string('export-html', 'filename')), -) -@click.option( - '--no-images', - is_flag=True, - flag_value=True, - help='Export the scene list including or excluding the saved images.%s' % - (USER_CONFIG.get_help_string('export-html', 'no-images')), -) -@click.option( - '--image-width', - '-w', - metavar='pixels', - type=click.INT, - help='Width in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-width', show_default=False)), -) -@click.option( - '--image-height', - '-h', - metavar='pixels', - type=click.INT, - help='Height in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-height', show_default=False)), -) -@click.pass_context -def export_html_command( - ctx: click.Context, - filename: Optional[AnyStr], - no_images: bool, - image_width: Optional[int], - image_height: Optional[int], -): - """Export scene list to HTML file. Requires `save-images` unless --no-images is specified.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_export_html( - filename=filename, - no_images=no_images, - image_width=image_width, - image_height=image_height, - ) - - -@click.command('list-scenes') -@click.option( - '--output', - '-o', - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'output', show_default=False)), -) -@click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.csv', - type=click.STRING, - help='Filename format to use for the scene list CSV file. You can use the' - ' $VIDEO_NAME macro in the file name. Note that you may have to wrap' - ' the name using single quotes.%s' % (USER_CONFIG.get_help_string('list-scenes', 'filename')), -) -@click.option( - '--no-output-file', - '-n', - is_flag=True, - flag_value=True, - help='Disable writing scene list CSV file to disk. If set, -o/--output and' - ' -f/--filename are ignored.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'no-output-file')), -) -@click.option( - '--quiet', - '-q', - is_flag=True, - flag_value=True, - help='Suppresses output of the table printed by the list-scenes command.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'quiet')), -) -@click.option( - '--skip-cuts', - '-s', - is_flag=True, - flag_value=True, - help='Skips outputting the cutting list as the first row in the CSV file.' - ' Set this option if compliance with RFC 4180 is required.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'skip-cuts')), -) -@click.pass_context -def list_scenes_command( - ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - no_output_file: bool, - quiet: bool, - skip_cuts: bool, -): - """Print scene list and outputs to a CSV file. Ddefault filename is $VIDEO_NAME-Scenes.csv.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_list_scenes( - output=output, - filename=filename, - no_output_file=no_output_file, - quiet=quiet, - skip_cuts=skip_cuts, - ) - - -@click.command('split-video') -@click.option( - '--output', - '-o', - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('split-video', 'output', show_default=False)), -) -@click.option( - '--filename', - '-f', - metavar='NAME', - default=None, - type=click.STRING, - help='File name format to use when saving videos (with or without extension). You can use the' - ' $VIDEO_NAME and $SCENE_NUMBER macros in the filename (e.g. $VIDEO_NAME-Part-$SCENE_NUMBER).' - ' Note that you may have to wrap the format in single quotes to avoid variable expansion.%s' % - (USER_CONFIG.get_help_string('split-video', 'filename')), -) -@click.option( - '--quiet', - '-q', - is_flag=True, - flag_value=True, - help='Hides any output from the external video splitting tool.%s' % - (USER_CONFIG.get_help_string('split-video', 'quiet')), -) -@click.option( - '--copy', - '-c', - is_flag=True, - flag_value=True, - help='Copy instead of re-encode. Much faster, but less precise. Equivalent to specifying' - ' -a "-map 0 -c:v copy -c:a copy".%s' % (USER_CONFIG.get_help_string('split-video', 'copy')), -) -@click.option( - '--high-quality', - '-hq', - is_flag=True, - flag_value=True, - help='Encode video with higher quality, overrides -f option if present.' - ' Equivalent to specifying --rate-factor 17 and --preset slow.%s' % - (USER_CONFIG.get_help_string('split-video', 'high-quality')), -) -@click.option( - '--rate-factor', - '-crf', - metavar='RATE', - default=None, - type=click.IntRange(CONFIG_MAP['split-video']['rate-factor'].min_val, - CONFIG_MAP['split-video']['rate-factor'].max_val), - help='Video encoding quality (x264 constant rate factor), from 0-100, where lower' - ' values represent better quality, with 0 indicating lossless.%s' % - (USER_CONFIG.get_help_string('split-video', 'rate-factor')), -) -@click.option( - '--preset', - '-p', - metavar='LEVEL', - default=None, - type=click.Choice(CHOICE_MAP['split-video']['preset']), - help='Video compression quality preset (x264 preset). Can be one of: ultrafast, superfast,' - ' veryfast, faster, fast, medium, slow, slower, and veryslow. Faster modes take less' - ' time to run, but the output files may be larger.%s' % - (USER_CONFIG.get_help_string('split-video', 'preset')), -) -@click.option( - '--args', - '-a', - metavar='ARGS', - type=click.STRING, - default=None, - help='Override codec arguments/options passed to FFmpeg when splitting and re-encoding' - ' scenes. Use double quotes (") around specified arguments. Must specify at least' - ' audio/video codec to use (e.g. -a "-c:v [...] -c:a [...]").%s' % - (USER_CONFIG.get_help_string('split-video', 'args')), -) -@click.option( - '--mkvmerge', - '-m', - is_flag=True, - flag_value=True, - help='Split the video using mkvmerge. Faster than re-encoding, but less precise. The output' - ' will be named $VIDEO_NAME-$SCENE_NUMBER.mkv. If set, all options other than -f/--filename,' - ' -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends a' - 'suffix of "-$SCENE_NUMBER".%s' % (USER_CONFIG.get_help_string('split-video', 'mkvmerge')), -) -@click.pass_context -def split_video_command( - ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - quiet: bool, - copy: bool, - high_quality: bool, - rate_factor: Optional[int], - preset: Optional[str], - args: Optional[str], - mkvmerge: bool, -): - """Split input video using ffmpeg or mkvmerge.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_split_video( - output=output, - filename=filename, - quiet=quiet, - copy=copy, - high_quality=high_quality, - rate_factor=rate_factor, - preset=preset, - args=args, - mkvmerge=mkvmerge, - ) - - -@click.command('save-images') -@click.option( - '--output', - '-o', - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save images to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('save-images', 'output', show_default=False)), -) -@click.option( - '--filename', - '-f', - metavar='NAME', - default=None, - type=click.STRING, - help='Filename format, *without* extension, to use when saving image files. You can use the' - ' $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name.' - ' Note that you may have to wrap the format in single quotes.%s' % - (USER_CONFIG.get_help_string('save-images', 'filename')), -) -@click.option( - '--num-images', - '-n', - metavar='N', - default=None, - type=click.INT, - help='Number of images to generate. Will always include start/end frame,' - ' unless N = 1, in which case the image will be the frame at the mid-point' - ' in the scene.%s' % (USER_CONFIG.get_help_string('save-images', 'num-images')), -) -@click.option( - '--jpeg', - '-j', - is_flag=True, - flag_value=True, - help='Set output format to JPEG (default).%s' % - (USER_CONFIG.get_help_string('save-images', 'format', show_default=False)), -) -@click.option( - '--webp', - '-w', - is_flag=True, - flag_value=True, - help='Set output format to WebP', -) -@click.option( - '--quality', - '-q', - metavar='Q', - default=None, - type=click.IntRange(0, 100), - help='JPEG/WebP encoding quality, from 0-100 (higher indicates better quality).' - ' For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s' % - (USER_CONFIG.get_help_string('save-images', 'quality', show_default=False)), -) -@click.option( - '--png', - '-p', - is_flag=True, - flag_value=True, - help='Set output format to PNG.', -) -@click.option( - '--compression', - '-c', - metavar='C', - default=None, - type=click.IntRange(0, 9), - help='PNG compression rate, from 0-9. Higher values produce smaller files but result' - ' in longer compression time. This setting does not affect image quality, only' - ' file size.%s' % (USER_CONFIG.get_help_string('save-images', 'compression')), -) -@click.option( - '-m', - '--frame-margin', - metavar='N', - default=None, - type=click.INT, - help='Number of frames to ignore at the beginning and end of scenes when saving images.%s' % - (USER_CONFIG.get_help_string('save-images', 'num-images')), -) -@click.option( - '--scale', - '-s', - metavar='S', - default=None, - type=click.FLOAT, - help='Optional factor by which saved images are rescaled. A scaling factor of 1 would' - ' not result in rescaling. A value <1 results in a smaller saved image, while a' - ' value >1 results in an image larger than the original. This value is ignored if' - ' either the height, -h, or width, -w, values are specified.%s' % - (USER_CONFIG.get_help_string('save-images', 'scale', show_default=False)), -) -@click.option( - '--height', - '-h', - metavar='H', - default=None, - type=click.INT, - help='Optional value for the height of the saved images. Specifying both the height' - ' and width, -w, will resize images to an exact size, regardless of aspect ratio.' - ' Specifying only height will rescale the image to that number of pixels in height' - ' while preserving the aspect ratio.%s' % - (USER_CONFIG.get_help_string('save-images', 'height', show_default=False)), -) -@click.option( - '--width', - '-w', - metavar='W', - default=None, - type=click.INT, - help='Optional value for the width of the saved images. Specifying both the width' - ' and height, -h, will resize images to an exact size, regardless of aspect ratio.' - ' Specifying only width will rescale the image to that number of pixels wide' - ' while preserving the aspect ratio.%s' % - (USER_CONFIG.get_help_string('save-images', 'width', show_default=False)), -) -@click.pass_context -def save_images_command( - ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - num_images: Optional[int], - jpeg: bool, - webp: bool, - quality: Optional[int], - png: bool, - compression: Optional[int], - frame_margin: Optional[int], - scale: Optional[float], - height: Optional[int], - width: Optional[int], -): - """Create images for each detected scene.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_save_images( - num_images=num_images, - output=output, - filename=filename, - jpeg=jpeg, - webp=webp, - quality=quality, - png=png, - compression=compression, - frame_margin=frame_margin, - scale=scale, - height=height, - width=width, - ) - - -def _add_cli_command(cli: click.Group, command: click.Command): - """Add the given `command` to the `cli` group as well as the global `_COMMAND_DICT`.""" - cli.add_command(command) - _COMMAND_DICT.append(command) - - -# ---------------------------------------------------------------------- -# Commands Omitted From Help List -# ---------------------------------------------------------------------- - -# Info Commands -_add_cli_command(scenedetect_cli, help_command) -_add_cli_command(scenedetect_cli, version_command) -_add_cli_command(scenedetect_cli, about_command) - -# ---------------------------------------------------------------------- -# Commands Added To Help List -# ---------------------------------------------------------------------- - -# Input / Output -_add_cli_command(scenedetect_cli, time_command) -_add_cli_command(scenedetect_cli, export_html_command) -_add_cli_command(scenedetect_cli, list_scenes_command) -_add_cli_command(scenedetect_cli, save_images_command) -_add_cli_command(scenedetect_cli, split_video_command) - -# Detection Algorithms -_add_cli_command(scenedetect_cli, detect_content_command) -_add_cli_command(scenedetect_cli, detect_threshold_command) -_add_cli_command(scenedetect_cli, detect_adaptive_command) -_add_cli_command(scenedetect_cli, detect_hash_command) diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 954a91d7..9289d774 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -17,7 +17,7 @@ """ from dataclasses import dataclass import math -from typing import List, NamedTuple, Optional +import typing as ty import numpy import cv2 @@ -54,7 +54,7 @@ class ContentDetector(SceneDetector): # TODO: Come up with some good weights for a new default if there is one that can pass # a wider variety of test cases. - class Components(NamedTuple): + class Components(ty.NamedTuple): """Components that make up a frame's score, and their default values.""" delta_hue: float = 1.0 """Difference between pixel hue values of adjacent frames.""" @@ -95,23 +95,25 @@ class _FrameData: """Frame saturation map [2D 8-bit].""" lum: numpy.ndarray """Frame luma/brightness map [2D 8-bit].""" - edges: Optional[numpy.ndarray] + edges: ty.Optional[numpy.ndarray] """Frame edge map [2D 8-bit, edges are 255, non edges 0]. Affected by `kernel_size`.""" def __init__( self, threshold: float = 27.0, min_scene_len: int = 15, - weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, + weights: Components = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: Optional[int] = None, - filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, + kernel_size: ty.Optional[int] = None, + flash_filter: ty.Optional[FlashFilter] = None, ): """ Arguments: threshold: Threshold the average change in pixel intensity must exceed to trigger a cut. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. + min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive + cuts that occur closer than this length will be merged. Equivalent to setting + `flash_filter = FlashFilter(length=min_scene_len)`. + Ignored if `flash_filter` is set. weights: Weight to place on each component when calculating frame score (`content_val` in a statsfile, the value `threshold` is compared against). luma_only: If True, only considers changes in the luminance channel of the video. @@ -119,24 +121,26 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If None, automatically set using video resolution. - filter_mode: Mode to use when filtering cuts to meet `min_scene_len`. + flash_filter: Filter to use for scene length compliance. If None, initialized as + `FlashFilter(length=min_scene_len)`. """ super().__init__() self._threshold: float = threshold self._min_scene_len: int = min_scene_len - self._last_above_threshold: Optional[int] = None - self._last_frame: Optional[ContentDetector._FrameData] = None + self._last_above_threshold: ty.Optional[int] = None + self._last_frame: ty.Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: self._weights = ContentDetector.LUMA_ONLY_WEIGHTS - self._kernel: Optional[numpy.ndarray] = None + self._kernel: ty.Optional[numpy.ndarray] = None if kernel_size is not None: print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError('kernel_size must be odd integer >= 3') self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) - self._frame_score: Optional[float] = None - self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) + self._frame_score: ty.Optional[float] = None + self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( + length=min_scene_len) def get_metrics(self): return ContentDetector.METRIC_KEYS @@ -186,7 +190,7 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) return frame_score - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -195,15 +199,14 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ self._frame_score = self._calculate_frame_score(frame_num, frame_img) if self._frame_score is None: return [] - - above_threshold: bool = self._frame_score >= self._threshold - return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) + return self._flash_filter.filter( + frame_num=frame_num, found_cut=self._frame_score >= self._threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 784bd1f9..304024d7 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -18,11 +18,11 @@ from enum import Enum from logging import getLogger -from typing import List, Optional +import typing as ty import numpy -from scenedetect.scene_detector import SceneDetector +from scenedetect.scene_detector import SceneDetector, FlashFilter logger = getLogger('pyscenedetect') @@ -76,14 +76,17 @@ def __init__( fade_bias: float = 0.0, add_final_scene: bool = False, method: Method = Method.FLOOR, + flash_filter: ty.Optional[FlashFilter] = None, block_size=None, ): """ Arguments: threshold: 8-bit intensity value that each pixel value (R, G, and B) must be <= to in order to trigger a fade in/out. - min_scene_len: FrameTimecode object or integer greater than 0 of the - minimum length, in frames, of a scene (or subsequent scene cut). + min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive + cuts that occur closer than this length will be merged. Equivalent to setting + `flash_filter = FlashFilter(length=min_scene_len)`. + Ignored if `flash_filter` is set. fade_bias: Float between -1.0 and +1.0 representing the percentage of timecode skew for the start of a scene (-1.0 causing a cut at the fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be @@ -91,6 +94,8 @@ def __init__( add_final_scene: Boolean indicating if the video ends on a fade-out to generate an additional scene at this timecode. method: How to treat `threshold` when detecting fade events. + flash_filter: Filter to use for scene length compliance. If None, initialized as + `FlashFilter(length=min_scene_len)`. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. @@ -109,15 +114,17 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - 'frame': 0, # frame number where the last detected fade is - 'type': None # type of fade, can be either 'in' or 'out' + 'frame': 0, # frame number where the last detected fade is + 'type': None # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] + self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( + length=min_scene_len) - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -126,7 +133,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ @@ -138,9 +145,6 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # If absolute value of pixel intensity delta is above the threshold, # then we trigger a new scene cut/break. - # List of cuts to return. - cut_list = [] - # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this @@ -153,35 +157,44 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: if self.stats_manager is not None: self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) - if self.processed_frame: - if self.last_fade['type'] == 'in' and (( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): - # Just faded out of a scene, wait for next fade in. - self.last_fade['type'] = 'out' - self.last_fade['frame'] = frame_num - - elif self.last_fade['type'] == 'out' and ( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): - # Only add the scene if min_scene_len frames have passed. - if (frame_num - self.last_scene_cut) >= self.min_scene_len: - # Just faded into a new scene, compute timecode for the scene - # split based on the fade bias. - f_out = self.last_fade['frame'] - f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) - cut_list.append(f_split) - self.last_scene_cut = frame_num - self.last_fade['type'] = 'in' - self.last_fade['frame'] = frame_num - else: + if not self.processed_frame: self.last_fade['frame'] = 0 if frame_avg < self.threshold: self.last_fade['type'] = 'out' else: self.last_fade['type'] = 'in' - self.processed_frame = True + self._flash_filter.filter(frame_num=frame_num, found_cut=False) + self.processed_frame = True + return [] + + cut_list = [] + if self.last_fade['type'] == 'in' and ( + ((self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): + # Just faded out of a scene, wait for next fade in. + f_in = self.last_fade['frame'] + self.last_fade['type'] = 'out' + self.last_fade['frame'] = frame_num + # The next cut will be placed at at or after this frame (the fade out position), and + # the previous cut was placed before or at the last fade in position. + # Thus we know no new cuts were generated between the two. + for frame_num in range(f_in + 1, frame_num): + cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) + + elif self.last_fade['type'] == 'out' and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): + # Just faded into a new scene, compute timecode based on the fade bias. + # f_split will be between the fade out position and frame_num. + f_out = self.last_fade['frame'] + f_split = int((frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) + self.last_scene_cut = frame_num + self.last_fade['type'] = 'in' + self.last_fade['frame'] = frame_num + # Update the filter state to determine where cuts occured. + for frame_num in range(f_out, frame_num + 1): + cut_list += self._flash_filter.filter( + frame_num=frame_num, found_cut=(frame_num == f_split)) return cut_list def post_process(self, frame_num: int): @@ -192,13 +205,13 @@ def post_process(self, frame_num: int): (since there is no corresponding fade-in) so it will be located at the exact frame where the fade-out crossed the detection threshold. """ - # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cut_times = [] - if self.last_fade['type'] == 'out' and self.add_final_scene and ( - (self.last_scene_cut is None and frame_num >= self.min_scene_len) or - (frame_num - self.last_scene_cut) >= self.min_scene_len): - cut_times.append(self.last_fade['frame']) - return cut_times + cut_list = [] + if self.last_fade['type'] == 'out' and self.add_final_scene: + f_out = self.last_fade['frame'] + cut_list += self._flash_filter.filter(frame_num=f_out, found_cut=True) + for frame_num in range(f_out + 1, frame_num + 1): + cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) + return cut_list diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index ded5d35d..14a03226 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -51,33 +51,6 @@ class SceneDetector: """Optional :class:`StatsManager ` to use for caching frame metrics to and from.""" - # TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain. - def is_processing_required(self, frame_num: int) -> bool: - """[DEPRECATED] DO NOT USE - - Test if all calculations for a given frame are already done. - - Returns: - False if the SceneDetector has assigned _metric_keys, and the - stats_manager property is set to a valid StatsManager object containing - the required frame metrics/calculations for the given frame - thus, not - needing the frame to perform scene detection. - - True otherwise (i.e. the frame_img passed to process_frame is required - to be passed to process_frame for the given frame_num). - """ - metric_keys = self.get_metrics() - return not metric_keys or not (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys)) - - def stats_manager_required(self) -> bool: - """Stats Manager Required: Prototype indicating if detector requires stats. - - Returns: - True if a StatsManager is required for the detector, False otherwise. - """ - return False - def get_metrics(self) -> ty.List[str]: """Get Metrics: Get a list of all metric names/keys used by the detector. @@ -121,17 +94,21 @@ def event_buffer_length(self) -> int: """ return 0 + # DEPRECATED - TO BE REMOVED -class SparseSceneDetector(SceneDetector): - """Base class to inherit from when implementing a sparse scene detection algorithm. + def is_processing_required(self, frame_num: int) -> bool: + """[DEPRECATED] DO NOT USE""" + metric_keys = self.get_metrics() + return not metric_keys or not (self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys)) - This class will be removed in v1.0 and should not be used. + def stats_manager_required(self) -> bool: + """[DEPRECATED] DO NOT USE""" + return False - Unlike dense detectors, sparse detectors detect "events" and return a *pair* of frames, - as opposed to just a single cut. - An example of a SparseSceneDetector is the MotionDetector. - """ +class SparseSceneDetector(SceneDetector): + """[DEPRECATED] DO NOT USE""" def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: @@ -158,55 +135,67 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: + """Filters scene cuts which occur too close together (less than `length` frames apart). + + If filter `length` is 0, filter is disabled. + """ class Mode(Enum): + """Mode specifying how the filter operates when active.""" MERGE = 0 - """Merge consecutive cuts shorter than filter length.""" + """Merge consecutive cuts shorter than filter length (default).""" SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" - def __init__(self, mode: Mode, length: int): + def __init__(self, length: int, mode: Mode = Mode.MERGE): self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filte. + self._merge_start = None # Frame number where we started the merge filter. + + def __str__(self) -> str: + return self.__repr__() + + def __repr__(self) -> str: + if self._filter_length <= 0: + return "FlashFilter(length=0 [DISABLED])" + return f"FlashFilter(mode={str(self._mode)}, length={self._filter_length})" - def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - if not self._filter_length > 0: - return [frame_num] if above_threshold else [] + def filter(self, frame_num: int, found_cut: bool) -> ty.List[int]: + if self._filter_length <= 0: + return [frame_num] if found_cut else [] if self._last_above is None: self._last_above = frame_num if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) + return self._filter_merge(frame_num=frame_num, found_cut=found_cut) if self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) + return self._filter_suppress(frame_num=frame_num, found_cut=found_cut) - def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + def _filter_suppress(self, frame_num: int, found_cut: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length - if not (above_threshold and min_length_met): + if not (found_cut and min_length_met): return [] - # Both length and threshold requirements were satisfied. Emit the cut, and wait until both - # requirements are met again. + # Only advance last frame when the length requirement is satisfied. self._last_above = frame_num return [frame_num] - def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + def _filter_merge(self, frame_num: int, found_cut: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length # Ensure last frame is always advanced to the most recent one that was above the threshold. - if above_threshold: + if found_cut: self._last_above = frame_num if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. num_merged_frames = self._last_above - self._merge_start - if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + if min_length_met and not found_cut and num_merged_frames >= self._filter_length: self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. return [] # Wait for next frame above the threshold. - if not above_threshold: + if not found_cut: return [] # If we met the minimum length requirement, no merging is necessary. if min_length_met: diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 3d3bd435..199809f1 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -82,6 +82,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import csv from enum import Enum +import typing as ty from typing import Iterable, List, Tuple, Optional, Dict, Callable, Union, TextIO import threading import queue @@ -97,8 +98,8 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): from scenedetect.platform import (tqdm, get_and_create_path, get_cv2_imwrite_params, Template) from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream -from scenedetect.scene_detector import SceneDetector, SparseSceneDetector -from scenedetect.stats_manager import StatsManager, FrameMetricRegistered +from scenedetect.scene_detector import SceneDetector +from scenedetect.stats_manager import StatsManager logger = logging.getLogger('pyscenedetect') @@ -549,7 +550,7 @@ class SceneManager: def __init__( self, - stats_manager: Optional[StatsManager] = None, + stats_manager: ty.Optional[StatsManager] = None, ): """ Arguments: @@ -559,16 +560,13 @@ def __init__( self._cutting_list = [] self._event_list = [] self._detector_list = [] - self._sparse_detector_list = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the # `stats_manager` argument to to `store_stats: bool=False`, or lazy-init one. - # TODO(v1.0): This class should own a VideoStream as well, instead of passing one # to the detect_scenes method. If concatenation is required, it can be implemented as # a generic VideoStream wrapper. self._stats_manager: Optional[StatsManager] = stats_manager - # Position of video that was first passed to detect_scenes. self._start_pos: FrameTimecode = None # Position of video on the last frame processed by detect_scenes. @@ -584,7 +582,6 @@ def __init__( # Set by decode thread when an exception occurs. self._exception_info = None self._stop = threading.Event() - self._frame_buffer = [] self._frame_buffer_size = 0 @@ -639,21 +636,10 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ - if self._stats_manager is None and detector.stats_manager_required(): - # Make sure the lists are empty so that the detectors don't get - # out of sync (require an explicit statsmanager instead) - assert not self._detector_list and not self._sparse_detector_list - self._stats_manager = StatsManager() - detector.stats_manager = self._stats_manager if self._stats_manager is not None: self._stats_manager.register_metrics(detector.get_metrics()) - - if not issubclass(type(detector), SparseSceneDetector): - self._detector_list.append(detector) - else: - self._sparse_detector_list.append(detector) - + self._detector_list.append(detector) self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) def get_num_detectors(self) -> int: @@ -677,7 +663,6 @@ def clear(self) -> None: def clear_detectors(self) -> None: """Remove all scene detectors added to the SceneManager via add_detector(). """ self._detector_list.clear() - self._sparse_detector_list.clear() def get_scene_list(self, base_timecode: Optional[FrameTimecode] = None, @@ -748,13 +733,6 @@ def _process_frame(self, for cut_frame_num in cuts: buffer_index = cut_frame_num - (frame_num + 1) callback(self._frame_buffer[buffer_index], cut_frame_num) - for detector in self._sparse_detector_list: - events = detector.process_frame(frame_num, frame_im) - self._event_list += events - if callback: - for event_start, _ in events: - buffer_index = event_start - (frame_num + 1) - callback(self._frame_buffer[buffer_index], event_start) return new_cuts def _post_process(self, frame_num: int) -> None: @@ -978,8 +956,6 @@ def get_cut_list(self, the scene list, noting that each scene is contiguous starting from the first frame and ending at the last frame detected. - If only sparse detectors are used (e.g. MotionDetector), this will always be empty. - Arguments: base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. show_warning: If set to False, suppresses the error from being warned. In v0.7, @@ -999,20 +975,7 @@ def get_event_list( self, base_timecode: Optional[FrameTimecode] = None ) -> List[Tuple[FrameTimecode, FrameTimecode]]: - """[DEPRECATED] DO NOT USE. - - Get a list of start/end timecodes of sparse detection events. - - Unlike get_scene_list, the event list returns a list of FrameTimecodes representing - the point in the input video where a new scene was detected only by sparse detectors, - otherwise it is the same. - - Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. - - Returns: - List of pairs of FrameTimecode objects denoting the detected scenes. - """ + """[DEPRECATED] DO NOT USE""" # TODO(v0.7): Use the warnings module to turn this into a warning. logger.error('`get_event_list()` is deprecated and will be removed in a future release.') return self._get_event_list() diff --git a/tests/test_api.py b/tests/test_api.py index 1ddb5596..5f29ddee 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -87,9 +87,11 @@ def test_api_timecode_types(): # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') timecode = base_timecode + '00:00:01.500' assert timecode.get_frames() == 15 - # Seconds (str, 'SSSs' or 'SSSS.SSSs') + # Seconds (str, 'SSSs' or 'SSSS.SSS') timecode = base_timecode + '1.5s' assert timecode.get_frames() == 15 + timecode = base_timecode + '1.5' + assert timecode.get_frames() == 15 def test_api_stats_manager(test_video_file: str): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index f304cf2f..a5648b2a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -9,8 +9,11 @@ Releases - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` - - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - - [bugfix] Remove extraneous console output when using `--drop-short-scenes` + - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed + - `filter-mode = drop`, replaces global `--drop-short-scenes` option + - [cli] Deprecate `--drop-short-scenes`, use `--filter-mode = drop` instead + - [bugfix] Remove extraneous console output when using `--filter-mode drop` (previously `--drop-short-scenes`) + - [api] Deprecate `SparseSceneDetector` and `SceneDetector.stats_manager_required()` function (no longer required) ### 0.6.3 (March 9, 2024) From e8c59ad60db76cf8837791665b00047d3fe5dbf7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Apr 2024 18:37:25 -0400 Subject: [PATCH 082/407] [detectors] Integrate FlashFilter with AdaptiveDetector #35 --- scenedetect/_cli/config.py | 16 +++- scenedetect/_cli/context.py | 32 +++---- scenedetect/detectors/adaptive_detector.py | 92 +++++++++------------ scenedetect/detectors/content_detector.py | 13 +-- scenedetect/detectors/threshold_detector.py | 78 +++++++++-------- scenedetect/scene_detector.py | 13 +-- website/pages/changelog.md | 5 +- 7 files changed, 123 insertions(+), 126 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 91767a07..600d0237 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -33,6 +33,11 @@ VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO'] +DEPRECATED_CONFIG_OPTIONS = { + "global": {"drop-short-scenes"}, + "detect-adaptive": {"min-delta-hsv"}, +} + class OptionParseFailure(Exception): """Raised when a value provided in a user config file fails validation.""" @@ -251,8 +256,7 @@ class FlashFilterMode(Enum): DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 -# TODO(v0.6.4): Warn if [detect-adaptive] min-delta-hsv and [global] drop-short-scenes are used. -# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv and [global] drop-short-scenes +# TODO(v0.7): Remove deprecated [detect-adaptive] min-delta-hsv and [global] drop-short-scenes CONFIG_MAP: ConfigDict = { 'backend-opencv': { 'max-decode-attempts': 5, @@ -543,6 +547,14 @@ def _load_from_disk(self, path=None): for log_str in errors: self._init_log.append((logging.ERROR, log_str)) raise ConfigLoadFailure(self._init_log) + for command in self._config: + for option in self._config[command]: + if (command in DEPRECATED_CONFIG_OPTIONS + and option in DEPRECATED_CONFIG_OPTIONS[command]): + self._init_log.append( + (logging.WARNING, "WARNING: Config file contains deprecated option:\n " + f"[{command}] {option} will be removed in a future version.")) + pass def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index f37d6806..235844cd 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -275,11 +275,13 @@ def handle_options( if drop_short_scenes: logger.warning( "WARNING: --drop-short-scenes is deprecated, use --filter-mode=drop instead.") - if filter_mode is None: - self.filter_mode = FlashFilterMode.DROP + if self.config.get_value("global", "drop-short-scenes", drop_short_scenes): + logger.info("drop-short-scenes set, overriding filter-mode") + self.filter_mode = FlashFilterMode.DROP else: self.filter_mode = FlashFilterMode[self.config.get_value("global", "filter-mode", filter_mode).upper()] + self.merge_last_scene = merge_last_scene or self.config.get_value( "global", "merge-last-scene") self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) @@ -360,13 +362,13 @@ def get_detect_adaptive_params( # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: - logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.') + logger.error("-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.") if min_content_val is None: min_content_val = min_delta_hsv # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated' - ', use `min-delta-hsv` instead.') + logger.error("[detect-adaptive] config file option `min-delta-hsv` is deprecated" + ", use `min-delta-hsv` instead.") if self.config.is_default("detect-adaptive", "min-content-val"): self.config.config_dict["detect-adaptive"]["min-content-val"] = ( self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) @@ -379,21 +381,21 @@ def get_detect_adaptive_params( weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='weights') + raise click.BadParameter(str(ex), param_hint="weights") return { - 'adaptive_threshold': + "adaptive_threshold": self.config.get_value("detect-adaptive", "threshold", threshold), - 'weights': - self.config.get_value("detect-adaptive", "weights", weights), - 'kernel_size': + "flash_filter": + self._init_flash_filter("detect-content", min_scene_len), + "kernel_size": self.config.get_value("detect-adaptive", "kernel-size", kernel_size), - 'luma_only': + "luma_only": luma_only or self.config.get_value("detect-adaptive", "luma-only"), - 'min_content_val': + "min_content_val": self.config.get_value("detect-adaptive", "min-content-val", min_content_val), - 'min_scene_len': - min_scene_len, - 'window_width': + "weights": + self.config.get_value("detect-adaptive", "weights", weights), + "window_width": self.config.get_value("detect-adaptive", "frame-window", frame_window), } diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 85778158..9fc9ac67 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -18,11 +18,12 @@ """ from logging import getLogger -from typing import List, Optional +import typing as ty import numpy as np from scenedetect.detectors import ContentDetector +from scenedetect.scene_detector import FlashFilter logger = getLogger('pyscenedetect') @@ -43,15 +44,19 @@ def __init__( min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: Optional[int] = None, + kernel_size: ty.Optional[int] = None, + flash_filter: ty.Optional[FlashFilter] = None, video_manager=None, - min_delta_hsv: Optional[float] = None, + min_delta_hsv: ty.Optional[float] = None, ): """ Arguments: adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a new scene (see frame metric adaptive_ratio in stats file). - min_scene_len: Minimum length of any scene. + min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive + cuts that occur closer than this length will be merged. Equivalent to setting + `flash_filter = FlashFilter(length=min_scene_len)`. + Ignored if `flash_filter` is set. window_width: Size of window (number of frames) before and after each frame to average together in order to detect deviations from the mean. Must be at least 1. min_content_val: Minimum threshold (float) that the content_val must exceed in order to @@ -65,8 +70,10 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel to use for post edge detection filtering. If None, automatically set based on video resolution. - video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. - min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead. + flash_filter: Filter to use for scene length compliance. If None, initialized as + `FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored. + video_manager: [DEPRECATED] DO NOT USE. + min_delta_hsv: [DEPRECATED] DO NOT USE. """ # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will # be removed in v0.8. @@ -77,36 +84,27 @@ def __init__( min_content_val = min_delta_hsv if window_width < 1: raise ValueError('window_width must be at least 1.') - super().__init__( threshold=255.0, - min_scene_len=0, + min_scene_len=min_scene_len, weights=weights, luma_only=luma_only, kernel_size=kernel_size, + flash_filter=flash_filter, ) - - # TODO: Turn these options into properties. - self.min_scene_len = min_scene_len - self.adaptive_threshold = adaptive_threshold - self.min_content_val = min_content_val - self.window_width = window_width - + self._adaptive_threshold = adaptive_threshold + self._min_content_val = min_content_val + self._window_width = window_width self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( window_width=window_width, luma_only='' if not luma_only else '_lum') - self._first_frame_num = None - - # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. - self._last_cut: Optional[int] = None - self._buffer = [] @property def event_buffer_length(self) -> int: """Number of frames any detected cuts will be behind the current frame due to buffering.""" - return self.window_width + return self._window_width - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" return super().get_metrics() + [self._adaptive_ratio_key] @@ -114,7 +112,7 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: + def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -126,31 +124,21 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - - # TODO(#283): Merge this with ContentDetector and turn it on by default. - - super().process_frame(frame_num=frame_num, frame_img=frame_img) - - # Initialize last scene cut point at the beginning of the frames of interest. - if self._last_cut is None: - self._last_cut = frame_num - - required_frames = 1 + (2 * self.window_width) - self._buffer.append((frame_num, self._frame_score)) + frame_score = self._calculate_frame_score(frame_num=frame_num, frame_img=frame_img) + required_frames = 1 + (2 * self._window_width) + self._buffer.append((frame_num, frame_score)) if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] - target = self._buffer[self.window_width] + target = self._buffer[self._window_width] average_window_score = ( - sum(frame[1] for i, frame in enumerate(self._buffer) if i != self.window_width) / - (2.0 * self.window_width)) - + sum(frame[1] for i, frame in enumerate(self._buffer) if i != self._window_width) / + (2.0 * self._window_width)) average_is_zero = abs(average_window_score) < 0.00001 - adaptive_ratio = 0.0 if not average_is_zero: adaptive_ratio = min(target[1] / average_window_score, 255.0) - elif average_is_zero and target[1] >= self.min_content_val: + elif average_is_zero and target[1] >= self._min_content_val: # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: @@ -158,15 +146,11 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut - threshold_met: bool = ( - adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val) - min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len - if threshold_met and min_length_met: - self._last_cut = target[0] - return [target[0]] - return [] - - def get_content_val(self, frame_num: int) -> Optional[float]: + found_cut: bool = ( + adaptive_ratio >= self._adaptive_threshold and target[1] >= self._min_content_val) + return self._flash_filter.apply(frame_num=target[0], found_cut=found_cut) + + def get_content_val(self, frame_num: int) -> ty.Optional[float]: """Returns the average content change for a frame.""" # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. logger.error("get_content_val is deprecated and will be removed. Lookup the value" @@ -175,6 +159,10 @@ def get_content_val(self, frame_num: int) -> Optional[float]: return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] return 0.0 - def post_process(self, _unused_frame_num: int): - """Not required for AdaptiveDetector.""" - return [] + def post_process(self, _frame_num: int): + # Already processed frame at self._window_width, process the rest. This ensures we emit any + # cuts the filtering mode might require. + cuts = [] + for (frame_num, _) in self._buffer[self._window_width + 1:]: + cuts += self._flash_filter.apply(frame_num=frame_num, found_cut=False) + return cuts diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 9289d774..ae03a5bf 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -122,12 +122,10 @@ def __init__( kernel_size: Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If None, automatically set using video resolution. flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. + `FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored. """ super().__init__() self._threshold: float = threshold - self._min_scene_len: int = min_scene_len - self._last_above_threshold: ty.Optional[int] = None self._last_frame: ty.Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: @@ -138,7 +136,6 @@ def __init__( if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError('kernel_size must be odd integer >= 3') self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) - self._frame_score: ty.Optional[float] = None self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( length=min_scene_len) @@ -202,11 +199,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - self._frame_score = self._calculate_frame_score(frame_num, frame_img) - if self._frame_score is None: - return [] - return self._flash_filter.filter( - frame_num=frame_num, found_cut=self._frame_score >= self._threshold) + frame_score = self._calculate_frame_score(frame_num, frame_img) + found_cut = frame_score >= self._threshold + return self._flash_filter.apply(frame_num=frame_num, found_cut=found_cut) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 304024d7..427fe8d2 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -95,7 +95,7 @@ def __init__( generate an additional scene at this timecode. method: How to treat `threshold` when detecting fade events. flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. + `FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. @@ -103,20 +103,16 @@ def __init__( logger.error('block_size is deprecated.') super().__init__() - self.threshold = int(threshold) - self.method = ThresholdDetector.Method(method) - self.fade_bias = fade_bias - self.min_scene_len = min_scene_len - self.processed_frame = False - self.last_scene_cut = None + self._threshold = int(threshold) + self._method = ThresholdDetector.Method(method) + self._fade_bias = fade_bias + self._processed_frame = False + self._last_scene_cut = None # Whether to add an additional scene or not when ending on a fade out # (as cuts are only added on fade ins; see post_process() for details). - self.add_final_scene = add_final_scene + self._add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. - self.last_fade = { - 'frame': 0, # frame number where the last detected fade is - 'type': None # type of fade, can be either 'in' or 'out' - } + self._last_fade = {'frame': 0, 'type': None} self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( length=min_scene_len) @@ -138,8 +134,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int """ # Initialize last scene cut point at the beginning of the frames of interest. - if self.last_scene_cut is None: - self.last_scene_cut = frame_num + if self._last_scene_cut is None: + self._last_scene_cut = frame_num # Compare the # of pixels under threshold in current_frame & last_frame. # If absolute value of pixel intensity delta is above the threshold, @@ -157,43 +153,43 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int if self.stats_manager is not None: self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) - if not self.processed_frame: - self.last_fade['frame'] = 0 - if frame_avg < self.threshold: - self.last_fade['type'] = 'out' + if not self._processed_frame: + self._last_fade['frame'] = 0 + if frame_avg < self._threshold: + self._last_fade['type'] = 'out' else: - self.last_fade['type'] = 'in' - self._flash_filter.filter(frame_num=frame_num, found_cut=False) - self.processed_frame = True + self._last_fade['type'] = 'in' + self._flash_filter.apply(frame_num=frame_num, found_cut=False) + self._processed_frame = True return [] cut_list = [] - if self.last_fade['type'] == 'in' and ( - ((self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): + if self._last_fade['type'] == 'in' and ( + ((self._method == ThresholdDetector.Method.FLOOR and frame_avg < self._threshold) or + (self._method == ThresholdDetector.Method.CEILING and frame_avg >= self._threshold))): # Just faded out of a scene, wait for next fade in. - f_in = self.last_fade['frame'] - self.last_fade['type'] = 'out' - self.last_fade['frame'] = frame_num + f_in = self._last_fade['frame'] + self._last_fade['type'] = 'out' + self._last_fade['frame'] = frame_num # The next cut will be placed at at or after this frame (the fade out position), and # the previous cut was placed before or at the last fade in position. # Thus we know no new cuts were generated between the two. for frame_num in range(f_in + 1, frame_num): - cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) + cut_list += self._flash_filter.apply(frame_num=frame_num, found_cut=False) - elif self.last_fade['type'] == 'out' and ( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): + elif self._last_fade['type'] == 'out' and ( + (self._method == ThresholdDetector.Method.FLOOR and frame_avg >= self._threshold) or + (self._method == ThresholdDetector.Method.CEILING and frame_avg < self._threshold)): # Just faded into a new scene, compute timecode based on the fade bias. # f_split will be between the fade out position and frame_num. - f_out = self.last_fade['frame'] - f_split = int((frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) - self.last_scene_cut = frame_num - self.last_fade['type'] = 'in' - self.last_fade['frame'] = frame_num + f_out = self._last_fade['frame'] + f_split = int((frame_num + f_out + int(self._fade_bias * (frame_num - f_out))) / 2) + self._last_scene_cut = frame_num + self._last_fade['type'] = 'in' + self._last_fade['frame'] = frame_num # Update the filter state to determine where cuts occured. for frame_num in range(f_out, frame_num + 1): - cut_list += self._flash_filter.filter( + cut_list += self._flash_filter.apply( frame_num=frame_num, found_cut=(frame_num == f_split)) return cut_list @@ -209,9 +205,9 @@ def post_process(self, frame_num: int): # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cut_list = [] - if self.last_fade['type'] == 'out' and self.add_final_scene: - f_out = self.last_fade['frame'] - cut_list += self._flash_filter.filter(frame_num=f_out, found_cut=True) + if self._last_fade['type'] == 'out' and self._add_final_scene: + f_out = self._last_fade['frame'] + cut_list += self._flash_filter.apply(frame_num=f_out, found_cut=True) for frame_num in range(f_out + 1, frame_num + 1): - cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) + cut_list += self._flash_filter.apply(frame_num=frame_num, found_cut=False) return cut_list diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 14a03226..c061709f 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -135,10 +135,8 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: - """Filters scene cuts which occur too close together (less than `length` frames apart). - - If filter `length` is 0, filter is disabled. - """ + """Online filter used by detection algorithms to filter scene cuts which occur too close + together (less than `length` frames apart).""" class Mode(Enum): """Mode specifying how the filter operates when active.""" @@ -148,6 +146,11 @@ class Mode(Enum): """Suppress consecutive cuts until the filter length has passed.""" def __init__(self, length: int, mode: Mode = Mode.MERGE): + """ + Arguments: + length: Number of frames defining how close cuts can be before the filter is activated. + mode: How the filter operates when active. + """ self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. self._last_above = None # Last frame above threshold. @@ -163,7 +166,7 @@ def __repr__(self) -> str: return "FlashFilter(length=0 [DISABLED])" return f"FlashFilter(mode={str(self._mode)}, length={self._filter_length})" - def filter(self, frame_num: int, found_cut: bool) -> ty.List[int]: + def apply(self, frame_num: int, found_cut: bool) -> ty.List[int]: if self._filter_length <= 0: return [frame_num] if found_cut else [] if self._last_above is None: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a5648b2a..aadd24ad 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -6,14 +6,15 @@ Releases ### 0.6.4 (In Development) - - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) + - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) + - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing [#35](https://github.com/Breakthrough/PySceneDetect/issues/35) - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - `filter-mode = drop`, replaces global `--drop-short-scenes` option - [cli] Deprecate `--drop-short-scenes`, use `--filter-mode = drop` instead - [bugfix] Remove extraneous console output when using `--filter-mode drop` (previously `--drop-short-scenes`) - [api] Deprecate `SparseSceneDetector` and `SceneDetector.stats_manager_required()` function (no longer required) + - [api] All detection algorithm properties not part of the `SceneDetector` interface are now private ### 0.6.3 (March 9, 2024) From e927ecf1b168cdf63e69728f493f0f277d93265e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Apr 2024 22:09:48 -0400 Subject: [PATCH 083/407] [site] Improve contributing guide. --- website/pages/contributing.md | 40 +++++++++++++++++++++-------------- website/pages/literature.md | 2 ++ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/website/pages/contributing.md b/website/pages/contributing.md index 33fcd308..f45b753f 100644 --- a/website/pages/contributing.md +++ b/website/pages/contributing.md @@ -1,17 +1,19 @@ ##   Bug Reports -Bugs, issues, features, and improvements to PySceneDetect are handled through [the issue tracker on Github](https://github.com/Breakthrough/PySceneDetect/issues). If you run into any bugs using PySceneDetect, please [create a new issue](https://github.com/Breakthrough/PySceneDetect/issues/new). Provide as much detail as you can - include an example that clearly demonstrates the problem (if possible), and make sure to include any/all relevant program output or error messages. +Bugs, issues, features, and improvements to PySceneDetect are handled through [the issue tracker on Github](https://github.com/Breakthrough/PySceneDetect/issues). If you run into any bugs using PySceneDetect, please [create a new issue](https://github.com/Breakthrough/PySceneDetect/issues/new/choose). -When submitting bug reports, please provide debug logs by adding `-l BUG_REPORT.txt` to your `scenedetect` command, and attach the generated `BUG_REPORT.txt` file. - -Before opening a new issue, please do [search for any existing issues](https://github.com/Breakthrough/PySceneDetect/issues?q=) (both open and closed) which might report similar issues/bugs to avoid creating duplicate entries. If you do find a duplicate report, feel free to add any additional information you feel may be relevant. +Try to [find an existing issue](https://github.com/Breakthrough/PySceneDetect/issues?q=) before creating a new one, as there may be a workaround posted there. Additional information is also helpful for existing reports. ##   Contributing to Development -The development of PySceneDetect is done on the Github Repo, guided by [the feature roadmap](features.md). Code you wish to submit should be attached to a dedicated entry in [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=) (with the appropriate tags for bugfixes, new features, enhancements, etc...), and allows for easier communication regarding development structure. Feel free to create a new entry if required, as some planned features or bugs/issues may not yet exist in the tracker. +Development of PySceneDetect happens on [github.com/Breakthrough/PySceneDetect](https://github.com/Breakthrough/PySceneDetect). Pull requests are accepted and encouraged. Where possible, PRs should be submitted with a dedicated entry in [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=). Issues and features are typically grouped into version milestones. + +The following checklist covers the basics of pre-submission requirements: -All submitted code should be linted with pylint, and follow the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) as closely as possible. Also, ensure that you search through [all existing issues](https://github.com/Breakthrough/PySceneDetect/issues?q=) (both open and closed) beforehand to avoid creating duplicate entries. + - Code passes all unit tests (run `pytest`) + - Code is formatted (run `python -m yapf -i -r scenedetect/ tests/` to format in place) + - Generally follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) Note that PySceneDetect is released under the BSD 3-Clause license, and submitted code should comply with this license (see [License & Copyright Information](copyright.md) for details). @@ -19,22 +21,28 @@ Note that PySceneDetect is released under the BSD 3-Clause license, and submitte The following is a "wishlist" of features which PySceneDetect eventually should have, but does not currently due to lack of resources. Anyone who is able to contribute in any capacity to these items is encouraged to do so by starting a dialogue by opening a new issue on Github as per above. -### GUI - -A graphical user interface will be crucial for making PySceneDetect approchable by a wider audience. There have been several suggested designs, but nothing concrete has been developed yet. Any proposed solution for the GUI should work across Windows, Linux, and OSX. - -### Localization +### Flash Suppression -PySceneDetect currently is not localized for other languages. Anyone who can help improve how localization can be approached for development material is encouraged to contribute in any way possible. Whether it is the GUI program, the command line interface, or documentation, localization will allow PySceneDetect to be used by much more users in their native languages. +Some detection methods struggle with bright flashes and fast camera movement. The detection pipeline has some filters in place to deal with these cases, but there are still drawbacks. We are actively seeking methods which can improve both performance and accuracy in these cases. -### Automatic Threshold / Peak Detection +### Automatic Thresholding The `detect-content` command requires a manual threshold to be set currently. Methods to use peak detection to dynamically determine when scene cuts occur would allow for the program to work with a much wider amount of material without requiring manual tuning, but would require statistical analysis. Ideally, this would be something like `-threshold=auto` as a default. -### Advanced Detection Strategies +### Dissolve Detection + +Depending on the length of the dissolve and parameters being used, detection accuracy for these types of cuts can vary widely. A method to improve accuracy with minimal performance loss is an open problem. -Research into advanced scene detection for content detection would be most useful, perhaps in terms of histogram analysis or edge detection. This could be integrated into the existing `detect-content` command, or be a separate command. The real blocker here is achieving reasonable performance utilizing the current software architecture. +### Advanced Strategies + +Research into detection methods and performance are ongoing. All contributions in this regard are most welcome. + +### GUI + +A graphical user interface will be crucial for making PySceneDetect approchable by a wider audience. There have been several suggested designs, but nothing concrete has been developed yet. Any proposed solution for the GUI should work across Windows, Linux, and OSX. + +### Localization -There are many open issues on the issue tracker that contain reference implementations contributed by various community members. There are already several concepts which are proven to be viable candidates for production, but still require some degree optimization. +PySceneDetect currently is not localized for other languages. Anyone who can help improve how localization can be approached for development material is encouraged to contribute in any way possible. Whether it is the GUI program, the command line interface, or documentation, localization will allow PySceneDetect to be used by much more users in their native languages. \ No newline at end of file diff --git a/website/pages/literature.md b/website/pages/literature.md index 65d49b3f..8bce6ca8 100644 --- a/website/pages/literature.md +++ b/website/pages/literature.md @@ -3,6 +3,8 @@ PySceneDetect is a useful tool for statistical analysis of video. Below are links to various research articles/papers which have either used PySceneDetect as a part of their analysis, or propose more accurate detection algorithms using the current implementation as a comparison. + - [Panda-70M: Captioning 70M Videos with Multiple Cross-Modality Teachers](https://arxiv.org/abs/2402.19479) by Tsai-Shien Chen, Aliaksandr Siarohin, Willi Menapace, Ekaterina Deyneka, Hsiang-wei Chao, Byung Eun Jeon, Yuwei Fang, Hsin-Ying Lee, Jian Ren, Ming-Hsuan Yang, Sergey Tulyakov (2024) + - [Stable Remaster: Bridging the Gap Between Old Content and New Displays](https://arxiv.org/pdf/2306.06803.pdf) by Nathan Paull, Shuvam Keshari, Yian Wong (2023) - [LoL-V2T: Large-Scale Esports Video Description Dataset](https://ieeexplore.ieee.org/abstract/document/9522986) by Tsunehiko Tanaka, Edgar Simo-Serra (2021) From 09057df816989b111a149e9e8424086302904293 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 23 Apr 2024 21:25:27 -0400 Subject: [PATCH 084/407] Revert "[detectors] Integrate FlashFilter with AdaptiveDetector #35" Reason: Performs worse due to AdaptiveDetector's windowing algorithm, which already acts as a bit of a filter. Will add the filter as a detector-only option. This reverts commit e8c59ad60db76cf8837791665b00047d3fe5dbf7. --- scenedetect/_cli/config.py | 16 +--- scenedetect/_cli/context.py | 32 ++++--- scenedetect/detectors/adaptive_detector.py | 92 ++++++++++++--------- scenedetect/detectors/content_detector.py | 13 ++- scenedetect/detectors/threshold_detector.py | 78 ++++++++--------- scenedetect/scene_detector.py | 13 ++- website/pages/changelog.md | 5 +- 7 files changed, 126 insertions(+), 123 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 600d0237..91767a07 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -33,11 +33,6 @@ VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO'] -DEPRECATED_CONFIG_OPTIONS = { - "global": {"drop-short-scenes"}, - "detect-adaptive": {"min-delta-hsv"}, -} - class OptionParseFailure(Exception): """Raised when a value provided in a user config file fails validation.""" @@ -256,7 +251,8 @@ class FlashFilterMode(Enum): DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 -# TODO(v0.7): Remove deprecated [detect-adaptive] min-delta-hsv and [global] drop-short-scenes +# TODO(v0.6.4): Warn if [detect-adaptive] min-delta-hsv and [global] drop-short-scenes are used. +# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv and [global] drop-short-scenes CONFIG_MAP: ConfigDict = { 'backend-opencv': { 'max-decode-attempts': 5, @@ -547,14 +543,6 @@ def _load_from_disk(self, path=None): for log_str in errors: self._init_log.append((logging.ERROR, log_str)) raise ConfigLoadFailure(self._init_log) - for command in self._config: - for option in self._config[command]: - if (command in DEPRECATED_CONFIG_OPTIONS - and option in DEPRECATED_CONFIG_OPTIONS[command]): - self._init_log.append( - (logging.WARNING, "WARNING: Config file contains deprecated option:\n " - f"[{command}] {option} will be removed in a future version.")) - pass def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 235844cd..f37d6806 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -275,13 +275,11 @@ def handle_options( if drop_short_scenes: logger.warning( "WARNING: --drop-short-scenes is deprecated, use --filter-mode=drop instead.") - if self.config.get_value("global", "drop-short-scenes", drop_short_scenes): - logger.info("drop-short-scenes set, overriding filter-mode") - self.filter_mode = FlashFilterMode.DROP + if filter_mode is None: + self.filter_mode = FlashFilterMode.DROP else: self.filter_mode = FlashFilterMode[self.config.get_value("global", "filter-mode", filter_mode).upper()] - self.merge_last_scene = merge_last_scene or self.config.get_value( "global", "merge-last-scene") self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) @@ -362,13 +360,13 @@ def get_detect_adaptive_params( # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: - logger.error("-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.") + logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.') if min_content_val is None: min_content_val = min_delta_hsv # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error("[detect-adaptive] config file option `min-delta-hsv` is deprecated" - ", use `min-delta-hsv` instead.") + logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated' + ', use `min-delta-hsv` instead.') if self.config.is_default("detect-adaptive", "min-content-val"): self.config.config_dict["detect-adaptive"]["min-content-val"] = ( self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) @@ -381,21 +379,21 @@ def get_detect_adaptive_params( weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="weights") + raise click.BadParameter(str(ex), param_hint='weights') return { - "adaptive_threshold": + 'adaptive_threshold': self.config.get_value("detect-adaptive", "threshold", threshold), - "flash_filter": - self._init_flash_filter("detect-content", min_scene_len), - "kernel_size": + 'weights': + self.config.get_value("detect-adaptive", "weights", weights), + 'kernel_size': self.config.get_value("detect-adaptive", "kernel-size", kernel_size), - "luma_only": + 'luma_only': luma_only or self.config.get_value("detect-adaptive", "luma-only"), - "min_content_val": + 'min_content_val': self.config.get_value("detect-adaptive", "min-content-val", min_content_val), - "weights": - self.config.get_value("detect-adaptive", "weights", weights), - "window_width": + 'min_scene_len': + min_scene_len, + 'window_width': self.config.get_value("detect-adaptive", "frame-window", frame_window), } diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 9fc9ac67..85778158 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -18,12 +18,11 @@ """ from logging import getLogger -import typing as ty +from typing import List, Optional import numpy as np from scenedetect.detectors import ContentDetector -from scenedetect.scene_detector import FlashFilter logger = getLogger('pyscenedetect') @@ -44,19 +43,15 @@ def __init__( min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: ty.Optional[int] = None, - flash_filter: ty.Optional[FlashFilter] = None, + kernel_size: Optional[int] = None, video_manager=None, - min_delta_hsv: ty.Optional[float] = None, + min_delta_hsv: Optional[float] = None, ): """ Arguments: adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a new scene (see frame metric adaptive_ratio in stats file). - min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive - cuts that occur closer than this length will be merged. Equivalent to setting - `flash_filter = FlashFilter(length=min_scene_len)`. - Ignored if `flash_filter` is set. + min_scene_len: Minimum length of any scene. window_width: Size of window (number of frames) before and after each frame to average together in order to detect deviations from the mean. Must be at least 1. min_content_val: Minimum threshold (float) that the content_val must exceed in order to @@ -70,10 +65,8 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel to use for post edge detection filtering. If None, automatically set based on video resolution. - flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored. - video_manager: [DEPRECATED] DO NOT USE. - min_delta_hsv: [DEPRECATED] DO NOT USE. + video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. + min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead. """ # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will # be removed in v0.8. @@ -84,27 +77,36 @@ def __init__( min_content_val = min_delta_hsv if window_width < 1: raise ValueError('window_width must be at least 1.') + super().__init__( threshold=255.0, - min_scene_len=min_scene_len, + min_scene_len=0, weights=weights, luma_only=luma_only, kernel_size=kernel_size, - flash_filter=flash_filter, ) - self._adaptive_threshold = adaptive_threshold - self._min_content_val = min_content_val - self._window_width = window_width + + # TODO: Turn these options into properties. + self.min_scene_len = min_scene_len + self.adaptive_threshold = adaptive_threshold + self.min_content_val = min_content_val + self.window_width = window_width + self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( window_width=window_width, luma_only='' if not luma_only else '_lum') + self._first_frame_num = None + + # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. + self._last_cut: Optional[int] = None + self._buffer = [] @property def event_buffer_length(self) -> int: """Number of frames any detected cuts will be behind the current frame due to buffering.""" - return self._window_width + return self.window_width - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> List[str]: """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" return super().get_metrics() + [self._adaptive_ratio_key] @@ -112,7 +114,7 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> ty.List[int]: + def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -124,21 +126,31 @@ def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> t List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - frame_score = self._calculate_frame_score(frame_num=frame_num, frame_img=frame_img) - required_frames = 1 + (2 * self._window_width) - self._buffer.append((frame_num, frame_score)) + + # TODO(#283): Merge this with ContentDetector and turn it on by default. + + super().process_frame(frame_num=frame_num, frame_img=frame_img) + + # Initialize last scene cut point at the beginning of the frames of interest. + if self._last_cut is None: + self._last_cut = frame_num + + required_frames = 1 + (2 * self.window_width) + self._buffer.append((frame_num, self._frame_score)) if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] - target = self._buffer[self._window_width] + target = self._buffer[self.window_width] average_window_score = ( - sum(frame[1] for i, frame in enumerate(self._buffer) if i != self._window_width) / - (2.0 * self._window_width)) + sum(frame[1] for i, frame in enumerate(self._buffer) if i != self.window_width) / + (2.0 * self.window_width)) + average_is_zero = abs(average_window_score) < 0.00001 + adaptive_ratio = 0.0 if not average_is_zero: adaptive_ratio = min(target[1] / average_window_score, 255.0) - elif average_is_zero and target[1] >= self._min_content_val: + elif average_is_zero and target[1] >= self.min_content_val: # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: @@ -146,11 +158,15 @@ def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> t # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut - found_cut: bool = ( - adaptive_ratio >= self._adaptive_threshold and target[1] >= self._min_content_val) - return self._flash_filter.apply(frame_num=target[0], found_cut=found_cut) - - def get_content_val(self, frame_num: int) -> ty.Optional[float]: + threshold_met: bool = ( + adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val) + min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len + if threshold_met and min_length_met: + self._last_cut = target[0] + return [target[0]] + return [] + + def get_content_val(self, frame_num: int) -> Optional[float]: """Returns the average content change for a frame.""" # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. logger.error("get_content_val is deprecated and will be removed. Lookup the value" @@ -159,10 +175,6 @@ def get_content_val(self, frame_num: int) -> ty.Optional[float]: return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] return 0.0 - def post_process(self, _frame_num: int): - # Already processed frame at self._window_width, process the rest. This ensures we emit any - # cuts the filtering mode might require. - cuts = [] - for (frame_num, _) in self._buffer[self._window_width + 1:]: - cuts += self._flash_filter.apply(frame_num=frame_num, found_cut=False) - return cuts + def post_process(self, _unused_frame_num: int): + """Not required for AdaptiveDetector.""" + return [] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index ae03a5bf..9289d774 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -122,10 +122,12 @@ def __init__( kernel_size: Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If None, automatically set using video resolution. flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored. + `FlashFilter(length=min_scene_len)`. """ super().__init__() self._threshold: float = threshold + self._min_scene_len: int = min_scene_len + self._last_above_threshold: ty.Optional[int] = None self._last_frame: ty.Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: @@ -136,6 +138,7 @@ def __init__( if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError('kernel_size must be odd integer >= 3') self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) + self._frame_score: ty.Optional[float] = None self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( length=min_scene_len) @@ -199,9 +202,11 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - frame_score = self._calculate_frame_score(frame_num, frame_img) - found_cut = frame_score >= self._threshold - return self._flash_filter.apply(frame_num=frame_num, found_cut=found_cut) + self._frame_score = self._calculate_frame_score(frame_num, frame_img) + if self._frame_score is None: + return [] + return self._flash_filter.filter( + frame_num=frame_num, found_cut=self._frame_score >= self._threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 427fe8d2..304024d7 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -95,7 +95,7 @@ def __init__( generate an additional scene at this timecode. method: How to treat `threshold` when detecting fade events. flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored. + `FlashFilter(length=min_scene_len)`. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. @@ -103,16 +103,20 @@ def __init__( logger.error('block_size is deprecated.') super().__init__() - self._threshold = int(threshold) - self._method = ThresholdDetector.Method(method) - self._fade_bias = fade_bias - self._processed_frame = False - self._last_scene_cut = None + self.threshold = int(threshold) + self.method = ThresholdDetector.Method(method) + self.fade_bias = fade_bias + self.min_scene_len = min_scene_len + self.processed_frame = False + self.last_scene_cut = None # Whether to add an additional scene or not when ending on a fade out # (as cuts are only added on fade ins; see post_process() for details). - self._add_final_scene = add_final_scene + self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. - self._last_fade = {'frame': 0, 'type': None} + self.last_fade = { + 'frame': 0, # frame number where the last detected fade is + 'type': None # type of fade, can be either 'in' or 'out' + } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( length=min_scene_len) @@ -134,8 +138,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int """ # Initialize last scene cut point at the beginning of the frames of interest. - if self._last_scene_cut is None: - self._last_scene_cut = frame_num + if self.last_scene_cut is None: + self.last_scene_cut = frame_num # Compare the # of pixels under threshold in current_frame & last_frame. # If absolute value of pixel intensity delta is above the threshold, @@ -153,43 +157,43 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int if self.stats_manager is not None: self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) - if not self._processed_frame: - self._last_fade['frame'] = 0 - if frame_avg < self._threshold: - self._last_fade['type'] = 'out' + if not self.processed_frame: + self.last_fade['frame'] = 0 + if frame_avg < self.threshold: + self.last_fade['type'] = 'out' else: - self._last_fade['type'] = 'in' - self._flash_filter.apply(frame_num=frame_num, found_cut=False) - self._processed_frame = True + self.last_fade['type'] = 'in' + self._flash_filter.filter(frame_num=frame_num, found_cut=False) + self.processed_frame = True return [] cut_list = [] - if self._last_fade['type'] == 'in' and ( - ((self._method == ThresholdDetector.Method.FLOOR and frame_avg < self._threshold) or - (self._method == ThresholdDetector.Method.CEILING and frame_avg >= self._threshold))): + if self.last_fade['type'] == 'in' and ( + ((self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): # Just faded out of a scene, wait for next fade in. - f_in = self._last_fade['frame'] - self._last_fade['type'] = 'out' - self._last_fade['frame'] = frame_num + f_in = self.last_fade['frame'] + self.last_fade['type'] = 'out' + self.last_fade['frame'] = frame_num # The next cut will be placed at at or after this frame (the fade out position), and # the previous cut was placed before or at the last fade in position. # Thus we know no new cuts were generated between the two. for frame_num in range(f_in + 1, frame_num): - cut_list += self._flash_filter.apply(frame_num=frame_num, found_cut=False) + cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) - elif self._last_fade['type'] == 'out' and ( - (self._method == ThresholdDetector.Method.FLOOR and frame_avg >= self._threshold) or - (self._method == ThresholdDetector.Method.CEILING and frame_avg < self._threshold)): + elif self.last_fade['type'] == 'out' and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): # Just faded into a new scene, compute timecode based on the fade bias. # f_split will be between the fade out position and frame_num. - f_out = self._last_fade['frame'] - f_split = int((frame_num + f_out + int(self._fade_bias * (frame_num - f_out))) / 2) - self._last_scene_cut = frame_num - self._last_fade['type'] = 'in' - self._last_fade['frame'] = frame_num + f_out = self.last_fade['frame'] + f_split = int((frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) + self.last_scene_cut = frame_num + self.last_fade['type'] = 'in' + self.last_fade['frame'] = frame_num # Update the filter state to determine where cuts occured. for frame_num in range(f_out, frame_num + 1): - cut_list += self._flash_filter.apply( + cut_list += self._flash_filter.filter( frame_num=frame_num, found_cut=(frame_num == f_split)) return cut_list @@ -205,9 +209,9 @@ def post_process(self, frame_num: int): # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cut_list = [] - if self._last_fade['type'] == 'out' and self._add_final_scene: - f_out = self._last_fade['frame'] - cut_list += self._flash_filter.apply(frame_num=f_out, found_cut=True) + if self.last_fade['type'] == 'out' and self.add_final_scene: + f_out = self.last_fade['frame'] + cut_list += self._flash_filter.filter(frame_num=f_out, found_cut=True) for frame_num in range(f_out + 1, frame_num + 1): - cut_list += self._flash_filter.apply(frame_num=frame_num, found_cut=False) + cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) return cut_list diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index c061709f..14a03226 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -135,8 +135,10 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: - """Online filter used by detection algorithms to filter scene cuts which occur too close - together (less than `length` frames apart).""" + """Filters scene cuts which occur too close together (less than `length` frames apart). + + If filter `length` is 0, filter is disabled. + """ class Mode(Enum): """Mode specifying how the filter operates when active.""" @@ -146,11 +148,6 @@ class Mode(Enum): """Suppress consecutive cuts until the filter length has passed.""" def __init__(self, length: int, mode: Mode = Mode.MERGE): - """ - Arguments: - length: Number of frames defining how close cuts can be before the filter is activated. - mode: How the filter operates when active. - """ self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. self._last_above = None # Last frame above threshold. @@ -166,7 +163,7 @@ def __repr__(self) -> str: return "FlashFilter(length=0 [DISABLED])" return f"FlashFilter(mode={str(self._mode)}, length={self._filter_length})" - def apply(self, frame_num: int, found_cut: bool) -> ty.List[int]: + def filter(self, frame_num: int, found_cut: bool) -> ty.List[int]: if self._filter_length <= 0: return [frame_num] if found_cut else [] if self._last_above is None: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index aadd24ad..a5648b2a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -6,15 +6,14 @@ Releases ### 0.6.4 (In Development) - - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) - - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing [#35](https://github.com/Breakthrough/PySceneDetect/issues/35) + - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - `filter-mode = drop`, replaces global `--drop-short-scenes` option - [cli] Deprecate `--drop-short-scenes`, use `--filter-mode = drop` instead - [bugfix] Remove extraneous console output when using `--filter-mode drop` (previously `--drop-short-scenes`) - [api] Deprecate `SparseSceneDetector` and `SceneDetector.stats_manager_required()` function (no longer required) - - [api] All detection algorithm properties not part of the `SceneDetector` interface are now private ### 0.6.3 (March 9, 2024) From df85d7ddb3ff9532ec74e06ac4c1edf639425713 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 23 Apr 2024 21:27:10 -0400 Subject: [PATCH 085/407] Revert "[cli] Add --filter-mode and replace --drop-short-scenes" Reason: Moving to a detector-only option. This reverts commit 894297d74a88621546fc183f971e7da3ba8e915a. --- scenedetect.cfg | 36 ++++---- scenedetect/__init__.py | 2 +- scenedetect/_cli/__init__.py | 11 --- scenedetect/_cli/config.py | 13 +-- scenedetect/_cli/context.py | 95 +++++++++++--------- scenedetect/_cli/controller.py | 7 +- scenedetect/detectors/content_detector.py | 41 ++++----- scenedetect/detectors/threshold_detector.py | 99 +++++++++------------ scenedetect/scene_detector.py | 89 ++++++++++-------- scenedetect/scene_manager.py | 49 ++++++++-- tests/test_api.py | 4 +- website/pages/changelog.md | 12 ++- 12 files changed, 235 insertions(+), 223 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index b8bc3815..8b004017 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -39,19 +39,16 @@ # Method to use for downscaling (nearest, linear, cubic, area, lanczos4). #downscale-method = linear -# Minimum length of a given scene. See filter-mode to control how this is enforced. +# Minimum length of a given scene. #min-scene-len = 0.6s -# Mode to use when filtering out scenes (merge, suppress, drop): -# merge: Consecutive scenes shorter than min-scene-len are combined. -# suppress: No new scenes can be generated until min-scene-len passes. -# drop: Drop all scenes shorter than global min-scene-len. -#filter-mode = merge - -# If the video ends less than min-scene-len after the last cut, merge it with the -# previous scene (yes/no). +# Merge last scene if it is shorter than min-scene-len (yes/no). This can occur +# when a cut is detected just before the video ends. #merge-last-scene = no +# Drop scenes shorter than min-scene-len instead of merging (yes/no). +#drop-short-scenes = no + # Verbosity of console output (debug, info, warning, error, or none). # Set to none for the same behavior as specifying -q/--quiet. #verbosity = debug @@ -68,6 +65,14 @@ # Sensitivity threshold from 0 to 255. Lower values are more sensitive. #threshold = 27 +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + +# Mode to use when filtering scenes to comply with min-scene-len: +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge + # Weight to place on each component when calculating frame score (the value # `threshold` is compared against). The components are in the order # (delta_hue, delta_sat, delta_lum, delta_edges). Description of components: @@ -87,10 +92,6 @@ # than or equal to 3. If None, automatically set using video resolution. #kernel-size = -1 -# Minimum length of a given scene. No new cuts can be emitted after one is found -# until this length of time passes. -#min-scene-len = 0.6s - [detect-threshold] # Average pixel intensity from 0-255 at which a fade event is triggered. @@ -106,8 +107,7 @@ # Discard colour information and only use luminance (yes/no). #luma-only = no -# Minimum length of a given scene. No new cuts can be emitted after one is found -# until this length of time passes. +# Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s @@ -121,8 +121,7 @@ # Window size (number of frames) before and after each frame to average together. #frame-window = 2 -# Minimum length of a given scene. No new cuts can be emitted after one is found -# until this length of time passes. +# Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s # The following parameters are the those used to calculate `content_val`. @@ -144,8 +143,7 @@ # Number of bits to use for image quantization before binning. #bits = 4 -# Minimum length of a given scene. No new cuts can be emitted after one is found -# until this length of time passes. +# Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index aa9fd929..f928ad4e 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.4-dev0' +__version__ = '0.7-dev0' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index c9eb8352..d5823703 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -206,19 +206,10 @@ def _print_command_help(ctx: click.Context, command: click.Command): help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s' % USER_CONFIG.get_help_string("global", "min-scene-len"), ) -@click.option( - "--filter-mode", - metavar="MODE", - type=click.Choice(CHOICE_MAP["global"]["filter-mode"], False), - default=None, - help='Mode used when enforcing min-scene-len. MODE must be one of: %s. %s' % (', '.join( - CHOICE_MAP["global"]["filter-mode"]), USER_CONFIG.get_help_string("global", "filter-mode")), -) @click.option( '--drop-short-scenes', is_flag=True, flag_value=True, - hidden=True, help='Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s' % (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), ) @@ -290,7 +281,6 @@ def scenedetect( config: Optional[AnyStr], framerate: Optional[float], min_scene_len: Optional[str], - filter_mode: Optional[str], drop_short_scenes: bool, merge_last_scene: bool, backend: Optional[str], @@ -335,7 +325,6 @@ def scenedetect( downscale=downscale, frame_skip=frame_skip, min_scene_len=min_scene_len, - filter_mode=filter_mode, drop_short_scenes=drop_short_scenes, merge_last_scene=merge_last_scene, backend=backend, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 91767a07..2f72e9ca 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -27,7 +27,6 @@ from scenedetect.detectors import ContentDetector from scenedetect.frame_timecode import FrameTimecode -from scenedetect.scene_detector import FlashFilter from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS @@ -233,13 +232,6 @@ def format(self, timecode: FrameTimecode) -> str: assert False -class FlashFilterMode(Enum): - """Filter mode for the CLI. Has additional DROP mode which runs as a post-processing step.""" - MERGE = FlashFilter.Mode.MERGE - SUPPRESS = FlashFilter.Mode.SUPPRESS - DROP = -1 - - ConfigValue = Union[bool, int, float, str] ConfigDict = Dict[str, Dict[str, ConfigValue]] @@ -251,8 +243,7 @@ class FlashFilterMode(Enum): DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 -# TODO(v0.6.4): Warn if [detect-adaptive] min-delta-hsv and [global] drop-short-scenes are used. -# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv and [global] drop-short-scenes +# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv CONFIG_MAP: ConfigDict = { 'backend-opencv': { 'max-decode-attempts': 5, @@ -314,7 +305,6 @@ class FlashFilterMode(Enum): 'downscale': 0, 'downscale-method': 'linear', 'drop-short-scenes': False, - 'filter-mode': 'merge', 'frame-skip': 0, 'merge-last-scene': False, 'min-scene-len': TimecodeValue('0.6s'), @@ -358,7 +348,6 @@ class FlashFilterMode(Enum): 'backend': ['opencv', 'pyav', 'moviepy'], 'default-detector': ['detect-adaptive', 'detect-content', 'detect-threshold'], 'downscale-method': [value.name.lower() for value in Interpolation], - 'filter-mode': [value.name.lower() for value in FlashFilterMode], 'verbosity': ['debug', 'info', 'warning', 'error', 'none'], }, 'list-scenes': { diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index f37d6806..c1ceb48d 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -23,8 +23,8 @@ from scenedetect import open_video, AVAILABLE_BACKENDS -from scenedetect.scene_detector import SceneDetector, FlashFilter -from scenedetect.platform import get_cv2_imwrite_params, init_logger +from scenedetect.scene_detector import SceneDetector +from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available @@ -32,9 +32,8 @@ from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, - FlashFilterMode, CHOICE_MAP, DEFAULT_JPG_QUALITY, - DEFAULT_WEBP_QUALITY) +from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP, + DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY) logger = logging.getLogger('pyscenedetect') @@ -117,9 +116,9 @@ def __init__(self): self.output_dir: str = None # -o/--output self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet self.stats_file_path: str = None # -s/--stats - self.min_scene_len: FrameTimecode = None # -m/--min-scene-len - self.filter_mode: FlashFilterMode = None # --filter-mode + self.drop_short_scenes: bool = None # --drop-short-scenes self.merge_last_scene: bool = None # --merge-last-scene + self.min_scene_len: FrameTimecode = None # -m/--min-scene-len self.frame_skip: int = None # -fs/--frame-skip self.default_detector: Tuple[Type[SceneDetector], Dict[str, Any]] = None # [global] default-detector @@ -187,7 +186,6 @@ def handle_options( downscale: Optional[int], frame_skip: int, min_scene_len: str, - filter_mode: Optional[str], drop_short_scenes: bool, merge_last_scene: bool, backend: Optional[str], @@ -271,15 +269,8 @@ def handle_options( self.min_scene_len = parse_timecode( min_scene_len if min_scene_len is not None else self.config.get_value( "global", "min-scene-len"), self.video_stream.frame_rate) - - if drop_short_scenes: - logger.warning( - "WARNING: --drop-short-scenes is deprecated, use --filter-mode=drop instead.") - if filter_mode is None: - self.filter_mode = FlashFilterMode.DROP - else: - self.filter_mode = FlashFilterMode[self.config.get_value("global", "filter-mode", - filter_mode).upper()] + self.drop_short_scenes = drop_short_scenes or self.config.get_value( + "global", "drop-short-scenes") self.merge_last_scene = merge_last_scene or self.config.get_value( "global", "merge-last-scene") self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) @@ -290,7 +281,6 @@ def handle_options( self.stats_manager = StatsManager() # Initialize default detector with values in the config file. - # TODO(v0.6.4): Integrate perceptual hash detector. default_detector = self.config.get_value("global", "default-detector") if default_detector == 'detect-adaptive': self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) @@ -330,18 +320,29 @@ def get_detect_content_params( ) -> Dict[str, Any]: """Handle detect-content command options and return dict to construct one with.""" self._ensure_input_open() + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default('detect-content', 'min-scene-len'): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value('detect-content', 'min-scene-len') + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="weights") + raise click.BadParameter(str(ex), param_hint='weights') return { - "weights": self.config.get_value("detect-content", "weights", weights), - "kernel_size": self.config.get_value("detect-content", "kernel-size", kernel_size), - "luma_only": luma_only or self.config.get_value("detect-content", "luma-only"), - "flash_filter": self._init_flash_filter("detect-content", min_scene_len), - "threshold": self.config.get_value("detect-content", "threshold", threshold), + 'weights': self.config.get_value('detect-content', 'weights', weights), + 'kernel_size': self.config.get_value('detect-content', 'kernel-size', kernel_size), + 'luma_only': luma_only or self.config.get_value('detect-content', 'luma-only'), + 'min_scene_len': min_scene_len, + 'threshold': self.config.get_value('detect-content', 'threshold', threshold), } def get_detect_adaptive_params( @@ -371,8 +372,15 @@ def get_detect_adaptive_params( self.config.config_dict["detect-adaptive"]["min-content-val"] = ( self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) - # TODO(v0.6.4): Integrate flash filter. - min_scene_len = self._init_flash_filter("detect-adaptive", min_scene_len)._filter_length + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-adaptive", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num if weights is not None: try: @@ -406,8 +414,16 @@ def get_detect_threshold_params( ) -> Dict[str, Any]: """Handle detect-threshold command options and return dict to construct one with.""" self._ensure_input_open() - # TODO(v0.6.4): Integrate flash filter. - min_scene_len = self._init_flash_filter("detect-adaptive", min_scene_len)._filter_length + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-threshold", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { 'add_final_scene': @@ -439,8 +455,15 @@ def get_detect_hist_params(self, threshold: Optional[float], bits: Optional[int] min_scene_len: Optional[str]) -> Dict[str, Any]: """Handle detect-hist command options and return dict to construct one with.""" self._ensure_input_open() - # TODO(v0.6.4): Integrate flash filter. - min_scene_len = self._init_flash_filter("detect-adaptive", min_scene_len)._filter_length + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-hist", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-hist", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num return { 'bits': self.config.get_value("detect-hist", "bits", bits), 'min_scene_len': min_scene_len, @@ -829,15 +852,3 @@ def _on_duplicate_command(self, command: str) -> None: raise click.BadParameter( '\n Command %s may only be specified once.' % command, param_hint='%s command' % command) - - def _init_flash_filter(self, detector_name: str, - min_scene_len: ty.Optional[int]) -> FlashFilter: - if self.filter_mode == FlashFilterMode.DROP: - return FlashFilter(length=0) - if min_scene_len is None: - if self.config.is_default(detector_name, 'min-scene-len'): - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value(detector_name, 'min-scene-len') - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num - return FlashFilter(length=min_scene_len, mode=self.filter_mode.value) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 412227cb..d7180542 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -28,7 +28,6 @@ from scenedetect.video_stream import SeekError from scenedetect._cli.context import CliContext, check_split_video_requirements -from scenedetect._cli.config import FlashFilterMode logger = logging.getLogger('pyscenedetect') @@ -331,13 +330,11 @@ def _postprocess_scene_list( # it will be merged with the previous one. if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: - logger.debug("Last scene is shorter than %d frames, merging with previous.", - context.min_scene_len.get_frames()) new_last_scene = (scene_list[-2][0], scene_list[-1][1]) scene_list = scene_list[:-2] + [new_last_scene] - if context.filter_mode == FlashFilterMode.DROP: - logger.debug("Dropping scenes shorter than %d frames.", context.min_scene_len.get_frames()) + # Handle --drop-short-scenes. + if context.drop_short_scenes and context.min_scene_len > 0: scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] return scene_list diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 9289d774..954a91d7 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -17,7 +17,7 @@ """ from dataclasses import dataclass import math -import typing as ty +from typing import List, NamedTuple, Optional import numpy import cv2 @@ -54,7 +54,7 @@ class ContentDetector(SceneDetector): # TODO: Come up with some good weights for a new default if there is one that can pass # a wider variety of test cases. - class Components(ty.NamedTuple): + class Components(NamedTuple): """Components that make up a frame's score, and their default values.""" delta_hue: float = 1.0 """Difference between pixel hue values of adjacent frames.""" @@ -95,25 +95,23 @@ class _FrameData: """Frame saturation map [2D 8-bit].""" lum: numpy.ndarray """Frame luma/brightness map [2D 8-bit].""" - edges: ty.Optional[numpy.ndarray] + edges: Optional[numpy.ndarray] """Frame edge map [2D 8-bit, edges are 255, non edges 0]. Affected by `kernel_size`.""" def __init__( self, threshold: float = 27.0, min_scene_len: int = 15, - weights: Components = DEFAULT_COMPONENT_WEIGHTS, + weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: ty.Optional[int] = None, - flash_filter: ty.Optional[FlashFilter] = None, + kernel_size: Optional[int] = None, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): """ Arguments: threshold: Threshold the average change in pixel intensity must exceed to trigger a cut. - min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive - cuts that occur closer than this length will be merged. Equivalent to setting - `flash_filter = FlashFilter(length=min_scene_len)`. - Ignored if `flash_filter` is set. + min_scene_len: Once a cut is detected, this many frames must pass before a new one can + be added to the scene list. weights: Weight to place on each component when calculating frame score (`content_val` in a statsfile, the value `threshold` is compared against). luma_only: If True, only considers changes in the luminance channel of the video. @@ -121,26 +119,24 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If None, automatically set using video resolution. - flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. + filter_mode: Mode to use when filtering cuts to meet `min_scene_len`. """ super().__init__() self._threshold: float = threshold self._min_scene_len: int = min_scene_len - self._last_above_threshold: ty.Optional[int] = None - self._last_frame: ty.Optional[ContentDetector._FrameData] = None + self._last_above_threshold: Optional[int] = None + self._last_frame: Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: self._weights = ContentDetector.LUMA_ONLY_WEIGHTS - self._kernel: ty.Optional[numpy.ndarray] = None + self._kernel: Optional[numpy.ndarray] = None if kernel_size is not None: print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError('kernel_size must be odd integer >= 3') self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) - self._frame_score: ty.Optional[float] = None - self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( - length=min_scene_len) + self._frame_score: Optional[float] = None + self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): return ContentDetector.METRIC_KEYS @@ -190,7 +186,7 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) return frame_score - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -199,14 +195,15 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 + List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ self._frame_score = self._calculate_frame_score(frame_num, frame_img) if self._frame_score is None: return [] - return self._flash_filter.filter( - frame_num=frame_num, found_cut=self._frame_score >= self._threshold) + + above_threshold: bool = self._frame_score >= self._threshold + return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 304024d7..784bd1f9 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -18,11 +18,11 @@ from enum import Enum from logging import getLogger -import typing as ty +from typing import List, Optional import numpy -from scenedetect.scene_detector import SceneDetector, FlashFilter +from scenedetect.scene_detector import SceneDetector logger = getLogger('pyscenedetect') @@ -76,17 +76,14 @@ def __init__( fade_bias: float = 0.0, add_final_scene: bool = False, method: Method = Method.FLOOR, - flash_filter: ty.Optional[FlashFilter] = None, block_size=None, ): """ Arguments: threshold: 8-bit intensity value that each pixel value (R, G, and B) must be <= to in order to trigger a fade in/out. - min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive - cuts that occur closer than this length will be merged. Equivalent to setting - `flash_filter = FlashFilter(length=min_scene_len)`. - Ignored if `flash_filter` is set. + min_scene_len: FrameTimecode object or integer greater than 0 of the + minimum length, in frames, of a scene (or subsequent scene cut). fade_bias: Float between -1.0 and +1.0 representing the percentage of timecode skew for the start of a scene (-1.0 causing a cut at the fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be @@ -94,8 +91,6 @@ def __init__( add_final_scene: Boolean indicating if the video ends on a fade-out to generate an additional scene at this timecode. method: How to treat `threshold` when detecting fade events. - flash_filter: Filter to use for scene length compliance. If None, initialized as - `FlashFilter(length=min_scene_len)`. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. @@ -114,17 +109,15 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - 'frame': 0, # frame number where the last detected fade is - 'type': None # type of fade, can be either 'in' or 'out' + 'frame': 0, # frame number where the last detected fade is + 'type': None # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] - self._flash_filter = flash_filter if not flash_filter is None else FlashFilter( - length=min_scene_len) - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -133,7 +126,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 + List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ @@ -145,6 +138,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int # If absolute value of pixel intensity delta is above the threshold, # then we trigger a new scene cut/break. + # List of cuts to return. + cut_list = [] + # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this @@ -157,44 +153,35 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int if self.stats_manager is not None: self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) - if not self.processed_frame: + if self.processed_frame: + if self.last_fade['type'] == 'in' and (( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): + # Just faded out of a scene, wait for next fade in. + self.last_fade['type'] = 'out' + self.last_fade['frame'] = frame_num + + elif self.last_fade['type'] == 'out' and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): + # Only add the scene if min_scene_len frames have passed. + if (frame_num - self.last_scene_cut) >= self.min_scene_len: + # Just faded into a new scene, compute timecode for the scene + # split based on the fade bias. + f_out = self.last_fade['frame'] + f_split = int( + (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) + cut_list.append(f_split) + self.last_scene_cut = frame_num + self.last_fade['type'] = 'in' + self.last_fade['frame'] = frame_num + else: self.last_fade['frame'] = 0 if frame_avg < self.threshold: self.last_fade['type'] = 'out' else: self.last_fade['type'] = 'in' - self._flash_filter.filter(frame_num=frame_num, found_cut=False) - self.processed_frame = True - return [] - - cut_list = [] - if self.last_fade['type'] == 'in' and ( - ((self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): - # Just faded out of a scene, wait for next fade in. - f_in = self.last_fade['frame'] - self.last_fade['type'] = 'out' - self.last_fade['frame'] = frame_num - # The next cut will be placed at at or after this frame (the fade out position), and - # the previous cut was placed before or at the last fade in position. - # Thus we know no new cuts were generated between the two. - for frame_num in range(f_in + 1, frame_num): - cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) - - elif self.last_fade['type'] == 'out' and ( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): - # Just faded into a new scene, compute timecode based on the fade bias. - # f_split will be between the fade out position and frame_num. - f_out = self.last_fade['frame'] - f_split = int((frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) - self.last_scene_cut = frame_num - self.last_fade['type'] = 'in' - self.last_fade['frame'] = frame_num - # Update the filter state to determine where cuts occured. - for frame_num in range(f_out, frame_num + 1): - cut_list += self._flash_filter.filter( - frame_num=frame_num, found_cut=(frame_num == f_split)) + self.processed_frame = True return cut_list def post_process(self, frame_num: int): @@ -205,13 +192,13 @@ def post_process(self, frame_num: int): (since there is no corresponding fade-in) so it will be located at the exact frame where the fade-out crossed the detection threshold. """ + # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cut_list = [] - if self.last_fade['type'] == 'out' and self.add_final_scene: - f_out = self.last_fade['frame'] - cut_list += self._flash_filter.filter(frame_num=f_out, found_cut=True) - for frame_num in range(f_out + 1, frame_num + 1): - cut_list += self._flash_filter.filter(frame_num=frame_num, found_cut=False) - return cut_list + cut_times = [] + if self.last_fade['type'] == 'out' and self.add_final_scene and ( + (self.last_scene_cut is None and frame_num >= self.min_scene_len) or + (frame_num - self.last_scene_cut) >= self.min_scene_len): + cut_times.append(self.last_fade['frame']) + return cut_times diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 14a03226..ded5d35d 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -51,6 +51,33 @@ class SceneDetector: """Optional :class:`StatsManager ` to use for caching frame metrics to and from.""" + # TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain. + def is_processing_required(self, frame_num: int) -> bool: + """[DEPRECATED] DO NOT USE + + Test if all calculations for a given frame are already done. + + Returns: + False if the SceneDetector has assigned _metric_keys, and the + stats_manager property is set to a valid StatsManager object containing + the required frame metrics/calculations for the given frame - thus, not + needing the frame to perform scene detection. + + True otherwise (i.e. the frame_img passed to process_frame is required + to be passed to process_frame for the given frame_num). + """ + metric_keys = self.get_metrics() + return not metric_keys or not (self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys)) + + def stats_manager_required(self) -> bool: + """Stats Manager Required: Prototype indicating if detector requires stats. + + Returns: + True if a StatsManager is required for the detector, False otherwise. + """ + return False + def get_metrics(self) -> ty.List[str]: """Get Metrics: Get a list of all metric names/keys used by the detector. @@ -94,21 +121,17 @@ def event_buffer_length(self) -> int: """ return 0 - # DEPRECATED - TO BE REMOVED - def is_processing_required(self, frame_num: int) -> bool: - """[DEPRECATED] DO NOT USE""" - metric_keys = self.get_metrics() - return not metric_keys or not (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys)) +class SparseSceneDetector(SceneDetector): + """Base class to inherit from when implementing a sparse scene detection algorithm. - def stats_manager_required(self) -> bool: - """[DEPRECATED] DO NOT USE""" - return False + This class will be removed in v1.0 and should not be used. + Unlike dense detectors, sparse detectors detect "events" and return a *pair* of frames, + as opposed to just a single cut. -class SparseSceneDetector(SceneDetector): - """[DEPRECATED] DO NOT USE""" + An example of a SparseSceneDetector is the MotionDetector. + """ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: @@ -135,67 +158,55 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: - """Filters scene cuts which occur too close together (less than `length` frames apart). - - If filter `length` is 0, filter is disabled. - """ class Mode(Enum): - """Mode specifying how the filter operates when active.""" MERGE = 0 - """Merge consecutive cuts shorter than filter length (default).""" + """Merge consecutive cuts shorter than filter length.""" SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" - def __init__(self, length: int, mode: Mode = Mode.MERGE): + def __init__(self, mode: Mode, length: int): self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filter. - - def __str__(self) -> str: - return self.__repr__() - - def __repr__(self) -> str: - if self._filter_length <= 0: - return "FlashFilter(length=0 [DISABLED])" - return f"FlashFilter(mode={str(self._mode)}, length={self._filter_length})" + self._merge_start = None # Frame number where we started the merge filte. - def filter(self, frame_num: int, found_cut: bool) -> ty.List[int]: - if self._filter_length <= 0: - return [frame_num] if found_cut else [] + def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + if not self._filter_length > 0: + return [frame_num] if above_threshold else [] if self._last_above is None: self._last_above = frame_num if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=frame_num, found_cut=found_cut) + return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) if self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=frame_num, found_cut=found_cut) + return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) - def _filter_suppress(self, frame_num: int, found_cut: bool) -> ty.List[int]: + def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length - if not (found_cut and min_length_met): + if not (above_threshold and min_length_met): return [] - # Only advance last frame when the length requirement is satisfied. + # Both length and threshold requirements were satisfied. Emit the cut, and wait until both + # requirements are met again. self._last_above = frame_num return [frame_num] - def _filter_merge(self, frame_num: int, found_cut: bool) -> ty.List[int]: + def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length # Ensure last frame is always advanced to the most recent one that was above the threshold. - if found_cut: + if above_threshold: self._last_above = frame_num if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. num_merged_frames = self._last_above - self._merge_start - if min_length_met and not found_cut and num_merged_frames >= self._filter_length: + if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. return [] # Wait for next frame above the threshold. - if not found_cut: + if not above_threshold: return [] # If we met the minimum length requirement, no merging is necessary. if min_length_met: diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 199809f1..3d3bd435 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -82,7 +82,6 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import csv from enum import Enum -import typing as ty from typing import Iterable, List, Tuple, Optional, Dict, Callable, Union, TextIO import threading import queue @@ -98,8 +97,8 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): from scenedetect.platform import (tqdm, get_and_create_path, get_cv2_imwrite_params, Template) from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream -from scenedetect.scene_detector import SceneDetector -from scenedetect.stats_manager import StatsManager +from scenedetect.scene_detector import SceneDetector, SparseSceneDetector +from scenedetect.stats_manager import StatsManager, FrameMetricRegistered logger = logging.getLogger('pyscenedetect') @@ -550,7 +549,7 @@ class SceneManager: def __init__( self, - stats_manager: ty.Optional[StatsManager] = None, + stats_manager: Optional[StatsManager] = None, ): """ Arguments: @@ -560,13 +559,16 @@ def __init__( self._cutting_list = [] self._event_list = [] self._detector_list = [] + self._sparse_detector_list = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the # `stats_manager` argument to to `store_stats: bool=False`, or lazy-init one. + # TODO(v1.0): This class should own a VideoStream as well, instead of passing one # to the detect_scenes method. If concatenation is required, it can be implemented as # a generic VideoStream wrapper. self._stats_manager: Optional[StatsManager] = stats_manager + # Position of video that was first passed to detect_scenes. self._start_pos: FrameTimecode = None # Position of video on the last frame processed by detect_scenes. @@ -582,6 +584,7 @@ def __init__( # Set by decode thread when an exception occurs. self._exception_info = None self._stop = threading.Event() + self._frame_buffer = [] self._frame_buffer_size = 0 @@ -636,10 +639,21 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ + if self._stats_manager is None and detector.stats_manager_required(): + # Make sure the lists are empty so that the detectors don't get + # out of sync (require an explicit statsmanager instead) + assert not self._detector_list and not self._sparse_detector_list + self._stats_manager = StatsManager() + detector.stats_manager = self._stats_manager if self._stats_manager is not None: self._stats_manager.register_metrics(detector.get_metrics()) - self._detector_list.append(detector) + + if not issubclass(type(detector), SparseSceneDetector): + self._detector_list.append(detector) + else: + self._sparse_detector_list.append(detector) + self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) def get_num_detectors(self) -> int: @@ -663,6 +677,7 @@ def clear(self) -> None: def clear_detectors(self) -> None: """Remove all scene detectors added to the SceneManager via add_detector(). """ self._detector_list.clear() + self._sparse_detector_list.clear() def get_scene_list(self, base_timecode: Optional[FrameTimecode] = None, @@ -733,6 +748,13 @@ def _process_frame(self, for cut_frame_num in cuts: buffer_index = cut_frame_num - (frame_num + 1) callback(self._frame_buffer[buffer_index], cut_frame_num) + for detector in self._sparse_detector_list: + events = detector.process_frame(frame_num, frame_im) + self._event_list += events + if callback: + for event_start, _ in events: + buffer_index = event_start - (frame_num + 1) + callback(self._frame_buffer[buffer_index], event_start) return new_cuts def _post_process(self, frame_num: int) -> None: @@ -956,6 +978,8 @@ def get_cut_list(self, the scene list, noting that each scene is contiguous starting from the first frame and ending at the last frame detected. + If only sparse detectors are used (e.g. MotionDetector), this will always be empty. + Arguments: base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. show_warning: If set to False, suppresses the error from being warned. In v0.7, @@ -975,7 +999,20 @@ def get_event_list( self, base_timecode: Optional[FrameTimecode] = None ) -> List[Tuple[FrameTimecode, FrameTimecode]]: - """[DEPRECATED] DO NOT USE""" + """[DEPRECATED] DO NOT USE. + + Get a list of start/end timecodes of sparse detection events. + + Unlike get_scene_list, the event list returns a list of FrameTimecodes representing + the point in the input video where a new scene was detected only by sparse detectors, + otherwise it is the same. + + Arguments: + base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. + + Returns: + List of pairs of FrameTimecode objects denoting the detected scenes. + """ # TODO(v0.7): Use the warnings module to turn this into a warning. logger.error('`get_event_list()` is deprecated and will be removed in a future release.') return self._get_event_list() diff --git a/tests/test_api.py b/tests/test_api.py index 5f29ddee..1ddb5596 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -87,11 +87,9 @@ def test_api_timecode_types(): # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') timecode = base_timecode + '00:00:01.500' assert timecode.get_frames() == 15 - # Seconds (str, 'SSSs' or 'SSSS.SSS') + # Seconds (str, 'SSSs' or 'SSSS.SSSs') timecode = base_timecode + '1.5s' assert timecode.get_frames() == 15 - timecode = base_timecode + '1.5' - assert timecode.get_frames() == 15 def test_api_stats_manager(test_video_file: str): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a5648b2a..070932e9 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -7,13 +7,11 @@ Releases ### 0.6.4 (In Development) - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - - [feature] Add new flash suppression filter with `filter-mode` config option, reduces number of cuts generated during strobing/flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) - - `filter-mode = merge`, the new default mode, merges consecutive scenes shorter than `min-scene-len` - - `filter-mode = suppress`, the previous behavior, disables generating new scenes until `min-scene-len` has passed - - `filter-mode = drop`, replaces global `--drop-short-scenes` option - - [cli] Deprecate `--drop-short-scenes`, use `--filter-mode = drop` instead - - [bugfix] Remove extraneous console output when using `--filter-mode drop` (previously `--drop-short-scenes`) - - [api] Deprecate `SparseSceneDetector` and `SceneDetector.stats_manager_required()` function (no longer required) + - [feature] Add flash suppression filter for `detect-content` / `ContentDetector`, greatly reduces number of cuts generated during strobing or flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) + - Can be configured using `--filter-mode` option, enabled by default + - `--filter-mode = merge` (new default) merges consecutive scenes shorter than `min-scene-len` + - `--filter-mode = suppress` (previous default) disables generating new scenes until `min-scene-len` has passed + - [bugfix] Remove extraneous console output when using `--drop-short-scenes` ### 0.6.3 (March 9, 2024) From d8397bcdeea344009763ff31b7223fbcd62ab91d Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 23 Apr 2024 21:38:17 -0400 Subject: [PATCH 086/407] [detectors] Fix min-scene-length check in AdaptiveDetector Was being checked against the current frame instead of the target frame. The default window size is quite small so this won't be off by more than one or two frames, but with larger window sizes, would cause scenes to get proportionally smaller. --- scenedetect/detectors/adaptive_detector.py | 16 ++++++++-------- website/pages/changelog.md | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 85778158..064255f5 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -140,30 +140,30 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] - target = self._buffer[self.window_width] + (target_frame, target_score) = self._buffer[self.window_width] average_window_score = ( - sum(frame[1] for i, frame in enumerate(self._buffer) if i != self.window_width) / + sum(score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width) / (2.0 * self.window_width)) average_is_zero = abs(average_window_score) < 0.00001 adaptive_ratio = 0.0 if not average_is_zero: - adaptive_ratio = min(target[1] / average_window_score, 255.0) - elif average_is_zero and target[1] >= self.min_content_val: + adaptive_ratio = min(target_score / average_window_score, 255.0) + elif average_is_zero and target_score >= self.min_content_val: # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: - self.stats_manager.set_metrics(target[0], {self._adaptive_ratio_key: adaptive_ratio}) + self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio}) # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut threshold_met: bool = ( - adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val) + adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val) min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: - self._last_cut = target[0] - return [target[0]] + self._last_cut = target_frame + return [target_frame] return [] def get_content_val(self, frame_num: int) -> Optional[float]: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 070932e9..b7441344 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -12,6 +12,7 @@ Releases - `--filter-mode = merge` (new default) merges consecutive scenes shorter than `min-scene-len` - `--filter-mode = suppress` (previous default) disables generating new scenes until `min-scene-len` has passed - [bugfix] Remove extraneous console output when using `--drop-short-scenes` + - [bugfix] Fix scene lengths being smaller than `min-scene-len` when using `detect-adaptive` / `AdaptiveDetector` with large values of `--frame-window` ### 0.6.3 (March 9, 2024) From 62238913e22456be024b297a2b1d875890b01257 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 26 Apr 2024 22:00:43 -0400 Subject: [PATCH 087/407] [detectors] Integrate flash filter arguments for detect-content. --- scenedetect.cfg | 5 +++ scenedetect/_cli/__init__.py | 62 +++++++++++++++++++++--------------- scenedetect/_cli/config.py | 7 +++- scenedetect/_cli/context.py | 22 +++++++++---- scenedetect/scene_manager.py | 2 +- 5 files changed, 65 insertions(+), 33 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 8b004017..e2c540ad 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -92,6 +92,11 @@ # than or equal to 3. If None, automatically set using video resolution. #kernel-size = -1 +# Mode to use for enforcing min-scene-len: +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge + [detect-threshold] # Average pixel intensity from 0-255 at which a fade event is triggered. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index d5823703..5abcfe15 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -443,52 +443,62 @@ def time_command( ) -@click.command('detect-content', cls=_Command) +@click.command("detect-content", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-content']['threshold'].min_val, - CONFIG_MAP['detect-content']['threshold'].max_val), + "--threshold", + "-t", + metavar="VAL", + type=click.FloatRange(CONFIG_MAP["detect-content"]["threshold"].min_val, + CONFIG_MAP["detect-content"]["threshold"].max_val), default=None, - help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "content_val" in stats file.%s' + help="Threshold (float) that frame score must exceed to trigger a cut. Refers to \"content_val\" in stats file.%s" % (USER_CONFIG.get_help_string("detect-content", "threshold")), ) @click.option( - '--weights', - '-w', + "--weights", + "-w", type=(float, float, float, float), default=None, - metavar='HUE SAT LUM EDGE', - help='Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).%s' + metavar="HUE SAT LUM EDGE", + help="Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).%s" % (USER_CONFIG.get_help_string("detect-content", "weights")), ) @click.option( - '--luma-only', - '-l', + "--luma-only", + "-l", is_flag=True, flag_value=True, - help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting "-w 0 0 1 0".%s' + help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w=\"0 0 1 0\".%s" % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( - '--kernel-size', - '-k', - metavar='N', + "--kernel-size", + "-k", + metavar="N", type=click.INT, default=None, - help='Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.%s' + help="Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.%s" % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' - % ('' if USER_CONFIG.is_default('detect-content', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-content', 'min-scene-len')), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" % + ("" if USER_CONFIG.is_default("detect-content", "min-scene-len") else + USER_CONFIG.get_help_string("detect-content", "min-scene-len")), +) +@click.option( + "--filter-mode", + "-f", + metavar="MODE", + type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), + default=None, + help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" % + (", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), + USER_CONFIG.get_help_string("detect-content", "filter-mode")), ) @click.pass_context def detect_content_command( @@ -498,6 +508,7 @@ def detect_content_command( luma_only: bool, kernel_size: Optional[int], min_scene_len: Optional[str], + filter_mode: Optional[str], ): """Perform content detection algorithm on input video. @@ -527,7 +538,8 @@ def detect_content_command( luma_only=luma_only, min_scene_len=min_scene_len, weights=weights, - kernel_size=kernel_size) + kernel_size=kernel_size, + filter_mode=filter_mode) logger.debug('Adding detector: ContentDetector(%s)', detector_args) ctx.obj.add_detector(ContentDetector(**detector_args)) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 2f72e9ca..95fb0b9f 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -27,6 +27,7 @@ from scenedetect.detectors import ContentDetector from scenedetect.frame_timecode import FrameTimecode +from scenedetect.scene_detector import FlashFilter from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS @@ -263,6 +264,7 @@ def format(self, timecode: FrameTimecode) -> str: 'min-delta-hsv': RangeValue(15.0, min_val=0.0, max_val=255.0), }, 'detect-content': { + 'filter-mode': 'merge', 'kernel-size': KernelSizeValue(-1), 'luma-only': False, 'min-scene-len': TimecodeValue(0), @@ -342,7 +344,10 @@ def format(self, timecode: FrameTimecode) -> str: CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { 'backend-pyav': { - 'threading_mode': [str(mode).lower() for mode in VALID_PYAV_THREAD_MODES], + 'threading_mode': [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + }, + 'detect-content': { + 'filter-mode': [mode.name.lower() for mode in FlashFilter.Mode], }, 'global': { 'backend': ['opencv', 'pyav', 'moviepy'], diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index c1ceb48d..a5f7103e 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -23,7 +23,7 @@ from scenedetect import open_video, AVAILABLE_BACKENDS -from scenedetect.scene_detector import SceneDetector +from scenedetect.scene_detector import SceneDetector, FlashFilter from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable @@ -317,6 +317,7 @@ def get_detect_content_params( min_scene_len: Optional[str] = None, weights: Optional[Tuple[float, float, float, float]] = None, kernel_size: Optional[int] = None, + filter_mode: Optional[str] = None, ) -> Dict[str, Any]: """Handle detect-content command options and return dict to construct one with.""" self._ensure_input_open() @@ -337,12 +338,21 @@ def get_detect_content_params( except ValueError as ex: logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint='weights') + return { - 'weights': self.config.get_value('detect-content', 'weights', weights), - 'kernel_size': self.config.get_value('detect-content', 'kernel-size', kernel_size), - 'luma_only': luma_only or self.config.get_value('detect-content', 'luma-only'), - 'min_scene_len': min_scene_len, - 'threshold': self.config.get_value('detect-content', 'threshold', threshold), + 'weights': + self.config.get_value('detect-content', 'weights', weights), + 'kernel_size': + self.config.get_value('detect-content', 'kernel-size', kernel_size), + 'luma_only': + luma_only or self.config.get_value('detect-content', 'luma-only'), + 'min_scene_len': + min_scene_len, + 'threshold': + self.config.get_value('detect-content', 'threshold', threshold), + 'filter_mode': + FlashFilter.Mode[self.config.get_value("detect-content", "filter-mode", + filter_mode).upper()], } def get_detect_adaptive_params( diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 3d3bd435..df7251c4 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -558,7 +558,7 @@ def __init__( """ self._cutting_list = [] self._event_list = [] - self._detector_list = [] + self._detector_list: List[SceneDetector] = [] self._sparse_detector_list = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the From 514927feb8ebcecd021824d78a524db284d12b2c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Apr 2024 17:25:52 -0400 Subject: [PATCH 088/407] [build] Use non-M1 builder until new release. --- .github/workflows/build.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08f6e755..3aaeae4e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,8 +27,13 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, ubuntu-20.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + # TODO(1.6.1): Deprecate Python 3.7 and support macos-14. + os: [macos-13, ubuntu-20.04, ubuntu-latest, windows-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + + env: + # Version is extracted below and used to find correct package install path. + scenedetect_version: "" steps: - uses: actions/checkout@v4 @@ -40,9 +45,9 @@ jobs: cache: 'pip' - name: Install Dependencies - # TODO: `setuptools` is pinned for the Python 3.7 builder and can be unpinned when removed. + # TODO(1.6.1): Unpin the following requirements when deprecating the Python 3.7 builder. run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools==62.3.4 + python -m pip install --upgrade pip build==1.1.1 wheel====0.42.0 virtualenv setuptools==62.3.4 pip install av opencv-python-headless --only-binary :all: pip install -r requirements_headless.txt @@ -84,15 +89,13 @@ jobs: git mv docs docs_src sphinx-build -b singlehtml docs_src docs - # TODO: Make the version extraction work on powershell so package smoke tests can run on Windows. - name: Build Package - if: ${{ matrix.os != 'windows-latest' }} + shell: bash run: | python -m build echo "scenedetect_version=`python -c \"import scenedetect; print(scenedetect.__version__.replace('-', '.'))\"`" >> "$GITHUB_ENV" - name: Smoke Test Package (Source Dist) - if: ${{ matrix.os != 'windows-latest' }} run: | python -m pip install dist/scenedetect-${{ env.scenedetect_version }}.tar.gz scenedetect version @@ -101,7 +104,6 @@ jobs: python -m pip uninstall -y scenedetect - name: Smoke Test Package (Wheel) - if: ${{ matrix.os != 'windows-latest' }} run: | python -m pip install dist/scenedetect-${{ env.scenedetect_version }}-py3-none-any.whl scenedetect version From 5dbac1ba5c4e56329770031f6d8eb28aae7dc839 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Apr 2024 17:31:06 -0400 Subject: [PATCH 089/407] [build] Add builder for macos-14 and deprecate Python 3.7. Python 3.7 still works but we no longer officially support it. --- .github/workflows/build.yml | 16 +++++++--------- .github/workflows/check-code-format.yml | 4 ++-- website/pages/changelog.md | 6 ++++++ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3aaeae4e..c12e41de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,9 +27,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # TODO(1.6.1): Deprecate Python 3.7 and support macos-14. - os: [macos-13, ubuntu-20.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] env: # Version is extracted below and used to find correct package install path. @@ -45,9 +44,8 @@ jobs: cache: 'pip' - name: Install Dependencies - # TODO(1.6.1): Unpin the following requirements when deprecating the Python 3.7 builder. run: | - python -m pip install --upgrade pip build==1.1.1 wheel====0.42.0 virtualenv setuptools==62.3.4 + python -m pip install --upgrade pip build wheel virtualenv setuptools pip install av opencv-python-headless --only-binary :all: pip install -r requirements_headless.txt @@ -59,7 +57,7 @@ jobs: # TODO: Cache this: https://github.com/actions/cache # TODO: Install ffmpeg/mkvtoolnix on all runners. - name: Download FFMPEG - if: ${{ matrix.os == 'windows-latest' }} + if: ${{ runner.os == 'Windows' }} uses: dsaltares/fetch-gh-release-asset@1.1.1 with: repo: 'GyanD/codexffmpeg' @@ -67,7 +65,7 @@ jobs: file: 'ffmpeg-6.0-full_build.7z' - name: Extract FFMPEG - if: ${{ matrix.os == 'windows-latest' }} + if: ${{ runner.os == 'Windows' }} run: | 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r @@ -83,7 +81,7 @@ jobs: python -m pip uninstall -y scenedetect - name: Build Documentation - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} + if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} run: | pip install -r docs/requirements.txt git mv docs docs_src @@ -112,7 +110,7 @@ jobs: python -m pip uninstall -y scenedetect - name: Upload Package - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} + if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} uses: actions/upload-artifact@v3 with: name: scenedetect-dist diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index ac291864..49c51b61 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -18,10 +18,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Update pip diff --git a/website/pages/changelog.md b/website/pages/changelog.md index b7441344..3b66ce36 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -6,6 +6,12 @@ Releases ### 0.6.4 (In Development) +#### Release Notes + +Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ash2703), adds flash filter to content detector, and includes various bugfixes. Minimum supported Python version is now **Python 3.8**. + +#### Changelog + - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - [feature] Add flash suppression filter for `detect-content` / `ContentDetector`, greatly reduces number of cuts generated during strobing or flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) - Can be configured using `--filter-mode` option, enabled by default From 486af67aa33d1e455552643aab53a67d4a3306fa Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Apr 2024 17:35:54 -0400 Subject: [PATCH 090/407] [build] Add macos-14 builder and unpin setuptools. --- .github/workflows/build.yml | 27 +++++++++++++++++-------- .github/workflows/check-code-format.yml | 4 ++-- .github/workflows/generate-docs.yml | 4 ++-- .github/workflows/generate-website.yml | 4 ++-- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3aaeae4e..2755b1ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,9 +27,12 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # TODO(1.6.1): Deprecate Python 3.7 and support macos-14. - os: [macos-13, ubuntu-20.04, ubuntu-latest, windows-latest] + os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + exclude: + # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. + - os: macos-14 + python-version: "3.7" env: # Version is extracted below and used to find correct package install path. @@ -45,9 +48,17 @@ jobs: cache: 'pip' - name: Install Dependencies - # TODO(1.6.1): Unpin the following requirements when deprecating the Python 3.7 builder. + if: ${{ matrix.python-version != '3.7' }} run: | - python -m pip install --upgrade pip build==1.1.1 wheel====0.42.0 virtualenv setuptools==62.3.4 + python -m pip install --upgrade pip build wheel virtualenv setuptools + pip install av opencv-python-headless --only-binary :all: + pip install -r requirements_headless.txt + + # TODO(1.6.1): Remove this branch when deprecating Python 3.7. + - name: Install Dependencies + if: ${{ matrix.python-version == '3.7' }} + run: | + python -m pip install --upgrade pip build wheel virtualenv setuptools==62.3.4 pip install av opencv-python-headless --only-binary :all: pip install -r requirements_headless.txt @@ -59,7 +70,7 @@ jobs: # TODO: Cache this: https://github.com/actions/cache # TODO: Install ffmpeg/mkvtoolnix on all runners. - name: Download FFMPEG - if: ${{ matrix.os == 'windows-latest' }} + if: ${{ runner.os == 'Windows' }} uses: dsaltares/fetch-gh-release-asset@1.1.1 with: repo: 'GyanD/codexffmpeg' @@ -67,7 +78,7 @@ jobs: file: 'ffmpeg-6.0-full_build.7z' - name: Extract FFMPEG - if: ${{ matrix.os == 'windows-latest' }} + if: ${{ runner.os == 'Windows' }} run: | 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r @@ -83,7 +94,7 @@ jobs: python -m pip uninstall -y scenedetect - name: Build Documentation - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} + if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} run: | pip install -r docs/requirements.txt git mv docs docs_src @@ -112,7 +123,7 @@ jobs: python -m pip uninstall -y scenedetect - name: Upload Package - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} + if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} uses: actions/upload-artifact@v3 with: name: scenedetect-dist diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index ac291864..49c51b61 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -18,10 +18,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Update pip diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 1ceac1e5..c3adbc9d 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -22,10 +22,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Set Destination (Releases) diff --git a/.github/workflows/generate-website.yml b/.github/workflows/generate-website.yml index a3221de4..328ac2cc 100644 --- a/.github/workflows/generate-website.yml +++ b/.github/workflows/generate-website.yml @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Install Dependencies From 02eda95ce69e1dc4420bf11418bdeefb6376a81f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Apr 2024 17:40:29 -0400 Subject: [PATCH 091/407] [tests] Tweak threshold value to get tests passing on M1 hardware. --- tests/test_detectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index b3661871..b1004ff9 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -133,7 +133,7 @@ def get_fade_in_out_test_cases(): TestCase( path=get_absolute_path("resources/fades.mp4"), detector=ThresholdDetector( - threshold=12.0, + threshold=11.0, method=ThresholdDetector.Method.FLOOR, add_final_scene=True, ), From 5dccc48f67554f7e015b2786df81dea2f27b9c94 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Apr 2024 17:47:43 -0400 Subject: [PATCH 092/407] [build] Enable ffmpeg on all targets and bump Windows distribution to use ffmpeg 7.0. --- .github/workflows/build-windows.yml | 11 +++++++---- .github/workflows/build.yml | 16 +--------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index d1eea8a2..39d8eabb 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -30,6 +30,9 @@ jobs: matrix: python-version: ["3.9"] + env: + ffmpeg-version: "7.0" + steps: - uses: actions/checkout@v4 @@ -54,12 +57,12 @@ jobs: uses: dsaltares/fetch-gh-release-asset@1.1.1 with: repo: 'GyanD/codexffmpeg' - version: 'tags/6.0' - file: 'ffmpeg-6.0-full_build.7z' + version: 'tags/${{ env.ffmpeg-version }}' + file: 'ffmpeg-${{ env.ffmpeg-version }}-full_build.7z' - name: Unit Test run: | - 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r + 7z e ffmpeg-${{ env.ffmpeg-version }}-full_build.7z ffmpeg.exe -r python -m pytest -vv - name: Build PySceneDetect @@ -77,7 +80,7 @@ jobs: Move-Item -Path dist/windows/README* -Destination dist/scenedetect/ Move-Item -Path dist/windows/LICENSE* -Destination dist/scenedetect/thirdparty/ Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ - 7z e -odist/ffmpeg ffmpeg-6.0-full_build.7z LICENSE -r + 7z e -odist/ffmpeg ffmpeg-${{ env.ffmpeg-version }}-full_build.7z LICENSE -r Move-Item -Path ffmpeg.exe -Destination dist/scenedetect/ffmpeg.exe Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/thirdparty/LICENSE-FFMPEG diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2755b1ed..69a9653c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,7 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: FedericoCarboni/setup-ffmpeg@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -67,21 +68,6 @@ jobs: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ - # TODO: Cache this: https://github.com/actions/cache - # TODO: Install ffmpeg/mkvtoolnix on all runners. - - name: Download FFMPEG - if: ${{ runner.os == 'Windows' }} - uses: dsaltares/fetch-gh-release-asset@1.1.1 - with: - repo: 'GyanD/codexffmpeg' - version: 'tags/6.0' - file: 'ffmpeg-6.0-full_build.7z' - - - name: Extract FFMPEG - if: ${{ runner.os == 'Windows' }} - run: | - 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r - - name: Unit Tests run: | python -m pytest -vv From 954b732ccd79cec7709e6e6f6ac1baeb02ea1f90 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 27 Apr 2024 17:59:03 -0400 Subject: [PATCH 093/407] [build] Fix build failures due to no ffmpeg binaries for arm64. --- .github/workflows/build.yml | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69a9653c..ba9aefc6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,10 +37,16 @@ jobs: env: # Version is extracted below and used to find correct package install path. scenedetect_version: "" + # Setuptools must be pinned for the Python 3.7 builders. + setuptools_version: "${{ matrix.python-version == '3.7' && '==62.3.4' || '' }}" steps: - uses: actions/checkout@v4 + - uses: FedericoCarboni/setup-ffmpeg@v3 + # TODO: This action currently does not work for non-x64 builders (e.g. macos-14): + # https://github.com/federicocarboni/setup-ffmpeg/issues/21 + if: ${{ runner.arch == 'X64' }} - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -49,17 +55,8 @@ jobs: cache: 'pip' - name: Install Dependencies - if: ${{ matrix.python-version != '3.7' }} run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools - pip install av opencv-python-headless --only-binary :all: - pip install -r requirements_headless.txt - - # TODO(1.6.1): Remove this branch when deprecating Python 3.7. - - name: Install Dependencies - if: ${{ matrix.python-version == '3.7' }} - run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools==62.3.4 + python -m pip install --upgrade pip build wheel virtualenv setuptools${{ env.setuptools_version }} pip install av opencv-python-headless --only-binary :all: pip install -r requirements_headless.txt @@ -79,13 +76,6 @@ jobs: python -m scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s python -m pip uninstall -y scenedetect - - name: Build Documentation - if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} - run: | - pip install -r docs/requirements.txt - git mv docs docs_src - sphinx-build -b singlehtml docs_src docs - - name: Build Package shell: bash run: | From 7eec3c80f9d867dfb77fb019e64108d932c260e5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 30 Apr 2024 22:27:52 -0400 Subject: [PATCH 094/407] [build] Add fallbacks for downloading ffmeg to reduce build flakes. --- .github/actions/setup-ffmpeg/action.yml | 41 +++++++++++++++++++++++++ .github/workflows/build-windows.yml | 2 +- .github/workflows/build.yml | 5 ++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .github/actions/setup-ffmpeg/action.yml diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml new file mode 100644 index 00000000..2bfb4f7e --- /dev/null +++ b/.github/actions/setup-ffmpeg/action.yml @@ -0,0 +1,41 @@ +name: 'Setup FFmpeg' +inputs: + github-token: + required: true + +runs: + using: 'composite' + steps: + - name: Setup FFmpeg (latest) + id: latest + continue-on-error: true + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + + - name: Setup FFmpeg (7.0.0) + if: ${{ steps.latest.outcome == 'failure' }} + id: v7-0-0 + continue-on-error: true + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + ffmpeg-version: "7.0.0" + + - name: Setup FFmpeg (6.1.1) + if: ${{ steps.v7-0-0.outcome == 'failure' }} + id: v6-1-1 + continue-on-error: true + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + ffmpeg-version: "6.1.1" + + # The oldest version we allow falling back to must not have `continue-on-error: true` + - name: Setup FFmpeg (6.1.0) + if: ${{ steps.v6-1-1.outcome == 'failure' }} + id: v6-1-0 + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + ffmpeg-version: "6.1.0" diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 39d8eabb..b9305634 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -53,7 +53,7 @@ jobs: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ - - name: Download FFMPEG + - name: Download FFMPEG ${{ env.ffmpeg-version }} uses: dsaltares/fetch-gh-release-asset@1.1.1 with: repo: 'GyanD/codexffmpeg' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba9aefc6..4ccdfb69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,10 +43,13 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: FedericoCarboni/setup-ffmpeg@v3 + - name: Setup FFmpeg # TODO: This action currently does not work for non-x64 builders (e.g. macos-14): # https://github.com/federicocarboni/setup-ffmpeg/issues/21 if: ${{ runner.arch == 'X64' }} + uses: ./.github/actions/setup-ffmpeg + with: + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 From 76296453e74b1a2aa1ff94824d0294990a67dbb9 Mon Sep 17 00:00:00 2001 From: Sara Veldhoen Date: Fri, 3 May 2024 03:59:50 +0200 Subject: [PATCH 095/407] Extend image_name_template to allow timestamp (#395) * Added TIMESTAMP_MS to file_path substitutions * Added $TIMESTAMP to image_name_template in test * Improved variable name * Added $TIMECODE to image name template options (also added $FRAME_NUMBER to test) * Added TIMESTAMP_MS to file_path substitutions * Added $TIMESTAMP to image_name_template in test * Improved variable name * Added $TIMECODE to image name template options (also added $FRAME_NUMBER to test) * Use semicolon as timestamp separator in filename * Proper string concatenation * Updated docstring --------- Co-authored-by: Sara Veldhoen --- scenedetect/scene_manager.py | 21 +++++++++++++-------- tests/test_scene_manager.py | 4 +++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index df7251c4..64db99dd 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -383,9 +383,9 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. - image_name_template: Template to use when creating the images on disk. Can - use the macros $VIDEO_NAME, $SCENE_NUMBER, and $IMAGE_NUMBER. The image - extension is applied automatically as per the argument image_extension. + image_name_template: Template to use when creating the images on disk. Can use the macros + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $FRAME_NUMBER, and $TIMESTAMP_MS. + The image extension is applied automatically as per the argument image_extension. output_dir: Directory to output the images into. If not set, the output is created in the working directory. show_progress: If True, shows a progress bar if tqdm is installed. @@ -489,11 +489,16 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], frame_im = video.read() if frame_im is not None: # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = '%s.%s' % (filename_template.safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=scene_num_format % (i + 1), - IMAGE_NUMBER=image_num_format % (j + 1), - FRAME_NUMBER=image_timecode.get_frames()), image_extension) + file_path = '%s.%s' % ( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (i + 1), + IMAGE_NUMBER=image_num_format % (j + 1), + FRAME_NUMBER=image_timecode.get_frames(), + TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";")), + image_extension, + ) image_filenames[i].append(file_path) # TODO: Combine this resize with the ones below. if aspect_ratio is not None: diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 20ef4677..c974398d 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -94,7 +94,9 @@ def test_save_images(test_video_file): sm.add_detector(ContentDetector()) image_name_glob = 'scenedetect.tempfile.*.jpg' - image_name_template = 'scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER' + image_name_template = ('scenedetect.tempfile.' + '$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.' + '$TIMESTAMP_MS.$TIMECODE') try: video_fps = video.frame_rate From abc66188d3dc54b9a2108e849016005529e8c71c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 May 2024 18:20:52 -0400 Subject: [PATCH 096/407] Bump jinja2 from 3.1.3 to 3.1.4 in /website (#397) Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.3...3.1.4) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index 3c69294b..cd132c7f 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.1.3 +jinja2==3.1.4 From 0cf4f81be2ce8fd04ba44a6b7c74d76e771af155 Mon Sep 17 00:00:00 2001 From: Aayush Singh <42978599+ash2703@users.noreply.github.com> Date: Tue, 21 May 2024 05:52:37 +0530 Subject: [PATCH 097/407] Luma Histogram Detector (#390) * Enhanced scene change detection using luma-based histograms in OpenCV: - Implemented histogram calculation on the luma (Y) channel of the YCbCr color space to enhance detection accuracy by minimizing false positives influenced by lighting variations. - Transitioned to OpenCV for histogram operations, achieving a performance improvement of at least 10x over previous numpy-based quantization and binning methods. - Introduced normalization of histograms to address scale and intensity variation issues, ensuring consistent histogram comparison across different video frames. - Refined histogram comparison to assess distribution patterns rather than solely absolute intensity changes, which allows for a more nuanced detection of scene transitions. * Update scene detection threshold value * fix: scene change triggered when hist diff is less than threshold * fix: load HistogramDetector in scenedetect.detectors module * feat: ability to choose bin size - Lower bin size is better for noisy images * feat: pass bins as arguements for granular control on histogram generation * docs: updated histogram detection info --- scenedetect.cfg | 5 +- scenedetect/__init__.py | 2 +- scenedetect/_cli/__init__.py | 16 +- scenedetect/_cli/config.py | 4 +- scenedetect/_cli/context.py | 4 +- scenedetect/detectors/histogram_detector.py | 155 ++++++++------------ website/pages/api.md | 2 +- 7 files changed, 78 insertions(+), 110 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index e2c540ad..782f32da 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -143,10 +143,9 @@ # Threshold value (float) that the calculated difference between subsequent # histograms must exceed to trigger a new scene. -#threshold = 20000.0 +#threshold = 0.95 +#bins = 256 -# Number of bits to use for image quantization before binning. -#bits = 4 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index f928ad4e..ad26d9c6 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -36,7 +36,7 @@ from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.scene_detector import SceneDetector -from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HashDetector +from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HistogramDetector, HashDetector from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, VideoStreamMoviePy, VideoCaptureAdapter) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 5abcfe15..afac2161 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -730,17 +730,17 @@ def detect_threshold_command( type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, CONFIG_MAP['detect-hist']['threshold'].max_val), default=None, - help='Threshold value (float) that the rgb histogram difference must exceed to trigger' + help='Threshold value (float) that the YCbCr histogram difference must exceed to trigger' ' a new scene. Refer to frame metric hist_diff in stats file.%s' % (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) @click.option( - '--bits', + '--bins', '-b', metavar='NUM', type=click.INT, - default=None, - help='The number of most significant figures to keep when quantizing the RGB color channels.%s' - % (USER_CONFIG.get_help_string("detect-hist", "bits"))) + default=256, + help='The number of bins to use for the histogram calculation.%s' + % (USER_CONFIG.get_help_string("detect-hist", "bins"))) @click.option( '--min-scene-len', '-m', @@ -753,7 +753,7 @@ def detect_threshold_command( ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( 'detect-hist', 'min-scene-len'))) @click.pass_context -def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Optional[int], +def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], min_scene_len: Optional[str]): """Perform detection of scenes by comparing differences in the RGB histograms of adjacent frames. @@ -762,13 +762,13 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Op detect-hist - detect-hist --threshold 20000.0 + detect-hist --threshold 0.8 --bins 128 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hist_params( - threshold=threshold, bits=bits, min_scene_len=min_scene_len) + threshold=threshold, bins=bins, min_scene_len=min_scene_len) logger.debug('Adding detector: HistogramDetector(%s)', detector_args) ctx.obj.add_detector(HistogramDetector(**detector_args)) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 95fb0b9f..d8e38867 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -278,9 +278,9 @@ def format(self, timecode: FrameTimecode) -> str: 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, 'detect-hist': { - 'bits': 4, 'min-scene-len': TimecodeValue(0), - 'threshold': RangeValue(20000.0, min_val=0.0, max_val=10000000000.0), + 'threshold': RangeValue(0.95, min_val=0.0, max_val=1.0), + 'bins': RangeValue(256, min_val=1, max_val=256), }, 'load-scenes': { 'start-col-name': 'Start Frame', diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index a5f7103e..56da6478 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -461,7 +461,7 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", start_col_name) - def get_detect_hist_params(self, threshold: Optional[float], bits: Optional[int], + def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int], min_scene_len: Optional[str]) -> Dict[str, Any]: """Handle detect-hist command options and return dict to construct one with.""" self._ensure_input_open() @@ -475,7 +475,7 @@ def get_detect_hist_params(self, threshold: Optional[float], bits: Optional[int] min_scene_len = self.config.get_value("detect-hist", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num return { - 'bits': self.config.get_value("detect-hist", "bits", bits), + 'bins': self.config.get_value("detect-hist", "bins", bins), 'min_scene_len': min_scene_len, 'threshold': self.config.get_value("detect-hist", "threshold", threshold), } diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 937b7e13..e6ba7f9b 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -18,6 +18,7 @@ from typing import List +import cv2 import numpy # PySceneDetect Library Imports @@ -30,29 +31,28 @@ class HistogramDetector(SceneDetector): METRIC_KEYS = ['hist_diff'] - def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int = 15): + def __init__(self, threshold: float = 0.95, bins: int = 256, min_scene_len: int = 15): """ Arguments: threshold: Threshold value (float) that the calculated difference between subsequent - histograms must exceed to trigger a new scene. - bits: Number of most significant bits to keep of the pixel values. Most videos and - images are 8-bit rgb (0-255) and the default is to just keep the 4 most siginificant - bits. This compresses the 3*8bit (24bit) image down to 3*4bits (12bits). This makes - quantizing the rgb histogram a bit easier and comparisons more meaningful. + histograms must exceed to trigger a new scene. + The threshold value should be between 0 and 1 (perfect positive correlation, identical histograms). + Values close to 1 indicate very similar frames, while lower values suggest changes. + bins: Number of bins to use for the histogram. min_scene_len: Minimum length of any scene. """ super().__init__() self._threshold = threshold - self._bits = bits + self._bins = bins self._min_scene_len = min_scene_len - self._hist_bins = range(2**(3 * self._bits)) self._last_hist = None self._last_scene_cut = None def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: - """First, compress the image according to the self.bits value, then build a histogram for - the input frame. Afterward, compare against the previously analyzed frame and check if the - difference is large enough to trigger a cut. + """Computes the histogram of the luma channel of the frame image and compares it with the + histogram of the luma channel of the previous frame. If the difference between the histograms + exceeds the threshold, a scene cut is detected. + Histogram difference is computed using the correlation metric. Arguments: frame_num: Frame number of frame that is being passed. @@ -77,25 +77,24 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: if not self._last_scene_cut: self._last_scene_cut = frame_num - # Quantize the image and separate the color channels - quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self._bits) - - # Perform bit shifting operations and bitwise combine color channels into one array - composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self._bits) - - # Create the histogram with a bin for every rgb value - hist, _ = numpy.histogram(composite_img, bins=self._hist_bins) + hist = self.calculate_histogram(frame_img, bins = self._bins) # We can only start detecting once we have a frame to compare with. if self._last_hist is not None: + #TODO: We can have EMA of histograms to make it more robust + # ema_hist = alpha * hist + (1 - alpha) * ema_hist + # Compute histogram difference between frames - hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) + hist_diff = cv2.compareHist(self._last_hist, hist, cv2.HISTCMP_CORREL) # Check if a new scene should be triggered - - # TODO(#53): We should probably normalize the threshold based on the frame size, as - # larger images will have more pixels in each bin. - if hist_diff >= self._threshold and ((frame_num - self._last_scene_cut) + # Set a correlation threshold to determine scene changes. + # The threshold value should be between -1 (perfect negative correlation, not applicable here) + # and 1 (perfect positive correlation, identical histograms). + # Values close to 1 indicate very similar frames, while lower values suggest changes. + # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation + # less than 0.8 between histograms will be considered significant enough to denote a scene change. + if hist_diff <= self._threshold and ((frame_num - self._last_scene_cut) >= self._min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num @@ -108,82 +107,52 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return cut_list - def _quantize_frame(self, frame_img, bits): - """Quantizes the image based on the number of most significant figures to be preserved. - - Arguments: - frame_img: The 8-bit rgb image of the frame being analyzed. - bits: The number of most significant bits to keep during quantization. - - Returns: - [red_img, green_img, blue_img]: - The three separated color channels of the frame image that have been quantized. + def calculate_histogram(self, + frame_img: numpy.ndarray, + bins: int = 256, + normalize: bool = True) -> numpy.ndarray: """ - # First, find the value of the number of most significant bits, padding with zeroes - bit_value = int(bin(2**bits - 1).ljust(10, '0'), 2) - - # Separate R, G, and B color channels and cast to int for easier bitwise operations - red_img = frame_img[:, :, 0].astype(int) - green_img = frame_img[:, :, 1].astype(int) - blue_img = frame_img[:, :, 2].astype(int) - - # Quantize the frame images - red_img = red_img & bit_value - green_img = green_img & bit_value - blue_img = blue_img & bit_value - - return [red_img, green_img, blue_img] - - def _shift_bits(self, quantized_imgs, bits): - """Takes care of the bit shifting operations to combine the RGB color - channels into a single array. - - Arguments: - quantized_imgs: A list of the three quantized images of the RGB color channels - respectively. - bits: The number of most significant bits to use for quantizing the image. + Calculates and optionally normalizes the histogram of the luma (Y) channel of an image converted from BGR to YUV color space. + + This function extracts the Y channel from the given BGR image, computes its histogram with the specified number of bins, + and optionally normalizes this histogram to have a sum of one across all bins. + + Args: + ----- + frame_img : np.ndarray + The input image in BGR color space, assumed to have shape (height, width, 3) + where the last dimension represents the BGR channels. + bins : int, optional (default=256) + The number of bins to use for the histogram. + normalize : bool, optional (default=True) + A boolean flag that determines whether the histogram should be normalized + such that the sum of all histogram bins equals 1. Returns: - composite_img: The resulting array after all bitwise operations. + -------- + np.ndarray + A 1D numpy array of length equal to `bins`, representing the histogram of the luma channel. + Each element in the array represents the count (or frequency) of a particular luma value in the image. + If normalized, these values represent the relative frequency. + + Examples: + --------- + >>> img = cv2.imread('path_to_image.jpg') + >>> hist = calculate_histogram(img, bins=256, normalize=True) + >>> print(hist.shape) + (256,) """ - # First, figure out how much each shift needs to be - blue_shift = 8 - bits - green_shift = 8 - 2 * bits - red_shift = 8 - 3 * bits - - # Separate our color channels for ease - red_img = quantized_imgs[0] - green_img = quantized_imgs[1] - blue_img = quantized_imgs[2] - - # Perform the bit shifting for each color - red_img = self._shift_images(img=red_img, img_shift=red_shift) - green_img = self._shift_images(img=green_img, img_shift=green_shift) - blue_img = self._shift_images(img=blue_img, img_shift=blue_shift) - - # Join our rgb arrays together - composite_img = numpy.bitwise_or(red_img, numpy.bitwise_or(green_img, blue_img)) + # Extract Luma channel from the frame image + y, _, _ = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2YUV)) - return composite_img - - def _shift_images(self, img, img_shift): - """Do bitwise shifting operations for a color channel image checking for shift direction. - - Arguments: - img: A quantized image of a single color channel - img_shift: How many bits to shift the values of img. If the value is negative, the shift - direction is to the left and 8 is added to make it a positive value. + # Create the histogram with a bin for every rgb value + hist = cv2.calcHist([y], [0], None, [bins], [0, 256]) - Returns: - shifted_img: The bitwise shifted image. - """ - if img_shift < 0: - img_shift += 8 - shifted_img = numpy.left_shift(img, img_shift) - else: - shifted_img = numpy.right_shift(img, img_shift) + if normalize: + # Normalize the histogram + hist = cv2.normalize(hist, hist).flatten() - return shifted_img + return hist def is_processing_required(self, frame_num: int) -> bool: return True diff --git a/website/pages/api.md b/website/pages/api.md index b3cb79a9..3b09d398 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -27,7 +27,7 @@ The threshold-based scene detector (`detect-threshold`) is how most traditional ## Histogram Detector -The color histogram detector uses color information to detect fast cuts. The input video for this detector must be in 8-bit color. The detection algorithm consists of separating the three RGB color channels and then quantizing them by eliminating all but the given number of most significant bits (`--bits/-b`). The resulting quantized color channels are then bit shifted and joined together into a new, composite image. A histogram is then constructed from the pixel values in the new, composite image. This histogram is compared element-wise with the histogram from the previous frame and if the total difference between the two adjacent histograms exceeds the given threshold (`--threshold/-t`), then a new scene is triggered. +The scene change detection algorithm uses histograms of the Y channel in the YCbCr color space to detect scene changes, which helps mitigate issues caused by lighting variations. Each frame of the video is converted from its original color space to the YCbCr color space.The Y channel, which represents luminance, is extracted from the YCbCr color space. This helps in focusing on intensity variations rather than color variations. A histogram of the Y channel is computed using the specified number of bins (--bins/-b). The histogram is normalized to ensure that it can be consistently compared with histograms from other frames. The normalized histogram of the current frame is compared with the normalized histogram of the previous frame using the correlation method (cv2.HISTCMP_CORREL). A scene change is detected if the correlation between the histograms of consecutive frames is below the specified threshold (--threshold/-t). This indicates a significant change in luminance, suggesting a scene change. ## Perceptual Hash Detector From 6fb87935fa289a9f03fab920ee4485a4b879fc2a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 20 May 2024 20:23:23 -0400 Subject: [PATCH 098/407] [cli] Fix formatting. --- scenedetect/_cli/__init__.py | 4 ++-- scenedetect/detectors/histogram_detector.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index afac2161..38646203 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -739,8 +739,8 @@ def detect_threshold_command( metavar='NUM', type=click.INT, default=256, - help='The number of bins to use for the histogram calculation.%s' - % (USER_CONFIG.get_help_string("detect-hist", "bins"))) + help='The number of bins to use for the histogram calculation.%s' % + (USER_CONFIG.get_help_string("detect-hist", "bins"))) @click.option( '--min-scene-len', '-m', diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index e6ba7f9b..b82da186 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -77,7 +77,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: if not self._last_scene_cut: self._last_scene_cut = frame_num - hist = self.calculate_histogram(frame_img, bins = self._bins) + hist = self.calculate_histogram(frame_img, bins=self._bins) # We can only start detecting once we have a frame to compare with. if self._last_hist is not None: From 86159d4746a6fce88ade54b2d494397a30a84a73 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 May 2024 13:21:04 -0400 Subject: [PATCH 099/407] [dist] Fix Github license detection. --- LICENSE | 77 +------------------------ README.md | 4 +- THIRD-PARTY.md | 75 ++++++++++++++++++++++++ scenedetect/_cli/__init__.py | 35 +++-------- scenedetect/_thirdparty/LICENSE-MOVIEPY | 28 +++++++++ 5 files changed, 118 insertions(+), 101 deletions(-) create mode 100644 THIRD-PARTY.md create mode 100644 scenedetect/_thirdparty/LICENSE-MOVIEPY diff --git a/LICENSE b/LICENSE index 59b46506..7ff55686 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,6 @@ -By downloading, copying, installing, or using this software, you agree -to the terms of this license, and those contained in the "Ancillary -Software Licenses" section below. If you do not agree to any of these -terms or licenses, do not download, install, copy, or use the software -or any other material included in in distribution. - ------------------------------------------------------------------------ - - PySceneDetect License (BSD 3-Clause) - < http://www.bcastell.com/projects/PySceneDetect > - -Copyright (C) 2014-2024, Brandon Castellano. +PySceneDetect License (BSD-3-Clause) +Copyright (C) 2014-2024 Brandon Castellano +< http://www.scenedetect.com > All rights reserved. Redistribution and use in source and binary forms, with or without @@ -39,65 +30,3 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ------------------------------------------------------------------------ - - Ancillary Software Licenses - -This software uses the following third-party open source libraries and -are released under the terms detailed below. By downloading, copying, -installing or using this software/tutorial, you agree to these terms. - ------------------------------------------------------------------------ - - -> click [Copyright (C) 2017, Armin Ronacher]: - This software uses OpenCV; see thirdparty/LICENSE-CLICK or visit: - [ http://click.pocoo.org/license/ ] - -> NumPy [Copyright (C) 2005-2016, Numpy Developers]: - This software uses Numpy; see thirdparty/LICENSE-NUMPY or visit: - [ http://www.numpy.org/license.html ] - -> OpenCV [Copyright (C) 2017, Itseez]: - This software uses OpenCV; see thirdparty/LICENSE-OPENCV or visit: - [ http://opencv.org/license.html ] - -> PyAV [Copyright (C) 2017, Mike Boers and others]: - This software uses PyAV; see thirdparty/LICENSE-PYAV or visit: - [ https://github.com/PyAV-Org/PyAV/blob/main/LICENSE.txt ] - -> pytest [Copyright (C) 2004-2017, Holger Krekel and others]: - This software uses pytest; see thirdparty/LICENSE-PYTEST or visit: - [ https://docs.pytest.org/en/latest/license.html ] - -> simpletable [Copyright (C) 2014-2019, Matheus Vieira Portela and others]: - This software uses simpletable; see thirdparty/LICENSE-SIMPLETABLE or visit: - [ https://github.com/matheusportela/simpletable/blob/master/LICENSE ] - -> tqdm [Copyright (C) 2013-2018, Casper da Costa-Luis, - Google Inc., and Noam Yorav-Raphael]: - This software uses tqdm; see thirdparty/LICENSE-TQDM or visit: - [ https://github.com/tqdm/tqdm/blob/master/LICENCE ] - - ------------------------------------------------------------------------ - -This software may also invoke FFmpeg or mkvmerge, if available. If required, -these programs can be obtained from following URLs: - - FFmpeg: [ https://ffmpeg.org/download.html ] - mkvmerge: [ https://mkvtoolnix.download/downloads.html ] - -Once installed, ensure the program is in your PATH variable (i.e. you can -run the `ffmpeg` or `mkvmerge` command from any location). - -Certain distributions of PySceneDetect may include ffmpeg. See -thirdparty/LICENSE-FFMPEG file or visit [ https://ffmpeg.org ] - -FFmpeg is a trademark of Fabrice Bellard -mkvmerge is Copyright (C) 2005-2016, Matroska - -Windows distributions may include a compiled Python distribution. For license -information regarding the distributed version of Python, see the -thirdparty/LICENSE-PYTHON file, or visit [ https://docs.python.org/3/license.html ] diff --git a/README.md b/README.md index 7c928677..b06e20d2 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,9 @@ This program uses free code signing provided by [SignPath.io](https://signpath.i ## License -Licensed under BSD 3-Clause (see the `LICENSE` file for details). +BSD-3-Clause; see [`LICENSE`](LICENSE) and [`THIRD-PARTY.md`](THIRD-PARTY.md) for details. + +---------------------------------------------------------- Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 00000000..959c2e4f --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,75 @@ +# Ancillary Software Licenses + +This file includes license information for various open-source projects +that are imported, derived into, or distributed with PySceneDetect. +See [LICENSE](LICENSE) for the main PySceneDetect license. + +Depending on the features being used, PySceneDetect uses the following +third-party software which are released under the terms detailed below. +By downloading, copying, installing or using this software, you agree +to these terms. + +In no particular order: + +----------------------------------------------------------------------- + +> click [Copyright (C) 2017, Armin Ronacher]: + This software uses OpenCV; see thirdparty/LICENSE-CLICK or visit: + [ http://click.pocoo.org/license/ ] + +> NumPy [Copyright (C) 2005-2016, Numpy Developers]: + This software uses Numpy; see thirdparty/LICENSE-NUMPY or visit: + [ http://www.numpy.org/license.html ] + +> OpenCV [Copyright (C) 2017, Itseez]: + This software uses OpenCV; see thirdparty/LICENSE-OPENCV or visit: + [ http://opencv.org/license.html ] + +> PyAV [Copyright (C) 2017, Mike Boers and others]: + This software uses PyAV; see thirdparty/LICENSE-PYAV or visit: + [ https://github.com/PyAV-Org/PyAV/blob/main/LICENSE.txt ] + +> pytest [Copyright (C) 2004-2017, Holger Krekel and others]: + This software uses pytest; see thirdparty/LICENSE-PYTEST or visit: + [ https://docs.pytest.org/en/latest/license.html ] + +> simpletable [Copyright (C) 2014-2019, Matheus Vieira Portela and others]: + This software uses simpletable; see thirdparty/LICENSE-SIMPLETABLE or visit: + [ https://github.com/matheusportela/simpletable/blob/master/LICENSE ] + +> tqdm [Copyright (C) 2013-2018, Casper da Costa-Luis, + Google Inc., and Noam Yorav-Raphael]: + This software uses tqdm; see thirdparty/LICENSE-TQDM or visit: + [ https://github.com/tqdm/tqdm/blob/master/LICENCE ] + +> MoviePy [ Copyright (C) 2015 Zulko ] + This software uses tqdm; see thirdparty/LICENSE-TQDM or visit: + [ https://github.com/Zulko/moviepy/blob/master/LICENCE.txt ] + +----------------------------------------------------------------------- + +This software may also invoke FFmpeg or mkvmerge, if available. If required, +these programs can be obtained from following URLs: + + FFmpeg: [ https://ffmpeg.org/download.html ] + mkvmerge: [ https://mkvtoolnix.download/downloads.html ] + +Once installed, ensure the program is in your PATH variable (i.e. you can +run the `ffmpeg` or `mkvmerge` command from any location). + +Certain distributions of PySceneDetect may include ffmpeg. See +thirdparty/LICENSE-FFMPEG file or visit [ https://ffmpeg.org ] + +FFmpeg is a trademark of Fabrice Bellard +mkvmerge is Copyright (C) 2005-2016, Matroska + +Windows distributions may include a compiled Python distribution. For license +information regarding the distributed version of Python, see the +thirdparty/LICENSE-PYTHON file, or visit [ https://docs.python.org/3/license.html ] + +----------------------------------------------------------------------- + +If any information above is incorrect, please let us know. +Visit [ https://www.scenedetect.com ] for contact information. + + diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 33ad89e3..29ebb988 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -44,50 +44,33 @@ # About & copyright message string shown for the 'about' CLI command (scenedetect about). _ABOUT_STRING = """ Site: http://scenedetect.com/ -Docs: http://manual.scenedetect.com/ +Docs: https://www.scenedetect.com/docs/ Code: https://github.com/Breakthrough/PySceneDetect/ Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. PySceneDetect is released under the BSD 3-Clause license. See the -included LICENSE file or visit the PySceneDetect website for details. +LICENSE file or visit [ https://www.scenedetect.com/copyright/ ]. This software uses the following third-party components: > NumPy [Copyright (C) 2018, Numpy Developers] > OpenCV [Copyright (C) 2018, OpenCV Team] > click [Copyright (C) 2018, Armin Ronacher] > simpletable [Copyright (C) 2014 Matheus Vieira Portela] + > PyAV [Copyright (C) 2017, Mike Boers and others] + > MoviePy [Copyright (C) 2015 Zulko] This software may also invoke the following third-party executables: > FFmpeg [Copyright (C) 2018, Fabrice Bellard] > mkvmerge [Copyright (C) 2005-2016, Matroska] -If included with your distribution of PySceneDetect, see the included -LICENSE-FFMPEG and LICENSE-MKVMERGE or visit: - [ https://scenedetect.com/copyright/ ] +Certain distributions of PySceneDetect may include ffmpeg. See +the included LICENSE-FFMPEG or visit [ https://ffmpeg.org ]. -FFmpeg and mkvmerge are distributed only with certain PySceneDetect -releases, in order to allow for automatic video splitting capability. -If they were not included with your distribution, they can usually be -installed from your operating system's package manager, or downloaded -from the following URLs: - - FFmpeg: [ https://ffmpeg.org/download.html ] - mkvmerge: [ https://mkvtoolnix.download/downloads.html ] - (Note that mkvmerge is a part of the mkvtoolnix package.) - -Once installed, ensure the respective program can be accessed from the -same location running PySceneDetect by calling the `ffmpeg` or -`mkvmerge` command from a terminal/command prompt. - -PySceneDetect will automatically use whichever program is available on -the computer, depending on the specified command-line options. - -Additionally, certain Windows distributions may include a compiled -Python distribution. For license information regarding the distributed -version of Python, see the included LICENSE-PYTHON file for details, -or visit the following URL: [ https://docs.python.org/3/license.html ] +Binary distributions of PySceneDetect include a compiled Python +distribution. See the included LICENSE-PYTHON file, or visit +[ https://docs.python.org/3/license.html ]. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED. """ diff --git a/scenedetect/_thirdparty/LICENSE-MOVIEPY b/scenedetect/_thirdparty/LICENSE-MOVIEPY new file mode 100644 index 00000000..1f7d430a --- /dev/null +++ b/scenedetect/_thirdparty/LICENSE-MOVIEPY @@ -0,0 +1,28 @@ +MoviePy license +Copyright (c) 2015 Zulko + +URL: https://github.com/Zulko/moviepy/blob/master/LICENCE.txt + +----------------------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2015 Zulko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. From 34847e38da8a8999530d0128616927ab12eebe29 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 May 2024 13:27:42 -0400 Subject: [PATCH 100/407] [dist] Use Github license template. Fixes #365. --- LICENSE | 46 +++++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/LICENSE b/LICENSE index 7ff55686..b8110e2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,32 +1,28 @@ -PySceneDetect License (BSD-3-Clause) -Copyright (C) 2014-2024 Brandon Castellano -< http://www.scenedetect.com > -All rights reserved. +BSD 3-Clause License + +Copyright (C) 2024, Brandon Castellano Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: +modification, are permitted provided that the following conditions are met: - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials - provided with the distribution. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. - 3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 448e6c463634d5572f9eb0b9a01fbdd5527c47e3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 26 May 2024 18:43:19 -0400 Subject: [PATCH 101/407] [video_splitter] Update default ffmpeg stream mapping Change default stream mapping so that only a single video stream is selected. This should reduce ffmpeg command failures when an unrecognized or unsupported stream is present in the video. Fixes #392. --- docs/cli.rst | 4 ++-- scenedetect.cfg | 2 +- scenedetect/_cli/__init__.py | 6 +++--- scenedetect/_cli/context.py | 8 ++++---- scenedetect/video_splitter.py | 3 ++- website/pages/changelog.md | 2 ++ 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index eae954c7..1b48e5c8 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -561,7 +561,7 @@ Options .. option:: -c, --copy - Copy instead of re-encode. Faster but less precise. Equivalent to: :option:`--args="-map 0 -c:v copy -c:a copy" <--args>` + Copy instead of re-encode. Faster but less precise. Equivalent to: :option:`--args="-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" <--args>` .. option:: -hq, --high-quality @@ -583,7 +583,7 @@ Options Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec. - Default: ``"-map 0 -c:v libx264 -preset veryfast -crf 22 -c:a aac"`` + Default: ``"-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac"`` .. option:: -m, --mkvmerge diff --git a/scenedetect.cfg b/scenedetect.cfg index 782f32da..e3708dcb 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -184,7 +184,7 @@ #preset = veryfast # Arguments to specify to ffmpeg for encoding. Quotes are not required. -#args = -map 0 -c:v libx264 -preset veryfast -crf 22 -c:a aac +#args = -map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac [save-images] diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 38646203..907bb03e 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -762,7 +762,7 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op detect-hist - detect-hist --threshold 0.8 --bins 128 + detect-hist --threshold 0.8 --bins 128 """ assert isinstance(ctx.obj, CliContext) @@ -948,8 +948,8 @@ def list_scenes_command( '-c', is_flag=True, flag_value=True, - help='Copy instead of re-encode. Faster but less precise. Equivalent to: --args="-map 0 -c:v copy -c:a copy"%s' - % (USER_CONFIG.get_help_string('split-video', 'copy')), + help="Copy instead of re-encode. Faster but less precise.%s" % + (USER_CONFIG.get_help_string('split-video', 'copy')), ) @click.option( '--high-quality', diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 56da6478..41c8d7da 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -615,14 +615,14 @@ def handle_split_video( ## ffmpeg-Specific Arguments/Options ## if copy: - args = '-map 0 -c:v copy -c:a copy' + args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" elif not args: if rate_factor is None: rate_factor = 22 if not high_quality else 17 if preset is None: - preset = 'veryfast' if not high_quality else 'slow' - args = ('-map 0 -c:v libx264 -preset {PRESET} -crf {RATE_FACTOR} -c:a aac'.format( - PRESET=preset, RATE_FACTOR=rate_factor)) + preset = "veryfast" if not high_quality else "slow" + args = ("-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac") logger.info('ffmpeg arguments: %s', args) self.split_args = args diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 4b92e21c..a4bce715 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -61,7 +61,8 @@ FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" -DEFAULT_FFMPEG_ARGS = '-map 0 -c:v libx264 -preset veryfast -crf 22 -c:a aac' +DEFAULT_FFMPEG_ARGS = ( + "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac") """Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" ## diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 45b27215..9287e1ad 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -19,6 +19,8 @@ Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ - `--filter-mode = suppress` (previous default) disables generating new scenes until `min-scene-len` has passed - [bugfix] Remove extraneous console output when using `--drop-short-scenes` - [bugfix] Fix scene lengths being smaller than `min-scene-len` when using `detect-adaptive` / `AdaptiveDetector` with large values of `--frame-window` + - [bugfix] Update default ffmpeg stream mapping from `-map 0` to `-map 0:v:0 -map 0:a? -map 0:s?` [#392](https://github.com/Breakthrough/PySceneDetect/issues/392) + ### 0.6.3 (March 9, 2024) From 20f6848d78f72c00f582058793376c951c43020c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 8 Jun 2024 21:29:33 -0400 Subject: [PATCH 102/407] [docs] Add CITATION.cff #399 --- CITATION.cff | 12 ++++++++++++ README.md | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..748d4dbf --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,12 @@ +cff-version: 1.2.0 +title: PySceneDetect +message: www.scenedetect.com +type: software +authors: + - given-names: Brandon + family-names: Castellano + affiliation: www.bcastell.com +repository-code: 'https://github.com/Breakthrough/PySceneDetect' +url: 'https://www.scenedetect.com' +abstract: Video Cut Detection and Analysis Tool +license: BSD-3-Clause diff --git a/README.md b/README.md index b06e20d2..2a770e1b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![PySceneDetect](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/main/website/pages/img/pyscenedetect_logo_small.png) ========================================================== -Video Scene Cut Detection and Analysis Tool +Video Cut Detection and Analysis Tool ---------------------------------------------------------- [![Build Status](https://img.shields.io/github/actions/workflow/status/Breakthrough/PySceneDetect/build.yml)](https://github.com/Breakthrough/PySceneDetect/actions) From 520112e0a4d007d71a0ba1085e934c06a83f78ca Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 8 Jun 2024 23:01:13 -0400 Subject: [PATCH 103/407] [cli] Add `detect-hash` command --- scenedetect.cfg | 92 ++++++----- scenedetect/_cli/__init__.py | 83 ++++++++-- scenedetect/_cli/config.py | 164 ++++++++++---------- scenedetect/_cli/context.py | 35 ++++- scenedetect/detectors/hash_detector.py | 34 ++-- scenedetect/detectors/histogram_detector.py | 32 ++-- scenedetect/scene_manager.py | 6 +- tests/test_cli.py | 5 +- website/pages/changelog.md | 10 +- 9 files changed, 285 insertions(+), 176 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index e3708dcb..0a72e881 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -61,6 +61,26 @@ # DETECTOR OPTIONS # +[detect-adaptive] +# Frame score threshold, refers to the `adaptive_ratio` metric in stats file. +#threshold = 3 + +# Minimum threshold that `content_val` metric from detect-content must exceed. +#min-content-val = 15 + +# Window size (number of frames) before and after each frame to average together. +#frame-window = 2 + +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + +# The following parameters are the those used to calculate `content_val`. +# See [detect-content] for detailed descriptions of these parameters. +#weights = 1.0, 1.0, 1.0, 0.0 +#luma-only = no +#kernel-size = -1 + + [detect-content] # Sensitivity threshold from 0 to 255. Lower values are more sensitive. #threshold = 27 @@ -98,59 +118,57 @@ #filter-mode = merge -[detect-threshold] -# Average pixel intensity from 0-255 at which a fade event is triggered. -#threshold = 12 +[detect-hash] +# Threshold value (float) that the calculated difference between subsequent +# histograms must exceed to trigger a new scene. -# Percent from -100.0 to 100.0 of timecode skew for where cuts should be placed. -# -100 indicates start frame, +100 indicates end frame, and 0 is the center. -#fade-bias 0 +# Threshold value from 0.0 and 1000000000.0 representing difference between +# subsequent hash values to trigger a shot change. +#threshold = 101.0 -# Generate a scene from the end of the last fade out to the end of the video. -#add-last-scene = yes +# The ratio between 1 and 256 of how much low frequency information to keep. +# Represents highest frequency which will pass the filter. 1 means keep all, +# 2 means keep lower 1/2 of frequency data, 4 means keep lower 1/4, etc... +#lowpass = 2 -# Discard colour information and only use luminance (yes/no). -#luma-only = no +# Size between 1 and 256 representing size of square of low frequency data to +# use for the direct cosine transform (DCT). # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s -[detect-adaptive] -# Frame score threshold, refers to the `adaptive_ratio` metric in stats file. -#threshold = 3 - -# Minimum threshold that `content_val` metric from detect-content must exceed. -#min-content-val = 15 +[detect-hist] +# Threshold value from 0.0 to 1.0 representing the difference between Y channel +# histograms after frame is converted to YUV. Values closer to 1.0 require higher +# correlation (more similar to current frame), while lower values allow lower +# correlation (higher difference between current frame). +#threshold = 0.95 -# Window size (number of frames) before and after each frame to average together. -#frame-window = 2 +# Number of bins between 1 and 256 to use for the histogram. +#bins = 256 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s -# The following parameters are the those used to calculate `content_val`. -# See [detect-content] for detailed descriptions of these parameters. -#weights = 1.0, 1.0, 1.0, 0.0 -#luma-only = no -#kernel-size = -1 +[detect-threshold] +# Average pixel intensity from 0-255 at which a fade event is triggered. +#threshold = 12 -[detect-hist] -# -# IN DEVELOPMENT, SUBJECT TO CHANGE -# +# Percent from -100.0 to 100.0 of timecode skew for where cuts should be placed. +# -100 indicates start frame, +100 indicates end frame, and 0 is the center. +#fade-bias 0 -# Threshold value (float) that the calculated difference between subsequent -# histograms must exceed to trigger a new scene. -#threshold = 0.95 -#bins = 256 +# Generate a scene from the end of the last fade out to the end of the video. +#add-last-scene = yes +# Discard colour information and only use luminance (yes/no). +#luma-only = no # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s - # # COMMAND OPTIONS # @@ -191,16 +209,16 @@ # Folder to output videos. Overrides [global] output option. #output = /usr/tmp/images -# Filename format of created images. Can use $VIDEO_NAME, $SCENE_NUMBER, -# and $IMAGE_NUMBER in the name. Extension not required. +# Filename format of created images. Can use $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, +# $TIMECODE, $FRAME_NUMBER, and $TIMESTAMP_MS. Should not include extension. #filename = $VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER -# Number of images to generate for each scene. -#num-images = 3 - # Image format (jpeg, png, webp). #format = jpeg +# Number of images to generate for each scene. +#num-images = 3 + # Image quality (jpeg/webp). Default is 95 for jpeg, 100 for webp #quality = 95 diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 907bb03e..e3988ddf 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,7 +27,8 @@ import click import scenedetect -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector +from scenedetect.detectors import (AdaptiveDetector, ContentDetector, HashDetector, + HistogramDetector, ThresholdDetector) from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.platform import get_system_version_info @@ -510,7 +511,7 @@ def detect_content_command( min_scene_len: Optional[str], filter_mode: Optional[str], ): - """Perform content detection algorithm on input video. + """Find fast cuts using differences in HSL (filtered). For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. @@ -629,7 +630,7 @@ def detect_adaptive_command( kernel_size: Optional[int], min_scene_len: Optional[str], ): - """Perform adaptive detection algorithm on input video. + """Find fast cuts using diffs in HSL colorspace (rolling average). Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. @@ -701,7 +702,7 @@ def detect_threshold_command( add_last_scene: bool, min_scene_len: Optional[str], ): - """Perform threshold detection algorithm on input video. + """Find fade in/out using averaging. Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. @@ -738,7 +739,7 @@ def detect_threshold_command( '-b', metavar='NUM', type=click.INT, - default=256, + default=None, help='The number of bins to use for the histogram calculation.%s' % (USER_CONFIG.get_help_string("detect-hist", "bins"))) @click.option( @@ -755,8 +756,7 @@ def detect_threshold_command( @click.pass_context def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], min_scene_len: Optional[str]): - """Perform detection of scenes by comparing differences in the RGB histograms of adjacent - frames. + """Finds fast cuts by differencing YUV histograms. Examples: @@ -773,6 +773,64 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op ctx.obj.add_detector(HistogramDetector(**detector_args)) +@click.command("detect-hash", cls=_Command) +@click.option( + "--threshold", + "-t", + metavar="VAL", + type=click.FloatRange(CONFIG_MAP["detect-hash"]["threshold"].min_val, + CONFIG_MAP["detect-hash"]["threshold"].max_val), + default=None, + help=("How much of a difference between subsequent hash values should trigger a cut.%s" % + (USER_CONFIG.get_help_string("detect-hash", "threshold")))) +@click.option( + "--size", + "-s", + metavar="SIZE", + type=click.INT, + default=None, + help="Size of square of low frequency data to include from the discrete cosine transform.%s" % + (USER_CONFIG.get_help_string("detect-hash", "size"))) +@click.option( + "--lowpass", + "-h", + metavar="FRAC", + type=click.INT, + default=None, + help=("How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " + "the frequency data, 4 means only keep 1/4, etc....%s" % + (USER_CONFIG.get_help_string("detect-hash", "lowpass")))) +@click.option( + "--min-scene-len", + "-m", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." + " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % + ("" if USER_CONFIG.is_default("detect-hist", "min-scene-len") else USER_CONFIG.get_help_string( + "detect-hash", "min-scene-len"))) +@click.pass_context +def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], + lowpass: Optional[int], min_scene_len: Optional[str]): + """Find fast cuts using perceptual hashing. + + Examples: + + detect-hist + + detect-hist --threshold 0.8 --bins 128 + """ + assert isinstance(ctx.obj, CliContext) + + assert isinstance(ctx.obj, CliContext) + detector_args = ctx.obj.get_detect_hash_params( + threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len) + logger.debug("Adding detector: HashDetector(%s)", detector_args) + ctx.obj.add_detector(HashDetector(**detector_args)) + + @click.command('load-scenes', cls=_Command) @click.option( '--input', @@ -1186,24 +1244,25 @@ def save_images_command( # ---------------------------------------------------------------------- # Info Commands +scenedetect.add_command(about_command) scenedetect.add_command(help_command) scenedetect.add_command(version_command) -scenedetect.add_command(about_command) # ---------------------------------------------------------------------- # Commands Added To Help List # ---------------------------------------------------------------------- # Input / Output -scenedetect.add_command(time_command) scenedetect.add_command(export_html_command) scenedetect.add_command(list_scenes_command) +scenedetect.add_command(load_scenes_command) scenedetect.add_command(save_images_command) scenedetect.add_command(split_video_command) +scenedetect.add_command(time_command) # Detection Algorithms -scenedetect.add_command(detect_content_command) -scenedetect.add_command(detect_threshold_command) scenedetect.add_command(detect_adaptive_command) +scenedetect.add_command(detect_content_command) +scenedetect.add_command(detect_hash_command) scenedetect.add_command(detect_hist_command) -scenedetect.add_command(load_scenes_command) +scenedetect.add_command(detect_threshold_command) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index d8e38867..29d61159 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -246,96 +246,102 @@ def format(self, timecode: FrameTimecode) -> str: # TODO(v0.7): Remove [detect-adaptive] min-delta-hsv CONFIG_MAP: ConfigDict = { - 'backend-opencv': { - 'max-decode-attempts': 5, + "backend-opencv": { + "max-decode-attempts": 5, }, - 'backend-pyav': { - 'suppress-output': False, - 'threading-mode': 'auto', + "backend-pyav": { + "suppress-output": False, + "threading-mode": "auto", }, - 'detect-adaptive': { - 'frame-window': 2, - 'kernel-size': KernelSizeValue(-1), - 'luma-only': False, - 'min-content-val': RangeValue(15.0, min_val=0.0, max_val=255.0), - 'min-scene-len': TimecodeValue(0), - 'threshold': RangeValue(3.0, min_val=0.0, max_val=255.0), - 'weights': ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), - 'min-delta-hsv': RangeValue(15.0, min_val=0.0, max_val=255.0), + "detect-adaptive": { + "frame-window": 2, + "kernel-size": KernelSizeValue(-1), + "luma-only": False, + "min-content-val": RangeValue(15.0, min_val=0.0, max_val=255.0), + "min-delta-hsv": RangeValue(15.0, min_val=0.0, max_val=255.0), + "min-scene-len": TimecodeValue(0), + "threshold": RangeValue(3.0, min_val=0.0, max_val=255.0), + "weights": ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), }, - 'detect-content': { - 'filter-mode': 'merge', - 'kernel-size': KernelSizeValue(-1), - 'luma-only': False, - 'min-scene-len': TimecodeValue(0), - 'threshold': RangeValue(27.0, min_val=0.0, max_val=255.0), - 'weights': ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), + "detect-content": { + "filter-mode": "merge", + "kernel-size": KernelSizeValue(-1), + "luma-only": False, + "min-scene-len": TimecodeValue(0), + "threshold": RangeValue(27.0, min_val=0.0, max_val=255.0), + "weights": ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), }, - 'detect-threshold': { - 'add-last-scene': True, - 'fade-bias': RangeValue(0, min_val=-100.0, max_val=100.0), - 'min-scene-len': TimecodeValue(0), - 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), + "detect-hash": { + "min-scene-len": TimecodeValue(0), + "lowpass": RangeValue(2, min_val=1, max_val=256), + "size": RangeValue(16, min_val=1, max_val=256), + "threshold": RangeValue(101.0, min_val=0.0, max_val=1000000000.0), }, - 'detect-hist': { - 'min-scene-len': TimecodeValue(0), - 'threshold': RangeValue(0.95, min_val=0.0, max_val=1.0), - 'bins': RangeValue(256, min_val=1, max_val=256), + "detect-hist": { + "min-scene-len": TimecodeValue(0), + "threshold": RangeValue(0.95, min_val=0.0, max_val=1.0), + "bins": RangeValue(256, min_val=1, max_val=256), }, - 'load-scenes': { - 'start-col-name': 'Start Frame', + "detect-threshold": { + "add-last-scene": True, + "fade-bias": RangeValue(0, min_val=-100.0, max_val=100.0), + "min-scene-len": TimecodeValue(0), + "threshold": RangeValue(12.0, min_val=0.0, max_val=255.0), }, - 'export-html': { - 'filename': '$VIDEO_NAME-Scenes.html', - 'image-height': 0, - 'image-width': 0, - 'no-images': False, + "load-scenes": { + "start-col-name": "Start Frame", }, - 'list-scenes': { - 'cut-format': 'timecode', - 'display-cuts': True, - 'display-scenes': True, - 'filename': '$VIDEO_NAME-Scenes.csv', - 'output': '', - 'no-output-file': False, - 'quiet': False, - 'skip-cuts': False, + "export-html": { + "filename": "$VIDEO_NAME-Scenes.html", + "image-height": 0, + "image-width": 0, + "no-images": False, }, - 'global': { - 'backend': 'opencv', - 'default-detector': 'detect-adaptive', - 'downscale': 0, - 'downscale-method': 'linear', - 'drop-short-scenes': False, - 'frame-skip': 0, - 'merge-last-scene': False, - 'min-scene-len': TimecodeValue('0.6s'), - 'output': '', - 'verbosity': 'info', + "list-scenes": { + "cut-format": "timecode", + "display-cuts": True, + "display-scenes": True, + "filename": "$VIDEO_NAME-Scenes.csv", + "output": "", + "no-output-file": False, + "quiet": False, + "skip-cuts": False, }, - 'save-images': { - 'compression': RangeValue(3, min_val=0, max_val=9), - 'filename': '$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', - 'format': 'jpeg', - 'frame-margin': 1, - 'height': 0, - 'num-images': 3, - 'output': '', - 'quality': RangeValue(_PLACEHOLDER, min_val=0, max_val=100), - 'scale': 1.0, - 'scale-method': 'linear', - 'width': 0, + "global": { + "backend": "opencv", + "default-detector": "detect-adaptive", + "downscale": 0, + "downscale-method": "linear", + "drop-short-scenes": False, + "frame-skip": 0, + "merge-last-scene": False, + "min-scene-len": TimecodeValue("0.6s"), + "output": "", + "verbosity": "info", }, - 'split-video': { - 'args': DEFAULT_FFMPEG_ARGS, - 'copy': False, - 'filename': '$VIDEO_NAME-Scene-$SCENE_NUMBER', - 'high-quality': False, - 'mkvmerge': False, - 'output': '', - 'preset': 'veryfast', - 'quiet': False, - 'rate-factor': RangeValue(22, min_val=0, max_val=100), + "save-images": { + "compression": RangeValue(3, min_val=0, max_val=9), + "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + "format": "jpeg", + "frame-margin": 1, + "height": 0, + "num-images": 3, + "output": "", + "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), + "scale": 1.0, + "scale-method": "linear", + "width": 0, + }, + "split-video": { + "args": DEFAULT_FFMPEG_ARGS, + "copy": False, + "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", + "high-quality": False, + "mkvmerge": False, + "output": "", + "preset": "veryfast", + "quiet": False, + "rate-factor": RangeValue(22, min_val=0, max_val=100), }, } """Mapping of valid configuration file parameters and their default values or placeholders. diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 41c8d7da..ee583727 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -286,10 +286,12 @@ def handle_options( self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) elif default_detector == 'detect-content': self.default_detector = (ContentDetector, self.get_detect_content_params()) - elif default_detector == 'detect-threshold': - self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) + elif default_detector == 'detect-hash': + self.default_detector = (HashDetector, self.get_detect_hash_params()) elif default_detector == 'detect-hist': self.default_detector = (HistogramDetector, self.get_detect_hist_params()) + elif default_detector == 'detect-threshold': + self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) else: raise click.BadParameter("Unknown detector type!", param_hint='default-detector') @@ -319,7 +321,7 @@ def get_detect_content_params( kernel_size: Optional[int] = None, filter_mode: Optional[str] = None, ) -> Dict[str, Any]: - """Handle detect-content command options and return dict to construct one with.""" + """Handle detect-content command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -366,7 +368,7 @@ def get_detect_adaptive_params( kernel_size: Optional[int] = None, min_delta_hsv: Optional[float] = None, ) -> Dict[str, Any]: - """Handle detect-adaptive command options and return dict to construct one with.""" + """Handle detect-adaptive command options and return args to construct one with.""" self._ensure_input_open() # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. @@ -422,7 +424,7 @@ def get_detect_threshold_params( add_last_scene: bool = None, min_scene_len: Optional[str] = None, ) -> Dict[str, Any]: - """Handle detect-threshold command options and return dict to construct one with.""" + """Handle detect-threshold command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -463,7 +465,7 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int], min_scene_len: Optional[str]) -> Dict[str, Any]: - """Handle detect-hist command options and return dict to construct one with.""" + """Handle detect-hist command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 @@ -480,6 +482,27 @@ def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int] 'threshold': self.config.get_value("detect-hist", "threshold", threshold), } + def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int], + lowpass: Optional[int], + min_scene_len: Optional[str]) -> Dict[str, Any]: + """Handle detect-hash command options and return args to construct one with.""" + self._ensure_input_open() + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-hash", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-hash", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + return { + "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), + "min_scene_len": min_scene_len, + "size": self.config.get_value("detect-hash", "size", size), + "threshold": self.config.get_value("detect-hash", "threshold", threshold), + } + def handle_export_html( self, filename: Optional[AnyStr], diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 7b94bed0..3906919a 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -42,7 +42,7 @@ from scenedetect.scene_detector import SceneDetector -def calculate_frame_hash(frame_img, hash_size, highfreq_factor): +def calculate_frame_hash(frame_img, hash_size, factor): """Helper function that calculates the hash of a frame and returns it. Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy @@ -53,7 +53,7 @@ def calculate_frame_hash(frame_img, hash_size, highfreq_factor): gray_img = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) # Resize image to square to help with DCT - imsize = hash_size * highfreq_factor + imsize = hash_size * factor resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) # Check to avoid dividing by zero @@ -79,35 +79,35 @@ def calculate_frame_hash(frame_img, hash_size, highfreq_factor): class HashDetector(SceneDetector): - """Detects cuts using a perceptual hashing algorithm. For more information - on the perceptual hashing algorithm see references below. + """Detects cuts using a perceptual hashing algorithm. Applies a direct cosine transform (DCT) + and lowpass filter, followed by binary thresholding on the median. See references below: 1. https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html 2. https://github.com/JohannesBuchner/imagehash - Since the difference between frames is used, unlike the ThresholdDetector, - only fast cuts are detected with this method. + Since the difference between frames is used, unlike the ThresholdDetector, only fast cuts + are detected with this method. Arguments: threshold: How much of a difference between subsequent hash values should trigger a cut - min_scene_len: Minimum length of any given scene, in frames (int) or FrameTimecode - hash_size: Size of square of low frequency data to include from the discrete cosine transform - highfreq_factor: How much high frequency information to filter from the DCT. A value of + size: Size of square of low frequency data to use for the DCT + lowpass: How much high frequency information to filter from the DCT. A value of 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... + min_scene_len: Minimum length of any given scene, in frames (int) or FrameTimecode """ def __init__( self, threshold: float = 101.0, + size: int = 16, + lowpass: int = 2, min_scene_len: int = 15, - hash_size: int = 16, - highfreq_factor: int = 2, ): super(HashDetector, self).__init__() self._threshold = threshold self._min_scene_len = min_scene_len - self._hash_size = hash_size - self._highfreq_factor = highfreq_factor + self._size = size + self._factor = lowpass self._last_frame = None self._last_scene_cut = None self._last_hash = numpy.array([]) @@ -147,18 +147,14 @@ def process_frame(self, frame_num, frame_img): if self._last_frame is not None: # We obtain the change in hash value between subsequent frames. curr_hash = calculate_frame_hash( - frame_img=frame_img, - hash_size=self._hash_size, - highfreq_factor=self._highfreq_factor) + frame_img=frame_img, hash_size=self._size, factor=self._factor) last_hash = self._last_hash if last_hash.size == 0: # Calculate hash of last frame last_hash = calculate_frame_hash( - frame_img=self._last_frame, - hash_size=self._hash_size, - highfreq_factor=self._highfreq_factor) + frame_img=self._last_frame, hash_size=self._size, factor=self._factor) # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index b82da186..efd5482c 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""":py:class:`HistogramDetector` compares the difference in the RGB histograms of subsequent +""":py:class:`HistogramDetector` compares the difference in the YUV histograms of subsequent frames. If the difference exceeds a given threshold, a cut is detected. This detector is available from the command-line as the `detect-hist` command. @@ -26,7 +26,7 @@ class HistogramDetector(SceneDetector): - """Compares the difference in the RGB histograms of subsequent + """Compares the difference in the YUV histograms of subsequent frames. If the difference exceeds a given threshold, a cut is detected.""" METRIC_KEYS = ['hist_diff'] @@ -34,10 +34,10 @@ class HistogramDetector(SceneDetector): def __init__(self, threshold: float = 0.95, bins: int = 256, min_scene_len: int = 15): """ Arguments: - threshold: Threshold value (float) that the calculated difference between subsequent - histograms must exceed to trigger a new scene. - The threshold value should be between 0 and 1 (perfect positive correlation, identical histograms). - Values close to 1 indicate very similar frames, while lower values suggest changes. + threshold: Threshold value (float between 0.0 and 1.0) representing the difference + between Y channel histograms after frame is converted to YUV. Values closer to 1.0 + require higher correlation (more similar to current shot), while lower values + allow lower correlation (higher probability of a new shot). bins: Number of bins to use for the histogram. min_scene_len: Minimum length of any scene. """ @@ -112,28 +112,30 @@ def calculate_histogram(self, bins: int = 256, normalize: bool = True) -> numpy.ndarray: """ - Calculates and optionally normalizes the histogram of the luma (Y) channel of an image converted from BGR to YUV color space. - - This function extracts the Y channel from the given BGR image, computes its histogram with the specified number of bins, - and optionally normalizes this histogram to have a sum of one across all bins. + Calculates and optionally normalizes the histogram of the luma (Y) channel of an image + converted from BGR to YUV color space. + + This function extracts the Y channel from the given BGR image, computes its histogram with + the specified number of bins, and optionally normalizes this histogram to have a sum of one + across all bins. Args: ----- frame_img : np.ndarray - The input image in BGR color space, assumed to have shape (height, width, 3) + The input image in BGR color space, assumed to have shape (height, width, 3) where the last dimension represents the BGR channels. bins : int, optional (default=256) The number of bins to use for the histogram. normalize : bool, optional (default=True) - A boolean flag that determines whether the histogram should be normalized + A boolean flag that determines whether the histogram should be normalized such that the sum of all histogram bins equals 1. Returns: -------- np.ndarray - A 1D numpy array of length equal to `bins`, representing the histogram of the luma channel. - Each element in the array represents the count (or frequency) of a particular luma value in the image. - If normalized, these values represent the relative frequency. + A 1D numpy array of length equal to `bins`, representing the histogram of the luma + channel. Each element in the array represents the count (or frequency) of a particular + luma value in the image. If normalized, these values represent the relative frequency. Examples: --------- diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 64db99dd..86fde4ce 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -383,9 +383,9 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. - image_name_template: Template to use when creating the images on disk. Can use the macros - $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $FRAME_NUMBER, and $TIMESTAMP_MS. - The image extension is applied automatically as per the argument image_extension. + image_name_template: Template to use for naming image files. Can use the template variables + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. + Should not include an extension. output_dir: Directory to output the images into. If not set, the output is created in the working directory. show_progress: If True, shows a progress bar if tqdm is installed. diff --git a/tests/test_cli.py b/tests/test_cli.py index 45c0098a..fffa0a56 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,8 +43,9 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. SCENEDETECT_CMD = 'python -m scenedetect' -# TODO(v0.7): Add `detect-hash` to this list. -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist'] +ALL_DETECTORS = [ + 'detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist', 'detect-hash' +] ALL_BACKENDS = ['opencv', 'pyav'] DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 9287e1ad..df9f634f 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -12,11 +12,15 @@ Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ #### Changelog - - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) - - [feature] Add flash suppression filter for `detect-content` / `ContentDetector`, greatly reduces number of cuts generated during strobing or flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) - - Can be configured using `--filter-mode` option, enabled by default + - [feature] New detectors: + - `detect-hist` / `HistogramDetector` [#295](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + - `detect-hash` / `HashDetector` [#290](https://github.com/Breakthrough/PySceneDetect/pull/290) + - [feature] Add flash suppression filter for `detect-content` / `ContentDetector` (enabled by default) [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) + - Reduces number of cuts generated during strobing or flashing effects + - Can be configured using `--filter-mode` option - `--filter-mode = merge` (new default) merges consecutive scenes shorter than `min-scene-len` - `--filter-mode = suppress` (previous default) disables generating new scenes until `min-scene-len` has passed + - [feature] Add more templates for `save-images` filename customization: `$TIMECODE`, `$FRAME_NUMBER`, `$TIMESTAMP_MS` (thanks @Veldhoen0) [#395](https://github.com/Breakthrough/PySceneDetect/pull/395) - [bugfix] Remove extraneous console output when using `--drop-short-scenes` - [bugfix] Fix scene lengths being smaller than `min-scene-len` when using `detect-adaptive` / `AdaptiveDetector` with large values of `--frame-window` - [bugfix] Update default ffmpeg stream mapping from `-map 0` to `-map 0:v:0 -map 0:a? -map 0:s?` [#392](https://github.com/Breakthrough/PySceneDetect/issues/392) From ef47b0f99756129b19cbddd33812fd63f4b1b9f0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Jun 2024 13:07:57 -0400 Subject: [PATCH 104/407] [scene_manager] Skip processing frames with incorrect resolution Most detectors fire an assertion when the frame size mismatches, so this avoids crashing in these cases. Log an error with the timecode when a frame hits this case. --- scenedetect/_cli/__init__.py | 5 ++++- scenedetect/scene_manager.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index e3988ddf..546541ef 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -758,6 +758,9 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op min_scene_len: Optional[str]): """Finds fast cuts by differencing YUV histograms. + Uses Y channel after converting each frame to YUV to create a histogram of each frame. + Histograms between frames are compared to determine a score for how similar they are. + Examples: detect-hist @@ -781,7 +784,7 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op type=click.FloatRange(CONFIG_MAP["detect-hash"]["threshold"].min_val, CONFIG_MAP["detect-hash"]["threshold"].max_val), default=None, - help=("How much of a difference between subsequent hash values should trigger a cut.%s" % + help=("Represents maximum difference between hash values before a cut is triggered.%s" % (USER_CONFIG.get_help_string("detect-hash", "threshold")))) @click.option( "--size", diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 86fde4ce..bbada707 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -111,6 +111,9 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): MAX_FRAME_QUEUE_LENGTH: int = 4 """Maximum number of decoded frames which can be buffered while waiting to be processed.""" +MAX_FRAME_SIZE_ERRORS: int = 16 +"""Maximum number of frame size error messages that can be logged.""" + PROGRESS_BAR_DESCRIPTION = ' Detected: %d | Progress' """Template to use for progress bar.""" @@ -578,6 +581,9 @@ def __init__( self._start_pos: FrameTimecode = None # Position of video on the last frame processed by detect_scenes. self._last_pos: FrameTimecode = None + # Size of the decoded frames. + self._frame_size: Tuple[int, int] = None + self._frame_size_errors: int = 0 self._base_timecode: Optional[FrameTimecode] = None self._downscale: int = 1 self._auto_downscale: bool = True @@ -677,6 +683,7 @@ def clear(self) -> None: self._event_list.clear() self._last_pos = None self._start_pos = None + self._frame_size = None self.clear_detectors() def clear_detectors(self) -> None: @@ -923,6 +930,28 @@ def _decode_thread( frame_im = video.read() if frame_im is False: break + # Verify the decoded frame size against the video container's reported + # resolution, and also verify that consecutive frames have the correct size. + decoded_size = (frame_im.shape[1], frame_im.shape[0]) + if self._frame_size is None: + self._frame_size = decoded_size + if video.frame_size != decoded_size: + logger.warn( + f"WARNING: Decoded frame size ({decoded_size}) does not match " + f" video resolution {video.frame_size}, possible corrupt input.") + elif self._frame_size != decoded_size: + self._frame_size_errors += 1 + if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: + logger.error( + f"ERROR: Frame at {str(video.position)} has incorrect size and " + f"cannot be processed: decoded size = {decoded_size}, " + f"expected = {self._frame_size}. Video may be corrupt.") + if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: + logger.warn( + f"WARNING: Too many errors emitted, skipping future messages.") + # Skip processing frames that have an incorrect size. + continue + if downscale_factor > 1: frame_im = cv2.resize( frame_im, (round(frame_im.shape[1] / downscale_factor), From 78b130bb2db93628f633289bc3902c8a0a548726 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Jun 2024 18:29:48 -0400 Subject: [PATCH 105/407] [docs] Update detector docs. --- docs/api/detectors.rst | 8 +++++++- scenedetect/_cli/__init__.py | 2 +- website/pages/changelog.md | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/api/detectors.rst b/docs/api/detectors.rst index 31ad5cc2..6ec7b85c 100644 --- a/docs/api/detectors.rst +++ b/docs/api/detectors.rst @@ -8,10 +8,16 @@ Detection Algorithms .. automodule:: scenedetect.detectors :members: +.. automodule:: scenedetect.detectors.adaptive_detector + :members: + .. automodule:: scenedetect.detectors.content_detector :members: -.. automodule:: scenedetect.detectors.adaptive_detector +.. automodule:: scenedetect.detectors.hash_detector + :members: + +.. automodule:: scenedetect.detectors.histogram_detector :members: .. automodule:: scenedetect.detectors.threshold_detector diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 546541ef..36df91d6 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -756,7 +756,7 @@ def detect_threshold_command( @click.pass_context def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], min_scene_len: Optional[str]): - """Finds fast cuts by differencing YUV histograms. + """Find fast cuts by differencing YUV histograms. Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index df9f634f..9621d309 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -23,6 +23,7 @@ Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ - [feature] Add more templates for `save-images` filename customization: `$TIMECODE`, `$FRAME_NUMBER`, `$TIMESTAMP_MS` (thanks @Veldhoen0) [#395](https://github.com/Breakthrough/PySceneDetect/pull/395) - [bugfix] Remove extraneous console output when using `--drop-short-scenes` - [bugfix] Fix scene lengths being smaller than `min-scene-len` when using `detect-adaptive` / `AdaptiveDetector` with large values of `--frame-window` + - [bugfix] Fix crash when decoded frames have incorrect resolution and log error instead [#319](https://github.com/Breakthrough/PySceneDetect/issues/319) - [bugfix] Update default ffmpeg stream mapping from `-map 0` to `-map 0:v:0 -map 0:a? -map 0:s?` [#392](https://github.com/Breakthrough/PySceneDetect/issues/392) From ecb462c5dac8c435988c942ef7db55b5fe01e235 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Jun 2024 19:42:42 -0400 Subject: [PATCH 106/407] [detectors] Normalize perceptual hash scores --- scenedetect.cfg | 10 ++++------ scenedetect/_cli/config.py | 2 +- scenedetect/detectors/hash_detector.py | 22 ++++++++++++--------- scenedetect/detectors/histogram_detector.py | 4 ++-- website/pages/features.md | 11 ++++++++--- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 0a72e881..248c37c5 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -119,12 +119,9 @@ [detect-hash] -# Threshold value (float) that the calculated difference between subsequent -# histograms must exceed to trigger a new scene. - -# Threshold value from 0.0 and 1000000000.0 representing difference between -# subsequent hash values to trigger a shot change. -#threshold = 101.0 +# Threshold value from 0.0 and 1.0 representing the normalized difference between +# perceptual hashes that is required to trigger a shot change. +#threshold = 0.395 # The ratio between 1 and 256 of how much low frequency information to keep. # Represents highest frequency which will pass the filter. 1 means keep all, @@ -133,6 +130,7 @@ # Size between 1 and 256 representing size of square of low frequency data to # use for the direct cosine transform (DCT). +#size = 16 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 29d61159..2066ebc5 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -275,7 +275,7 @@ def format(self, timecode: FrameTimecode) -> str: "min-scene-len": TimecodeValue(0), "lowpass": RangeValue(2, min_val=1, max_val=256), "size": RangeValue(16, min_val=1, max_val=256), - "threshold": RangeValue(101.0, min_val=0.0, max_val=1000000000.0), + "threshold": RangeValue(0.395, min_val=0.0, max_val=1000000000.0), }, "detect-hist": { "min-scene-len": TimecodeValue(0), diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 3906919a..66dcfcd2 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -42,7 +42,7 @@ from scenedetect.scene_detector import SceneDetector -def calculate_frame_hash(frame_img, hash_size, factor): +def calculate_frame_hash(frame_img, hash_size, factor) -> numpy.ndarray: """Helper function that calculates the hash of a frame and returns it. Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy @@ -89,7 +89,8 @@ class HashDetector(SceneDetector): are detected with this method. Arguments: - threshold: How much of a difference between subsequent hash values should trigger a cut + threshold: Threshold value from 0.0 and 1.0 representing the normalized difference between + perceptual hashes that is required to trigger a shot change. size: Size of square of low frequency data to use for the DCT lowpass: How much high frequency information to filter from the DCT. A value of 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... @@ -98,7 +99,7 @@ class HashDetector(SceneDetector): def __init__( self, - threshold: float = 101.0, + threshold: float = 0.395, size: int = 16, lowpass: int = 2, min_scene_len: int = 15, @@ -107,14 +108,15 @@ def __init__( self._threshold = threshold self._min_scene_len = min_scene_len self._size = size + self._size_sq = float(size * size) self._factor = lowpass self._last_frame = None self._last_scene_cut = None self._last_hash = numpy.array([]) - self._metric_keys = ['hash_dist'] + self._metric_key = f"hash_dist [size={self._size} lowpass={self._factor}]" def get_metrics(self): - return self._metric_keys + return [self._metric_key] def is_processing_required(self, frame_num): return True @@ -137,7 +139,6 @@ def process_frame(self, frame_num, frame_img): """ cut_list = [] - metric_keys = self._metric_keys # Initialize last scene cut point at the beginning of the frames of interest. if self._last_scene_cut is None: @@ -159,15 +160,18 @@ def process_frame(self, frame_num, frame_img): # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) + # Normalize based on size of the hash + hash_dist_norm = hash_dist / self._size_sq + if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {metric_keys[0]: hash_dist}) + self.stats_manager.set_metrics(frame_num, {self._metric_key: hash_dist_norm}) self._last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - if hash_dist >= self._threshold and ((frame_num - self._last_scene_cut) - >= self._min_scene_len): + if hash_dist_norm >= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index efd5482c..06196a3c 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -26,8 +26,8 @@ class HistogramDetector(SceneDetector): - """Compares the difference in the YUV histograms of subsequent - frames. If the difference exceeds a given threshold, a cut is detected.""" + """Compares the difference in the Y channel of YUV histograms for adjacent frames. When the + difference exceeds a given threshold, a cut is detected.""" METRIC_KEYS = ['hist_diff'] diff --git a/website/pages/features.md b/website/pages/features.md index 09b26963..61136faf 100644 --- a/website/pages/features.md +++ b/website/pages/features.md @@ -37,10 +37,15 @@ ### Detection Methods - - **threshold scene detection** (`detect-threshold`): analyzes video for changes in average frame intensity/brightness - - **content-aware scene detection** (`detect-content`): based on changes between frames in the HSV color space to find fast cuts - - **adaptive content scene detection** (`detect-adaptive`): based on `detect-content`, handles fast camera movement better by comparing neighboring frames in a rolling window +PySceneDetect implements a variety of different detection algorithms which can be used independently or combined depending on the source material being analyzed. + - **adaptive content scene detection** (`detect-adaptive`): uses rolling average of differences in HSL colorspace combined with thresholding to detect shot changes (fast cut) + - **content-aware scene detection** (`detect-content`): uses differences in HSL colorspace combined with filtering to detect shot changes (fast cut) + - **content-aware scene detection** (`detect-hash`): uses perceptual hashing to determine differences between frames to find shot changes (fast cut) + - **content-aware scene detection** (`detect-hist`): uses differences in histograms of Y channel of frames after conversion to YUV (fast cut) + - **threshold scene detection** (`detect-threshold`): uses average frame intensity (brightness) to detect slow transitions (fade in/out) + + By default, detection methods are tuned to provide high performance during processing, while maintaining reasonable accuracy. Each detection method is configurable, and different parameters can be changed for specific use cases. See [the documentation](docs.md) for details. ------------------------------------------------------------------------ From e8fede741d6e68a9adf31303dce918ffb83df9ed Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Jun 2024 19:42:42 -0400 Subject: [PATCH 107/407] [detectors] Shift range of histogram threshold to match other detectors --- docs/cli.rst | 92 +++++++++++++++++++ scenedetect/_cli/__init__.py | 81 ++++++++-------- scenedetect/_cli/config.py | 4 +- scenedetect/detectors/__init__.py | 13 ++- scenedetect/detectors/hash_detector.py | 92 +++++++++---------- scenedetect/detectors/histogram_detector.py | 25 ++--- website/pages/changelog.md | 6 +- website/pages/img/0.6.4-score-comparison.png | Bin 0 -> 111795 bytes 8 files changed, 213 insertions(+), 100 deletions(-) create mode 100644 website/pages/img/0.6.4-score-comparison.png diff --git a/docs/cli.rst b/docs/cli.rst index 1b48e5c8..0f426bdb 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -271,6 +271,98 @@ Options Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). +.. _command-detect-hash: + +.. program:: scenedetect detect-hash + + +``detect-hash`` +======================================================================== + +Find fast cuts using perceptual hashing. + +The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. + +Saved as the `hash_dist` metric in a statsfile. + + +Examples +------------------------------------------------------------------------ + + ``scenedetect -i video.mp4 detect-hash`` + + ``scenedetect -i video.mp4 detect-hash --size 32 --lowpass 3`` + + +Options +------------------------------------------------------------------------ + +.. option:: -t VAL, --threshold VAL + + Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are more sensitive to changes. + + Default: ``0.395`` + +.. option:: -s SIZE, --size SIZE + + Size of square of low frequency data to include from the discrete cosine transform. + + Default: ``16`` + +.. option:: -l FRAC, --lowpass FRAC + + How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... + + Default: ``2`` + +.. option:: -m TIMECODE, --min-scene-len TIMECODE + + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + + +.. _command-detect-hist: + +.. program:: scenedetect detect-hist + + +``detect-hist`` +======================================================================== + +Find fast cuts by differencing YUV histograms. + +Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. + +Saved as the `hist_diff` metric in a statsfile. + + +Examples +------------------------------------------------------------------------ + + ``scenedetect -i video.mp4 detect-hist`` + + ``scenedetect -i video.mp4 detect-hist --threshold 0.1 --bins 240`` + + +Options +------------------------------------------------------------------------ + +.. option:: -t VAL, --threshold VAL + + Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower values are more sensitive to changes. + + Default: ``0.05`` + +.. option:: -b NUM, --bins NUM + + The number of bins to use for the histogram calculation + + Default: ``16`` + +.. option:: -m TIMECODE, --min-scene-len TIMECODE + + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + + .. _command-detect-threshold: .. program:: scenedetect detect-threshold diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 36df91d6..95f3e88a 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -452,7 +452,7 @@ def time_command( type=click.FloatRange(CONFIG_MAP["detect-content"]["threshold"].min_val, CONFIG_MAP["detect-content"]["threshold"].max_val), default=None, - help="Threshold (float) that frame score must exceed to trigger a cut. Refers to \"content_val\" in stats file.%s" + help="The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to \"content_val\" in stats file.%s" % (USER_CONFIG.get_help_string("detect-content", "threshold")), ) @click.option( @@ -723,56 +723,58 @@ def detect_threshold_command( ctx.obj.add_detector(ThresholdDetector(**detector_args)) -@click.command('detect-hist', cls=_Command) +@click.command("detect-hist", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, - CONFIG_MAP['detect-hist']['threshold'].max_val), + "--threshold", + "-t", + metavar="VAL", + type=click.FloatRange(CONFIG_MAP["detect-hist"]["threshold"].min_val, + CONFIG_MAP["detect-hist"]["threshold"].max_val), default=None, - help='Threshold value (float) that the YCbCr histogram difference must exceed to trigger' - ' a new scene. Refer to frame metric hist_diff in stats file.%s' % - (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) -@click.option( - '--bins', - '-b', - metavar='NUM', - type=click.INT, + help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " + "values are more sensitive to changes.%s" % + (USER_CONFIG.get_help_string("detect-hist", "threshold"))) +@click.option( + "--bins", + "-b", + metavar="NUM", + type=click.IntRange(CONFIG_MAP["detect-hist"]["bins"].min_val, + CONFIG_MAP["detect-hist"]["bins"].max_val), default=None, - help='The number of bins to use for the histogram calculation.%s' % + help="The number of bins to use for the histogram calculation.%s" % (USER_CONFIG.get_help_string("detect-hist", "bins"))) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( - 'detect-hist', 'min-scene-len'))) + help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." + " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % + ("" if USER_CONFIG.is_default("detect-hist", "min-scene-len") else USER_CONFIG.get_help_string( + "detect-hist", "min-scene-len"))) @click.pass_context def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], min_scene_len: Optional[str]): """Find fast cuts by differencing YUV histograms. - Uses Y channel after converting each frame to YUV to create a histogram of each frame. - Histograms between frames are compared to determine a score for how similar they are. +Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. - Examples: +Saved as the `hist_diff` metric in a statsfile. - detect-hist +Examples: - detect-hist --threshold 0.8 --bins 128 + {scenedetect_with_video} detect-hist + + {scenedetect_with_video} detect-hist --threshold 0.8 --size 64 --lowpass 3 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hist_params( threshold=threshold, bins=bins, min_scene_len=min_scene_len) - logger.debug('Adding detector: HistogramDetector(%s)', detector_args) + logger.debug("Adding detector: HistogramDetector(%s)", detector_args) ctx.obj.add_detector(HistogramDetector(**detector_args)) @@ -784,13 +786,15 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op type=click.FloatRange(CONFIG_MAP["detect-hash"]["threshold"].min_val, CONFIG_MAP["detect-hash"]["threshold"].max_val), default=None, - help=("Represents maximum difference between hash values before a cut is triggered.%s" % + help=("Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " + "more sensitive to changes.%s" % (USER_CONFIG.get_help_string("detect-hash", "threshold")))) @click.option( "--size", "-s", metavar="SIZE", - type=click.INT, + type=click.IntRange(CONFIG_MAP["detect-hash"]["size"].min_val, + CONFIG_MAP["detect-hash"]["size"].max_val), default=None, help="Size of square of low frequency data to include from the discrete cosine transform.%s" % (USER_CONFIG.get_help_string("detect-hash", "size"))) @@ -798,7 +802,8 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op "--lowpass", "-h", metavar="FRAC", - type=click.INT, + type=click.IntRange(CONFIG_MAP["detect-hash"]["lowpass"].min_val, + CONFIG_MAP["detect-hash"]["lowpass"].max_val), default=None, help=("How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " "the frequency data, 4 means only keep 1/4, etc....%s" % @@ -819,11 +824,15 @@ def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Op lowpass: Optional[int], min_scene_len: Optional[str]): """Find fast cuts using perceptual hashing. - Examples: +The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. + +Saved as the `hash_dist` metric in a statsfile. + +Examples: - detect-hist + {scenedetect_with_video} detect-hash - detect-hist --threshold 0.8 --bins 128 + {scenedetect_with_video} detect-hash --size 32 --lowpass 3 """ assert isinstance(ctx.obj, CliContext) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 2066ebc5..3407b2a5 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -275,11 +275,11 @@ def format(self, timecode: FrameTimecode) -> str: "min-scene-len": TimecodeValue(0), "lowpass": RangeValue(2, min_val=1, max_val=256), "size": RangeValue(16, min_val=1, max_val=256), - "threshold": RangeValue(0.395, min_val=0.0, max_val=1000000000.0), + "threshold": RangeValue(0.395, min_val=0.0, max_val=1.0), }, "detect-hist": { "min-scene-len": TimecodeValue(0), - "threshold": RangeValue(0.95, min_val=0.0, max_val=1.0), + "threshold": RangeValue(0.05, min_val=0.0, max_val=1.0), "bins": RangeValue(256, min_val=1, max_val=256), }, "detect-threshold": { diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 55ec8689..c7a0833c 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -15,13 +15,20 @@ This module contains the following scene detection algorithms: * :mod:`ContentDetector `: - Detects shot changes by considering pixel changes in the HSV colorspace. + Detects shot changes using weighted average of pixel changes in the HSV colorspace. * :mod:`ThresholdDetector `: - Detects transitions below a set pixel intensity (cuts or fades to black). + Detects slow transitions using average pixel intensity in RGB (fade in/fade out) * :mod:`AdaptiveDetector `: - Two-pass version of `ContentDetector` that handles fast camera movement better in some cases. + Performs rolling average on differences in HSV colorspace. In some cases, this can improve + handling of fast motion. + + * :mod:`HistogramDetector `: + Uses histogram differences for Y channel in YUV space to find fast cuts. + + * :mod:`HashDetector `: + Uses perceptual hashing to calculate similarity between adjacent frames. Detection algorithms are created by implementing the :class:`SceneDetector ` interface. Detectors are diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 66dcfcd2..1ec508a7 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -23,7 +23,7 @@ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # -""" ``scenedetect.detectors.hash_detector`` Module +"""``scenedetect.detectors.hash_detector`` Module This module implements the :py:class:`HashDetector`, which calculates a hash value for each from of a video using a perceptual hashing algorithm. Then, the @@ -42,42 +42,6 @@ from scenedetect.scene_detector import SceneDetector -def calculate_frame_hash(frame_img, hash_size, factor) -> numpy.ndarray: - """Helper function that calculates the hash of a frame and returns it. - - Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy - https://github.com/JohannesBuchner/imagehash - """ - - # Transform to grayscale - gray_img = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) - - # Resize image to square to help with DCT - imsize = hash_size * factor - resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) - - # Check to avoid dividing by zero - max_value = numpy.max(numpy.max(resized_img)) - if max_value == 0: - # Just set the max to 1 to not change the values - max_value = 1 - - # Calculate discrete cosine tranformation of the image - resized_img = numpy.float32(resized_img) / max_value - dct_complete = cv2.dct(resized_img) - - # Only keep the low frequency information - dct_low_freq = dct_complete[:hash_size, :hash_size] - - # Calculate the median of the low frequency informations - med = numpy.median(dct_low_freq) - - # Transform the low frequency information into a binary image based on > or < median - hash_img = dct_low_freq > med - - return hash_img - - class HashDetector(SceneDetector): """Detects cuts using a perceptual hashing algorithm. Applies a direct cosine transform (DCT) and lowpass filter, followed by binary thresholding on the median. See references below: @@ -85,15 +49,15 @@ class HashDetector(SceneDetector): 1. https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html 2. https://github.com/JohannesBuchner/imagehash - Since the difference between frames is used, unlike the ThresholdDetector, only fast cuts - are detected with this method. - Arguments: - threshold: Threshold value from 0.0 and 1.0 representing the normalized difference between - perceptual hashes that is required to trigger a shot change. + threshold: Value from 0.0 and 1.0 representing the relative hamming distance between + the perceptual hashes of adjacent frames. A distance of 0 means the image is the same, + and 1 means no correlation. Smaller threshold values thus require more correlation, + making the detector more sensitive. The hamming distance is divided by `size` x `size` + before comparing to `threshold` for normalization. size: Size of square of low frequency data to use for the DCT - lowpass: How much high frequency information to filter from the DCT. A value of - 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... + lowpass: How much high frequency information to filter from the DCT. A value of 2 means + keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... min_scene_len: Minimum length of any given scene, in frames (int) or FrameTimecode """ @@ -122,7 +86,7 @@ def is_processing_required(self, frame_num): return True def process_frame(self, frame_num, frame_img): - """ Similar to ContentDetector, but using a perceptual hashing algorithm + """Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference frame to frame. @@ -147,14 +111,14 @@ def process_frame(self, frame_num, frame_img): # We can only start detecting once we have a frame to compare with. if self._last_frame is not None: # We obtain the change in hash value between subsequent frames. - curr_hash = calculate_frame_hash( + curr_hash = self.hash_frame( frame_img=frame_img, hash_size=self._size, factor=self._factor) last_hash = self._last_hash if last_hash.size == 0: # Calculate hash of last frame - last_hash = calculate_frame_hash( + last_hash = self.hash_frame( frame_img=self._last_frame, hash_size=self._size, factor=self._factor) # Hamming distance is calculated to compare to last frame @@ -178,3 +142,37 @@ def process_frame(self, frame_num, frame_img): self._last_frame = frame_img.copy() return cut_list + + @staticmethod + def hash_frame(frame_img, hash_size, factor) -> numpy.ndarray: + """Calculates the perceptual hash of a frame and returns it. Based on phash from + https://github.com/JohannesBuchner/imagehash. + """ + + # Transform to grayscale + gray_img = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) + + # Resize image to square to help with DCT + imsize = hash_size * factor + resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) + + # Check to avoid dividing by zero + max_value = numpy.max(numpy.max(resized_img)) + if max_value == 0: + # Just set the max to 1 to not change the values + max_value = 1 + + # Calculate discrete cosine tranformation of the image + resized_img = numpy.float32(resized_img) / max_value + dct_complete = cv2.dct(resized_img) + + # Only keep the low frequency information + dct_low_freq = dct_complete[:hash_size, :hash_size] + + # Calculate the median of the low frequency informations + med = numpy.median(dct_low_freq) + + # Transform the low frequency information into a binary image based on > or < median + hash_img = dct_low_freq > med + + return hash_img diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 06196a3c..ad469489 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -31,22 +31,25 @@ class HistogramDetector(SceneDetector): METRIC_KEYS = ['hist_diff'] - def __init__(self, threshold: float = 0.95, bins: int = 256, min_scene_len: int = 15): + def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): """ Arguments: - threshold: Threshold value (float between 0.0 and 1.0) representing the difference - between Y channel histograms after frame is converted to YUV. Values closer to 1.0 - require higher correlation (more similar to current shot), while lower values - allow lower correlation (higher probability of a new shot). + threshold: maximum relative difference between 0.0 and 1.0 that the histograms can + differ. Histograms are calculated on the Y channel after converting the frame to + YUV, and normalized based on the number of bins. Higher dicfferences imply greater + change in content, so larger threshold values are less sensitive to cuts. bins: Number of bins to use for the histogram. min_scene_len: Minimum length of any scene. """ super().__init__() - self._threshold = threshold + # Internally, threshold represents the correlation between two histograms and has values + # between -1.0 and 1.0. + self._threshold = max(0.0, min(1.0, 1.0 - threshold)) self._bins = bins self._min_scene_len = min_scene_len self._last_hist = None self._last_scene_cut = None + self._metric_key = f"hist_diff [bins={self._bins}]" def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """Computes the histogram of the luma channel of the frame image and compares it with the @@ -90,7 +93,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # Check if a new scene should be triggered # Set a correlation threshold to determine scene changes. # The threshold value should be between -1 (perfect negative correlation, not applicable here) - # and 1 (perfect positive correlation, identical histograms). + # and +1 (perfect positive correlation, identical histograms). # Values close to 1 indicate very similar frames, while lower values suggest changes. # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation # less than 0.8 between histograms will be considered significant enough to denote a scene change. @@ -101,14 +104,14 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # Save stats to a StatsManager if it is being used if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self.METRIC_KEYS[0]: hist_diff}) + self.stats_manager.set_metrics(frame_num, {self._metric_key: hist_diff}) self._last_hist = hist return cut_list - def calculate_histogram(self, - frame_img: numpy.ndarray, + @staticmethod + def calculate_histogram(frame_img: numpy.ndarray, bins: int = 256, normalize: bool = True) -> numpy.ndarray: """ @@ -160,4 +163,4 @@ def is_processing_required(self, frame_num: int) -> bool: return True def get_metrics(self) -> List[str]: - return HistogramDetector.METRIC_KEYS + return [self._metric_key] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 9621d309..6e49fb7e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -8,7 +8,11 @@ Releases #### Release Notes -Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ash2703), adds flash filter to content detector, and includes various bugfixes. +Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ash2703), adds flash filter to content detector, and includes various bugfixes. Below shows the scores of the new detectors normalized against `detect-content` for comparison on a difficult segment with 3 cuts: + +comparison of new detector scores + +Thanks to everyone who contributed for their help and support! #### Changelog diff --git a/website/pages/img/0.6.4-score-comparison.png b/website/pages/img/0.6.4-score-comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..1b2c68b32bfe64a88b21e47013ed3ace368d151e GIT binary patch literal 111795 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

oae^58^pEE1IRFYYN34p1V#oiM zt3lCxUHOtAM>LX7Ar)1punbdP$x*}M4rSjvOa?@ zjP&FnmNPP5tNck4g+Tei<;LVltwDFnc##|ry8T9XC9oEEtQxu z)cCz=`Y~!%}Xgfb%?OJ!TC~7 zztYD=Z>rBtBP~0g4d9irfjXjL%w6ZPMq&`Yb@Q$JG1%Tti@*Q8k7#y}baXqcvBO}B z6>vP9gI4)O083H*c?d{u&>A|bf)VeeWqkSm*L>*k=ddnwf}k$KZumtd*d~}fORDZ? zy!g9CHD?5l!VjOA_nUD3VA({$bh0^1%Ah{(pn^ib ztokZqP9>5Fo-=iApDe>m;FH+;`!#b8Rwn%L-NTl-^@ic6-aI1LwwM}B>3jbe~z8%B>|%RU_1cp z_zhQ+pnMoEv1yI4eOqc)YK*Y5s!$1<#&xP{eh+N`sNs+K|AE>beNa0R-=XG2@B0iK zU%xo2D)OZgl?kf`!@1#6R?H%;M{rzYZBtHvF3 zn42+7#wM(Sedk@N2;5EO^3>SEvff@MT|}uk>XvO_yjdFfyH1Y7&~m9YHqa5A`fky8 zQ+>W_ZA|Ii(>NJ(ijXe4ao0ssK!Eb>5AK;bB)i3d1xlGxmpTs4^lywvzMYp>UR-<= zZ(VF<;zKl!Pc82CA*qnGqCHFKm*0mjlD*O{y_LP7{vjuq1Xj=8Au&CMv6C~rCCw63 z9|dJJHec6+j9=0#`TNAdQ`a$#g<@`E`77?0f8z>T<^+b!xfvQNdUp~2F)2$!7K2Y6 z;=B-}Op$6il4I~>wpCbp=|S>lUaK1I-gLZYf9)we^5akIj`#%OUTbZY!NiK-mgbS& z+F5}r3CclwiW(+;mF@Otz~kDzW4qGvPO;kjY-}9f4BMBx^hw)ZXQXfa)o!5kA3Jsl zOm;3;7cK4VL3;LZq{TKcws66!fW9_rdP(aXlbU6}W2%%dBV~|M)$Pn{#{1=w(&oya zi(uiK7%LROx}|CO!&ul#L*;L%Cs~DmL9z@yL_NZ2@iIG4NTghOyR#N**8Q97|4@!P z5qR|2#M9YQl+s~Cdk3*5GocYlW$07#Vl!4d(k^a?uKmd&59L^tt*^iq*%OHktV(#P z8lZQ8pQb4{R7B*!1jg~EeF3JQiNp0kq8D1=RhGq!W>{pWepIy^^9+hA>qdLm4&1p1 z_%98)(2SW{TLj};CP|m;VBRzHXR{Vv7eun97aF|j=IH3{jD+_5e%6SEsr)^cnAM{z zTR;zW)gF%=$E~F|rnR+qOakI?7QTkuk$P)L$*EG@QG`8>>dtbte^68t`_gDx__fJH z=bJID?O(c)W@iT6)pe(W%9>O_adT{UWgMWqs`kH9ac(@9mOR~$T42>e7c{PaNtKDP`Wv?7U`uHyaE1#bL!>FxgU~^=h%8$0wS{^5bn;V%vF}#Nx{y;??K5csKRE7z7m!3 zP$DL8g=9?SMz+dY&((S!VEJE*v=GS+#it%dp?={M$=+D2pqj~iAT$h()GT*|s=krP z@vGp7uCSz}Juig;S41O!wicT@EM!#j4eg+`Sy+Ci=u&P$08;Wf;^fDa3nx@y(ki(cwy8~Q>ZQz%9U(`yYVcy% zB(0ZoS3X9ty_UKR-_Efs@lXXF$}>JQVD#w-p)rmqvdWnO%1bM%Cp$@Gxya40UrfEF zLP*fnQ};1lr8B#*E{X>If1azj%AihU*QvntT=B!@?mmkMIpC}wsbw1D zR7tz|dtF9qF>T&LsTcAYYb zGCXHzSz1THXW)>p(D&>tA^14XXzhFSoS1g3uKZtIHx&CLhYnrzM`nUTU5BtJA_Kz; z;a)p(Xw2v`0fHK#tm-7zOuiM?!sXO)WVNgYr4;ge%~d66xO+qXJerc}ku#Z9G`fr{ z)F`Fq$7dn<;l_li95)8UB}Ay z6Dl^^$X)!NngcwVp<*?RGh%a@l`o8ytr>0_e07~NyM%bcNPXDZvZ*z;&u|_8^xV{_ z<47?%ApdC0l$z~f&*p-3!f()r-dDQ9*|6ncM_JdK)2d)4Dx#QQLqbQkt?>>F?9L+ z*FvVGqUTiPzAszbQBvw)`gVE1?8PLV0bzvW9YQosiukXFPupi=eQkO|uA&W2ek;RR6PP zWwnA4#1=3OOqnZ{KS4H=8C6t?*(oQ?)QYpO=~S2P)ZBypLLF^h+Cdg}8p3KM4F2d1 zJnCv;hMH#m34QV1_>8yfrl+>DkK`YYVHh|E4Qg}}1Poz*!a^D(=zU$pvn&<6zb5ab zyHx3O-Tl11M5ohYrqgmj{;W?boG!xsBQkXXQHs<$q<<3DOXtHcH(lSZQ|XG*INN|S zX*YR|ev{aXar54fw!UZ$8Cgs!aelUCY~Rrkht*{rhG2XrN``(2S+C0SC0@m-!%&bt z=mTB|KTcO`=Xm0})YQKGv;E+h1U5B(g5@0r--*|7#oa{f?!tv$Dq1j^wKn!9 ztriGTy)$=pq;Dl)!b1Zk$+?nTjd&3LDQc=E-+ zR6i|cdBn2U^`dUA!%WybBfrhNNU%?=jLlhl0JEu>u>1?hoJJFeo)Q1Bm#nB@FOmf5 z1vezp30-IckGO0QV-B?R>&Of7Y!_#npLAcn&_GtfN1iWR)wXFk`6PW(-%Z9PRUTm9 z*wgUGqdkr9SC?*gBJcDvLeDQ|MY+ia$h=PytMVRm)6ik;>8*f~xan%N3^1a{w?Qa= zDVX939#%ZNdG1K==qE6FHg_BvVj%{o~LfU=gu7p!ot*-z9snD05avBjzWZ5y5G50R>P5z2calcHkX` zH$X)gm2r}{FsgY8lr;1au%fYf0=LrXcMB%P7txK6erN}Lb5HA(*Z#jHOpJw%L*$OJ zla?uCDK|ox68~<%Hr(XJ(*6wn`z8G8ivbSq)j5rci$Iu9oFF#j08Zxsy&=dQzZRH- z${>*dM$T~O0rr=WFfS2bO$ZW6igp!JDNkG)@N0`2!3x;Kw_-^ui6@ZHIH%#$@Uq+o zDxNsE{CPMjygx@_klw#ObC)oWhoK6Ae@UUhAis854`{kvB;Rp$_pa~j#JZ0~^mE}z z;8vsiq@kU8(|oiGXmkSpV?N#m0NB&C_aDIA#W?#9!yhotdii-|u|hg9iBUTE;Lr?M z#Rw1+HBlr8I$MPLII!}rJ#c?YF%`z`7;D9VR_ZIP3;l$TeQ7Fd-LEQE_t12;raT@2dWH5^)Gn-qYy}IZW%$%r#1# zEkkkBba?4cSL!(Wz1xpUL@Ri7ZYzhgR)hm7Q${TDC9^yhCt={0MkM8J4UhR7j?W%EDv1qFoPCfN<^h!UVfC=}vK@8J#v zMVN$`rAe3r{Bq(D^FO|<)|KR#%N8(HL%WPwufw5tB9msS&F+8>=&0tH| z3jP64#1;nAW#lD|trcAm4rk|nOj1n*SEh3ILkgow!Xn&=Qhm9;l6Aoo&HDR$*RJS% z%kM+K(9hQ|m$)$W27evM0yaQlj$@4SUGZEc6y$-Y75Z47QZ)bY=e{`BPS*q&T^y$~u2U2Kg_dgGdG*cl*9TQ@hycgFcH!rMkin3P%-~O&5>y%SLGL&g zVc-$#lgY9SXTqjXCQBKh9$hGUEhYR{KMG#9Ild=5m_ZF(4Thp@yfIiJlbpeHx(!#Q z>SjsjW*ntrXK%eige!XH^<(p2_s34ZdgsKowlSdZ-wlqf@AoR61um^832wCf{KN)( z{%Wrf6(^i!XC7EXrY!0!O*T%`Vi3bEwV8v-{r_?8v8)hms0nSByt3Y>!Urk_j3P~o zM}+*$hbPya^OI6>O+wf|`tRBAJ4or-94AyZ5L~!{34I*u1v- zF{PfSl#JTNTL&ie%MNz>W+f;}M=^%Rmb`Q(~GP@E|9R zL@FOEohI4?D@v->Ao8y;IOYObfNhg(hwtz5rTM{O1?}K_^;wSAS1P=cxj*oqy|bYm zr6h`pBL9YuV%!e5aOn?^dHWTgs)~#3N4eAbqgV((X`f~ko}jojqkxbQDt`)vuVLlGi^49PJ7y1 zbl4}})nywZph0Ahb2W>%+-Zy zoy5#QxW|d92%SxUsw|0H>lQaBqB zwg&2m?}CRou_W{f;*kb@U^W*)%&wIfY3D3}<=y|_7jdRUvX!Thx6E$zYq75Xx+-MXjW5{Af@XqD1pwoh@_KPO~R@37z5V*pU| z+TKezwji)mPraB2#z6wi?}2J@;fCb40s(5=vd157j97|h0~BxNVY|= zBQvTg9&D_O?acYmUl!M$2DZ||MR$!o_Cf`)bTfm>xP}Hk-I}Zq9*wLY#X0=-PmWQQ z@Un6?mCOq{PR$`K4fc-B$epEbE30_Y->aH{FOJ2*-YP73oT9IP%0wh0YZJ!07gj(; zy(o;-JoFtdZeLaBvu|HLI>keH!UozfxY6U3u*Myq7JgqDqk0RO>*}W(c?e6edQJoq zCtyNp4_{J7;cbe`R4yT!H@SUJW`24|3Bc{}wVjdhnC1Y)M!T5%A;$*u(*Y+xOm zypKxF;9Mt9WtT_pcwx-`5r=K)dw-YQTU85u2i(<7_*!_T?Dn&5fpgE2Z;eY}manH* zu{0gM=&N$x-~HLU-t8XbIBLl$TgN^(K+SEG{p6`0{D|lJu^J9swTcsM>-Y#iqy;T; zkOwVlOmNM6jBX2J02qgg5YXM6Lp%%|vJBdhI$s1jMOU}T=jQ+FaQMS`E?;_iTf|$a zKw45PaBSU(>|Dt8%RE44ll7TjL#K^b(>$T>D&0Q;JJG4Ma!Z0mISWi?v&_a8y`&gNR=-C>aJMO{_Q5@b}Y%IF_&#NUo((Ay)3g9si zVjUTsk`f(U|LjSfl4i<7iLl5$5W$K$J#iS;92Lxra=R66pHSd}=k_N?)@)#_K5_08 zQ9bu$BU^qJn`l5eKe`eqHg}*zAQbhvw?mqa^nr^L+{TYh#V4>CleE=Q<$>_-p8=PT zF)Xi^wA&vQeh6AU0QGg5($W}W!nC3;Wz9h*x<)?X72FK5>@OjM^cIuf=f8b4eelAa z526i@j@%NglQRcF-VaVGt;PJbBdn?EDpKi|LfjHRi#YU=ojniHGAGLJhG*tj7?%{c ziBIx6$^b(kQ)Ns)MuOkNOMQFM?>J*NhBhf4Nb-wy(AQtkcwoyG@Efw6RroCH&_w58 zAD#EEchRr$@eLS%_Wx1hziBDZ+$d_h)wR~U?VF$x_tr!2NQWmoXO)1K=rhDG92IYW z1*qFXDa3IHN1&A5Sa1dq;9=M42EVmNFqJ)}|O)-dxZ|&Hu!V&$e^9^1R zOQ&VByiCO{mBz=2sm55})%I2n8QRwe4qMP`+tvlMcgkXSI@Uw#72ZVNI*?CLZT=U) zqnMOWD@(iUS#utTg3)fXP%62#5Ni*%Nx=kAj74KJ$l#csI#maf?rG}QeCFH0LbX_9 z{6Z{Jw)HSI7(MSnI6nHv;0R-au2IW(P)fre zm|~|(M-vh&HRp9dDTKst-EvOY$0P|Sf*nNWS>|{SFZ-d!BT9f~(q)1bjhGO43HpHs zao}2l2+gDzF-6ThKfWEJ-j{A-ztt4T>0HuGK!`~&1Q&YXf4qyj@*`h^KeU5#ftmJC zjpQ}>*4TU##>TJ{f&|q<{s{ymY&7;}u~TB3ao85? zA&i0>{*{~g-Tz?UTYX^D<@6b_t^JnI5pdt^ngfKjSXp+;{)A$e^PlHWYH4&vPFeP^N{^)@n~V8KWh9{c2ybqu{^MYWq#b^8&6rgFDZXF=ZO{Op=uE zmjB)Z@U(`=_&Pio80i>4UhLAZgxw5DYtJF4N1z~pSs%}clIj%hjB^j1iTN)x7w-JY}EGH(W>kl^=R`>;(v-O5qGRBF-AOkTvYYFz6HPIID6kIH{$k zUlM%1*Vc$5L|Cn7=OdbQD*#r-Vs9JI^z_r5XJoKs07;#*WL0?aeA9gm3$Go;a?hP@ zkR3}DOp$Pmm?M%ixt#dieYk{Wt-VJ1FcKjWBBZ?_P*QB1+-09r8&`fd$Mz$K>yMqP zzJQCe(@R90%m%9{p(U<&k+p1WzAO# zVc~b8I6h3<3L|=c&^Fc14~nKnGaVIdG^GrL(NLm@13^w(I}}Es@trM*F_k$s;ewDH zf|`UCfqi?ofCUIXtf5xXZ5+#S@#y!4t>?Gj;Y*5W5~-7dV_u<-Rz!baYP=pnw-PES z7FIgr_sJIP zu0Y;g-9lq^Px5kOzVFV~FU6l`3Tp)-s3|1Ho80|P&$ss-!Da$pb?wyC;og7SgZ4Ug3)sr_PT2PP`N5N zP{6ttxoqKWF*9xRJ?&cW&CjLAeq%G)$v!Xn(EnY&P0qpQ=zEO2>OMpk^9kPFSA;Zb zjN<4#&%d361v!fy{_#(M&8$~DRV`}zE@poQ7WY+;BQ;joh%D0tlAy|8E< z%uPpz0nwt2v_P=S+vnP;3ceKmyd6Q2o6{r5-@3BV(dCLNKuYNc#gX%3m>K9daBlvS zvHHDO!|v$6VzIngb*^{W&q=+a=-{Fg8qKtFt(==>zX%_w8wK)L zV!zv8lc;o!cy(&yn0J&g)n-^l7rcUs^bdCBF(L;fazGofHrL>^;uPopB^wO$kfWP^ zxxs8oA_m^2#5-_2cM!N`UxRGby6{FSH?Q(GOa+-3^b_r*VPHaQVDUPtUpp*0tHl6! zjK-1fGV((_$1R*C$Vjxvw6ym^648=-d7va3ew3hPZHz^quuEG(iC%5s5@w{j2nuM|fZh%GF9c1))8x_a zj*r^rO-YK}3iaS72?HsYA9@j@;9#%(1==TMfJD5io}vY&0=YUwK5}1dNRIm=5x9lu z##g@a^09woJG=N>ctN`t39)O-yAH+%LAo1xJCIg9TUoeKI)0>{V8@c4VbXhHlWj(m zQp1wAtWIm96P|GP*Ya91rnh9o-F%a&;_!d@pPW#s~d^ONz0N*rV0=zK%aUjLmJ zQSwz?Q-3N!+r`8-Ncz(qq)~ScyL_uzk-+(t!+@dFszOEb_7nfjTKs7ZyYcv1ZpZq- z-@6O;$0i#w$i1d2oJTha7M(@d|EWVvBNg)kiV+hM6Gs`r(e#0m38aP~a+i@Mw(-DL zRlw7Nf65QlJ$uL6Abs^R!6uCL;;KK+!|aB$;$|o)CIv93<=C17IM9i~>EqL4_XmA2 zd$p9V87ijl3E0<1WU(!IbsCU#DK%ThIoeV>rkKOZ6+LB@kP>mSlo%P&ST(+-5;KcQ z)R(Lr9+Ik24=W%f?MaiwcE+sFL?5D*9n=UWmFsBK1ARf`|Kvk2AeXOgvB=sV9!`nX z04ELhtd>J2OUL3oqkRvp*#W2fmQsJt`6=e)G}a|Co8lmsV|n_xthwZfp7z>|f_n7^c`T(?w{L z-Up3Il~_OcJd8EwL<(RKFjosL%rq47E1;RVYm#i{2QFc@f@qq@b{?cxtZym!SbA>n zd9l5nZ)J{cZ(HZ8R=v4^{FDL-t7kU^zv$rB%m#jJ?k8mM9PR9Yqt^64GK(vs+u_;a z4|Idv%=Zl6PFb>R71z~w%Hk^)Xkn^Ff4>B+(zfz$DqK#XWRwj)3rhI`T^XR+uvg9#28Wbfc3P zB5}t3?7&DbY#h;wcsW1q*?NcCQ@ARE0plQ19lZ#Nu=saB5=Hz_Z%%A86WdXH9YizU zHmfM$`sbj1VvIIli|h|)v|rz|TI$&^Tqb|U8@Oy`cn~yVmv=Nxa`C6m-=P7IE`(d1 zOXgDlcb;_V*LZV$f?dK``b{E@y8B^5S&w!yktAGylD}zb9UYRm|6M< z#99B%m#N^2NC%!gt;aVS_p19_)jH=3M~(Z<)Hoi1T{WEmIEtgzQ!}@r(P_zAFU$XB zG|}n&pKn3bzwad4Db4#rg{)zcYf>Jh^Y=U-Px}a7Dxo7!c2^~e z>h^7cORz0r^S?~QqpWCy=ou1<+Un|;7}+&?zq$g9fLJBI$AeC={PiXyzSrAxDV}|7 z@RD&sS9c$&$;GdGVi6porTcLi-JSpBq(I&+m$W}~r^upxMycZgjnyRfoQE}zE-%=! z1Y60c=}cad%#IvOR$I))woEA5Zn{qHAj|Lg`h1C=kn|C^5p_w#MYtsZ zorcDIWCPqL`4$k3V zzxW&dIs?$8xSM-VVJ%zT2^onCqs;Dh69vtYMix8z7&WZeLTaLSG;%$HD&16@Z`rKh%iZHPbCUw}oPM&>OwUQj)q&Za>=6UHF z+vg^fFPj7ud>>wpQd^waX&`)i6n_3KQhW1DXv)iWwe5e!g#sveaTttgkItD8gMr27 z1~^jcq(J3(R3yV#@_}Koresu2oZ1BBXzh*2QHh73o~b;M?LU|cJ#{+K{&r!1x!8^A z^Y5P5_>YT^0gbcQJyg$DP8WWiSwkij$BtQRNdWnVvkdr2Y+aOav?%g$N7|Byr4(3+$a_bj;5U zcoUapmxQ|N?s;4QUBf0LP?%u3zRm7DBuB`*kA_0<&$9iQB$PavIFjP148z4Jwb4;k zZHoY7nl1QUHu!XclrKY!nsx9kwI4()!8cPD2(o679iI_5G$;I`jFEp5Sbz z-Jp%+W;lmE>o02deq9O%^+t&=tMof(UXI#=ZPJz4s(*a@j_l&DbIL2&tI#i-x<27n z*bAM?a!m#HV?eqjTO}%pe$b2Rp|sFpHNZ_0nvffVuBY20NE8#`xM(+(josCZ0GmKj zvP^A!niAVub$pEz6z`le>VZaOo!-G#x2Mh0vBsB>R_r^WBPvWa$_pnE5zo7|MaSHE zZKxK4WA6u|ATJuS4A@j+h=C%0hfPEw?C$6?>7oJW>pi{z1$TLE$mDzfZRW&^55?Z! z7*8xG2klF8yJ5k=xHEfSnoyR#y~^Fo@on#ismSjY{dy_+9okq`RLWpNj%0+n&3Mml z$9wcVA43xnAd`Rt0*pMRygnT>OwywwF!Jz7g=3x}o}HdDdcLD84H5=o>Nwu>`wf+W z^%#&)=S==IE#}BC8VX`y_ge5-bo7$s8zw-NB*Uq3%R|%!HOQ;tRzvE(iKEgCfeb(5i*|o^6 zUb0<%As0jkhm(uu%x}s0|CB0R5y8S(e99t04)B$0Cy%ex5(YPTp`x@F450unEfR<~ zLXwJvTA6unAP6O2tkA2%xI6S$@zIp5qXgeE1$LU;H2OICH(%WP!Kb3ExScBFs#&Ct z#<{A7PSSH!?#~XUH*sd+y&8o}hyp^Pg%DIF^2~u^4RwWw?LQGQrl_)G}r^?8xyM(W$BM^9p-WN!u2{PQ!m8jt)Z#Uz`V2Gyj z6*#-BfSqvqIQfJBN*f`K!B<$1*JI&NZH;PgPpf=6*)LdL5!bEAjH8WSz}RgkL?Q)7 zw*l2}fk0Wt=NEXq?PhmH1UnSK<{oZpF7seA{pnsTocOn_JuERSAzfv+1-hk_YyonT zET@}q%DUP|khz)BaUSZEJJp~Y%-a|Ir|u*vWYJFfwSq}B>4=fAIOSw41lmxekT}9% zK3KQ+TNddcf=>1=A@MQCbhpCq>aA+-dT77A;M8^!|07;l)No!a+CkEfme9eonW4)X zlb}I5`7aVSjacJh!UEOT9p|p_Ye>2nRUWlq3FZ5}3FVU52J4~&&1-t|R7L;UYo2#O zIhdY{gBre>22O0mSfyJW$*ot-kQ`u1C8sedUbr*@a@!ov`49V|fCVk}g?(=qEzBVg zxCS*F@{e@l{oypS*$SCsn?LiQpv_R~39m^uJK68jjlUlOg3Gk#lfAXaXI3Gmbz2|; z?=_1c|8qk=*J$26h+b>@A}c07Dzjokn`c=aRuq>AT{L`Y+9t-IR6zYFW_n6gEycTQuld<#7-R!>b4Pc%P?gr{V8`nG5VIjV<_**~t&4CCq zQvBj*-Z|4~aX-xVuK|F;{~FcIZ-1X{tLF+2%%S8{EDQaE)(249KulAGlW5!FQafri zXby&A$5e#Kib^>HVgj7WV!(%qmyH$|&;f5X<;}@L%YV-z*6bW(*5p4XK-SN24Rn?9 z7Hcm7GdsY4Bw>CL7%{Z+w|`O!Orarlfy$Z^&9k^>g^``0^FCmB#T`)pwVFN3nEg&dIa*8Wl3sfa4B+9Hu|1-n+?+SL&Bq$^seKCPn?mec_Zlc0LH! zTi&v~ukJF90wvdMTUm0j-G#nUbCCzDTe`X&udQdYkL-M02&TFt-9oCo4k1oF7E^Tb z*%6IbHx8#hBO4{={;M7iPvG%m(Il*S94Xgi*~h%pYclSV_KChY@rci zZ|rLpWwVswK*094VkH^ycYa#i=h~lB$nFH4d5_bJ3kHsOun){tgs37llT%llbh<^cKzV?RseUNz2JcVST|gQ!nX@!Gqu%k^J7k3iOP<+( zzQ~6<#4|gBA8G%%<`{(tkl@gcwhAdTL53f1gn%!U$7lNR7 z!1;NrqY=>PAy-j3EgrjJmIm&0KWU)WneOV|Y=V@iC|`v`{{L;k-(o(WhHn4=yRCU_=D*#JEc@PcB47m-krobceOH8{EdKWPWH z;al#9_gT`4QnOr6fxZ-eCBWuXY4Pnt7aid%S^rq^mGbvGWQgK&&M2teBsdVQ8uXQg9N+5>jy2x`>%sZu?+QTZD}Mo_`rfJb_-EzL zXrB`jxZfGBz>)Hstk=Mw@%tq4mMGAYi8t>dLcG_Y00rm)3K={W=CZ}|;Jy^mN(*X% zB3vckG+SUVsm=NU@!=`bISqL-QBz3GGdeU)RMqLvMduPgVUKdZ9y?ZyGWmh^yfRSx*J;CwY?OH zppvp5>@z)skJ+gVzEfH~W?np7Eu8QB9e3OU_-#uh#@Nna3rdnr%WHEGZ>inZb9wRN zJ|;o+33S%+u60VBiG+p2?`~d;Z4BUi{ufnQDMs7@@eVs@v@z(4P|a+Fspp;^4FR!; zCWbAH73teAJvz(JF?%&aiIk|uz5VIa$TYtax zR5awJ0Cc49svRBQ0NY>6^FSk9Y!T7Tr05Naz74AW*=yZ%oIl2FlzP$VZM9mINA(N1 zM)Kw%8ZI2G_2m}@6Cc3@eQC-PZ8;NNbg(qTpw0{vwIs4Lu6;A+wclAq876l!8_!a8 zo?=F5eRWxACNDRGbrWqH>rF4@tnYnWRUtHQ2RN33h*c1fMCYCJJQIIuL1Q4a>8o9` z!D6td=_jo+3KxDMIE4J3pm`z8{GZB=k%(QE1ajK>5j0@hMHLwJ^8(1w6U5XTl%gPj zDX9*5_)Z(-CaB0|B2%ecJV8nk_H84EPi)}!*LOMfU1!B2h3{KT2GX5RXpnDHz0+Ti zeOzZ1{zFt6rYX2Jo6YTeJnvhGNn7&sdz+%b`=b*ps)& zq4y-~WxURRilZR$40qS?0+=1a-S^x5)_-RmQ$6^jzUu&L(r~4 zP&zdpmxdo;Am0~MV#57I(0`^`eY!#as$ozODo2ueE~i*pkNUEF93V1K`ll1O65Wkj z5qyyG3Ekhdg{+b5f-=SSVL|~@!kiSVK@5z=97|!G$Ar$3&U#$M)gt9B+~GL$VOApA zp3FM@2At|K?+!54IUVijCJoSd>s11}oJg8x^@x${=qS#nyd?fuOIpbQL*pLnrDxr9 zNMcfPC}zGjQ=C#klt3tpo{zspd*W*834@5`pEBN04UE5C-zKDwBb~|n6zM4ZyWEEj zP~ywSe+e+=#*xjQQ~m}+e5Dg*l+nUMx@I3iP!1*|s_u63H-x6)_Rh0SO#4OTntO#m z7^L4!@%H${m0y=@3H-LwGB7cRG1Q(YPxG*NUg3_lllgW10(UtSjL*Tfaa@#@87o2a zfkjXs?5kfRPN%qHpgw6AX$&tlIr>`&aZRuqgHtXA8T|=|b`T zO-10CcH{0zLDg%cwvzApQxUKGwRfp_i|0=oKOqT>(o~y8vC6jwPH+~iW6Pb5(5>Oe zHe+@;k10hVjYXYsNK&j&Ay;}|hT=>d8Pj(UX5Z)_ywideHXNqKm=6Sg#Z!-D?YRO9 zf(B-%CqaA_My(w%nA!L%xP^RZ=2E?A-gy+e_8=hl>+?XSit#J$VhMqC#br}ts+?NT z+SLH7Eg zfYNMciUS4jJ7wUB6O5-+NxBkQ^c_pSH)0N~%9H?61OXRzJBp1r+9|LXsn~Cba7xGM zDHL*ldH{}a+qxN!N>Fk%Q2hS@EI~b=h0m&)Jv(;RL7J|9ldPptfEDDpH5e8 zb!o+tCQ&r8e%>78*tyBqIm-$}wdytVCb2CeN3@>t#sDc{UV|;@iJ^eV)d-myd35z~ zZS3&9d-ap_WHUMhMAR!HWTOof>fp~W`Uc?m!?zp8=F3NdA3N0kGnz1~reC8qa^=0_ z#Ct1^ocZHUjowF+R%)GA4rox`l2EPY4!y;7oTefw)1#OV$MKzW zUF(6!qM_@AHZvyrHp4dVPr)E}DjDartQ&QqJbG?u5{UHYIYfRt0UVc={_BRz9dp}7Z3QZF0xj+af-7&GJ{r5eL(L6M(N$Fy6dQM zFF>1`oD-55;32aJy3LR+!MRZsJOF3UioWhBGH_?nd+aDm8_D_d*HT7mBWWCs2aLPC zznYwP&?ghK;h53xWN9>D`_3)u{nQWi`-DO-|%7`9~p2*&iU2V0kZ} z0T=H!oNeavt$Lksfc2qYy{~M9eh)cFnxyl@Q(7Yy$3?&U&#ZCIxej>e732B~d;HAV zJ~z$mpAVOBV0(`~FwTfH)x0GEMZS^390-Ue5} zAxgA4Dw`XaZ=Sc`j;Nxwd~+lqFUWsmWR1x=CZ0EBJ@Gbd2hca-!K8JrZ$dak;O36} zw_NSuwr6g%&XM4#1J;iCPuH@N;XiT(hstebn{J;1JCwT}+`Bf-)ds@oEzcd=_f(`k zmWZVPj3(m7W&sS80(mAw#g4pxiU+NYGnt4bY#;GgdgZI~!c?sxh>4GU0l*2FKhqOu zL=VdC@sSPWJ&h)ij*j%Lip5XsbO~DoQBx2GlDfK+MtUypLo}WZj?#HEjN|4eeb!l4 z(8DwemCPQ;wu~HYhxrS}H5U+N>!r~)q<#(S!Z};(E4l}*cG3=nw9lTBBrSF0h0B-DY3B+zD8Ugb`<92YtJKV4rq(%_ zmj0ZMS{l^lMf03`{@mF%H}zx2vcqMOY0ZWTj5BZ#NniSE#ftKa>b8`?|PfqCn{8 zVc-o^OUTQfflNNW4>)^X9A?@Myo#6O+GK=l*Qw^Y_4LRJYEB@)BwBS10%6Ay-?M_+ zIjso@n&Q7PG9FRrlD8p37e;8_M$aE*-Mo-I&GJof(`%envFJ%%cwDxFl`s5MRqxMr zwL|s{Sl=*K=#xPgU(whUua_6Ebg7V={hS|02JxUP_s$c5cnr zQBuB?_%=G_JZI#uRPXeM)#%LsR<=lKbPSL5^+))DGy#nk*3Fr095XM;pJrFk<4eY} z)iX!2Eh7Uaxv{v8Z~@P^SK3^=`azg1gmkr_Fv+je4({K&#`W=Cy0Oj`Aqm3p$RjR; z0nwb$9fy1bj1KUcBH9e8%_nxv*UJ&*$B(^s*Ckcm?m1(UbD_*0oyx)c&tUJd7qAtI zMT7#ckF}p|v?k>CTvH!3T7(3LY_k!@Zkx6uwujC!`b@bTk&j2WPj#*pDO@N}aqiRs zb_cwHRDBq$X@4lJ;RQqk?^@YaojSHrF4B%Y7_LAVrXhc^cI`4JT8W6lvIJ<#zL-2& zw;!zu?Y9gD6eJ6G;uQ!Oh?XyWTz^kO1)MPW_EYq~7vQwuzh(dQyR`mf9F52A4G}Sq zCFQHRS?<)uf$6t9wG}YA1#A%{69+%2j&7T*HqE4o?cHD+nu$Ic8KsL( zzL7Fmk749GkdT1`rF!3T*D?^%)%(}?aYa}YOQ-(H6(Nmcti>Zu2b^)t`fEgX0lqgg z2HGQiz=@eZQDeKA-?pM2zjTh1n__++4{Th`&LaB2I0N@PoH0GJs?aC3B!qGuF!1nx zo8*sTjWs*Ys6iQjcYf0$8MmtZ3Epy2;K5Bp^|s^;Xo|?LrUu4Ir6bLvdO68oc9vH? z_ej`I|K^6&s)xK;xMP7mEVD!s;|m*bFGHEbb3ir$DgDuqxkALIJl8;6y2*V^DP3VJ zz#0h8irNK4uP9ekpC@b>HCV0A}bKi z)Q?wAg)m+TxBrYb;>KnMq*>r)h{nLjOab?C@7fHxT2uiccK(usDB>W`qcKF}3?^}y z%<$^#p`gP!pPYvcm^eZAR30x7CuILpbxKWFwvbWu!BqUO*luKIPZW@0^$X z31iv*b%oBdSODV;-1B)JsqHo((;^ykfM|jW{suZPq;B(m8}A6EFvkiQ$1EOwyX!ZX zlto`rz!xARItmQ7Bt+xe28USDVAHUYll)a@dDZj6*j(B2jm^-G=GXH<+PnJq3r7Ye zZ;Q9#7IF?Yq|P~~7U#Pi=7E5^CD>;)zR&cW5-|owL@|*U<^++YBS*+Gnivcufx$t= zZsqo%*O-M4A4-*%g|ZqcZLhEkbGX0C>DT0mA~juCD~Ki=4E|R z%^UIu9pQqinO+m0V8lcZDGQ+hdV@HpC1n?NdNsC#!5SS3^gN}(h_iUq_tfaD->ab+ zH>>RQ$5pq~E^5!7tIFPQB99*v7;~OBM zm;j36G&K+{YXee=5k(saS1z7cIqdhs|Nov^V?1MA&kd2KaNNk}{n(Bf&N@ipKDJ}J z9^~tHHNbn~#gw#{b($-V|Icw}RZYUX#w6$5vW3p^yg>aYjExxddJRMa0V#cmo(Kh) zjPh$(mTg}5(e3_XnY=PFwDCzt*r$i;ZIDMs#F&g1 zsFkb55hkEh+%9E=>pi#g8r3;9NzEDXOUtszh(LhD6=PiOfe4uv$aDa5+yG;6?wrm> z+w82bs$m)Kj5kTh{>Zji5XNd-Ax;oQMnn15pdhKwDDPfIEYJLn^E>AznT~{gYeE}8 zMgl(0@RIDZ0htQugKrl( z15jYFr2qjCjg&{8~y0cEz3y+${<{jJE9$F?2Y#d-*Pp|Yx!hX&W zT`HebA_|b&@Myo&s>|rmGnIguYkbFfZb;*wEm==M7T@G`S%_R3hB8Ve-SbKNI0%vq za$DkC9@$u4_KY$Sg-jWy2n6BnUPgK;nL&X9a?VZCo6z}3amD5Y9Edn4$bZ-*PNE6Y zvwos~6dPzP5jQq7Ae9#{!<`wY8&$c~>qS^S z=Eri?s6u*-hh~_?twv`2PCb~kMla)$WQf+&lOs#me=uUuh>FR9058w-CBwAN=j`5r zCnm%VGjG$fBQQ*tA22(#2OGrqt4>|fI(B>+h(R8_ZoJK=Yb7IbiIeWf&ASC29|ssG=cXwQ9VsoAetka}fyVPwX*%uiUh2jpqx`t@KtR%3emO zA;KTtSs0kPTtZ_wA@ir|+2pzEZ>gtLO;(aI$vHFIFX`X~>OaXDfRw0kifur?1KVLh z8cNqH4H%HZnbGH``u{K}b0aH^yJ$z+75W+u816A3fMadL^jo#2FC1LQDrc)ZQ;s<2 zggy+;79BCpj4Zl;^Kd|f13XhFb}x|4Ee_Do$ zLhaC0$G$3dA684U0vnDIQN4No!>)G8(kXxR)K~{M&AnIGxRC8Ih$HI)eVhEw*a(Lh z?m5O?C=}>(Q>2W05Sa`}`vIavr$XP`=RfaSwr!CwJ2bDCd#twl-Rso8yMOFz2Ql`+yi&Gf&IrJUC6~cj zhk7u)4U(_Y8IBBO^jjz@8VFRUAru+HP;8*FMBLci07NlBLy2Az>Odm_s@}_o41^Jb z7pjRixMvcD8&EBnAO9c8OPuAlf*eTEhi)hF*q#;YgI>LKWaRLS|Me^`2;+~`8^vS` zFV>pzzi_P|m>*GNM!%<0=A3oTaZ9quxvz7hDM^OS znnNquR?!Cy=D|0|n(qTNQ)!uusEj4>ypr}NBHTE5RE|yimQc5IrSW^^Cw{_szL0)q z10)xCQ6@0dYy(twM~cwFvLV`-iGpe{sCpTxp{cnk>oIls=3hFKoYNCV%2RTI`cLi* zK;%fMKtwfd2hBbkFd)XjG<`Pov;5b;yuMF>-s$jVC$uvJ1r{Xsm1bL=1cC760N?vB zC0o!BLXQi&+9wYLml$bnkm;KFTg-0-CMSqUYi)p09nO?sD41XtXiOP8R<069n3%r$ zoLZc1M!oJ|-&Ze#p$Zy(UVR#f+7f?Gs8JnLGS!N{pVjG&aG=*5EYkZxVNCRq=qtA_ zdr^+9pp?g`1B#=V6O(hLIPUEtQ3x>;L~P-3Fs99D*F{ru<&_$I!|LAZwPA z_L6IVL>QVO!GY_;wsJWL<2UsG08CQc;}V~)Ac_Lw8WE99?Qoh?mIr{bC$h-o_C(w> zTc+BXla=-T;V>=jLB@DErlAMVad^u(J<*c;t;qa?vrb;3IfmR_yol`~y5QpsL`H@J zWK+?E`QbjNbuqK%#=&ZHLT{U!AeEl4L!|Uv$Q&S2sxjnq1~??-?+i(yjw#t%pPaYD zX#q%wM9Jd!jB|xivAKcYACY1>AJT;n7x=v>$~C)VH{+UflD_4vSJn1$)7ZS-cb{4^ zP}q@Q53<5=oyZEJ=Omb6R~DkjwUE%a zOP~&p)T>?FkhzH{P+tWRjnb8Qs zkoN+{FTM_s6qbe1(f>lSgU-Vy70^^52O=0t$u`^zZg7RnL?}S!wS4}FhuJ*6If1zu z|9vrD#+k|a0_R8=D&d?Iiw^G$yLSro~D`^kl5SHJAEl znAO6e-OR7gHej`Z57_@Np;i~~u}z>##{N{N2l$GXEzwcja-CqDm-Tt|T=FdEcu2dz z3nlfixRDF#NwTsr20k)X%T~k*sgish-l-q2ob56I!T{DGV~91fBvP!NWTvI%_xOsl ztl$Gv^QUNHI@>aQ6l}eyW@P!IqtM|m-r!IFK3w6K^FU-)EWxABd4+Xd<+vg373>s7 zG*V-MqIz`wh}h!bMWK)euh4N;?B@T#tBBO=(azBG1lAlf%j zBwRuAN7dn5f1xKir-t~9I)Om_CwB(mLl??qT&7ydNLd24#1lW|X% z_5Gq;#TPx?*X{6L(Cd1mZo{gIV<9=7NQaXbrIOLjh&o1;FUQG)h1!uW0Vl1L+j%H; zi&~Z~#}Piwc`}3pFz8}}#n*vh7*1dgnZ^$8Ec8vV0Mx#~L<^2n82|tv>IcV``#^EP zPzyjKP1UQu19a?52b%>Gw`BgwS)P|*Mw#y{oX@t7F8DYDMxbDmF+di&@=EJ(N|5#{ z(Ka`EN)!SnQq-ct}(_^QfG7NfN$xb z7}`G>=9&|zWkTnJX9jW@;es38dUbr?xaQo%?>OsKz4(M_Y~EJE_R>;u#gzg3x5RzM zbK;hU@B?30lD4r?2A=@g3;gq1otbwg_%|A|7JR>hmf61tD6ECZUwIxN%}u044aV|b zkQue@8?NV>21A>TR8MHj(&zO3L=j>M7#-zyoYkkydlJZ=Ar0STe6`hlBBDmejw6?~ z3?Ogk8pyJbfdh;Oec?WbQphaYKn5l^08^hB3rM*W1jB^_S=^PgC47Sb%*Xk8AsY>V zT!=hp;{DmRI$IdoX$aK2J<&9@dJzWO3+qZ$UaDKlYuAI{R|g;egR32^8RWB(NT)Y4 zyMWy}zF<4f-G$xsvXy;J*-~)B8`rBN^?h?rmKnGSX(+&^D-F3zU`O6RUCS^&gN=;0 zEzJIuAz8y6OR#P6pg<0p%@j=kJ(vwN)`%OM8$c6}TN97#e<86DDQv>vL=4no1*#_d zKvJpVk%1rz@9+l`nlPaN1xBFHDLaw0GVSytH9G!{hT0z4m`wNwo&AG=|h6!~nfTs?FHGc0mHB?KMp7lG@ z?J1+t2BN~`M4xnb&(-ZSBI?#m_%E@wA4iG?8~ez6|0~Y51gnCqHJ&`O0MMU6a|$}e z=T9G!n|cC95}W6hxa08~^(3b*+n&0|f<k2X_X5p~w!rBb-HEhDs{fiRdVPo{--` zp$QcEh$hHW?-gyj{(}BsLW1$(9n+*fO-&y>gW}!bJbE z5wa5~GKy&4#sWsbtaN{b z3o^t;&Rix})8K|=6ae+oW9QCKNG?Iff&&>Ymo9o6M6j%Mihr^Oi6~@qL6wSZqP-i<@#x3bF`!&0b>^_$%64TGAD0KIBYed zdpR{?0UasUpessD@))al0cqtt(y!0kU5x9^P4q>*0aY&vWAoOl!Pu;HaZ!FvPbM!n z?iuEbvhX)SY8T_Vy(OXA`5OU75)=_hv`KA9EZktu|6Uzw&q$T;yCmi&2y)r$l#-!mt^>9nO?!EU%>dE*BLzBxa|%&J3;4!~B(e&ej$^)f_L%Voa8@mJJ}UVbAw6bPOg zP@`=fq`}PZp-np->q>p>Ku$g&m<=@6h#Q+5fG9V3UEraAA$hv~!Nb9c2G8P!vnP}s zP;sJA6aj;qNOX+oSQ3<;gd%$Um=iL8W;~QI7=;7*;qtLo7n+mTXJ;F&?e#%g7ag_u zmxN>L%`s=SmlFm>_rCpPdFIS&rD++-sd6Rj>)GiN`$DY#=`cTBGpX( z#uFNyB#=@pOh!hy0vY?Y#)<#Ef*>GTKg0XIHmv1}QaV&)24cfp+&!O#M z@IVA}W2JX0HW%nj2gp(I^IP}Uzv&Mt3I+xP`cKN+Zl zo#(r$-J>pF15m$)L2?|S1CtY*EYXJrhxZg2&*#+aAIMdj9k#wVnuzxP2Mm1MxTh=U zwwJ3on7gvFew_Awy)-K$AMF zk?`)sEh=x|myF+UMW0Wqhm(q3>w>nix|eSFtgNrAVN>Ti>zzM!P^Vw#5BRJjWrgbq zC8||s7lZ;tn=M|m+cd+OasBpikk}v%l5Y;#q6=jGIiXJfjpvn*C-HkrX^OitP6rJN8-> zlClSUZsL_80V3c-2A=V(H)ICOZ6tBOL&-3SCTtD`*bKr2qTGY^KT_@l&u(21Ss9F1 zb`kpchf$C;5@xQ zOO_90EP+Ft&1(h8c91vFl$z2J-{lvbqNbdP%9rD9Y2O093WXAa*+65B52djgA@w{O z1V-Qc(hq1oQ~dDrvp%ctO)S$@R5@hiD*=xI(#ru^KtVdcpxZOPnhRk1-W1fzfzO=C8%|`U#H}Uc8|T%|RSd%46U| zgfuo;)t3`dk~UBUSP&mTDr83F-e_5H5Ja07X3U%9GSop25fQP$;PuQ9R*p+I_M6}R z24`7CxBoJhO-=ckq3sh6YU@tAiWNbF`$X+oT|`a(cHqea`Eoa50Rv8H$deK0AU)3% zna>$5eJ!uw;qbPpuImP2^u?s5V4z#a>A;5m#y)7@yJxjG)?5DekM%W#Gg#QOI#FS; zS-$WIPos9YK>vh^-!sYc&1+2mh&poXFVx8{Z^%ttK>sNR-9$Oi<$%7rTpvV)iv)@e zV%gYC^SNJ~GtVXE5DicoQcKZ9yIOz(E1?)}S}QJ`J!#ZKfK0;WOTqgouRbUiK%WZz z(O3Y#f3zdpM`WADRJN}5MWWb%>w3J`0o6DCex0%}`xdXLIvdcZ#S$<~$n*1(5%M9l zbBALRSvFqL&!NB<{T$ly!ok_O(;WjEQc#a)b##{H_x!x^J8#RG!nTYaY~zez5s_NP zEB&;N(nYlGk}S7AcEzH91dJMw1==+f`r_ns6E2@;Bb!sD$>j`x8TbW_$2#ld`D$luy2Nxu#kjQ2>%-y0~=@k-MFVn!6(QfYQtl(L96*M?xTnKsb=ciWK-Z!r4IQg8(AR0+cdOMA(NGXJ6+$(~En3 zR`pDKOFZWtv}`AD1Gleer;h#Y_p0=trco9|R5SX^+zivUmY@7lb#D10eNK?F;LA?z z@wFc=^UH-2QI7*yKg4ao-uC3T^|4CcF^%4+2Ld4bU2cPG0#J`bE+fZ2BD#4y)Cis% za>mhMmXzb|wiexcuK#XB+}PYipc}n+tUVvs2UV7;*gVz=vfQ$;9lKCGBQONwEY^)Y8 zqLOJ}e@i@|mM!eB^`@ZPOeqm4cvE}Cch6-CkQXW9N2cm`14_g+Tr_>cO-M zYjFw=q#nYn#<6n6K-c+$xp~vALiXVLY4%j+Pyvst~ySlZW2$>v0{C)ox{O>4+PzzMWtl6fDObog(54dJ9g!FYf|P|*gQG>9IVon`7$ z?Rf0Fs`~ZkN^?P$JDQ3UQBx=|NyOL}mFH1Q0rLtX#)l}X z2o2Ui^%a6a%E*B*yb@%=HLgzzb{1NjTxT^HVAR7lR9KpG*UGNi7#HZ+v)0+_e+ zb?S$NLe#;*7#{k)UVx@MHyZCOdPmK!KRzV$2zY0XY@MJZ>TJa}0}=Z^zH<&cq6rwH z__c!r-hRW_M{dH8o%O3;dCD|4SNIBnwsb&Vh+h+Q?J+-=22KPHNpL;ugfxF}#?;L3 z>aic%NmdUSC?ds)sJG)4A&V$Lxjx9#%wZ^dsE>KRsOux=jhLIfCY(Jm$OePlh7-XV zJW?4M6tbXM;o$3?x&u=35e^_3LCb@XY#E3k`V3jiDA?Qt(Ux*QL=@eVkbRMjxbLCJ zl|=*OK$PbY?7D5hJx5=R0&ZTg**XR<_y0CHAJ_yqWO3J#dFNeHC(Ac`JnR7g2~~5Z_(dhoYb;{~Li@+jGr665KiOP2DG!y(;JDZL|X$%TRZSFDp#= zm^#1OR|f-gMg)ry$;f+b>vY$#$Pm48m|F*W=fnS@k5$^i9Je6j7^pc^?Am5eiBFcY z=Flpm9a5bH)H2}9ATJ{CA1BlZNcvW0hj2yzY-C*C_ErR9~Kz2phL{}(J3`eE$u6-GjDam;9+nM73io)jJ;TV@b=Vy zB$4JOi_yHEGSu}zqCh}nFg(W`0C0mjD(Ti8Tkrgh4o?>Fvym4Mzz5&fd4cdQdI5uK z>I9)<$G1cJ4}eGunbyw-qc*^hh-mJlw05p%1Ug&f8Ivg4>FX2%MPn3>GBtk+y88== zoP6sQRovCo5`HhWo3+>>hcA7jjXGF1*lAlhL`J3fICI2<*63ZLtvY=uz%NOfiDk^_ zfeP>PHwE!Am2DuRT^kDwilDuS^gMjr5N(fi<>HY)bX{*KMC1$=jiTarh@`3*ZOU=X zd-E2(%x36rYS7>`CGADy9PtsYdd}a^d7-4f|Fk&+h_(T^Ol9PhPBz>lv_F`%);K?C zlEw&SbGE>F`CrfPjSEs}t0za|P#(Q$1yv$kFl5ui>o^eg6 zH3CS(F!Zlwq#c4!u&0qFCh?wfGB5qTo8SRjPeaO zH^H0^=mM$N#8YPQ5s=Z4PbDo@BeKls=A+vtJ03v&@d2$9^Reh;|2b?K~D@JSmFL}Oxy2bf9QMo=6aLq)Dq?c8lIgvsl+lc#woJ}wwG8mJg zi|4A1;t(qq_I6z#=e8BEx(Wq)5gKa%4L}~RpLCTC{k^fyjMwjr==@^=GKxQ)ywEtu zytI#~%6tA6)NnLEXQS(P;gm&Fpe+uAlw6M}`wS}b`C7_Ib%Rx^~$Iauo-N3ZaNUIBH`F^t<%k3nzjxRPc1^XhpZLE?H{a%b9)r2AM#x)snDj zFgGWpoEM0|fYyvNv!|NI=3XhzvixqQ=I`pUzG6DTfQZyk>tyWMG|wf25GiM6eM7yQ z+EY!={DEs(G>NEaTb7*d`2jKB05I-AJO`+hM_yr*C+5}VcvvqI|%19jWc*u>ElT`js`m? zk5UHVgzBEwS(QxwlU`OZ=!;s?0mZC~hJC}eJ&{Wu1)?pY9lyVn(34!=>!T`V_;lBO z1_yV3$yhhH=YKl)_G&dY`wBh!uL(we0CEp9vK`}N`u#yf=-rjLL(R+jqU%25Uf+^% z!jZ@b7N~^472ABU&mwy?G@QLKe(RtuA+y0FgUMNIBj%vqGcP}|j?DlEO=N&F2EYlK zKXu&;aOU7!^gYA;k?&)C{kAloC-UqdcB@zJ{ISs-9OXGXu zgT4Lz0tllp0@W^@J+AiyqZwarV<>hEDV4(MRsRTudSw6^jPB%2LI4!{J_#vzLL|pL zjz+&FYy*HS?uzFf&%G>4iS5Dxvc*Va88(l`LY@zI+yK4BJNNg*y{^DV8_!g4(*bR= zeP7pmb)SeL_Pl+5*4Lc=vbgIfjb*F4eo9^1yURE)2^nn+`ik}`wPl>CMGqdXpTnPg z`!%DVuFdG_><{A-xea7VgQ0wZSwBk#h2lAoQaO13P^6Q$Nm6Yxyw~!5i5r`n2zkRy z!EkSy#y3PlFCB1$uDCoc^E+x##+|CXAW2p2S)z@LT!{b4e#ec-X$*4yns`VhroC?L zvv6K#;{gdJt&(?CAavX~<`(a9Y0Qn8G}!epf-qV)2Jqr@U1fBZUJ+aH#_(gyk35J$ zfHb(G?A92_Ie=p~$Y@yHuh&oL!>AOA>R;wtpWxv2k*I@M)eECWe6g$pH1|HIaAIQ#DW~MVsp{s0 zD*c=NCEhiyp|2dv5-jM>)}{Sa%Su0DE`aO2gWS_ zIKxhiGi>h!jEq`Vq1Uxqjt|a!NROaEpgh-TvUtR|)!@Nz*N>@JJLl44@?lk1waYQq zv`^_5)L^|h`{Vjv(>^dh!m~59^9#m$xjlVRz(DnD&zQ#M%^RI%5#!1gb$ViKvIRI~ z6gi^!0A%nWJFQ>(eQH|PjmCZTu~ruZl=qL=DK*uJ;*^0(_oP=l^@508PKF?h=W$jX zxa^7whB1&E9=#%i!El_J@;D#`tI_-r_eb6XKd(`DCsw%j0S$I(*rbeJT!oCx_F|}W z6TH)uk%~SAl0=wC+r}}r!ewx<2Ma!dQv%VSkRIB1a9==88HG^5=ik`ReoIDvIFJqp zNS$&>xs|t4vn|iF=qo*}g}At@}ho78fLbOwGyonzq4>>U*C$@z`B@nJgX% zUFFLse9j6lAd^QP>&(eYy=?m=QxWoT!KpK~C6H2YCZ7W_e zwt-A5zkdko;6bill=d{d`*=#EhCUcKHa8LELnJ?z;DpSZbjsL5CTbptIek8^4Uf>K zxhJVywN0L4ET5M3ZT%y+OEERBCiee}@euT1a$Z+M4wO17i+n&po_p73jx!^zmh}IY znpkB2=yIb_qez>(fU>V#Oe{(<%7i2hOsSo!aTnZA}+*z@3=(rsw{k9VpL7 za%QHPn!?#Od8!(gVWx-^*G%}Oqr)8xGMy41eR=;wWjN8NW>sfQ0zRoarevx)S)z(M zBPJ3rr_a+LbKl3vJ+VNB1OFS7pdK^o>filQ`Xh)`xW%ca?&zr3+DAomI$EH2F(cj7 zbuVrI#rnZyoi$2?&|$TS$#(~aoM;2GFtWHVoP5!OjI@x^Esf|)J ziYK@sAB;v6Pc(r@I*KgmIP!r07g18J8E0lpGL6lB{J-bA2Od+!-ApM1MD+$LbZ9HW zgN&;k`ERL}!_3St8=(zsi&}-QvTOs#W&Tn%!R+YW=kTb1hFzYwJj0Ndp7M1ArX!b0VTyN*Hhs zU+A0o6Ci&f&mkBq7C)_447JzW0P)OJ=66)JhfB1>N=o)^ZRj(}==kbe1*%K3SzE=% zh1s7{Z=?)X=~?Ym-jHwWvFE%LbF7`)<5$kLRkwRK>c-}C`ODSOTmM^K+UwUMj|J9@ z|G9BpYbX9n9j+Lo*3N#+hy=$yz!(|#5ytoAG;_Wqi9m?=CLYtrzr2`=*P-{BF;(mzp0EkM#MdbEzpUGd5sM)SV6m;Qt|l{!U3j3DETSUZ4&1o+_mXxn%4*0F(BnXC?E03t8v{$u53v;QrhS;-AaQxd7 z_td`!gEZgD7^HTtA5s5oj8z%4Pq@y5*e(Uqj}6aE-*2k>6W6=eu`$@(5C(O*4N>a9 zpRL_vWWOUrnCc?PY>`L$x})mlI-GFKOGy~?mx!q6ZF~+T?IfWF#$n8X;2yp$bfH59 ziVw?qmz?X#`Xkc$F*?I6&}U=*;2WInrIhEvzxs**f(P!nMUBcmuLBohL`P&X+Shh8 zw^{r4=hT(+fo()K*$WuEft}NQ4sBvi<~8cdh5G$nzN}6>dZ*D>AN|Y!s8b^b*1yOX zb>La))IT|_D_x=;>=(-H?+CE7$P6;XID+n-cA^~jJzO$aQxcg^Qo02ayEbP{+}PYi z;6H5H>^pUeJt|968w%;&k+@s`C>S^f^JIs@{uy`c{XLXaVzk4Ujnq7J_|PGn)aQ?S zkO>b7A_?%(M)hS5stPS1Gyt9CvR)rIjA?lZsS}%(%}~mTNf-2iWIKq#G9pZ#Om^!U z5%ULpE~El>Qkxka=)D0x{DCMdCKC>p4Keh=QS=9u%jG#sLSE#9>`e7k@*+Jkq=_79 zB4HxS*Dsy&2VK+?k?XvDxG4T9Iq=nZOvUuL!O zn*{R8BZ`5yBefEaF|R&@_fs?X4K2nXGI8?op+QTt|p>GS+|G+T^!xp|n!2l@`iYh}vBaD?~^=m`| z?OmG|wjq6Pe>0nA{_@3g6;D9ZmPfn67|~ME+=Kn5LLg&v>4Q(G-4Fag6h4MCw>R5^ zALmaU)N%>NyA~p$1q@@&%?T&GNl6eP7c1h4wC<*=56iQ9l;@sFtI^`XoVv znkxL=MbH5Nr-6L0Ik`?J0q+@{^~ZKh*T>XFwa5LZ>%475G$^v_u(;mj0aUT)c=vg6LCU8+OCzHHTIj`T4=Jxn+XaCjh zo->Wjn*tX_4Fy#%J*oFq)AJpUoc&WB6h_N+)TfY=z&A}6J0!|%9vCBc z7QM>?kJ@Ni8j^z`$I6|F+x5R(@45Y0jCaj~-7@MM(jtfkr{m+=>6=U0fJ8+3@`zS1 zzLGrFaXg{_q53m6AOLP(X)l#O@bkubE$RCuEzc-%?%XLgKI@mpy2OGZ->m;F#<|Yt zzN9j>D9d~X;BqoLqMpb4OcWT(Rg$y+@hkknKDJS{kCIf!7O5EE&WydY=Q&Fbba{dx+1UNlFBI%`%IX zY)>4d-;Z&p*)O)SeotVSC9Iq5&%Vy>^--f=ta-l!J4*dCwrrtpU*Fyw7p(7iy|JDR z_y)`KAcw-9S!PykVXx0P+npXi!ZbEFu|2*<9BW#?-#L=EJV4DBtU`qTJs=$c@|hww_xSTeXKXGW`CT1FOc*#} zl8$7!D{-6tLC8SaBdw$Bp$wPrrFPZ(!b60?iCi8!+o&-azt;PEFlmkR@SxH;23Dn0 z+#jft9cNs+P9QuU(MYX@^atNBq825}517Y?P7}b34oD4b3CM-ec}wGJFnDQK5h;Qp zRLICo+yh9d>{|K0XXMJjj61ZH3f7l0Ae-kuWUQN$agEyj&a=*PGBzK+^%p8P-IRBM zLqr;{w=6X^yKPKQdrrMR`jksb0qmE4uXAm0rVLa|2HT@UknYKiyaZHc-I4cCRl1<9 zE&u|5V_CWq(kCR`6z8PMoK6qZvdi*-SIGPK1X6IzdO4)v{nebm~d%^pvZDyYF%s-br z%P5G2lyRzt9SKfXRk#YC5hrE-SoN6dPAfcHyIU72qxBHsiD(uZkmiW=`Z*)+RueOS zQa>s5ediF}^k`C%PD=*rj;?c}f>1Dubnt=!6SZZ%%nYI83SXp2sNfOT(iqT^(A

    2mw)K@m=9$mB>T8>Bxhi1Qgu0tUVfrvEb?;Z#v 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 literal 0 HcmV?d00001 From 6afbd3528407a434a3762dba6ff7912a57e96022 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 21:50:38 -0400 Subject: [PATCH 108/407] [dist] Prepare for v0.6.4 release. --- appveyor.yml | 3 +- dist/{.version_info => pre_release.py} | 40 +++++++++++++++++++++----- scenedetect.cfg | 13 ++++----- scenedetect/__init__.py | 2 +- scenedetect/_cli/__init__.py | 12 ++++---- 5 files changed, 48 insertions(+), 22 deletions(-) rename dist/{.version_info => pre_release.py} (57%) diff --git a/appveyor.yml b/appveyor.yml index e7eb4c07..8d9c5a36 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -46,6 +46,7 @@ install: - echo * * BUILDING WINDOWS EXE * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # Build Windows .EXE and create portable .ZIP + - python dist/pre_release.py - pyinstaller dist/scenedetect.spec - sphinx-build -b singlehtml docs dist/scenedetect/docs - mkdir dist\scenedetect\thirdparty @@ -68,7 +69,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 21.5\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 21.8.1\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer diff --git a/dist/.version_info b/dist/pre_release.py similarity index 57% rename from dist/.version_info rename to dist/pre_release.py index 03778c42..0673020a 100644 --- a/dist/.version_info +++ b/dist/pre_release.py @@ -1,6 +1,31 @@ -# UTF-8 -# -# TODO: Generate this using Python. +# -*- coding: utf-8 -*- +import os +import sys +import xml +sys.path.append(os.path.abspath(".")) + +import scenedetect +VERSION = scenedetect.__version__ + + +installer_aip = '' +with open("dist/installer/PySceneDetect.aip", "r") as f: + installer_aip = f.read() + +aip_version = f"" + +assert aip_version in installer_aip, f"Installer project version does not match {VERSION}." + +with open("dist/.version_info", "wb") as f: + v = VERSION.split(".") + assert 2 <= len(v) <= 3, f"Unrecognized version format: {VERSION}" + + if len(v) == 3: + (maj, min, pat) = int(v[0]), int(v[1]), int(v[2]) + else: + (maj, min, pat) = int(v[0]), int(v[1]), 0 + + f.write(f"""# UTF-8 # # For more details about fixed file info 'ffi' see: # http://msdn.microsoft.com/en-us/library/ms646997.aspx @@ -8,8 +33,8 @@ ffi=FixedFileInfo( # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # Set not needed items to zero 0. -filevers=(0, 6, 3, 0), -prodvers=(0, 6, 3, 0), +filevers=(0, {maj}, {min}, {pat}), +prodvers=(0, {maj}, {min}, {pat}), # Contains a bitmask that specifies the valid bits 'flags'r mask=0x3f, # Contains a bitmask that specifies the Boolean attributes of the file. @@ -33,13 +58,14 @@ u'040904B0', [StringStruct(u'CompanyName', u'github.com/Breakthrough'), StringStruct(u'FileDescription', u'www.scenedetect.com'), - StringStruct(u'FileVersion', u'v0.6.3'), + StringStruct(u'FileVersion', u'{VERSION}'), StringStruct(u'InternalName', u'PySceneDetect'), StringStruct(u'LegalCopyright', u'Copyright © 2024 Brandon Castellano'), StringStruct(u'OriginalFilename', u'scenedetect.exe'), StringStruct(u'ProductName', u'PySceneDetect'), - StringStruct(u'ProductVersion', u'v0.6.3')]) + StringStruct(u'ProductVersion', u'{VERSION}')]) ]), VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) ] ) +""".encode()) diff --git a/scenedetect.cfg b/scenedetect.cfg index 248c37c5..bde791de 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -119,8 +119,8 @@ [detect-hash] -# Threshold value from 0.0 and 1.0 representing the normalized difference between -# perceptual hashes that is required to trigger a shot change. +# Threshold between 0.0 and 1.0 to set the relative difference between +# hashes required to trigger a shot change. Lower values are more sensitive. #threshold = 0.395 # The ratio between 1 and 256 of how much low frequency information to keep. @@ -137,11 +137,10 @@ [detect-hist] -# Threshold value from 0.0 to 1.0 representing the difference between Y channel -# histograms after frame is converted to YUV. Values closer to 1.0 require higher -# correlation (more similar to current frame), while lower values allow lower -# correlation (higher difference between current frame). -#threshold = 0.95 +# Threshold between 0.0 to 1.0 to set the relative difference between Y +# channel histograms (YUV) required to trigger a shot change. Lower values +# are more sensitive. +#threshold = 0.05 # Number of bins between 1 and 256 to use for the histogram. #bins = 256 diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index ad26d9c6..160bee61 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.7-dev0' +__version__ = '0.6.4' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 95f3e88a..66008f6f 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -155,8 +155,8 @@ def _print_command_help(ctx: click.Context, command: click.Command): invoke_without_command=True, epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""" ) -# We cannot make this a required argument otherwise we will reject commands of the form -# `scenedetect help detect-content` or `scenedetect detect-content --help`. +# *NOTE*: Although input is required, we cannot mark it as `required=True`, otherwise we will reject +# commands of the form `scenedetect detect-content --help`. @click.option( '--input', '-i', @@ -767,7 +767,7 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op {scenedetect_with_video} detect-hist - {scenedetect_with_video} detect-hist --threshold 0.8 --size 64 --lowpass 3 + {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 """ assert isinstance(ctx.obj, CliContext) @@ -800,13 +800,13 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op (USER_CONFIG.get_help_string("detect-hash", "size"))) @click.option( "--lowpass", - "-h", + "-l", metavar="FRAC", type=click.IntRange(CONFIG_MAP["detect-hash"]["lowpass"].min_val, CONFIG_MAP["detect-hash"]["lowpass"].max_val), default=None, help=("How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " - "the frequency data, 4 means only keep 1/4, etc....%s" % + "the frequency data, 4 means only keep 1/4, etc...%s" % (USER_CONFIG.get_help_string("detect-hash", "lowpass")))) @click.option( "--min-scene-len", @@ -817,7 +817,7 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % - ("" if USER_CONFIG.is_default("detect-hist", "min-scene-len") else USER_CONFIG.get_help_string( + ("" if USER_CONFIG.is_default("detect-hash", "min-scene-len") else USER_CONFIG.get_help_string( "detect-hash", "min-scene-len"))) @click.pass_context def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], From 50679e4fe03fbac1bbb18d6ec4f33e1f577d43c6 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 21:52:15 -0400 Subject: [PATCH 109/407] [build] Auto-generate .version_info and verify installer version. --- appveyor.yml | 3 +- dist/{.version_info => pre_release.py} | 40 +++++++++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) rename dist/{.version_info => pre_release.py} (57%) diff --git a/appveyor.yml b/appveyor.yml index e7eb4c07..8d9c5a36 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -46,6 +46,7 @@ install: - echo * * BUILDING WINDOWS EXE * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # Build Windows .EXE and create portable .ZIP + - python dist/pre_release.py - pyinstaller dist/scenedetect.spec - sphinx-build -b singlehtml docs dist/scenedetect/docs - mkdir dist\scenedetect\thirdparty @@ -68,7 +69,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 21.5\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 21.8.1\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer diff --git a/dist/.version_info b/dist/pre_release.py similarity index 57% rename from dist/.version_info rename to dist/pre_release.py index 03778c42..0673020a 100644 --- a/dist/.version_info +++ b/dist/pre_release.py @@ -1,6 +1,31 @@ -# UTF-8 -# -# TODO: Generate this using Python. +# -*- coding: utf-8 -*- +import os +import sys +import xml +sys.path.append(os.path.abspath(".")) + +import scenedetect +VERSION = scenedetect.__version__ + + +installer_aip = '' +with open("dist/installer/PySceneDetect.aip", "r") as f: + installer_aip = f.read() + +aip_version = f"" + +assert aip_version in installer_aip, f"Installer project version does not match {VERSION}." + +with open("dist/.version_info", "wb") as f: + v = VERSION.split(".") + assert 2 <= len(v) <= 3, f"Unrecognized version format: {VERSION}" + + if len(v) == 3: + (maj, min, pat) = int(v[0]), int(v[1]), int(v[2]) + else: + (maj, min, pat) = int(v[0]), int(v[1]), 0 + + f.write(f"""# UTF-8 # # For more details about fixed file info 'ffi' see: # http://msdn.microsoft.com/en-us/library/ms646997.aspx @@ -8,8 +33,8 @@ ffi=FixedFileInfo( # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # Set not needed items to zero 0. -filevers=(0, 6, 3, 0), -prodvers=(0, 6, 3, 0), +filevers=(0, {maj}, {min}, {pat}), +prodvers=(0, {maj}, {min}, {pat}), # Contains a bitmask that specifies the valid bits 'flags'r mask=0x3f, # Contains a bitmask that specifies the Boolean attributes of the file. @@ -33,13 +58,14 @@ u'040904B0', [StringStruct(u'CompanyName', u'github.com/Breakthrough'), StringStruct(u'FileDescription', u'www.scenedetect.com'), - StringStruct(u'FileVersion', u'v0.6.3'), + StringStruct(u'FileVersion', u'{VERSION}'), StringStruct(u'InternalName', u'PySceneDetect'), StringStruct(u'LegalCopyright', u'Copyright © 2024 Brandon Castellano'), StringStruct(u'OriginalFilename', u'scenedetect.exe'), StringStruct(u'ProductName', u'PySceneDetect'), - StringStruct(u'ProductVersion', u'v0.6.3')]) + StringStruct(u'ProductVersion', u'{VERSION}')]) ]), VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) ] ) +""".encode()) From b5bcf709c3ed35ebb98348e015770f1ddc89bddc Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 21:58:42 -0400 Subject: [PATCH 110/407] [build] Add missing pre-release script invocation for Windows build on Github. --- .github/workflows/build-windows.yml | 1 + dist/pre_release.py | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index b9305634..c95931c4 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -67,6 +67,7 @@ jobs: - name: Build PySceneDetect run: | + python pre_release.py --ignore-installer pyinstaller dist/scenedetect.spec - name: Build Documentation diff --git a/dist/pre_release.py b/dist/pre_release.py index 0673020a..d7fc6735 100644 --- a/dist/pre_release.py +++ b/dist/pre_release.py @@ -1,16 +1,15 @@ # -*- coding: utf-8 -*- import os import sys -import xml sys.path.append(os.path.abspath(".")) import scenedetect VERSION = scenedetect.__version__ - -installer_aip = '' -with open("dist/installer/PySceneDetect.aip", "r") as f: - installer_aip = f.read() +if len(sys.argv) <= 2 or not ("--ignore-installer" in sys.argv): + installer_aip = '' + with open("dist/installer/PySceneDetect.aip", "r") as f: + installer_aip = f.read() aip_version = f"" From 24ae19215102f7233d2dc49ac54baa90ddd7dc2b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 22:04:50 -0400 Subject: [PATCH 111/407] [build] Fix incorrect path to pre_release script. --- .github/workflows/build-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index c95931c4..60b72022 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -67,7 +67,7 @@ jobs: - name: Build PySceneDetect run: | - python pre_release.py --ignore-installer + python dist/pre_release.py --ignore-installer pyinstaller dist/scenedetect.spec - name: Build Documentation From c7a6d462fbd5ec84f7ea5e2aa88547be2731ed41 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 22:42:49 -0400 Subject: [PATCH 112/407] [build] Omit unnecessary files in distributed docs. --- .github/workflows/build-windows.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 60b72022..60bee42e 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -73,6 +73,7 @@ jobs: - name: Build Documentation run: | sphinx-build -b singlehtml docs dist/scenedetect/docs + rm -r dist/scenedetect/docs/.doctrees - name: Assemble Portable Distribution run: | From ef3236b09af6322c23cbea133d1a6f2e5a862d06 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 22:58:21 -0400 Subject: [PATCH 113/407] [dist] Update Windows installer for v0.6.4. Bump OpenCV to 4.10. --- .github/workflows/build-windows.yml | 2 +- dist/installer/PySceneDetect.aip | 81 +++++++++-------------------- dist/requirements_windows.txt | 2 +- 3 files changed, 27 insertions(+), 58 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index b9305634..9e0cfd92 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -45,8 +45,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools - pip install -r dist/requirements_windows.txt pip install -r docs/requirements.txt + pip install --upgrade -r dist/requirements_windows.txt - name: Download Resources run: | diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 12a00714..00d73f4d 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -1,8 +1,5 @@ - - - - + @@ -26,10 +23,10 @@ - + - + @@ -50,11 +47,9 @@ - - @@ -62,11 +57,10 @@ - - + @@ -81,7 +75,7 @@ - + @@ -101,7 +95,7 @@ - + @@ -146,8 +140,8 @@ - - + + @@ -212,12 +206,12 @@ - + - + @@ -232,7 +226,6 @@ - @@ -277,8 +270,6 @@ - - @@ -328,8 +319,7 @@ - - + @@ -352,22 +342,6 @@ - - - - - - - - - - - - - - - - @@ -525,18 +499,10 @@ - - - - - - - - @@ -586,6 +552,13 @@ + + + + + + + @@ -601,7 +574,7 @@ - + @@ -629,9 +602,9 @@ - - - + + + @@ -736,9 +709,6 @@ - - - @@ -837,11 +807,8 @@ - - - @@ -856,6 +823,8 @@ + + @@ -961,7 +930,7 @@ - + diff --git a/dist/requirements_windows.txt b/dist/requirements_windows.txt index 644aea37..a4b1f675 100644 --- a/dist/requirements_windows.txt +++ b/dist/requirements_windows.txt @@ -2,7 +2,7 @@ av==10.0 click>=8.0 numpy -opencv-python-headless==4.8.0.74 +opencv-python-headless==4.10.0.82 platformdirs pyinstaller pytest From 8601b95cf3c68e68cf9e451f885a5d49943bc2ea Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 23:01:09 -0400 Subject: [PATCH 114/407] [build] Use specific OpenCV version for Windows build. --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 8d9c5a36..72c6a84f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -35,8 +35,8 @@ install: - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - python -m pip install --upgrade pip - - python -m pip install -r dist/requirements_windows.txt - python -m pip install -r docs/requirements.txt + - python -m pip install --upgrade -r dist/requirements_windows.txt # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z From 6a1e2fe5ccb09e998ccecfd0e58d7a38ccfd2661 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 23:02:03 -0400 Subject: [PATCH 115/407] [dist] Release v0.6.4. --- .github/workflows/build-windows.yml | 4 +++- .github/workflows/generate-docs.yml | 2 +- README.md | 4 ++-- appveyor.yml | 2 +- docs/api.rst | 6 +++--- docs/api/migration_guide.rst | 2 +- docs/cli/config_file.rst | 2 +- website/pages/changelog.md | 2 +- website/pages/cli.md | 4 ++-- website/pages/docs.md | 1 + website/pages/download.md | 8 ++++---- website/pages/index.md | 2 +- 12 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 9e0cfd92..60bee42e 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -45,8 +45,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools + pip install -r dist/requirements_windows.txt pip install -r docs/requirements.txt - pip install --upgrade -r dist/requirements_windows.txt - name: Download Resources run: | @@ -67,11 +67,13 @@ jobs: - name: Build PySceneDetect run: | + python dist/pre_release.py --ignore-installer pyinstaller dist/scenedetect.spec - name: Build Documentation run: | sphinx-build -b singlehtml docs dist/scenedetect/docs + rm -r dist/scenedetect/docs/.doctrees - name: Assemble Portable Distribution run: | diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index c3adbc9d..1f9d2590 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -16,7 +16,7 @@ jobs: env: # TODO: Figure out a better way to handle figuring out what version /latest should be, # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.3' + scenedetect_docs_latest: '0.6.4' scenedetect_docs_dest: '' steps: diff --git a/README.md b/README.md index 7c928677..b0aef51d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Scene Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.3 (March 9, 2024) +### Latest Release: v0.6.4 (June 10, 2024) **Website**: [scenedetect.com](https://www.scenedetect.com) @@ -102,7 +102,7 @@ See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for mo - [Documentation](https://www.scenedetect.com/docs/) (covers application and Python API) - [CLI Example](https://www.scenedetect.com/cli/) - - [Config File](https://www.scenedetect.com/docs/0.6.3/cli/config_file.html) + - [Config File](https://www.scenedetect.com/docs/0.6.4/cli/config_file.html) ## Help & Contributing diff --git a/appveyor.yml b/appveyor.yml index 8d9c5a36..72c6a84f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -35,8 +35,8 @@ install: - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - python -m pip install --upgrade pip - - python -m pip install -r dist/requirements_windows.txt - python -m pip install -r docs/requirements.txt + - python -m pip install --upgrade -r dist/requirements_windows.txt # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z diff --git a/docs/api.rst b/docs/api.rst index b08d1c7c..7271b42c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -85,7 +85,7 @@ Now that we know where each scene is, we can also :ref:`split the input video `_ file. +In the next example, we show how the library components can be used to create a more customizable scene cut/shot detection pipeline. Additional demonstrations/recipes can be found in the `tests/test_api.py `_ file. .. _scenedetect-detailed_example: @@ -115,7 +115,7 @@ Using a :class:`SceneManager ` directly For a more advanced example of using the PySceneDetect API to with a stats file (to save per-frame metrics to disk and/or speed up multiple passes of the same video), take a look at the :ref:`example in the SceneManager reference`. -In addition to module-level examples, demonstrations of some common use cases can be found in the `tests/test_api.py `_ file. +In addition to module-level examples, demonstrations of some common use cases can be found in the `tests/test_api.py `_ file. ======================================================================= @@ -156,4 +156,4 @@ PySceneDetect outputs messages to a logger named ``pyscenedetect`` which does no Migrating From 0.5 ======================================================================= -PySceneDetect 0.6 introduces several breaking changes which are incompatible with 0.5. See :ref:`Migration Guide ` for details on how to update your application. In addition, demonstrations of common use cases can be found in the `tests/test_api.py `_ file. +PySceneDetect 0.6 introduces several breaking changes which are incompatible with 0.5. See :ref:`Migration Guide ` for details on how to update your application. In addition, demonstrations of common use cases can be found in the `tests/test_api.py `_ file. diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index c4c51b0b..f24baefa 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -5,7 +5,7 @@ Migration Guide --------------------------------------------------------------- -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Quickstart ` and :ref:`Example ` sections first, as they should cover the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. +This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Quickstart ` and :ref:`Example ` sections first, as they should cover the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. diff --git a/docs/cli/config_file.rst b/docs/cli/config_file.rst index ff354687..95fd9135 100644 --- a/docs/cli/config_file.rst +++ b/docs/cli/config_file.rst @@ -60,7 +60,7 @@ Example Template ======================================================================= -This template shows every possible configuration option and default values. It can be used as a ``scenedetect.cfg`` file. You can also `download it from Github `_. +This template shows every possible configuration option and default values. It can be used as a ``scenedetect.cfg`` file. You can also `download it from Github `_. .. literalinclude:: ../../scenedetect.cfg :language: ini diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6e49fb7e..54047e65 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,7 +4,7 @@ Releases ## PySceneDetect 0.6 -### 0.6.4 (In Development) +### 0.6.4 (June 10, 2024) #### Release Notes diff --git a/website/pages/cli.md b/website/pages/cli.md index d7800feb..8816dc62 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -192,7 +192,7 @@ A configuration file path can be specified using the `-c`/`--config` argument. P * Mac: * `~/Library/Preferences/PySceneDetect/scenedetect.cfg` -Run `scenedetect --help` to see the exact path on your system which will be used (it will be listed under the help text for the -c/--config option). You can [click here to download a `scenedetect.cfg` config file](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.3-release/scenedetect.cfg) to use as a template. Note that lines starting with a `#` are comments and will be ignored. The `scenedetect.cfg` template file is also available in the folder where PySceneDetect is installed. +Run `scenedetect --help` to see the exact path on your system which will be used (it will be listed under the help text for the -c/--config option). You can [click here to download a `scenedetect.cfg` config file](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.4-release/scenedetect.cfg) to use as a template. Note that lines starting with a `#` are comments and will be ignored. The `scenedetect.cfg` template file is also available in the folder where PySceneDetect is installed. Specifying a config file path using -c/--config overrides the user config file. Specifying values on the command line will override those values in the config file. @@ -228,7 +228,7 @@ quality = 80 num-images = 3 ``` -See the `scenedetect.cfg` file in the location you installed PySceneDetect or [download it from Github](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.3-release/scenedetect.cfg) for a complete listing of all configuration options. +See the `scenedetect.cfg` file in the location you installed PySceneDetect or [download it from Github](https://raw.githubusercontent.com/Breakthrough/PySceneDetect/v0.6.4-release/scenedetect.cfg) for a complete listing of all configuration options. ##   Video Splitting Requirements diff --git a/website/pages/docs.md b/website/pages/docs.md index b270cafa..c45123ba 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,7 @@ ## Stable * [latest](latest/) + * [v0.6.4](0.6.4/) * [v0.6.3](0.6.3/) * [v0.6.2](0.6.2/) * [v0.6.1](0.6.1/) diff --git a/website/pages/download.md b/website/pages/download.md index f05dddf2..4d016e4b 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi ## Windows Build (64-bit Only)   diff --git a/website/pages/index.md b/website/pages/index.md index 6d7e41e9..2b13d5f7 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
    -

      Latest Release: v0.6.3 (March 9, 2024)

    +

      Latest Release: v0.6.4 (June 10, 2024)

      Download        Changelog        Documentation        Getting Started
    See the changelog for the latest release notes and known issues. From f78c7f783533bf3a804249de6fe36880c8f26756 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 10 Jun 2024 23:45:38 -0400 Subject: [PATCH 116/407] [docs] Update changelog and image URI. --- website/pages/changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 54047e65..6e58c1a9 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -10,9 +10,9 @@ Releases Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ash2703), adds flash filter to content detector, and includes various bugfixes. Below shows the scores of the new detectors normalized against `detect-content` for comparison on a difficult segment with 3 cuts: -comparison of new detector scores +comparison of new detector scores -Thanks to everyone who contributed for their help and support! +Feedback on the new detection methods and their default values is most welcome. Thanks to everyone who contributed for their help and support! #### Changelog From 8d7d26c191f858dd36dcb277ae676d8e76ac52d1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 26 Jun 2024 22:15:01 -0400 Subject: [PATCH 117/407] [build] Fix incorrect installer version check. --- dist/pre_release.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/dist/pre_release.py b/dist/pre_release.py index d7fc6735..c40751e1 100644 --- a/dist/pre_release.py +++ b/dist/pre_release.py @@ -10,10 +10,8 @@ installer_aip = '' with open("dist/installer/PySceneDetect.aip", "r") as f: installer_aip = f.read() - -aip_version = f"" - -assert aip_version in installer_aip, f"Installer project version does not match {VERSION}." + aip_version = f"" + assert aip_version in installer_aip, f"Installer project version does not match {VERSION}." with open("dist/.version_info", "wb") as f: v = VERSION.split(".") From 63db02a012945d6ca1f7d72c77682c611a0ebee1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 7 Jul 2024 18:08:54 -0400 Subject: [PATCH 118/407] [build] Support ruff for format/lint checks. --- .github/workflows/check-code-format.yml | 34 ++++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index 49c51b61..7d19726d 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -1,5 +1,5 @@ -# Use YAPF to format PySceneDetect. -name: Check Code Format +# Check PySceneDetect code lint warnings and formatting. +name: Static Analysis on: pull_request: @@ -13,7 +13,6 @@ on: jobs: check_format: - runs-on: ubuntu-latest steps: @@ -24,17 +23,22 @@ jobs: python-version: '3.12' cache: 'pip' - - name: Update pip - run: python -m pip install --upgrade pip - - name: Install yapf - run: python -m pip install --upgrade yapf toml + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install av opencv-python-headless --only-binary ":all:" + python -m pip install -r requirements_headless.txt + + - name: Check Code Format (yapf) + if: ${{ hashFiles('.style.yapf') != '' }} + run: | + python -m pip install --upgrade yapf toml + python -m yapf --diff --recursive scenedetect/ tests/ - - name: Install Binary Dependencies - run: python -m pip install av opencv-python-headless --only-binary ":all:" - - name: Install Remaining Dependencies - run: python -m pip install -r requirements_headless.txt + - name: Static Analysis (ruff) + if: ${{ hashFiles('.style.yapf') == '' }} + run: | + python -m pip install --upgrade ruff + python -m ruff check + python -m ruff format --check - - name: Check Code Format (scenedetect) - run: python -m yapf --diff --recursive scenedetect/ - - name: Check Code Format (tests) - run: python -m yapf --diff --recursive tests/ From bb5f5b1a711a06d17416e9244be3c56622c8641f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Sep 2024 21:06:18 -0400 Subject: [PATCH 119/407] Bump actions/download-artifact from 3 to 4.1.7 in /.github/workflows in the github_actions group across 1 directory (#417) Bump actions/download-artifact Bumps the github_actions group with 1 update in the /.github/workflows directory: [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/download-artifact` from 3 to 4.1.7 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v3...v4.1.7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 60bee42e..0be62ee5 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -104,7 +104,7 @@ jobs: with: ref: resources - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4.1.7 with: name: PySceneDetect-win64_portable path: build From f1537dd79c23a3846781bbb84b73a673f9f5834f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 4 Sep 2024 21:39:56 -0400 Subject: [PATCH 120/407] [build] Update workflow actions. --- .github/workflows/build-windows.yml | 5 +++-- .github/workflows/build.yml | 2 +- .github/workflows/check-code-format.yml | 2 +- .github/workflows/codeql.yml | 6 +++--- .github/workflows/dependency-review.yml | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 0be62ee5..56d4b832 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -54,7 +54,7 @@ jobs: git checkout refs/remotes/origin/resources -- tests/resources/ - name: Download FFMPEG ${{ env.ffmpeg-version }} - uses: dsaltares/fetch-gh-release-asset@1.1.1 + uses: dsaltares/fetch-gh-release-asset@1.1.2 with: repo: 'GyanD/codexffmpeg' version: 'tags/${{ env.ffmpeg-version }}' @@ -91,10 +91,11 @@ jobs: ./dist/scenedetect/scenedetect -i tests/resources/goldeneye.mp4 detect-content time -e 2s - name: Upload Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: PySceneDetect-win64_portable path: dist/scenedetect + include-hidden-files: true test: runs-on: windows-latest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ccdfb69..dc79ea7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -103,7 +103,7 @@ jobs: - name: Upload Package if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: scenedetect-dist path: | diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index 7d19726d..502579a1 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b99eaeb3..9532dec3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,15 +40,15 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4e751977..0d4a0136 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,4 +17,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v4 - name: 'Dependency Review' - uses: actions/dependency-review-action@v3 + uses: actions/dependency-review-action@v4 From c23eee83b17d0b51e4e55dad852abcf0441d4b91 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 4 Sep 2024 21:40:02 -0400 Subject: [PATCH 121/407] [build] Update workflow actions. --- docs/conf.py | 118 +-- docs/generate_cli_docs.py | 158 ++-- scenedetect/__init__.py | 37 +- scenedetect/__main__.py | 19 +- scenedetect/_cli/__init__.py | 933 +++++++++++--------- scenedetect/_cli/config.py | 188 ++-- scenedetect/_cli/context.py | 726 +++++++++------ scenedetect/_cli/controller.py | 208 +++-- scenedetect/_thirdparty/simpletable.py | 36 +- scenedetect/backends/__init__.py | 14 +- scenedetect/backends/moviepy.py | 25 +- scenedetect/backends/opencv.py | 113 ++- scenedetect/backends/pyav.py | 68 +- scenedetect/detectors/adaptive_detector.py | 41 +- scenedetect/detectors/content_detector.py | 37 +- scenedetect/detectors/hash_detector.py | 21 +- scenedetect/detectors/histogram_detector.py | 25 +- scenedetect/detectors/threshold_detector.py | 81 +- scenedetect/frame_timecode.py | 162 ++-- scenedetect/platform.py | 109 ++- scenedetect/scene_detector.py | 43 +- scenedetect/scene_manager.py | 458 ++++++---- scenedetect/stats_manager.py | 97 +- scenedetect/video_manager.py | 257 ++++-- scenedetect/video_splitter.py | 156 ++-- scenedetect/video_stream.py | 14 +- setup.py | 2 +- tests/__init__.py | 2 +- tests/conftest.py | 20 +- tests/test_api.py | 43 +- tests/test_backend_opencv.py | 10 +- tests/test_backend_pyav.py | 4 +- tests/test_backwards_compat.py | 50 +- tests/test_cli.py | 433 ++++++--- tests/test_detectors.py | 61 +- tests/test_frame_timecode.py | 262 +++--- tests/test_platform.py | 18 +- tests/test_scene_manager.py | 66 +- tests/test_stats_manager.py | 74 +- tests/test_video_splitter.py | 25 +- tests/test_video_stream.py | 106 ++- 41 files changed, 3268 insertions(+), 2052 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0cb4f243..fcfbe6ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,15 +15,15 @@ import os import sys -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) from scenedetect import __version__ as scenedetect_version # -- Project information ----------------------------------------------------- -project = 'PySceneDetect' -copyright = '2014-2024, Brandon Castellano' -author = 'Brandon Castellano' +project = "PySceneDetect" +copyright = "2014-2024, Brandon Castellano" +author = "Brandon Castellano" # The short X.Y version version = scenedetect_version @@ -36,49 +36,49 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.autodoc', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", ] autoclass_content = "both" autodoc_member_order = "groupwise" -autodoc_typehints = 'description' -autodoc_typehints_format = 'short' +autodoc_typehints = "description" +autodoc_typehints_format = "short" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The root toctree document. -root_doc = 'index' +root_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = 'en' +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] -html_css_files = ['pyscenedetect.css'] +html_static_path = ["_static"] +html_css_files = ["pyscenedetect.css"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -93,40 +93,43 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'PySceneDetectdoc' +htmlhelp_basename = "PySceneDetectdoc" # -- Options for LaTeX output ------------------------------------------------ latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (root_doc, 'PySceneDetect.tex', 'PySceneDetect Documentation', 'Brandon Castellano', 'manual'), + ( + root_doc, + "PySceneDetect.tex", + "PySceneDetect Documentation", + "Brandon Castellano", + "manual", + ), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(root_doc, 'pyscenedetect', 'PySceneDetect Documentation', [author], 1)] +man_pages = [(root_doc, "pyscenedetect", "PySceneDetect Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -134,31 +137,38 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (root_doc, 'PySceneDetect', 'PySceneDetect Documentation', author, 'PySceneDetect', - 'Python API and `scenedetect` command reference.', 'Miscellaneous'), + ( + root_doc, + "PySceneDetect", + "PySceneDetect Documentation", + author, + "PySceneDetect", + "Python API and `scenedetect` command reference.", + "Miscellaneous", + ), ] # -- Theme ------------------------------------------------- # TODO: Consider switching to sphinx_material. -html_theme = 'alabaster' +html_theme = "alabaster" html_theme_options = { - 'sidebar_width': '235px', - 'description': 'Version: [%s]' % (release), - 'show_relbar_bottom': True, - 'show_relbar_top': False, - 'github_user': 'Breakthrough', - 'github_repo': 'PySceneDetect', - 'github_type': 'star', - 'tip_bg': '#f0f6fa', - 'tip_border': '#c2dcf2', - 'hint_bg': '#f0faf0', - 'hint_border': '#d3ebdc', - 'warn_bg': '#f5ebd0', - 'warn_border': '#f2caa2', - 'attention_bg': '#f5dcdc', - 'attention_border': '#ffaaaa', - 'logo': 'pyscenedetect_logo.png', - 'logo_name': False, + "sidebar_width": "235px", + "description": "Version: [%s]" % (release), + "show_relbar_bottom": True, + "show_relbar_top": False, + "github_user": "Breakthrough", + "github_repo": "PySceneDetect", + "github_type": "star", + "tip_bg": "#f0f6fa", + "tip_border": "#c2dcf2", + "hint_bg": "#f0faf0", + "hint_border": "#d3ebdc", + "warn_bg": "#f5ebd0", + "warn_border": "#f2caa2", + "attention_bg": "#f5dcdc", + "attention_border": "#ffaaaa", + "logo": "pyscenedetect_logo.png", + "logo_name": False, } diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index cd5c6f6f..82fc297f 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -28,22 +28,21 @@ StrGenerator = ty.Generator[str, None, None] -INDENT = ' ' * 4 +INDENT = " " * 4 -PAGE_SEP = '*' * 72 -TITLE_SEP = '=' * 72 -HEADING_SEP = '-' * 72 +PAGE_SEP = "*" * 72 +TITLE_SEP = "=" * 72 +HEADING_SEP = "-" * 72 OPTION_HELP_OVERRIDES = { - 'scenedetect': { - 'config': - 'Path to config file. See :ref:`config file reference ` for details.' + "scenedetect": { + "config": "Path to config file. See :ref:`config file reference ` for details." }, } -TITLE_LEVELS = ['*', '=', '-'] +TITLE_LEVELS = ["*", "=", "-"] -INFO_COMMANDS = ['help', 'about', 'version'] +INFO_COMMANDS = ["help", "about", "version"] INFO_COMMAND_OVERRIDE = """ .. _command-help: @@ -73,26 +72,28 @@ def patch_help(s: str, commands: ty.List[str]) -> str: # Patch some TODOs still not handled correctly below. pos = 0 while True: - pos = s.find('global option :option:', pos) + pos = s.find("global option :option:", pos) if pos < 0: break - pos = s.find('<-', pos) + pos = s.find("<-", pos) assert pos > 0 - s = s[:pos + 1] + 'scenedetect ' + s[pos + 1:] + s = s[: pos + 1] + "scenedetect " + s[pos + 1 :] for command in [command for command in commands if not command in INFO_COMMANDS]: + def add_link(_match: re.Match) -> str: - return ':ref:`%s `' % (command, command) - s = re.sub('``%s``(?!\\n)' % command, add_link, s) + return ":ref:`%s `" % (command, command) + + s = re.sub("``%s``(?!\\n)" % command, add_link, s) return s def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator: - yield '\n' + yield "\n" if level == 0: - yield TITLE_LEVELS[level] * len + '\n' - yield s + '\n' - yield TITLE_LEVELS[level] * len + '\n\n' + yield TITLE_LEVELS[level] * len + "\n" + yield s + "\n" + yield TITLE_LEVELS[level] * len + "\n\n" @dataclass @@ -103,11 +104,11 @@ class ReplaceWithReference: def transform_backquotes(s: str) -> str: - return s.replace('``', '`').replace('`', '``') + return s.replace("``", "`").replace("`", "``") def add_backquotes(match: re.Match) -> str: - return '``%s``' % match.string[match.start():match.end()] + return "``%s``" % match.string[match.start() : match.end()] def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: @@ -115,13 +116,13 @@ def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: references to any found options.""" def _add_backquotes(s: re.Match) -> str: - to_add: str = s.string[s.start():s.end()] - flag = re.search('-+[\w-]+[^\.\=\s\/]*', to_add) - if flag is not None and flag.string[flag.start():flag.end()] in refs: + to_add: str = s.string[s.start() : s.end()] + flag = re.search("-+[\w-]+[^\.\=\s\/]*", to_add) + if flag is not None and flag.string[flag.start() : flag.end()] in refs: # add cross reference - cross_ref = flag.string[flag.start():flag.end()] - option = s.string[s.start():s.end()] - return ':option:`%s <%s>`' % (option, cross_ref) + cross_ref = flag.string[flag.start() : flag.end()] + option = s.string[s.start() : s.end()] + return ":option:`%s <%s>`" % (option, cross_ref) else: return add_backquotes(s) @@ -129,13 +130,13 @@ def _add_backquotes(s: re.Match) -> str: def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: - default = re.search('\[default: .*\]', s) + default = re.search("\[default: .*\]", s) if default is not None: span = default.span() assert span[1] == len(s) - s, default = s[:span[0]].strip(), s[span[0]:span[1]][len('[default: '):-1] + s, default = s[: span[0]].strip(), s[span[0] : span[1]][len("[default: ") : -1] # Double-quote any default values that contain spaces. - if ' ' in default and not '"' in default and not ',' in default: + if " " in default and not '"' in default and not "," in default: default = '"%s"' % default return (s, default) @@ -145,57 +146,68 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. # -c/--command - s = re.sub('-\w/--\w[\w-]*', transform, s) + s = re.sub("-\w/--\w[\w-]*", transform, s) # --arg=value, --arg=1.2.3, --arg=1,2,3 s = re.sub('-+[\w-]+=[^"\s\)]+(? StrGenerator: +def format_option( + command: click.Command, opt: click.Option, flags: ty.List[str] +) -> StrGenerator: if isinstance(opt, click.Argument): - yield '\n.. option:: %s\n' % opt.name + yield "\n.. option:: %s\n" % opt.name return - yield '\n.. option:: %s\n' % ', '.join(arg if opt.metavar is None else '%s %s' % - (arg, opt.metavar) - for arg in sorted(opt.opts, reverse=True)) - - help = OPTION_HELP_OVERRIDES[command.name][ - opt.name] if command.name in OPTION_HELP_OVERRIDES and opt.name in OPTION_HELP_OVERRIDES[ - command.name] else opt.help.strip() + yield "\n.. option:: %s\n" % ", ".join( + arg if opt.metavar is None else "%s %s" % (arg, opt.metavar) + for arg in sorted(opt.opts, reverse=True) + ) + + help = ( + OPTION_HELP_OVERRIDES[command.name][opt.name] + if command.name in OPTION_HELP_OVERRIDES + and opt.name in OPTION_HELP_OVERRIDES[command.name] + else opt.help.strip() + ) # TODO: Make metavars link to the option as well. help, default = extract_default_value(help) help = transform_add_option_refs(help, flags) - yield '\n %s\n' % help + yield "\n %s\n" % help if default is not None: - yield '\n Default: ``%s``\n' % default + yield "\n Default: ``%s``\n" % default -def generate_command_help(ctx: click.Context, - command: click.Command, - parent_name: ty.Optional[str] = None) -> StrGenerator: +def generate_command_help( + ctx: click.Context, command: click.Command, parent_name: ty.Optional[str] = None +) -> StrGenerator: # TODO: Add references to long options. Requires splitting out examples. # TODO: Add references to subcommands. Need to add actual refs, since programs can't be ref'd. # TODO: Handle dollar signs in examples by having both escaped and unescaped versions - yield '\n.. _command-%s:\n' % command.name - yield '\n.. program:: %s\n\n' % ( - command.name if parent_name is None else '%s %s' % (parent_name, command.name)) + yield "\n.. _command-%s:\n" % command.name + yield "\n.. program:: %s\n\n" % ( + command.name if parent_name is None else "%s %s" % (parent_name, command.name) + ) if parent_name: - yield from generate_title('``%s``' % command.name, 1) + yield from generate_title("``%s``" % command.name, 1) replacements = [ - opt for opts in [param.opts for param in command.params if hasattr(param, 'opts')] + opt + for opts in [param.opts for param in command.params if hasattr(param, "opts")] for opt in opts ] help = command.help - help = help.replace('Examples:\n', - ''.join(generate_title('Examples', 0 if not parent_name else 2))) - help = help.replace('\b\n', '') - help = help.format(scenedetect='scenedetect', scenedetect_with_video='scenedetect -i video.mp4') + help = help.replace( + "Examples:\n", "".join(generate_title("Examples", 0 if not parent_name else 2)) + ) + help = help.replace("\b\n", "") + help = help.format( + scenedetect="scenedetect", scenedetect_with_video="scenedetect -i video.mp4" + ) help = transform_backquotes(help) help = transform_add_option_refs(help, replacements) @@ -203,20 +215,19 @@ def generate_command_help(ctx: click.Context, if line.startswith(INDENT): indent = line.count(INDENT) line = line.strip() - yield '%s``%s``\n' % (indent * INDENT, line) if line else '\n' + yield "%s``%s``\n" % (indent * INDENT, line) if line else "\n" else: - yield '%s\n' % line + yield "%s\n" % line if command.params: - yield '\n' - yield from generate_title('Options', 0 if not parent_name else 2) + yield "\n" + yield from generate_title("Options", 0 if not parent_name else 2) for param in command.params: yield from format_option(command, param, replacements) - yield '\n' + yield "\n" def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGenerator: - processed = set() for info_command in INFO_COMMANDS: @@ -224,19 +235,24 @@ def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGener processed.add(info_command) yield INFO_COMMAND_OVERRIDE - yield from generate_title('Detectors', 0) - detectors = [command for command in commands if command.startswith('detect-')] + yield from generate_title("Detectors", 0) + detectors = [command for command in commands if command.startswith("detect-")] for detector in detectors: - yield from generate_command_help(ctx, ctx.command.get_command(ctx, detector), ctx.info_name) + yield from generate_command_help( + ctx, ctx.command.get_command(ctx, detector), ctx.info_name + ) processed.add(detector) - yield from generate_title('Commands', 0) + yield from generate_title("Commands", 0) output_commands = [ - command for command in commands - if (not command.startswith('detect-') and not command in INFO_COMMANDS) + command + for command in commands + if (not command.startswith("detect-") and not command in INFO_COMMANDS) ] for command in output_commands: - yield from generate_command_help(ctx, ctx.command.get_command(ctx, command), ctx.info_name) + yield from generate_command_help( + ctx, ctx.command.get_command(ctx, command), ctx.info_name + ) processed.add(command) assert set(commands) == processed @@ -246,22 +262,22 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) - #ctx.to_info_dict lacks metavar so we have to use the context directly. + # ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ - generate_title('``scenedetect`` 🎬 Command', level=0), + generate_title("``scenedetect`` 🎬 Command", level=0), generate_command_help(ctx, ctx.command), generate_subcommands(ctx, commands), ] lines = [] for action in actions: lines.extend(action) - return ''.join(lines), commands + return "".join(lines), commands def main(): help, commands = create_help() help = patch_help(help, commands) - with open('docs/cli.rst', 'wb') as f: + with open("docs/cli.rst", "wb") as f: f.write(help.encode()) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 160bee61..a3b7402f 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -27,7 +27,7 @@ except ModuleNotFoundError as ex: raise ModuleNotFoundError( "OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python", - name='cv2', + name="cv2", ) from ex # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. @@ -36,9 +36,20 @@ from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.scene_detector import SceneDetector -from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HistogramDetector, HashDetector -from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, - VideoStreamMoviePy, VideoCaptureAdapter) +from scenedetect.detectors import ( + ContentDetector, + AdaptiveDetector, + ThresholdDetector, + HistogramDetector, + HashDetector, +) +from scenedetect.backends import ( + AVAILABLE_BACKENDS, + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + VideoCaptureAdapter, +) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt from scenedetect.scene_manager import SceneManager, save_images @@ -47,16 +58,16 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.4' +__version__ = "0.6.4" init_logger() -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") def open_video( path: str, framerate: Optional[float] = None, - backend: str = 'opencv', + backend: str = "opencv", **kwargs, ) -> VideoStream: """Open a video at the given path. If `backend` is specified but not available on the current @@ -83,22 +94,24 @@ def open_video( if backend in AVAILABLE_BACKENDS: backend_type = AVAILABLE_BACKENDS[backend] try: - logger.debug('Opening video with %s...', backend_type.BACKEND_NAME) + logger.debug("Opening video with %s...", backend_type.BACKEND_NAME) return backend_type(path, framerate, **kwargs) except VideoOpenFailure as ex: - logger.warning('Failed to open video with %s: %s', backend_type.BACKEND_NAME, str(ex)) + logger.warning( + "Failed to open video with %s: %s", backend_type.BACKEND_NAME, str(ex) + ) if backend == VideoStreamCv2.BACKEND_NAME: raise last_error = ex else: - logger.warning('Backend %s not available.', backend) + logger.warning("Backend %s not available.", backend) # Fallback to OpenCV if `backend` is unavailable, or specified backend failed to open `path`. backend_type = VideoStreamCv2 - logger.warning('Trying another backend: %s', backend_type.BACKEND_NAME) + logger.warning("Trying another backend: %s", backend_type.BACKEND_NAME) try: return backend_type(path, framerate) except VideoOpenFailure as ex: - logger.debug('Failed to open video: %s', str(ex)) + logger.debug("Failed to open video: %s", str(ex)) if last_error is None: last_error = ex # Propagate any exceptions raised from specified backend, instead of errors from the fallback. diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 7a8cfb9a..23481eee 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -27,35 +27,38 @@ def main(): cli_ctx = CliContext() try: # Process command line arguments and subcommands to initialize the context. - scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. + scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. except SystemExit as exit: - help_command = any(arg in sys.argv for arg in ['-h', '--help']) + help_command = any(arg in sys.argv for arg in ["-h", "--help"]) if help_command or exit.code != 0: raise # If we get here, processing the command line and loading the context worked. Let's run # the controller if we didn't process any help requests. - logger = getLogger('pyscenedetect') + logger = getLogger("pyscenedetect") # Ensure log messages don't conflict with any progress bars. If we're in quiet mode, where # no progress bars get created, we instead create a fake context manager. This is done here # to avoid needing a separate context manager at each point a progress bar is created. - log_redirect = FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm( - loggers=[logger]) + log_redirect = ( + FakeTqdmLoggingRedirect() + if cli_ctx.quiet_mode + else logging_redirect_tqdm(loggers=[logger]) + ) with log_redirect: try: run_scenedetect(cli_ctx) except KeyboardInterrupt: - logger.info('Stopped.') + logger.info("Stopped.") if __debug__: raise except BaseException as ex: if __debug__: raise else: - logger.critical('Unhandled exception:', exc_info=ex) + logger.critical("Unhandled exception:", exc_info=ex) raise SystemExit(1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1890b9b5..e85e44da 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,8 +27,13 @@ import click import scenedetect -from scenedetect.detectors import (AdaptiveDetector, ContentDetector, HashDetector, - HistogramDetector, ThresholdDetector) +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.platform import get_system_version_info @@ -38,9 +43,9 @@ _PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") -_LINE_SEPARATOR = '-' * 72 +_LINE_SEPARATOR = "-" * 72 # About & copyright message string shown for the 'about' CLI command (scenedetect about). _ABOUT_STRING = """ @@ -83,16 +88,16 @@ class _Command(click.Command): def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help into the formatter if it exists.""" if ctx.parent: - formatter.write(click.style('`%s` Command' % ctx.command.name, fg='cyan')) + formatter.write(click.style("`%s` Command" % ctx.command.name, fg="cyan")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg='cyan')) + formatter.write(click.style(_LINE_SEPARATOR, fg="cyan")) formatter.write_paragraph() else: - formatter.write(click.style(_LINE_SEPARATOR, fg='yellow')) + formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() - formatter.write(click.style('PySceneDetect Help', fg='yellow')) + formatter.write(click.style("PySceneDetect Help", fg="yellow")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg='yellow')) + formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() self.format_usage(ctx, formatter) @@ -100,12 +105,18 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) - def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + def format_help_text( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: """Writes the help text to the formatter if it exists.""" if self.help: - base_command = (ctx.parent.info_name if ctx.parent is not None else ctx.info_name) + base_command = ( + ctx.parent.info_name if ctx.parent is not None else ctx.info_name + ) formatted_help = self.help.format( - scenedetect=base_command, scenedetect_with_video='%s -i video.mp4' % base_command) + scenedetect=base_command, + scenedetect_with_video="%s -i video.mp4" % base_command, + ) text = inspect.cleandoc(formatted_help).partition("\f")[0] formatter.write_paragraph() formatter.write_text(text) @@ -120,6 +131,7 @@ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> N class _CommandGroup(_Command, click.Group): """Custom formatting for command groups.""" + pass @@ -127,133 +139,139 @@ def _print_command_help(ctx: click.Context, command: click.Command): """Print help/usage for a given command. Modifies `ctx` in-place.""" ctx.info_name = command.name ctx.command = command - click.echo('') + click.echo("") click.echo(command.get_help(ctx)) @click.group( cls=_CommandGroup, chain=True, - context_settings=dict(help_option_names=['-h', '--help']), + context_settings=dict(help_option_names=["-h", "--help"]), invoke_without_command=True, - epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""" + epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""", ) # *NOTE*: Although input is required, we cannot mark it as `required=True`, otherwise we will reject # commands of the form `scenedetect detect-content --help`. @click.option( - '--input', - '-i', + "--input", + "-i", multiple=False, required=False, - metavar='VIDEO', + metavar="VIDEO", type=click.STRING, - help='[REQUIRED] Input video file. Image sequences and URLs are supported.', + help="[REQUIRED] Input video file. Image sequences and URLs are supported.", ) @click.option( - '--output', - '-o', + "--output", + "-o", multiple=False, required=False, - metavar='DIR', + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help='Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s' + help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s" % (USER_CONFIG.get_help_string("global", "output", show_default=False)), ) @click.option( - '--config', - '-c', - metavar='FILE', + "--config", + "-c", + metavar="FILE", type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help='Path to config file. If unset, tries to load config from %s' % (CONFIG_FILE_PATH), + help="Path to config file. If unset, tries to load config from %s" + % (CONFIG_FILE_PATH), ) @click.option( - '--stats', - '-s', - metavar='CSV', + "--stats", + "-s", + metavar="CSV", type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.', + help="Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.", ) @click.option( - '--framerate', - '-f', - metavar='FPS', + "--framerate", + "-f", + metavar="FPS", type=click.FLOAT, default=None, - help='Override framerate with value as frames/sec.', + help="Override framerate with value as frames/sec.", ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s' + help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s" % USER_CONFIG.get_help_string("global", "min-scene-len"), ) @click.option( - '--drop-short-scenes', + "--drop-short-scenes", is_flag=True, flag_value=True, - help='Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s' % - (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), + help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" + % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), ) @click.option( - '--merge-last-scene', + "--merge-last-scene", is_flag=True, flag_value=True, - help='Merge last scene with previous if shorter than -m/--min-scene-len.%s' % - (USER_CONFIG.get_help_string('global', 'merge-last-scene')), + help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" + % (USER_CONFIG.get_help_string("global", "merge-last-scene")), ) @click.option( - '--backend', - '-b', - metavar='BACKEND', + "--backend", + "-b", + metavar="BACKEND", type=click.Choice(CHOICE_MAP["global"]["backend"]), default=None, - help='Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s' - % (', '.join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), + help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" + % ( + ", ".join(AVAILABLE_BACKENDS.keys()), + USER_CONFIG.get_help_string("global", "backend"), + ), ) @click.option( - '--downscale', - '-d', - metavar='N', + "--downscale", + "-d", + metavar="N", type=click.INT, default=None, - help='Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s' + help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s" % (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), ) @click.option( - '--frame-skip', - '-fs', - metavar='N', + "--frame-skip", + "-fs", + metavar="N", type=click.INT, default=None, - help='Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s' + help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s" % USER_CONFIG.get_help_string("global", "frame-skip"), ) @click.option( - '--verbosity', - '-v', - metavar='LEVEL', - type=click.Choice(CHOICE_MAP['global']['verbosity'], False), + "--verbosity", + "-v", + metavar="LEVEL", + type=click.Choice(CHOICE_MAP["global"]["verbosity"], False), default=None, - help='Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s' % - (', '.join(CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string( - "global", "verbosity")), + help="Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s" + % ( + ", ".join(CHOICE_MAP["global"]["verbosity"]), + USER_CONFIG.get_help_string("global", "verbosity"), + ), ) @click.option( - '--logfile', - '-l', - metavar='FILE', + "--logfile", + "-l", + metavar="FILE", type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Save debug log to FILE. Appends to existing file if present.', + help="Save debug log to FILE. Appends to existing file if present.", ) @click.option( - '--quiet', - '-q', + "--quiet", + "-q", is_flag=True, flag_value=True, - help='Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.', + help="Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.", ) @click.pass_context # pylint: disable=redefined-builtin @@ -276,30 +294,30 @@ def scenedetect( ): """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: - {scenedetect_with_video} [detector] [commands] + {scenedetect_with_video} [detector] [commands] -For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. + For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. -Examples: + Examples: -Split video wherever a new scene is detected: + Split video wherever a new scene is detected: - {scenedetect_with_video} split-video + {scenedetect_with_video} split-video -Save scene list in CSV format with images at the start, middle, and end of each scene: + Save scene list in CSV format with images at the start, middle, and end of each scene: - {scenedetect_with_video} list-scenes save-images + {scenedetect_with_video} list-scenes save-images -Skip the first 10 seconds of the input video: + Skip the first 10 seconds of the input video: - {scenedetect_with_video} time --start 10s detect-content + {scenedetect_with_video} time --start 10s detect-content -Show summary of all options and commands: + Show summary of all options and commands: - {scenedetect} --help + {scenedetect} --help -Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. -""" + Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_options( input_path=input, @@ -323,9 +341,9 @@ def scenedetect( # pylint: enable=redefined-builtin -@click.command('help', cls=_Command) +@click.command("help", cls=_Command) @click.argument( - 'command_name', + "command_name", required=False, type=click.STRING, ) @@ -339,11 +357,11 @@ def help_command(ctx: click.Context, command_name: str): if command_name is not None: if not command_name in all_commands: error_strs = [ - 'unknown command. List of valid commands:', - ' %s' % ', '.join(sorted(all_commands)) + "unknown command. List of valid commands:", + " %s" % ", ".join(sorted(all_commands)), ] - raise click.BadParameter('\n'.join(error_strs), param_hint='command') - click.echo('') + raise click.BadParameter("\n".join(error_strs), param_hint="command") + click.echo("") _print_command_help(ctx, parent_command.get_command(ctx, command_name)) else: click.echo(ctx.parent.get_help()) @@ -352,53 +370,53 @@ def help_command(ctx: click.Context, command_name: str): ctx.exit() -@click.command('about', cls=_Command, add_help_option=False) +@click.command("about", cls=_Command, add_help_option=False) @click.pass_context def about_command(ctx: click.Context): """Print license/copyright info.""" assert isinstance(ctx.obj, CliContext) - click.echo('') - click.echo(click.style(_LINE_SEPARATOR, fg='cyan')) - click.echo(click.style(' About PySceneDetect %s' % _PROGRAM_VERSION, fg='yellow')) - click.echo(click.style(_LINE_SEPARATOR, fg='cyan')) + click.echo("") + click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) + click.echo(click.style(" About PySceneDetect %s" % _PROGRAM_VERSION, fg="yellow")) + click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) click.echo(_ABOUT_STRING) ctx.exit() -@click.command('version', cls=_Command, add_help_option=False) +@click.command("version", cls=_Command, add_help_option=False) @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" assert isinstance(ctx.obj, CliContext) - click.echo('') + click.echo("") click.echo(get_system_version_info()) ctx.exit() -@click.command('time', cls=_Command) +@click.command("time", cls=_Command) @click.option( - '--start', - '-s', - metavar='TIMECODE', + "--start", + "-s", + metavar="TIMECODE", type=click.STRING, default=None, - help='Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).', + help="Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).", ) @click.option( - '--duration', - '-d', - metavar='TIMECODE', + "--duration", + "-d", + metavar="TIMECODE", type=click.STRING, default=None, - help='Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.', + help="Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.", ) @click.option( - '--end', - '-e', - metavar='TIMECODE', + "--end", + "-e", + metavar="TIMECODE", type=click.STRING, default=None, - help='Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration', + help="Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration", ) @click.pass_context def time_command( @@ -409,16 +427,16 @@ def time_command( ): """Set start/end/duration of input video. -Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: + Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - {scenedetect_with_video} time --end 00:01:00 + {scenedetect_with_video} time --end 00:01:00 - {scenedetect_with_video} time --duration 60.0 + {scenedetect_with_video} time --duration 60.0 -Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: + Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: - {scenedetect_with_video} time --start 0 --end 1000 -""" + {scenedetect_with_video} time --start 0 --end 1000 + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_time( start=start, @@ -432,10 +450,12 @@ def time_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange(CONFIG_MAP["detect-content"]["threshold"].min_val, - CONFIG_MAP["detect-content"]["threshold"].max_val), + type=click.FloatRange( + CONFIG_MAP["detect-content"]["threshold"].min_val, + CONFIG_MAP["detect-content"]["threshold"].max_val, + ), default=None, - help="The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to \"content_val\" in stats file.%s" + help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.%s' % (USER_CONFIG.get_help_string("detect-content", "threshold")), ) @click.option( @@ -452,7 +472,7 @@ def time_command( "-l", is_flag=True, flag_value=True, - help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w=\"0 0 1 0\".%s" + help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w="0 0 1 0".%s' % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( @@ -470,9 +490,12 @@ def time_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" % - ("" if USER_CONFIG.is_default("detect-content", "min-scene-len") else - USER_CONFIG.get_help_string("detect-content", "min-scene-len")), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" + % ( + "" + if USER_CONFIG.is_default("detect-content", "min-scene-len") + else USER_CONFIG.get_help_string("detect-content", "min-scene-len") + ), ) @click.option( "--filter-mode", @@ -480,9 +503,11 @@ def time_command( metavar="MODE", type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), default=None, - help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" % - (", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), - USER_CONFIG.get_help_string("detect-content", "filter-mode")), + help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" + % ( + ", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), + USER_CONFIG.get_help_string("detect-content", "filter-mode"), + ), ) @click.pass_context def detect_content_command( @@ -496,26 +521,26 @@ def detect_content_command( ): """Find fast cuts using differences in HSL (filtered). -For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. + For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. -Scores are calculated from several components which are also recorded in the statsfile: + Scores are calculated from several components which are also recorded in the statsfile: - - *delta_hue*: Difference between pixel hue values of adjacent frames. + - *delta_hue*: Difference between pixel hue values of adjacent frames. - - *delta_sat*: Difference between pixel saturation values of adjacent frames. + - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. -Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. + Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. -Examples: + Examples: - {scenedetect_with_video} detect-content + {scenedetect_with_video} detect-content - {scenedetect_with_video} detect-content --threshold 27.5 -""" + {scenedetect_with_video} detect-content --threshold 27.5 + """ assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_content_params( threshold=threshold, @@ -523,83 +548,87 @@ def detect_content_command( min_scene_len=min_scene_len, weights=weights, kernel_size=kernel_size, - filter_mode=filter_mode) - logger.debug('Adding detector: ContentDetector(%s)', detector_args) + filter_mode=filter_mode, + ) + logger.debug("Adding detector: ContentDetector(%s)", detector_args) ctx.obj.add_detector(ContentDetector(**detector_args)) -@click.command('detect-adaptive', cls=_Command) +@click.command("detect-adaptive", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', + "--threshold", + "-t", + metavar="VAL", type=click.FLOAT, default=None, help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.%s' - % (USER_CONFIG.get_help_string('detect-adaptive', 'threshold')), + % (USER_CONFIG.get_help_string("detect-adaptive", "threshold")), ) @click.option( - '--min-content-val', - '-c', - metavar='VAL', + "--min-content-val", + "-c", + metavar="VAL", type=click.FLOAT, default=None, - help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-content-val')), + help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' + % (USER_CONFIG.get_help_string("detect-adaptive", "min-content-val")), ) @click.option( - '--min-delta-hsv', - '-d', - metavar='VAL', + "--min-delta-hsv", + "-d", + metavar="VAL", type=click.FLOAT, default=None, - help='[DEPRECATED] Use -c/--min-content-val instead.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-delta-hsv')), + help="[DEPRECATED] Use -c/--min-content-val instead.%s" + % (USER_CONFIG.get_help_string("detect-adaptive", "min-delta-hsv")), hidden=True, ) @click.option( - '--frame-window', - '-f', - metavar='VAL', + "--frame-window", + "-f", + metavar="VAL", type=click.INT, default=None, - help='Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s' - % (USER_CONFIG.get_help_string('detect-adaptive', 'frame-window')), + help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s" + % (USER_CONFIG.get_help_string("detect-adaptive", "frame-window")), ) @click.option( - '--weights', - '-w', + "--weights", + "-w", type=(float, float, float, float), default=None, help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".%s' % (USER_CONFIG.get_help_string("detect-content", "weights")), ) @click.option( - '--luma-only', - '-l', + "--luma-only", + "-l", is_flag=True, flag_value=True, help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".%s' % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( - '--kernel-size', - '-k', - metavar='N', + "--kernel-size", + "-k", + metavar="N", type=click.INT, default=None, - help='Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s' + help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s" % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' - % ('' if USER_CONFIG.is_default('detect-adaptive', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-adaptive', 'min-scene-len')), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + % ( + "" + if USER_CONFIG.is_default("detect-adaptive", "min-scene-len") + else USER_CONFIG.get_help_string("detect-adaptive", "min-scene-len") + ), ) @click.pass_context def detect_adaptive_command( @@ -615,14 +644,14 @@ def detect_adaptive_command( ): """Find fast cuts using diffs in HSL colorspace (rolling average). -Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. + Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. -Examples: + Examples: - {scenedetect_with_video} detect-adaptive + {scenedetect_with_video} detect-adaptive - {scenedetect_with_video} detect-adaptive --threshold 3.2 -""" + {scenedetect_with_video} detect-adaptive --threshold 3.2 + """ assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_adaptive_params( threshold=threshold, @@ -634,48 +663,55 @@ def detect_adaptive_command( weights=weights, kernel_size=kernel_size, ) - logger.debug('Adding detector: AdaptiveDetector(%s)', detector_args) + logger.debug("Adding detector: AdaptiveDetector(%s)", detector_args) ctx.obj.add_detector(AdaptiveDetector(**detector_args)) -@click.command('detect-threshold', cls=_Command) +@click.command("detect-threshold", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['threshold'].min_val, - CONFIG_MAP['detect-threshold']['threshold'].max_val), + "--threshold", + "-t", + metavar="VAL", + type=click.FloatRange( + CONFIG_MAP["detect-threshold"]["threshold"].min_val, + CONFIG_MAP["detect-threshold"]["threshold"].max_val, + ), default=None, help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.%s' - % (USER_CONFIG.get_help_string('detect-threshold', 'threshold')), + % (USER_CONFIG.get_help_string("detect-threshold", "threshold")), ) @click.option( - '--fade-bias', - '-f', - metavar='PERCENT', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['fade-bias'].min_val, - CONFIG_MAP['detect-threshold']['fade-bias'].max_val), + "--fade-bias", + "-f", + metavar="PERCENT", + type=click.FloatRange( + CONFIG_MAP["detect-threshold"]["fade-bias"].min_val, + CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, + ), default=None, - help='Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s' - % (USER_CONFIG.get_help_string('detect-threshold', 'fade-bias')), + help="Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s" + % (USER_CONFIG.get_help_string("detect-threshold", "fade-bias")), ) @click.option( - '--add-last-scene', - '-l', + "--add-last-scene", + "-l", is_flag=True, flag_value=True, - help='If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s' - % (USER_CONFIG.get_help_string('detect-threshold', 'add-last-scene')), + help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s" + % (USER_CONFIG.get_help_string("detect-threshold", "add-last-scene")), ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' - % ('' if USER_CONFIG.is_default('detect-threshold', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-threshold', 'min-scene-len')), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + % ( + "" + if USER_CONFIG.is_default("detect-threshold", "min-scene-len") + else USER_CONFIG.get_help_string("detect-threshold", "min-scene-len") + ), ) @click.pass_context def detect_threshold_command( @@ -687,14 +723,14 @@ def detect_threshold_command( ): """Find fade in/out using averaging. -Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. + Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. -Examples: + Examples: - {scenedetect_with_video} detect-threshold + {scenedetect_with_video} detect-threshold - {scenedetect_with_video} detect-threshold --threshold 15 -""" + {scenedetect_with_video} detect-threshold --threshold 15 + """ assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_threshold_params( threshold=threshold, @@ -702,7 +738,7 @@ def detect_threshold_command( add_last_scene=add_last_scene, min_scene_len=min_scene_len, ) - logger.debug('Adding detector: ThresholdDetector(%s)', detector_args) + logger.debug("Adding detector: ThresholdDetector(%s)", detector_args) ctx.obj.add_detector(ThresholdDetector(**detector_args)) @@ -711,21 +747,27 @@ def detect_threshold_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange(CONFIG_MAP["detect-hist"]["threshold"].min_val, - CONFIG_MAP["detect-hist"]["threshold"].max_val), + type=click.FloatRange( + CONFIG_MAP["detect-hist"]["threshold"].min_val, + CONFIG_MAP["detect-hist"]["threshold"].max_val, + ), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " - "values are more sensitive to changes.%s" % - (USER_CONFIG.get_help_string("detect-hist", "threshold"))) + "values are more sensitive to changes.%s" + % (USER_CONFIG.get_help_string("detect-hist", "threshold")), +) @click.option( "--bins", "-b", metavar="NUM", - type=click.IntRange(CONFIG_MAP["detect-hist"]["bins"].min_val, - CONFIG_MAP["detect-hist"]["bins"].max_val), + type=click.IntRange( + CONFIG_MAP["detect-hist"]["bins"].min_val, + CONFIG_MAP["detect-hist"]["bins"].max_val, + ), default=None, - help="The number of bins to use for the histogram calculation.%s" % - (USER_CONFIG.get_help_string("detect-hist", "bins"))) + help="The number of bins to use for the histogram calculation.%s" + % (USER_CONFIG.get_help_string("detect-hist", "bins")), +) @click.option( "--min-scene-len", "-m", @@ -734,29 +776,38 @@ def detect_threshold_command( default=None, help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," - " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % - ("" if USER_CONFIG.is_default("detect-hist", "min-scene-len") else USER_CONFIG.get_help_string( - "detect-hist", "min-scene-len"))) + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" + % ( + "" + if USER_CONFIG.is_default("detect-hist", "min-scene-len") + else USER_CONFIG.get_help_string("detect-hist", "min-scene-len") + ), +) @click.pass_context -def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], - min_scene_len: Optional[str]): +def detect_hist_command( + ctx: click.Context, + threshold: Optional[float], + bins: Optional[int], + min_scene_len: Optional[str], +): """Find fast cuts by differencing YUV histograms. -Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. + Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. -Saved as the `hist_diff` metric in a statsfile. + Saved as the `hist_diff` metric in a statsfile. -Examples: + Examples: - {scenedetect_with_video} detect-hist + {scenedetect_with_video} detect-hist - {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 + {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hist_params( - threshold=threshold, bins=bins, min_scene_len=min_scene_len) + threshold=threshold, bins=bins, min_scene_len=min_scene_len + ) logger.debug("Adding detector: HistogramDetector(%s)", detector_args) ctx.obj.add_detector(HistogramDetector(**detector_args)) @@ -766,31 +817,44 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op "--threshold", "-t", metavar="VAL", - type=click.FloatRange(CONFIG_MAP["detect-hash"]["threshold"].min_val, - CONFIG_MAP["detect-hash"]["threshold"].max_val), + type=click.FloatRange( + CONFIG_MAP["detect-hash"]["threshold"].min_val, + CONFIG_MAP["detect-hash"]["threshold"].max_val, + ), default=None, - help=("Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " - "more sensitive to changes.%s" % - (USER_CONFIG.get_help_string("detect-hash", "threshold")))) + help=( + "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " + "more sensitive to changes.%s" + % (USER_CONFIG.get_help_string("detect-hash", "threshold")) + ), +) @click.option( "--size", "-s", metavar="SIZE", - type=click.IntRange(CONFIG_MAP["detect-hash"]["size"].min_val, - CONFIG_MAP["detect-hash"]["size"].max_val), + type=click.IntRange( + CONFIG_MAP["detect-hash"]["size"].min_val, + CONFIG_MAP["detect-hash"]["size"].max_val, + ), default=None, - help="Size of square of low frequency data to include from the discrete cosine transform.%s" % - (USER_CONFIG.get_help_string("detect-hash", "size"))) + help="Size of square of low frequency data to include from the discrete cosine transform.%s" + % (USER_CONFIG.get_help_string("detect-hash", "size")), +) @click.option( "--lowpass", "-l", metavar="FRAC", - type=click.IntRange(CONFIG_MAP["detect-hash"]["lowpass"].min_val, - CONFIG_MAP["detect-hash"]["lowpass"].max_val), + type=click.IntRange( + CONFIG_MAP["detect-hash"]["lowpass"].min_val, + CONFIG_MAP["detect-hash"]["lowpass"].max_val, + ), default=None, - help=("How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " - "the frequency data, 4 means only keep 1/4, etc...%s" % - (USER_CONFIG.get_help_string("detect-hash", "lowpass")))) + help=( + "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " + "the frequency data, 4 means only keep 1/4, etc...%s" + % (USER_CONFIG.get_help_string("detect-hash", "lowpass")) + ), +) @click.option( "--min-scene-len", "-m", @@ -799,97 +863,111 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op default=None, help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," - " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % - ("" if USER_CONFIG.is_default("detect-hash", "min-scene-len") else USER_CONFIG.get_help_string( - "detect-hash", "min-scene-len"))) + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" + % ( + "" + if USER_CONFIG.is_default("detect-hash", "min-scene-len") + else USER_CONFIG.get_help_string("detect-hash", "min-scene-len") + ), +) @click.pass_context -def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], - lowpass: Optional[int], min_scene_len: Optional[str]): +def detect_hash_command( + ctx: click.Context, + threshold: Optional[float], + size: Optional[int], + lowpass: Optional[int], + min_scene_len: Optional[str], +): """Find fast cuts using perceptual hashing. -The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. + The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. -Saved as the `hash_dist` metric in a statsfile. + Saved as the `hash_dist` metric in a statsfile. -Examples: + Examples: - {scenedetect_with_video} detect-hash + {scenedetect_with_video} detect-hash - {scenedetect_with_video} detect-hash --size 32 --lowpass 3 + {scenedetect_with_video} detect-hash --size 32 --lowpass 3 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hash_params( - threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len) + threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len + ) logger.debug("Adding detector: HashDetector(%s)", detector_args) ctx.obj.add_detector(HashDetector(**detector_args)) -@click.command('load-scenes', cls=_Command) +@click.command("load-scenes", cls=_Command) @click.option( - '--input', - '-i', + "--input", + "-i", multiple=False, - metavar='FILE', + metavar="FILE", required=True, type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=True), - help='Scene list to read cut information from.') + help="Scene list to read cut information from.", +) @click.option( - '--start-col-name', - '-c', - metavar='STRING', + "--start-col-name", + "-c", + metavar="STRING", type=click.STRING, default=None, - help='Name of column used to mark scene cuts.%s' % - (USER_CONFIG.get_help_string('load-scenes', 'start-col-name'))) + help="Name of column used to mark scene cuts.%s" + % (USER_CONFIG.get_help_string("load-scenes", "start-col-name")), +) @click.pass_context -def load_scenes_command(ctx: click.Context, input: Optional[str], start_col_name: Optional[str]): +def load_scenes_command( + ctx: click.Context, input: Optional[str], start_col_name: Optional[str] +): """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). -Examples: + Examples: - {scenedetect_with_video} load-scenes -i scenes.csv + {scenedetect_with_video} load-scenes -i scenes.csv - {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" -""" + {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" + """ assert isinstance(ctx.obj, CliContext) - logger.debug('Loading scenes from %s (start_col_name = %s)', input, start_col_name) + logger.debug("Loading scenes from %s (start_col_name = %s)", input, start_col_name) ctx.obj.handle_load_scenes(input=input, start_col_name=start_col_name) -@click.command('export-html', cls=_Command) +@click.command("export-html", cls=_Command) @click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.html', + "--filename", + "-f", + metavar="NAME", + default="$VIDEO_NAME-Scenes.html", type=click.STRING, - help='Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s' - % (USER_CONFIG.get_help_string('export-html', 'filename')), + help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s" + % (USER_CONFIG.get_help_string("export-html", "filename")), ) @click.option( - '--no-images', + "--no-images", is_flag=True, flag_value=True, - help='Export the scene list including or excluding the saved images.%s' % - (USER_CONFIG.get_help_string('export-html', 'no-images')), + help="Export the scene list including or excluding the saved images.%s" + % (USER_CONFIG.get_help_string("export-html", "no-images")), ) @click.option( - '--image-width', - '-w', - metavar='pixels', + "--image-width", + "-w", + metavar="pixels", type=click.INT, - help='Width in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-width', show_default=False)), + help="Width in pixels of the images in the resulting HTML table.%s" + % (USER_CONFIG.get_help_string("export-html", "image-width", show_default=False)), ) @click.option( - '--image-height', - '-h', - metavar='pixels', + "--image-height", + "-h", + metavar="pixels", type=click.INT, - help='Height in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-height', show_default=False)), + help="Height in pixels of the images in the resulting HTML table.%s" + % (USER_CONFIG.get_help_string("export-html", "image-height", show_default=False)), ) @click.pass_context def export_html_command( @@ -909,46 +987,47 @@ def export_html_command( ) -@click.command('list-scenes', cls=_Command) +@click.command("list-scenes", cls=_Command) @click.option( - '--output', - '-o', - metavar='DIR', + "--output", + "-o", + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'output', show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output if set.%s" + % (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)), ) @click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.csv', + "--filename", + "-f", + metavar="NAME", + default="$VIDEO_NAME-Scenes.csv", type=click.STRING, - help='Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\$VIDEO_NAME-Scenes.csv).%s' - % (USER_CONFIG.get_help_string('list-scenes', 'filename')), + help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\$VIDEO_NAME-Scenes.csv).%s" + % (USER_CONFIG.get_help_string("list-scenes", "filename")), ) @click.option( - '--no-output-file', - '-n', + "--no-output-file", + "-n", is_flag=True, flag_value=True, - help='Only print scene list.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'no-output-file')), + help="Only print scene list.%s" + % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), ) @click.option( - '--quiet', - '-q', + "--quiet", + "-q", is_flag=True, flag_value=True, - help='Suppress printing scene list.%s' % (USER_CONFIG.get_help_string('list-scenes', 'quiet')), + help="Suppress printing scene list.%s" + % (USER_CONFIG.get_help_string("list-scenes", "quiet")), ) @click.option( - '--skip-cuts', - '-s', + "--skip-cuts", + "-s", is_flag=True, flag_value=True, - help='Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'skip-cuts')), + help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s" + % (USER_CONFIG.get_help_string("list-scenes", "skip-cuts")), ) @click.pass_context def list_scenes_command( @@ -970,84 +1049,88 @@ def list_scenes_command( ) -@click.command('split-video', cls=_Command) +@click.command("split-video", cls=_Command) @click.option( - '--output', - '-o', - metavar='DIR', + "--output", + "-o", + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('split-video', 'output', show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output if set.%s" + % (USER_CONFIG.get_help_string("split-video", "output", show_default=False)), ) @click.option( - '--filename', - '-f', - metavar='NAME', + "--filename", + "-f", + metavar="NAME", default=None, type=click.STRING, - help='File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s' - % (USER_CONFIG.get_help_string('split-video', 'filename')), + help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" + % (USER_CONFIG.get_help_string("split-video", "filename")), ) @click.option( - '--quiet', - '-q', + "--quiet", + "-q", is_flag=True, flag_value=True, - help='Hide output from external video splitting tool.%s' % - (USER_CONFIG.get_help_string('split-video', 'quiet')), + help="Hide output from external video splitting tool.%s" + % (USER_CONFIG.get_help_string("split-video", "quiet")), ) @click.option( - '--copy', - '-c', + "--copy", + "-c", is_flag=True, flag_value=True, - help="Copy instead of re-encode. Faster but less precise.%s" % - (USER_CONFIG.get_help_string('split-video', 'copy')), + help="Copy instead of re-encode. Faster but less precise.%s" + % (USER_CONFIG.get_help_string("split-video", "copy")), ) @click.option( - '--high-quality', - '-hq', + "--high-quality", + "-hq", is_flag=True, flag_value=True, - help='Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s' - % (USER_CONFIG.get_help_string('split-video', 'high-quality')), + help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s" + % (USER_CONFIG.get_help_string("split-video", "high-quality")), ) @click.option( - '--rate-factor', - '-crf', - metavar='RATE', + "--rate-factor", + "-crf", + metavar="RATE", default=None, - type=click.IntRange(CONFIG_MAP['split-video']['rate-factor'].min_val, - CONFIG_MAP['split-video']['rate-factor'].max_val), - help='Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s' - % (USER_CONFIG.get_help_string('split-video', 'rate-factor')), + type=click.IntRange( + CONFIG_MAP["split-video"]["rate-factor"].min_val, + CONFIG_MAP["split-video"]["rate-factor"].max_val, + ), + help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s" + % (USER_CONFIG.get_help_string("split-video", "rate-factor")), ) @click.option( - '--preset', - '-p', - metavar='LEVEL', + "--preset", + "-p", + metavar="LEVEL", default=None, - type=click.Choice(CHOICE_MAP['split-video']['preset']), - help='Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s' - % (', '.join( - CHOICE_MAP['split-video']['preset']), USER_CONFIG.get_help_string('split-video', 'preset')), + type=click.Choice(CHOICE_MAP["split-video"]["preset"]), + help="Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s" + % ( + ", ".join(CHOICE_MAP["split-video"]["preset"]), + USER_CONFIG.get_help_string("split-video", "preset"), + ), ) @click.option( - '--args', - '-a', - metavar='ARGS', + "--args", + "-a", + metavar="ARGS", type=click.STRING, default=None, help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.%s' - % (USER_CONFIG.get_help_string('split-video', 'args')), + % (USER_CONFIG.get_help_string("split-video", "args")), ) @click.option( - '--mkvmerge', - '-m', + "--mkvmerge", + "-m", is_flag=True, flag_value=True, - help='Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s' - % (USER_CONFIG.get_help_string('split-video', 'mkvmerge')), + help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s" + % (USER_CONFIG.get_help_string("split-video", "mkvmerge")), ) @click.pass_context def split_video_command( @@ -1064,14 +1147,14 @@ def split_video_command( ): """Split input video using ffmpeg or mkvmerge. -Examples: + Examples: - {scenedetect_with_video} split-video + {scenedetect_with_video} split-video - {scenedetect_with_video} split-video --copy + {scenedetect_with_video} split-video --copy - {scenedetect_with_video} split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER -""" + {scenedetect_with_video} split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_split_video( output=output, @@ -1086,108 +1169,108 @@ def split_video_command( ) -@click.command('save-images', cls=_Command) +@click.command("save-images", cls=_Command) @click.option( - '--output', - '-o', - metavar='DIR', + "--output", + "-o", + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory for images. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('save-images', 'output', show_default=False)), + help="Output directory for images. Overrides global option -o/--output if set.%s" + % (USER_CONFIG.get_help_string("save-images", "output", show_default=False)), ) @click.option( - '--filename', - '-f', - metavar='NAME', + "--filename", + "-f", + metavar="NAME", default=None, type=click.STRING, - help='Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s' - % (USER_CONFIG.get_help_string('save-images', 'filename')), + help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" + % (USER_CONFIG.get_help_string("save-images", "filename")), ) @click.option( - '--num-images', - '-n', - metavar='N', + "--num-images", + "-n", + metavar="N", default=None, type=click.INT, - help='Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s' - % (USER_CONFIG.get_help_string('save-images', 'num-images')), + help="Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s" + % (USER_CONFIG.get_help_string("save-images", "num-images")), ) @click.option( - '--jpeg', - '-j', + "--jpeg", + "-j", is_flag=True, flag_value=True, - help='Set output format to JPEG (default).%s' % - (USER_CONFIG.get_help_string('save-images', 'format', show_default=False)), + help="Set output format to JPEG (default).%s" + % (USER_CONFIG.get_help_string("save-images", "format", show_default=False)), ) @click.option( - '--webp', - '-w', + "--webp", + "-w", is_flag=True, flag_value=True, - help='Set output format to WebP', + help="Set output format to WebP", ) @click.option( - '--quality', - '-q', - metavar='Q', + "--quality", + "-q", + metavar="Q", default=None, type=click.IntRange(0, 100), - help='JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s' - % (USER_CONFIG.get_help_string('save-images', 'quality', show_default=False)), + help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s" + % (USER_CONFIG.get_help_string("save-images", "quality", show_default=False)), ) @click.option( - '--png', - '-p', + "--png", + "-p", is_flag=True, flag_value=True, - help='Set output format to PNG.', + help="Set output format to PNG.", ) @click.option( - '--compression', - '-c', - metavar='C', + "--compression", + "-c", + metavar="C", default=None, type=click.IntRange(0, 9), - help='PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s' - % (USER_CONFIG.get_help_string('save-images', 'compression')), + help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s" + % (USER_CONFIG.get_help_string("save-images", "compression")), ) @click.option( - '-m', - '--frame-margin', - metavar='N', + "-m", + "--frame-margin", + metavar="N", default=None, type=click.INT, - help='Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s' - % (USER_CONFIG.get_help_string('save-images', 'num-images')), + help="Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s" + % (USER_CONFIG.get_help_string("save-images", "num-images")), ) @click.option( - '--scale', - '-s', - metavar='S', + "--scale", + "-s", + metavar="S", default=None, type=click.FLOAT, - help='Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s' % - (USER_CONFIG.get_help_string('save-images', 'scale', show_default=False)), + help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s" + % (USER_CONFIG.get_help_string("save-images", "scale", show_default=False)), ) @click.option( - '--height', - '-H', - metavar='H', + "--height", + "-H", + metavar="H", default=None, type=click.INT, - help='Height (pixels) of images.%s' % - (USER_CONFIG.get_help_string('save-images', 'height', show_default=False)), + help="Height (pixels) of images.%s" + % (USER_CONFIG.get_help_string("save-images", "height", show_default=False)), ) @click.option( - '--width', - '-W', - metavar='W', + "--width", + "-W", + metavar="W", default=None, type=click.INT, - help='Width (pixels) of images.%s' % - (USER_CONFIG.get_help_string('save-images', 'width', show_default=False)), + help="Width (pixels) of images.%s" + % (USER_CONFIG.get_help_string("save-images", "width", show_default=False)), ) @click.pass_context def save_images_command( @@ -1207,16 +1290,16 @@ def save_images_command( ): """Create images for each detected scene. -Images can be resized + Images can be resized -Examples: + Examples: - {scenedetect_with_video} save-images + {scenedetect_with_video} save-images - {scenedetect_with_video} save-images --width 1024 + {scenedetect_with_video} save-images --width 1024 - {scenedetect_with_video} save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER -""" + {scenedetect_with_video} save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_save_images( num_images=num_images, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 3407b2a5..fcb8ab38 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -31,7 +31,7 @@ from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS -VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO'] +VALID_PYAV_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] class OptionParseFailure(Exception): @@ -53,7 +53,7 @@ def value(self) -> Any: @staticmethod @abstractmethod - def from_config(config_value: str, default: 'ValidatedValue') -> 'ValidatedValue': + def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue": """Validate and get the user-specified configuration option. Raises: @@ -83,12 +83,13 @@ def __str__(self) -> str: return str(self.value) @staticmethod - def from_config(config_value: str, default: 'TimecodeValue') -> 'TimecodeValue': + def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": try: return TimecodeValue(config_value) except ValueError as ex: raise OptionParseFailure( - 'Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS.') from ex + "Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS." + ) from ex class RangeValue(ValidatedValue): @@ -128,22 +129,25 @@ def __str__(self) -> str: return str(self.value) @staticmethod - def from_config(config_value: str, default: 'RangeValue') -> 'RangeValue': + def from_config(config_value: str, default: "RangeValue") -> "RangeValue": try: return RangeValue( - value=int(config_value) if isinstance(default.value, int) else float(config_value), + value=int(config_value) + if isinstance(default.value, int) + else float(config_value), min_val=default.min_val, max_val=default.max_val, ) except ValueError as ex: - raise OptionParseFailure('Value must be between %s and %s.' % - (default.min_val, default.max_val)) from ex + raise OptionParseFailure( + "Value must be between %s and %s." % (default.min_val, default.max_val) + ) from ex class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" - _IGNORE_CHARS = [',', '/', '(', ')'] + _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" def __init__(self, value: Union[str, ContentDetector.Components]): @@ -151,7 +155,8 @@ def __init__(self, value: Union[str, ContentDetector.Components]): self._value = value else: translation_table = str.maketrans( - {char: ' ' for char in ScoreWeightsValue._IGNORE_CHARS}) + {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} + ) values = value.translate(translation_table).split() if not len(values) == 4: raise ValueError("Score weights must be specified as four numbers!") @@ -165,16 +170,19 @@ def __repr__(self) -> str: return str(self.value) def __str__(self) -> str: - return '%.3f, %.3f, %.3f, %.3f' % self.value + return "%.3f, %.3f, %.3f, %.3f" % self.value @staticmethod - def from_config(config_value: str, default: 'ScoreWeightsValue') -> 'ScoreWeightsValue': + def from_config( + config_value: str, default: "ScoreWeightsValue" + ) -> "ScoreWeightsValue": try: return ScoreWeightsValue(config_value) except ValueError as ex: raise OptionParseFailure( - 'Score weights must be specified as four numbers in the form (H,S,L,E),' - ' e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored.') from ex + "Score weights must be specified as four numbers in the form (H,S,L,E)," + " e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored." + ) from ex class KernelSizeValue(ValidatedValue): @@ -201,21 +209,22 @@ def __repr__(self) -> str: def __str__(self) -> str: if self.value is None: - return 'auto' + return "auto" return str(self.value) @staticmethod - def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeValue': + def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue": try: return KernelSizeValue(int(config_value)) except ValueError as ex: raise OptionParseFailure( - 'Value must be an odd integer greater than 1, or set to -1 for auto kernel size.' + "Value must be an odd integer greater than 1, or set to -1 for auto kernel size." ) from ex class TimecodeFormat(Enum): """Format to display timecodes.""" + FRAMES = 0 """Print timecodes as exact frame number.""" TIMECODE = 1 @@ -229,16 +238,18 @@ def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return '%.3f' % timecode.get_seconds() + return "%.3f" % timecode.get_seconds() assert False ConfigValue = Union[bool, int, float, str] ConfigDict = Dict[str, Dict[str, ConfigValue]] -_CONFIG_FILE_NAME: AnyStr = 'scenedetect.cfg' +_CONFIG_FILE_NAME: AnyStr = "scenedetect.cfg" _CONFIG_FILE_DIR: AnyStr = user_config_dir("PySceneDetect", False) -_PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format +_PLACEHOLDER = ( + 0 # Placeholder for image quality default, as the value depends on output format +) CONFIG_FILE_PATH: AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) DEFAULT_JPG_QUALITY = 95 @@ -349,29 +360,36 @@ def format(self, timecode: FrameTimecode) -> str: certain string options are stored in `CHOICE_MAP`.""" CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { - 'backend-pyav': { - 'threading_mode': [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + "backend-pyav": { + "threading_mode": [mode.lower() for mode in VALID_PYAV_THREAD_MODES], }, - 'detect-content': { - 'filter-mode': [mode.name.lower() for mode in FlashFilter.Mode], + "detect-content": { + "filter-mode": [mode.name.lower() for mode in FlashFilter.Mode], }, - 'global': { - 'backend': ['opencv', 'pyav', 'moviepy'], - 'default-detector': ['detect-adaptive', 'detect-content', 'detect-threshold'], - 'downscale-method': [value.name.lower() for value in Interpolation], - 'verbosity': ['debug', 'info', 'warning', 'error', 'none'], + "global": { + "backend": ["opencv", "pyav", "moviepy"], + "default-detector": ["detect-adaptive", "detect-content", "detect-threshold"], + "downscale-method": [value.name.lower() for value in Interpolation], + "verbosity": ["debug", "info", "warning", "error", "none"], }, - 'list-scenes': { - 'cut-format': [value.name.lower() for value in TimecodeFormat], + "list-scenes": { + "cut-format": [value.name.lower() for value in TimecodeFormat], }, - 'save-images': { - 'format': ['jpeg', 'png', 'webp'], - 'scale-method': [value.name.lower() for value in Interpolation], + "save-images": { + "format": ["jpeg", "png", "webp"], + "scale-method": [value.name.lower() for value in Interpolation], }, - 'split-video': { - 'preset': [ - 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', - 'veryslow' + "split-video": { + "preset": [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", ], }, } @@ -391,11 +409,13 @@ def _validate_structure(config: ConfigParser) -> List[str]: errors: List[str] = [] for section in config.sections(): if not section in CONFIG_MAP.keys(): - errors.append('Unsupported config section: [%s]' % (section)) + errors.append("Unsupported config section: [%s]" % (section)) continue - for (option_name, _) in config.items(section): + for option_name, _ in config.items(section): if not option_name in CONFIG_MAP[section].keys(): - errors.append('Unsupported config option in [%s]: %s' % (section, option_name)) + errors.append( + "Unsupported config option in [%s]: %s" % (section, option_name) + ) return errors @@ -414,20 +434,22 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: try: value_type = None if isinstance(CONFIG_MAP[command][option], bool): - value_type = 'yes/no value' + value_type = "yes/no value" out_map[command][option] = config.getboolean(command, option) continue elif isinstance(CONFIG_MAP[command][option], int): - value_type = 'integer' + value_type = "integer" out_map[command][option] = config.getint(command, option) continue elif isinstance(CONFIG_MAP[command][option], float): - value_type = 'number' + value_type = "number" out_map[command][option] = config.getfloat(command, option) continue except ValueError as _: - errors.append('Invalid [%s] value for %s: %s is not a valid %s.' % - (command, option, config.get(command, option), value_type)) + errors.append( + "Invalid [%s] value for %s: %s is not a valid %s." + % (command, option, config.get(command, option), value_type) + ) continue # Handle custom validation types. @@ -437,21 +459,34 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: if issubclass(option_type, ValidatedValue): try: out_map[command][option] = option_type.from_config( - config_value=config_value, default=default) + config_value=config_value, default=default + ) except OptionParseFailure as ex: - errors.append('Invalid [%s] value for %s:\n %s\n%s' % - (command, option, config_value, ex.error)) + errors.append( + "Invalid [%s] value for %s:\n %s\n%s" + % (command, option, config_value, ex.error) + ) continue # If we didn't process the value as a given type, handle it as a string. We also # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: - config_value = config.get(command, option).replace('\n', ' ').strip() + config_value = ( + config.get(command, option).replace("\n", " ").strip() + ) if command in CHOICE_MAP and option in CHOICE_MAP[command]: if config_value.lower() not in CHOICE_MAP[command][option]: - errors.append('Invalid [%s] value for %s: %s. Must be one of: %s.' % - (command, option, config.get(command, option), ', '.join( - choice for choice in CHOICE_MAP[command][option]))) + errors.append( + "Invalid [%s] value for %s: %s. Must be one of: %s." + % ( + command, + option, + config.get(command, option), + ", ".join( + choice for choice in CHOICE_MAP[command][option] + ), + ) + ) continue out_map[command][option] = config_value continue @@ -469,9 +504,8 @@ def __init__(self, init_log: Tuple[int, str], reason: Optional[Exception] = None class ConfigRegistry: - def __init__(self, path: Optional[str] = None, throw_exception: bool = True): - self._config: ConfigDict = {} # Options set in the loaded config file. + self._config: ConfigDict = {} # Options set in the loaded config file. self._init_log: List[Tuple[int, str]] = [] self._initialized = False @@ -487,7 +521,7 @@ def __init__(self, path: Optional[str] = None, throw_exception: bool = True): self._init_log = ex.init_log if ex.reason is not None: self._init_log += [ - (logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' ')), + (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")), ] self._initialized = False @@ -513,7 +547,9 @@ def _log(self, log_level, log_str): def _load_from_disk(self, path=None): # Validate `path`, or if not provided, use CONFIG_FILE_PATH if it exists. if path: - self._init_log.append((logging.INFO, "Loading config from file:\n %s" % path)) + self._init_log.append( + (logging.INFO, "Loading config from file:\n %s" % path) + ) if not os.path.exists(path): self._init_log.append((logging.ERROR, "File not found: %s" % (path))) raise ConfigLoadFailure(self._init_log) @@ -523,11 +559,13 @@ def _load_from_disk(self, path=None): self._init_log.append((logging.DEBUG, "User config file not found.")) return path = CONFIG_FILE_PATH - self._init_log.append((logging.INFO, "Loading user config file:\n %s" % path)) + self._init_log.append( + (logging.INFO, "Loading user config file:\n %s" % path) + ) # Try to load and parse the config file at `path`. config = ConfigParser() try: - with open(path, 'r') as config_file: + with open(path, "r") as config_file: config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) except ParsingError as ex: @@ -548,11 +586,13 @@ def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" return not (command in self._config and option in self._config[command]) - def get_value(self, - command: str, - option: str, - override: Optional[ConfigValue] = None, - ignore_default: bool = False) -> ConfigValue: + def get_value( + self, + command: str, + option: str, + override: Optional[ConfigValue] = None, + ignore_default: bool = False, + ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: @@ -567,10 +607,9 @@ def get_value(self, return value.value return value - def get_help_string(self, - command: str, - option: str, - show_default: Optional[bool] = None) -> str: + def get_help_string( + self, command: str, option: str, show_default: Optional[bool] = None + ) -> str: """Get a string to specify for the help text indicating the current command option value, if set, or the default. @@ -584,11 +623,12 @@ def get_help_string(self, is_flag = isinstance(CONFIG_MAP[command][option], bool) if command in self._config and option in self._config[command]: if is_flag: - value_str = 'on' if self._config[command][option] else 'off' + value_str = "on" if self._config[command][option] else "off" else: value_str = str(self._config[command][option]) - return ' [setting: %s]' % (value_str) - if show_default is False or (show_default is None and is_flag - and CONFIG_MAP[command][option] is False): - return '' - return ' [default: %s]' % (str(CONFIG_MAP[command][option])) + return " [setting: %s]" % (value_str) + if show_default is False or ( + show_default is None and is_flag and CONFIG_MAP[command][option] is False + ): + return "" + return " [default: %s]" % (str(CONFIG_MAP[command][option])) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index ee583727..806dabe7 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -24,32 +24,47 @@ from scenedetect import open_video, AVAILABLE_BACKENDS from scenedetect.scene_detector import SceneDetector, FlashFilter -from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger +from scenedetect.platform import ( + get_and_create_path, + get_cv2_imwrite_params, + init_logger, +) from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + ThresholdDetector, + HistogramDetector, +) from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP, - DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY) +from scenedetect._cli.config import ( + ConfigRegistry, + ConfigLoadFailure, + TimecodeFormat, + CHOICE_MAP, + DEFAULT_JPG_QUALITY, + DEFAULT_WEBP_QUALITY, +) -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") USER_CONFIG = ConfigRegistry(throw_exception=False) -def parse_timecode(value: ty.Optional[str], - frame_rate: float, - correct_pts: bool = False) -> FrameTimecode: +def parse_timecode( + value: ty.Optional[str], frame_rate: float, correct_pts: bool = False +) -> FrameTimecode: """Parses a user input string into a FrameTimecode assuming the given framerate. If value is None, None will be returned instead of processing the value. Raises: click.BadParameter - """ + """ if value is None: return None try: @@ -60,16 +75,17 @@ def parse_timecode(value: ty.Optional[str], return FrameTimecode(timecode=value, fps=frame_rate) except ValueError as ex: raise click.BadParameter( - 'timecode must be in seconds (100.0), frames (100), or HH:MM:SS') from ex + "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" + ) from ex def contains_sequence_or_url(video_path: str) -> bool: """Checks if the video path is a URL or image sequence.""" - return '%' in video_path or '://' in video_path + return "%" in video_path or "://" in video_path def check_split_video_requirements(use_mkvmerge: bool) -> None: - """ Validates that the proper tool is available on the system to perform the + """Validates that the proper tool is available on the system to perform the `split-video` command. Arguments: @@ -81,16 +97,19 @@ def check_split_video_requirements(use_mkvmerge: bool) -> None: if (use_mkvmerge and not is_mkvmerge_available()) or not is_ffmpeg_available(): error_strs = [ "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format( - EXTERN_TOOL='mkvmerge' if use_mkvmerge else 'ffmpeg', - EXTRA_ARGS=' when mkvmerge (-m) is set' if use_mkvmerge else '') + EXTERN_TOOL="mkvmerge" if use_mkvmerge else "ffmpeg", + EXTRA_ARGS=" when mkvmerge (-m) is set" if use_mkvmerge else "", + ) ] - error_strs += ['Ensure the program is available on your system and try again.'] + error_strs += ["Ensure the program is available on your system and try again."] if not use_mkvmerge and is_mkvmerge_available(): - error_strs += ['You can specify mkvmerge (-m) to use mkvmerge for splitting.'] + error_strs += [ + "You can specify mkvmerge (-m) to use mkvmerge for splitting." + ] elif use_mkvmerge and is_ffmpeg_available(): - error_strs += ['You can specify copy (-c) to use ffmpeg stream copying.'] - error_str = '\n'.join(error_strs) - raise click.BadParameter(error_str, param_hint='split-video') + error_strs += ["You can specify copy (-c) to use ffmpeg stream copying."] + error_str = "\n".join(error_strs) + raise click.BadParameter(error_str, param_hint="split-video") # pylint: disable=too-many-instance-attributes,too-many-arguments,too-many-locals @@ -113,65 +132,68 @@ def __init__(self): self.added_detector: bool = False # Global `scenedetect` Options - self.output_dir: str = None # -o/--output - self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet - self.stats_file_path: str = None # -s/--stats - self.drop_short_scenes: bool = None # --drop-short-scenes - self.merge_last_scene: bool = None # --merge-last-scene - self.min_scene_len: FrameTimecode = None # -m/--min-scene-len - self.frame_skip: int = None # -fs/--frame-skip - self.default_detector: Tuple[Type[SceneDetector], - Dict[str, Any]] = None # [global] default-detector + self.output_dir: str = None # -o/--output + self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet + self.stats_file_path: str = None # -s/--stats + self.drop_short_scenes: bool = None # --drop-short-scenes + self.merge_last_scene: bool = None # --merge-last-scene + self.min_scene_len: FrameTimecode = None # -m/--min-scene-len + self.frame_skip: int = None # -fs/--frame-skip + self.default_detector: Tuple[Type[SceneDetector], Dict[str, Any]] = ( + None # [global] default-detector + ) # `time` Command Options self.time: bool = False - self.start_time: FrameTimecode = None # time -s/--start - self.end_time: FrameTimecode = None # time -e/--end - self.duration: FrameTimecode = None # time -d/--duration + self.start_time: FrameTimecode = None # time -s/--start + self.end_time: FrameTimecode = None # time -e/--end + self.duration: FrameTimecode = None # time -d/--duration # `save-images` Command Options self.save_images: bool = False - self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_dir: str = None # save-images -o/--output - self.image_param: int = None # save-images -q/--quality if -j/-w, - # otherwise -c/--compression if -p - self.image_name_format: str = None # save-images -f/--name-format - self.num_images: int = None # save-images -n/--num-images - self.frame_margin: int = 1 # save-images -m/--frame-margin - self.scale: float = None # save-images -s/--scale - self.height: int = None # save-images -h/--height - self.width: int = None # save-images -w/--width - self.scale_method: Interpolation = None # [save-images] scale-method + self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png + self.image_dir: str = None # save-images -o/--output + self.image_param: int = None # save-images -q/--quality if -j/-w, + # otherwise -c/--compression if -p + self.image_name_format: str = None # save-images -f/--name-format + self.num_images: int = None # save-images -n/--num-images + self.frame_margin: int = 1 # save-images -m/--frame-margin + self.scale: float = None # save-images -s/--scale + self.height: int = None # save-images -h/--height + self.width: int = None # save-images -w/--width + self.scale_method: Interpolation = None # [save-images] scale-method # `split-video` Command Options self.split_video: bool = False - self.split_mkvmerge: bool = None # split-video -m/--mkvmerge - self.split_args: str = None # split-video -a/--args, -c/--copy - self.split_dir: str = None # split-video -o/--output - self.split_name_format: str = None # split-video -f/--filename - self.split_quiet: bool = None # split-video -q/--quiet + self.split_mkvmerge: bool = None # split-video -m/--mkvmerge + self.split_args: str = None # split-video -a/--args, -c/--copy + self.split_dir: str = None # split-video -o/--output + self.split_name_format: str = None # split-video -f/--filename + self.split_quiet: bool = None # split-video -q/--quiet # `list-scenes` Command Options self.list_scenes: bool = False - self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_dir: str = None # list-scenes -o/--output - self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output-file - self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = True # [list-scenes] display-cuts - self.display_scenes: bool = True # [list-scenes] display-scenes - self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format + self.list_scenes_quiet: bool = None # list-scenes -q/--quiet + self.scene_list_dir: str = None # list-scenes -o/--output + self.scene_list_name_format: str = None # list-scenes -f/--filename + self.scene_list_output: bool = None # list-scenes -n/--no-output-file + self.skip_cuts: bool = None # list-scenes -s/--skip-cuts + self.display_cuts: bool = True # [list-scenes] display-cuts + self.display_scenes: bool = True # [list-scenes] display-scenes + self.cut_format: TimecodeFormat = ( + TimecodeFormat.TIMECODE + ) # [list-scenes] cut-format # `export-html` Command Options self.export_html: bool = False - self.html_name_format: str = None # export-html -f/--filename - self.html_include_images: bool = None # export-html --no-images - self.image_width: int = None # export-html -w/--image-width - self.image_height: int = None # export-html -h/--image-height + self.html_name_format: str = None # export-html -f/--filename + self.html_include_images: bool = None # export-html --no-images + self.image_width: int = None # export-html -w/--image-width + self.image_height: int = None # export-html -h/--image-height # `load-scenes` Command Options - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name # # Command Handlers @@ -218,9 +240,11 @@ def handle_options( self.config = ConfigRegistry(config) init_log += self.config.get_init_log() # Re-initialize logger with the correct verbosity. - if verbosity is None and not self.config.is_default('global', 'verbosity'): - verbosity_str = self.config.get_value('global', 'verbosity') - assert verbosity_str in CHOICE_MAP['global']['verbosity'] + if verbosity is None and not self.config.is_default( + "global", "verbosity" + ): + verbosity_str = self.config.get_value("global", "verbosity") + assert verbosity_str in CHOICE_MAP["global"]["verbosity"] self.quiet_mode = False self._initialize_logging(verbosity=verbosity_str, logfile=logfile) @@ -228,11 +252,13 @@ def handle_options( init_failure = True init_log += ex.init_log if ex.reason is not None: - init_log += [(logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' '))] + init_log += [ + (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")) + ] finally: # Make sure we print the version number even on any kind of init failure. - logger.info('PySceneDetect %s', scenedetect.__version__) - for (log_level, log_str) in init_log: + logger.info("PySceneDetect %s", scenedetect.__version__) + for log_level, log_str in init_log: logger.log(log_level, log_str) if init_failure: logger.critical("Error processing configuration file.") @@ -241,16 +267,17 @@ def handle_options( if self.config.config_dict: logger.debug("Current configuration:\n%s", str(self.config.config_dict)) - logger.debug('Parsing program options.') + logger.debug("Parsing program options.") if stats is not None and frame_skip: error_strs = [ - 'Unable to detect scenes with stats file if frame skip is not 0.', - ' Either remove the -fs/--frame-skip option, or the -s/--stats file.\n' + "Unable to detect scenes with stats file if frame skip is not 0.", + " Either remove the -fs/--frame-skip option, or the -s/--stats file.\n", ] - logger.error('\n'.join(error_strs)) + logger.error("\n".join(error_strs)) raise click.BadParameter( - 'Combining the -s/--stats and -fs/--frame-skip options is not supported.', - param_hint='frame skip + stats file') + "Combining the -s/--stats and -fs/--frame-skip options is not supported.", + param_hint="frame skip + stats file", + ) # Handle the case where -i/--input was not specified (e.g. for the `help` command). if input_path is None: @@ -260,19 +287,29 @@ def handle_options( self._open_video_stream( input_path=input_path, framerate=framerate, - backend=self.config.get_value("global", "backend", backend, ignore_default=True)) - - self.output_dir = output if output else self.config.get_value("global", "output") + backend=self.config.get_value( + "global", "backend", backend, ignore_default=True + ), + ) + + self.output_dir = ( + output if output else self.config.get_value("global", "output") + ) if self.output_dir: - logger.info('Output directory set:\n %s', self.output_dir) + logger.info("Output directory set:\n %s", self.output_dir) self.min_scene_len = parse_timecode( - min_scene_len if min_scene_len is not None else self.config.get_value( - "global", "min-scene-len"), self.video_stream.frame_rate) + min_scene_len + if min_scene_len is not None + else self.config.get_value("global", "min-scene-len"), + self.video_stream.frame_rate, + ) self.drop_short_scenes = drop_short_scenes or self.config.get_value( - "global", "drop-short-scenes") + "global", "drop-short-scenes" + ) self.merge_last_scene = merge_last_scene or self.config.get_value( - "global", "merge-last-scene") + "global", "merge-last-scene" + ) self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) # Create StatsManager if --stats is specified. @@ -282,20 +319,28 @@ def handle_options( # Initialize default detector with values in the config file. default_detector = self.config.get_value("global", "default-detector") - if default_detector == 'detect-adaptive': - self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) - elif default_detector == 'detect-content': + if default_detector == "detect-adaptive": + self.default_detector = ( + AdaptiveDetector, + self.get_detect_adaptive_params(), + ) + elif default_detector == "detect-content": self.default_detector = (ContentDetector, self.get_detect_content_params()) - elif default_detector == 'detect-hash': + elif default_detector == "detect-hash": self.default_detector = (HashDetector, self.get_detect_hash_params()) - elif default_detector == 'detect-hist': + elif default_detector == "detect-hist": self.default_detector = (HistogramDetector, self.get_detect_hist_params()) - elif default_detector == 'detect-threshold': - self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) + elif default_detector == "detect-threshold": + self.default_detector = ( + ThresholdDetector, + self.get_detect_threshold_params(), + ) else: - raise click.BadParameter("Unknown detector type!", param_hint='default-detector') + raise click.BadParameter( + "Unknown detector type!", param_hint="default-detector" + ) - logger.debug('Initializing SceneManager.') + logger.debug("Initializing SceneManager.") scene_manager = SceneManager(self.stats_manager) if downscale is None and self.config.is_default("global", "downscale"): @@ -307,9 +352,10 @@ def handle_options( scene_manager.downscale = downscale except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='downscale factor') - scene_manager.interpolation = Interpolation[self.config.get_value( - 'global', 'downscale-method').upper()] + raise click.BadParameter(str(ex), param_hint="downscale factor") + scene_manager.interpolation = Interpolation[ + self.config.get_value("global", "downscale-method").upper() + ] self.scene_manager = scene_manager def get_detect_content_params( @@ -328,33 +374,39 @@ def get_detect_content_params( min_scene_len = 0 else: if min_scene_len is None: - if self.config.is_default('detect-content', 'min-scene-len'): + if self.config.is_default("detect-content", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value('detect-content', 'min-scene-len') - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.config.get_value( + "detect-content", "min-scene-len" + ) + min_scene_len = parse_timecode( + min_scene_len, self.video_stream.frame_rate + ).frame_num if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='weights') + raise click.BadParameter(str(ex), param_hint="weights") return { - 'weights': - self.config.get_value('detect-content', 'weights', weights), - 'kernel_size': - self.config.get_value('detect-content', 'kernel-size', kernel_size), - 'luma_only': - luma_only or self.config.get_value('detect-content', 'luma-only'), - 'min_scene_len': - min_scene_len, - 'threshold': - self.config.get_value('detect-content', 'threshold', threshold), - 'filter_mode': - FlashFilter.Mode[self.config.get_value("detect-content", "filter-mode", - filter_mode).upper()], + "weights": self.config.get_value("detect-content", "weights", weights), + "kernel_size": self.config.get_value( + "detect-content", "kernel-size", kernel_size + ), + "luma_only": luma_only + or self.config.get_value("detect-content", "luma-only"), + "min_scene_len": min_scene_len, + "threshold": self.config.get_value( + "detect-content", "threshold", threshold + ), + "filter_mode": FlashFilter.Mode[ + self.config.get_value( + "detect-content", "filter-mode", filter_mode + ).upper() + ], } def get_detect_adaptive_params( @@ -373,16 +425,21 @@ def get_detect_adaptive_params( # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: - logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.') + logger.error( + "-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead." + ) if min_content_val is None: min_content_val = min_delta_hsv # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated' - ', use `min-delta-hsv` instead.') + logger.error( + "[detect-adaptive] config file option `min-delta-hsv` is deprecated" + ", use `min-delta-hsv` instead." + ) if self.config.is_default("detect-adaptive", "min-content-val"): self.config.config_dict["detect-adaptive"]["min-content-val"] = ( - self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) + self.config.config_dict["detect-adaptive"]["min-deleta-hsv"] + ) if self.drop_short_scenes: min_scene_len = 0 @@ -391,30 +448,36 @@ def get_detect_adaptive_params( if self.config.is_default("detect-adaptive", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.config.get_value( + "detect-adaptive", "min-scene-len" + ) + min_scene_len = parse_timecode( + min_scene_len, self.video_stream.frame_rate + ).frame_num if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='weights') + raise click.BadParameter(str(ex), param_hint="weights") return { - 'adaptive_threshold': - self.config.get_value("detect-adaptive", "threshold", threshold), - 'weights': - self.config.get_value("detect-adaptive", "weights", weights), - 'kernel_size': - self.config.get_value("detect-adaptive", "kernel-size", kernel_size), - 'luma_only': - luma_only or self.config.get_value("detect-adaptive", "luma-only"), - 'min_content_val': - self.config.get_value("detect-adaptive", "min-content-val", min_content_val), - 'min_scene_len': - min_scene_len, - 'window_width': - self.config.get_value("detect-adaptive", "frame-window", frame_window), + "adaptive_threshold": self.config.get_value( + "detect-adaptive", "threshold", threshold + ), + "weights": self.config.get_value("detect-adaptive", "weights", weights), + "kernel_size": self.config.get_value( + "detect-adaptive", "kernel-size", kernel_size + ), + "luma_only": luma_only + or self.config.get_value("detect-adaptive", "luma-only"), + "min_content_val": self.config.get_value( + "detect-adaptive", "min-content-val", min_content_val + ), + "min_scene_len": min_scene_len, + "window_width": self.config.get_value( + "detect-adaptive", "frame-window", frame_window + ), } def get_detect_threshold_params( @@ -434,37 +497,53 @@ def get_detect_threshold_params( if self.config.is_default("detect-threshold", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.config.get_value( + "detect-threshold", "min-scene-len" + ) + min_scene_len = parse_timecode( + min_scene_len, self.video_stream.frame_rate + ).frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { - 'add_final_scene': - add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"), - 'fade_bias': - self.config.get_value("detect-threshold", "fade-bias", fade_bias), - 'min_scene_len': - min_scene_len, - 'threshold': - self.config.get_value("detect-threshold", "threshold", threshold), + "add_final_scene": add_last_scene + or self.config.get_value("detect-threshold", "add-last-scene"), + "fade_bias": self.config.get_value( + "detect-threshold", "fade-bias", fade_bias + ), + "min_scene_len": min_scene_len, + "threshold": self.config.get_value( + "detect-threshold", "threshold", threshold + ), } def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): """Handle `load-scenes` command options.""" self._ensure_input_open() if self.added_detector: - raise click.ClickException("The load-scenes command cannot be used with detectors.") + raise click.ClickException( + "The load-scenes command cannot be used with detectors." + ) if self.load_scenes_input: - raise click.ClickException("The load-scenes command must only be specified once.") + raise click.ClickException( + "The load-scenes command must only be specified once." + ) input = os.path.abspath(input) if not os.path.exists(input): raise click.BadParameter( - f'Could not load scenes, file does not exist: {input}', param_hint='-i/--input') + f"Could not load scenes, file does not exist: {input}", + param_hint="-i/--input", + ) self.load_scenes_input = input - self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", - start_col_name) + self.load_scenes_column_name = self.config.get_value( + "load-scenes", "start-col-name", start_col_name + ) - def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int], - min_scene_len: Optional[str]) -> Dict[str, Any]: + def get_detect_hist_params( + self, + threshold: Optional[float], + bins: Optional[int], + min_scene_len: Optional[str], + ) -> Dict[str, Any]: """Handle detect-hist command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -474,17 +553,25 @@ def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int] if self.config.is_default("detect-hist", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.config.get_value( + "detect-hist", "min-scene-len" + ) + min_scene_len = parse_timecode( + min_scene_len, self.video_stream.frame_rate + ).frame_num return { - 'bins': self.config.get_value("detect-hist", "bins", bins), - 'min_scene_len': min_scene_len, - 'threshold': self.config.get_value("detect-hist", "threshold", threshold), + "bins": self.config.get_value("detect-hist", "bins", bins), + "min_scene_len": min_scene_len, + "threshold": self.config.get_value("detect-hist", "threshold", threshold), } - def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int], - lowpass: Optional[int], - min_scene_len: Optional[str]) -> Dict[str, Any]: + def get_detect_hash_params( + self, + threshold: Optional[float], + size: Optional[int], + lowpass: Optional[int], + min_scene_len: Optional[str], + ) -> Dict[str, Any]: """Handle detect-hash command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -494,8 +581,12 @@ def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int] if self.config.is_default("detect-hash", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.config.get_value( + "detect-hash", "min-scene-len" + ) + min_scene_len = parse_timecode( + min_scene_len, self.video_stream.frame_rate + ).frame_num return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), "min_scene_len": min_scene_len, @@ -513,20 +604,27 @@ def handle_export_html( """Handle `export-html` command options.""" self._ensure_input_open() if self.export_html: - self._on_duplicate_command('export_html') + self._on_duplicate_command("export_html") - no_images = no_images or self.config.get_value('export-html', 'no-images') + no_images = no_images or self.config.get_value("export-html", "no-images") self.html_include_images = not no_images - self.html_name_format = self.config.get_value('export-html', 'filename', filename) - self.image_width = self.config.get_value('export-html', 'image-width', image_width) - self.image_height = self.config.get_value('export-html', 'image-height', image_height) + self.html_name_format = self.config.get_value( + "export-html", "filename", filename + ) + self.image_width = self.config.get_value( + "export-html", "image-width", image_width + ) + self.image_height = self.config.get_value( + "export-html", "image-height", image_height + ) if not self.save_images and not no_images: raise click.BadArgumentUsage( - 'The export-html command requires that the save-images command\n' - 'is specified before it, unless --no-images is specified.') - logger.info('HTML file name format:\n %s', filename) + "The export-html command requires that the save-images command\n" + "is specified before it, unless --no-images is specified." + ) + logger.info("HTML file name format:\n %s", filename) self.export_html = True @@ -546,15 +644,24 @@ def handle_list_scenes( self.display_cuts = self.config.get_value("list-scenes", "display-cuts") self.display_scenes = self.config.get_value("list-scenes", "display-scenes") self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts") - self.cut_format = TimecodeFormat[self.config.get_value("list-scenes", "cut-format").upper()] + self.cut_format = TimecodeFormat[ + self.config.get_value("list-scenes", "cut-format").upper() + ] self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") - no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") + no_output_file = no_output_file or self.config.get_value( + "list-scenes", "no-output-file" + ) self.scene_list_dir = self.config.get_value( - "list-scenes", "output", output, ignore_default=True) - self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) + "list-scenes", "output", output, ignore_default=True + ) + self.scene_list_name_format = self.config.get_value( + "list-scenes", "filename", filename + ) if self.scene_list_name_format is not None and not no_output_file: - logger.info("Scene list filename format:\n %s", self.scene_list_name_format) + logger.info( + "Scene list filename format:\n %s", self.scene_list_name_format + ) self.scene_list_output = not no_output_file if self.scene_list_dir is not None: logger.info("Scene list output directory:\n %s", self.scene_list_dir) @@ -576,62 +683,74 @@ def handle_split_video( """Handle `split-video` command options.""" self._ensure_input_open() if self.split_video: - self._on_duplicate_command('split-video') + self._on_duplicate_command("split-video") check_split_video_requirements(use_mkvmerge=mkvmerge) if contains_sequence_or_url(self.video_stream.path): - error_str = 'The split-video command is incompatible with image sequences/URLs.' - raise click.BadParameter(error_str, param_hint='split-video') + error_str = ( + "The split-video command is incompatible with image sequences/URLs." + ) + raise click.BadParameter(error_str, param_hint="split-video") ## ## Common Arguments/Options ## self.split_video = True - self.split_quiet = quiet or self.config.get_value('split-video', 'quiet') - self.split_dir = self.config.get_value('split-video', 'output', output, ignore_default=True) + self.split_quiet = quiet or self.config.get_value("split-video", "quiet") + self.split_dir = self.config.get_value( + "split-video", "output", output, ignore_default=True + ) if self.split_dir is not None: - logger.info('Video output path set: \n%s', self.split_dir) - self.split_name_format = self.config.get_value('split-video', 'filename', filename) + logger.info("Video output path set: \n%s", self.split_dir) + self.split_name_format = self.config.get_value( + "split-video", "filename", filename + ) # We only load the config values for these flags/options if none of the other # encoder flags/options were set via the CLI to avoid any conflicting options # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). if not (mkvmerge or copy or high_quality or args or rate_factor or preset): - mkvmerge = self.config.get_value('split-video', 'mkvmerge') - copy = self.config.get_value('split-video', 'copy') - high_quality = self.config.get_value('split-video', 'high-quality') - rate_factor = self.config.get_value('split-video', 'rate-factor') - preset = self.config.get_value('split-video', 'preset') - args = self.config.get_value('split-video', 'args') + mkvmerge = self.config.get_value("split-video", "mkvmerge") + copy = self.config.get_value("split-video", "copy") + high_quality = self.config.get_value("split-video", "high-quality") + rate_factor = self.config.get_value("split-video", "rate-factor") + preset = self.config.get_value("split-video", "preset") + args = self.config.get_value("split-video", "args") # Disallow certain combinations of flags/options. if mkvmerge or copy: - command = 'mkvmerge (-m)' if mkvmerge else 'copy (-c)' + command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" if high_quality: raise click.BadParameter( - 'high-quality (-hq) cannot be used with %s' % (command), - param_hint='split-video') + "high-quality (-hq) cannot be used with %s" % (command), + param_hint="split-video", + ) if args: raise click.BadParameter( - 'args (-a) cannot be used with %s' % (command), param_hint='split-video') + "args (-a) cannot be used with %s" % (command), + param_hint="split-video", + ) if rate_factor: raise click.BadParameter( - 'rate-factor (crf) cannot be used with %s' % (command), - param_hint='split-video') + "rate-factor (crf) cannot be used with %s" % (command), + param_hint="split-video", + ) if preset: raise click.BadParameter( - 'preset (-p) cannot be used with %s' % (command), param_hint='split-video') + "preset (-p) cannot be used with %s" % (command), + param_hint="split-video", + ) ## ## mkvmerge-Specific Arguments/Options ## if mkvmerge: if copy: - logger.warning('copy mode (-c) ignored due to mkvmerge mode (-m).') + logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") self.split_mkvmerge = True - logger.info('Using mkvmerge for video splitting.') + logger.info("Using mkvmerge for video splitting.") return ## @@ -644,13 +763,15 @@ def handle_split_video( rate_factor = 22 if not high_quality else 17 if preset is None: preset = "veryfast" if not high_quality else "slow" - args = ("-map 0:v:0 -map 0:a? -map 0:s? " - f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac") + args = ( + "-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" + ) - logger.info('ffmpeg arguments: %s', args) + logger.info("ffmpeg arguments: %s", args) self.split_args = args if filename: - logger.info('Output file name format: %s', filename) + logger.info("Output file name format: %s", filename) def handle_save_images( self, @@ -670,70 +791,86 @@ def handle_save_images( """Handle `save-images` command options.""" self._ensure_input_open() if self.save_images: - self._on_duplicate_command('save-images') + self._on_duplicate_command("save-images") - if '://' in self.video_stream.path: - error_str = '\nThe save-images command is incompatible with URLs.' + if "://" in self.video_stream.path: + error_str = "\nThe save-images command is incompatible with URLs." logger.error(error_str) - raise click.BadParameter(error_str, param_hint='save-images') + raise click.BadParameter(error_str, param_hint="save-images") num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) if num_flags > 1: - logger.error('Multiple image type flags set for save-images command.') + logger.error("Multiple image type flags set for save-images command.") raise click.BadParameter( - 'Only one image type (JPG/PNG/WEBP) can be specified.', param_hint='save-images') + "Only one image type (JPG/PNG/WEBP) can be specified.", + param_hint="save-images", + ) # Only use config params for image format if one wasn't specified. elif num_flags == 0: - image_format = self.config.get_value('save-images', 'format').lower() - jpeg = image_format == 'jpeg' - webp = image_format == 'webp' - png = image_format == 'png' + image_format = self.config.get_value("save-images", "format").lower() + jpeg = image_format == "jpeg" + webp = image_format == "webp" + png = image_format == "png" # Only use config params for scale/height/width if none of them are specified explicitly. if scale is None and height is None and width is None: - self.scale = self.config.get_value('save-images', 'scale') - self.height = self.config.get_value('save-images', 'height') - self.width = self.config.get_value('save-images', 'width') + self.scale = self.config.get_value("save-images", "scale") + self.height = self.config.get_value("save-images", "height") + self.width = self.config.get_value("save-images", "width") else: self.scale = scale self.height = height self.width = width - self.scale_method = Interpolation[self.config.get_value('save-images', - 'scale-method').upper()] + self.scale_method = Interpolation[ + self.config.get_value("save-images", "scale-method").upper() + ] default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY quality = ( - default_quality if self.config.is_default('save-images', 'quality') else - self.config.get_value('save-images', 'quality')) + default_quality + if self.config.is_default("save-images", "quality") + else self.config.get_value("save-images", "quality") + ) - compression = self.config.get_value('save-images', 'compression', compression) + compression = self.config.get_value("save-images", "compression", compression) self.image_param = compression if png else quality - self.image_extension = 'jpg' if jpeg else 'png' if png else 'webp' + self.image_extension = "jpg" if jpeg else "png" if png else "webp" valid_params = get_cv2_imwrite_params() - if not self.image_extension in valid_params or valid_params[self.image_extension] is None: + if ( + not self.image_extension in valid_params + or valid_params[self.image_extension] is None + ): error_strs = [ - 'Image encoder type `%s` not supported.' % self.image_extension.upper(), - 'The specified encoder type could not be found in the current OpenCV module.', - 'To enable this output format, please update the installed version of OpenCV.', - 'If you build OpenCV, ensure the the proper dependencies are enabled. ' + "Image encoder type `%s` not supported." % self.image_extension.upper(), + "The specified encoder type could not be found in the current OpenCV module.", + "To enable this output format, please update the installed version of OpenCV.", + "If you build OpenCV, ensure the the proper dependencies are enabled. ", ] - logger.debug('\n'.join(error_strs)) - raise click.BadParameter('\n'.join(error_strs), param_hint='save-images') - - self.image_dir = self.config.get_value('save-images', 'output', output, ignore_default=True) - - self.image_name_format = self.config.get_value('save-images', 'filename', filename) - self.num_images = self.config.get_value('save-images', 'num-images', num_images) - self.frame_margin = self.config.get_value('save-images', 'frame-margin', frame_margin) - - image_type = ('jpeg' if jpeg else self.image_extension).upper() - image_param_type = 'Compression' if png else 'Quality' - image_param_type = ' [%s: %d]' % (image_param_type, self.image_param) - logger.info('Image output format set: %s%s', image_type, image_param_type) + logger.debug("\n".join(error_strs)) + raise click.BadParameter("\n".join(error_strs), param_hint="save-images") + + self.image_dir = self.config.get_value( + "save-images", "output", output, ignore_default=True + ) + + self.image_name_format = self.config.get_value( + "save-images", "filename", filename + ) + self.num_images = self.config.get_value("save-images", "num-images", num_images) + self.frame_margin = self.config.get_value( + "save-images", "frame-margin", frame_margin + ) + + image_type = ("jpeg" if jpeg else self.image_extension).upper() + image_param_type = "Compression" if png else "Quality" + image_param_type = " [%s: %d]" % (image_param_type, self.image_param) + logger.info("Image output format set: %s%s", image_type, image_param_type) if self.image_dir is not None: - logger.info('Image output directory set:\n %s', os.path.abspath(self.image_dir)) + logger.info( + "Image output directory set:\n %s", os.path.abspath(self.image_dir) + ) self.save_images = True @@ -741,17 +878,24 @@ def handle_time(self, start, duration, end): """Handle `time` command options.""" self._ensure_input_open() if self.time: - self._on_duplicate_command('time') + self._on_duplicate_command("time") if duration is not None and end is not None: raise click.BadParameter( - 'Only one of --duration/-d or --end/-e can be specified, not both.', - param_hint='time') - logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration, - end) + "Only one of --duration/-d or --end/-e can be specified, not both.", + param_hint="time", + ) + logger.debug( + "Setting video time:\n start: %s, duration: %s, end: %s", + start, + duration, + end, + ) # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to # match the default start number used by `ffmpeg` when saving frames as images. As such, # we must correct start time if set as frames. See the test_cli_time* tests for for details. - self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True) + self.start_time = parse_timecode( + start, self.video_stream.frame_rate, correct_pts=True + ) self.end_time = parse_timecode(end, self.video_stream.frame_rate) self.duration = parse_timecode(duration, self.video_stream.frame_rate) if self.start_time and self.end_time and (self.start_time + 1) > self.end_time: @@ -774,31 +918,35 @@ def _initialize_logging( curr_verbosity = logging.INFO # Convert verbosity into it's log level enum, and override quiet mode if set. if verbosity is not None: - assert verbosity in CHOICE_MAP['global']['verbosity'] - if verbosity.lower() == 'none': + assert verbosity in CHOICE_MAP["global"]["verbosity"] + if verbosity.lower() == "none": self.quiet_mode = True - verbosity = 'info' + verbosity = "info" else: # Override quiet mode if verbosity is set. self.quiet_mode = False curr_verbosity = getattr(logging, verbosity.upper()) else: - verbosity_str = USER_CONFIG.get_value('global', 'verbosity') - assert verbosity_str in CHOICE_MAP['global']['verbosity'] - if verbosity_str.lower() == 'none': + verbosity_str = USER_CONFIG.get_value("global", "verbosity") + assert verbosity_str in CHOICE_MAP["global"]["verbosity"] + if verbosity_str.lower() == "none": self.quiet_mode = True else: curr_verbosity = getattr(logging, verbosity_str.upper()) # Override quiet mode if verbosity is set. - if not USER_CONFIG.is_default('global', 'verbosity'): + if not USER_CONFIG.is_default("global", "verbosity"): self.quiet_mode = False # Initialize logger with the set CLI args / user configuration. - init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) + init_logger( + log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile + ) def add_detector(self, detector): - """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """ + """Add Detector: Adds a detection algorithm to the CliContext's SceneManager.""" if self.load_scenes_input: - raise click.ClickException("The load-scenes command cannot be used with detectors.") + raise click.ClickException( + "The load-scenes command cannot be used with detectors." + ) self._ensure_input_open() self.scene_manager.add_detector(detector) self.added_detector = True @@ -812,40 +960,51 @@ def _ensure_input_open(self) -> None: click.BadParameter: self.video_stream was not initialized. """ if self.video_stream is None: - raise click.ClickException('No input video (-i/--input) was specified.') + raise click.ClickException("No input video (-i/--input) was specified.") - def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float], - backend: Optional[str]): - if '%' in input_path and backend != 'opencv': + def _open_video_stream( + self, input_path: AnyStr, framerate: Optional[float], backend: Optional[str] + ): + if "%" in input_path and backend != "opencv": raise click.BadParameter( - 'The OpenCV backend (`--backend opencv`) must be used to process image sequences.', - param_hint='-i/--input') + "The OpenCV backend (`--backend opencv`) must be used to process image sequences.", + param_hint="-i/--input", + ) if framerate is not None and framerate < MAX_FPS_DELTA: - raise click.BadParameter('Invalid framerate specified!', param_hint='-f/--framerate') + raise click.BadParameter( + "Invalid framerate specified!", param_hint="-f/--framerate" + ) try: if backend is None: - backend = self.config.get_value('global', 'backend') + backend = self.config.get_value("global", "backend") else: if not backend in AVAILABLE_BACKENDS: raise click.BadParameter( - 'Specified backend %s is not available on this system!' % backend, - param_hint='-b/--backend') + "Specified backend %s is not available on this system!" + % backend, + param_hint="-b/--backend", + ) # Open the video with the specified backend, loading any required config settings. - if backend == 'pyav': + if backend == "pyav": self.video_stream = open_video( path=input_path, framerate=framerate, backend=backend, - threading_mode=self.config.get_value('backend-pyav', 'threading-mode'), - suppress_output=self.config.get_value('backend-pyav', 'suppress-output'), + threading_mode=self.config.get_value( + "backend-pyav", "threading-mode" + ), + suppress_output=self.config.get_value( + "backend-pyav", "suppress-output" + ), ) - elif backend == 'opencv': + elif backend == "opencv": self.video_stream = open_video( path=input_path, framerate=framerate, backend=backend, - max_decode_attempts=self.config.get_value('backend-opencv', - 'max-decode-attempts'), + max_decode_attempts=self.config.get_value( + "backend-opencv", "max-decode-attempts" + ), ) # Handle backends without any config options. else: @@ -854,19 +1013,25 @@ def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float], framerate=framerate, backend=backend, ) - logger.debug('Video opened using backend %s', type(self.video_stream).__name__) + logger.debug( + "Video opened using backend %s", type(self.video_stream).__name__ + ) except FrameRateUnavailable as ex: raise click.BadParameter( - 'Failed to obtain framerate for input video. Manually specify framerate with the' - ' -f/--framerate option, or try re-encoding the file.', - param_hint='-i/--input') from ex + "Failed to obtain framerate for input video. Manually specify framerate with the" + " -f/--framerate option, or try re-encoding the file.", + param_hint="-i/--input", + ) from ex except VideoOpenFailure as ex: raise click.BadParameter( - 'Failed to open input video%s: %s' % - (' using %s backend' % backend if backend else '', str(ex)), - param_hint='-i/--input') from ex + "Failed to open input video%s: %s" + % (" using %s backend" % backend if backend else "", str(ex)), + param_hint="-i/--input", + ) from ex except OSError as ex: - raise click.BadParameter('Input error:\n\n\t%s\n' % str(ex), param_hint='-i/--input') + raise click.BadParameter( + "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" + ) def _on_duplicate_command(self, command: str) -> None: """Called when a command is duplicated to stop parsing and raise an error. @@ -878,10 +1043,11 @@ def _on_duplicate_command(self, command: str) -> None: click.BadParameter """ error_strs = [] - error_strs.append('Error: Command %s specified multiple times.' % command) - error_strs.append('The %s command may appear only one time.') + error_strs.append("Error: Command %s specified multiple times." % command) + error_strs.append("The %s command may appear only one time.") - logger.error('\n'.join(error_strs)) + logger.error("\n".join(error_strs)) raise click.BadParameter( - '\n Command %s may only be specified once.' % command, - param_hint='%s command' % command) + "\n Command %s may only be specified once." % command, + param_hint="%s command" % command, + ) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index d7180542..0c86b19b 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -23,13 +23,18 @@ from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path -from scenedetect.scene_manager import get_scenes_from_cuts, save_images, write_scene_list, write_scene_list_html +from scenedetect.scene_manager import ( + get_scenes_from_cuts, + save_images, + write_scene_list, + write_scene_list_html, +) from scenedetect.video_splitter import split_video_mkvmerge, split_video_ffmpeg from scenedetect.video_stream import SeekError from scenedetect._cli.context import CliContext, check_split_video_requirements -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") def run_scenedetect(context: CliContext): @@ -47,7 +52,9 @@ def run_scenedetect(context: CliContext): if context.load_scenes_input: # Skip detection if load-scenes was used. - logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) + logger.info( + "Skipping detection, loading scenes from: %s", context.load_scenes_input + ) if context.stats_file_path: logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") scene_list, cut_list = _load_scenes(context) @@ -61,11 +68,18 @@ def run_scenedetect(context: CliContext): _save_stats(context) if scene_list: logger.info( - 'Detected %d scenes, average shot length %.1f seconds.', len(scene_list), - sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) - / float(len(scene_list))) + "Detected %d scenes, average shot length %.1f seconds.", + len(scene_list), + sum( + [ + (end_time - start_time).get_seconds() + for start_time, end_time in scene_list + ] + ) + / float(len(scene_list)), + ) else: - logger.info('No scenes detected.') + logger.info("No scenes detected.") # Handle list-scenes command. _list_scenes(context, scene_list, cut_list) @@ -84,18 +98,23 @@ def _detect(context: CliContext): # Use default detector if one was not specified. if context.scene_manager.get_num_detectors() == 0: detector_type, detector_args = context.default_detector - logger.debug('Using default detector: %s(%s)' % (detector_type.__name__, detector_args)) + logger.debug( + "Using default detector: %s(%s)" % (detector_type.__name__, detector_args) + ) context.scene_manager.add_detector(detector_type(**detector_args)) perf_start_time = time.time() if context.start_time is not None: - logger.debug('Seeking to start time...') + logger.debug("Seeking to start time...") try: context.video_stream.seek(target=context.start_time) except SeekError as ex: - logger.critical('Failed to seek to %s / frame %d: %s', - context.start_time.get_timecode(), context.start_time.get_frames(), - str(ex)) + logger.critical( + "Failed to seek to %s / frame %d: %s", + context.start_time.get_timecode(), + context.start_time.get_frames(), + str(ex), + ) return num_frames = context.scene_manager.detect_scenes( @@ -103,25 +122,30 @@ def _detect(context: CliContext): duration=context.duration, end_time=context.end_time, frame_skip=context.frame_skip, - show_progress=not context.quiet_mode) + show_progress=not context.quiet_mode, + ) # Handle case where video failure is most likely due to multiple audio tracks (#179). # TODO(#380): Ensure this does not erroneusly fire. - if num_frames <= 0 and context.video_stream.BACKEND_NAME == 'opencv': + if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv": logger.critical( - 'Failed to read any frames from video file. This could be caused by the video' - ' having multiple audio tracks. If so, try installing the PyAV backend:\n' - ' pip install av\n' - 'Or remove the audio tracks by running either:\n' - ' ffmpeg -i input.mp4 -c copy -an output.mp4\n' - ' mkvmerge -o output.mkv input.mp4\n' - 'For details, see https://scenedetect.com/faq/') + "Failed to read any frames from video file. This could be caused by the video" + " having multiple audio tracks. If so, try installing the PyAV backend:\n" + " pip install av\n" + "Or remove the audio tracks by running either:\n" + " ffmpeg -i input.mp4 -c copy -an output.mp4\n" + " mkvmerge -o output.mkv input.mp4\n" + "For details, see https://scenedetect.com/faq/" + ) return perf_duration = time.time() - perf_start_time - logger.info('Processed %d frames in %.1f seconds (average %.2f FPS).', num_frames, - perf_duration, - float(num_frames) / perf_duration) + logger.info( + "Processed %d frames in %.1f seconds (average %.2f FPS).", + num_frames, + perf_duration, + float(num_frames) / perf_duration, + ) # Get list of detected cuts/scenes from the SceneManager to generate the required output # files, based on the given commands (list-scenes, split-video, save-images, etc...). @@ -137,34 +161,42 @@ def _save_stats(context: CliContext) -> None: return if context.stats_manager.is_save_required(): path = get_and_create_path(context.stats_file_path, context.output_dir) - logger.info('Saving frame metrics to stats file: %s', path) + logger.info("Saving frame metrics to stats file: %s", path) with open(path, mode="w") as file: context.stats_manager.save_to_csv(csv_file=file) else: - logger.debug('No frame metrics updated, skipping update of the stats file.') + logger.debug("No frame metrics updated, skipping update of the stats file.") -def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - cut_list: List[FrameTimecode]) -> None: +def _list_scenes( + context: CliContext, + scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + cut_list: List[FrameTimecode], +) -> None: """Handles the `list-scenes` command.""" if not context.list_scenes: return # Write scene list CSV to if required. if context.scene_list_output: - scene_list_filename = Template( - context.scene_list_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) - if not scene_list_filename.lower().endswith('.csv'): - scene_list_filename += '.csv' + scene_list_filename = Template(context.scene_list_name_format).safe_substitute( + VIDEO_NAME=context.video_stream.name + ) + if not scene_list_filename.lower().endswith(".csv"): + scene_list_filename += ".csv" scene_list_path = get_and_create_path( scene_list_filename, - context.scene_list_dir if context.scene_list_dir is not None else context.output_dir) - logger.info('Writing scene list to CSV file:\n %s', scene_list_path) - with open(scene_list_path, 'wt') as scene_list_file: + context.scene_list_dir + if context.scene_list_dir is not None + else context.output_dir, + ) + logger.info("Writing scene list to CSV file:\n %s", scene_list_path) + with open(scene_list_path, "wt") as scene_list_file: write_scene_list( output_csv_file=scene_list_file, scene_list=scene_list, include_cut_list=not context.skip_cuts, - cut_list=cut_list) + cut_list=cut_list, + ) # Suppress output if requested. if context.list_scenes_quiet: return @@ -176,26 +208,37 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s ------------------------------------------------------------------------""", '\n'.join([ - " | %5d | %11d | %s | %11d | %s |" % - (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), - end_time.get_frames(), end_time.get_timecode()) - for i, (start_time, end_time) in enumerate(scene_list) - ])) +-----------------------------------------------------------------------""", + "\n".join( + [ + " | %5d | %11d | %s | %11d | %s |" + % ( + i + 1, + start_time.get_frames() + 1, + start_time.get_timecode(), + end_time.get_frames(), + end_time.get_timecode(), + ) + for i, (start_time, end_time) in enumerate(scene_list) + ] + ), + ) # Print cut list. if cut_list and context.display_cuts: - logger.info("Comma-separated timecode list:\n %s", - ",".join([context.cut_format.format(cut) for cut in cut_list])) + logger.info( + "Comma-separated timecode list:\n %s", + ",".join([context.cut_format.format(cut) for cut in cut_list]), + ) def _save_images( - context: CliContext, - scene_list: List[Tuple[FrameTimecode, FrameTimecode]]) -> Optional[Dict[int, List[str]]]: + context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]] +) -> Optional[Dict[int, List[str]]]: """Handles the `save-images` command.""" if not context.save_images: return None # Command can override global output directory setting. - output_dir = (context.output_dir if context.image_dir is None else context.image_dir) + output_dir = context.output_dir if context.image_dir is None else context.image_dir return save_images( scene_list=scene_list, video=context.video_stream, @@ -209,23 +252,28 @@ def _save_images( scale=context.scale, height=context.height, width=context.width, - interpolation=context.scale_method) + interpolation=context.scale_method, + ) -def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - cut_list: List[FrameTimecode], image_filenames: Optional[Dict[int, - List[str]]]) -> None: +def _export_html( + context: CliContext, + scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + cut_list: List[FrameTimecode], + image_filenames: Optional[Dict[int, List[str]]], +) -> None: """Handles the `export-html` command.""" if not context.export_html: return # Command can override global output directory setting. - output_dir = (context.output_dir if context.image_dir is None else context.image_dir) - html_filename = Template( - context.html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) - if not html_filename.lower().endswith('.html'): - html_filename += '.html' + output_dir = context.output_dir if context.image_dir is None else context.image_dir + html_filename = Template(context.html_name_format).safe_substitute( + VIDEO_NAME=context.video_stream.name + ) + if not html_filename.lower().endswith(".html"): + html_filename += ".html" html_path = get_and_create_path(html_filename, output_dir) - logger.info('Exporting to html file:\n %s:', html_path) + logger.info("Exporting to html file:\n %s:", html_path) if not context.html_include_images: image_filenames = None write_scene_list_html( @@ -234,24 +282,26 @@ def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram cut_list, image_filenames=image_filenames, image_width=context.image_width, - image_height=context.image_height) + image_height=context.image_height, + ) -def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, - FrameTimecode]]) -> None: +def _split_video( + context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]] +) -> None: """Handles the `split-video` command.""" if not context.split_video: return output_path_template = context.split_name_format # Add proper extension to filename template if required. - dot_pos = output_path_template.rfind('.') + dot_pos = output_path_template.rfind(".") extension_length = 0 if dot_pos < 0 else len(output_path_template) - (dot_pos + 1) # If using mkvmerge, force extension to .mkv. - if context.split_mkvmerge and not output_path_template.endswith('.mkv'): - output_path_template += '.mkv' + if context.split_mkvmerge and not output_path_template.endswith(".mkv"): + output_path_template += ".mkv" # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. elif not 2 <= extension_length <= 4: - output_path_template += '.mp4' + output_path_template += ".mp4" # Ensure the appropriate tool is available before handling split-video. check_split_video_requirements(context.split_mkvmerge) # Command can override global output directory setting. @@ -275,29 +325,32 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, show_output=not (context.quiet_mode or context.split_quiet), ) if scene_list: - logger.info('Video splitting completed, scenes written to disk.') + logger.info("Video splitting completed, scenes written to disk.") def _load_scenes( - context: CliContext -) -> ty.Tuple[ty.Iterable[ty.Tuple[FrameTimecode, FrameTimecode]], ty.Iterable[FrameTimecode]]: + context: CliContext, +) -> ty.Tuple[ + ty.Iterable[ty.Tuple[FrameTimecode, FrameTimecode]], ty.Iterable[FrameTimecode] +]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) - with open(context.load_scenes_input, 'r') as input_file: + with open(context.load_scenes_input, "r") as input_file: file_reader = csv.reader(input_file) csv_headers = next(file_reader) if not context.load_scenes_column_name in csv_headers: csv_headers = next(file_reader) # Check to make sure column headers are present if context.load_scenes_column_name not in csv_headers: - raise ValueError('specified column header for scene start is not present') + raise ValueError("specified column header for scene start is not present") col_idx = csv_headers.index(context.load_scenes_column_name) cut_list = sorted( FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 - for row in file_reader) + for row in file_reader + ) # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` # but this part of the API is being reworked and hasn't been used by any detectors yet. @@ -319,17 +372,24 @@ def _load_scenes( cut_list = [cut for cut in cut_list if cut < end_time] return get_scenes_from_cuts( - cut_list=cut_list, start_pos=start_time, end_pos=end_time), cut_list + cut_list=cut_list, start_pos=start_time, end_pos=end_time + ), cut_list def _postprocess_scene_list( context: CliContext, scene_list: ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] ) -> ty.List[ty.Tuple[FrameTimecode, FrameTimecode]]: - # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, # it will be merged with the previous one. - if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: - if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: + if ( + context.merge_last_scene + and context.min_scene_len is not None + and context.min_scene_len > 0 + ): + if ( + len(scene_list) > 1 + and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len + ): new_last_scene = (scene_list[-2][0], scene_list[-1][1]) scene_list = scene_list[:-2] + [new_last_scene] diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py index e940a432..a000f972 100644 --- a/scenedetect/_thirdparty/simpletable.py +++ b/scenedetect/_thirdparty/simpletable.py @@ -56,9 +56,11 @@ def quote(string): try: from urllib.parse import quote + return quote(string) except ModuleNotFoundError: from urllib import pathname2url + return pathname2url(string) @@ -82,9 +84,9 @@ def __init__(self, text, header=False): def __str__(self): """Return the HTML code for the table cell.""" if self.header: - return '%s' % (self.text) + return "%s" % (self.text) else: - return '%s' % (self.text) + return "%s" % (self.text) class SimpleTableImage(object): @@ -121,7 +123,7 @@ def __str__(self): output += ' height="%s"' % (self.height) if self.width: output += ' width="%s"' % (self.width) - output += '>' + output += ">" return output @@ -161,14 +163,14 @@ def __str__(self): """Return the HTML code for the table row and its cells as a string.""" row = [] - row.append('') + row.append("") for cell in self.cells: row.append(str(cell)) - row.append('') + row.append("") - return '\n'.join(row) + return "\n".join(row) def __iter__(self): """Iterate through row cells""" @@ -232,9 +234,9 @@ def __str__(self): table = [] if self.css_class: - table.append('' % self.css_class) + table.append("
    " % self.css_class) else: - table.append('
    ') + table.append("
    ") if self.header_row: table.append(str(self.header_row)) @@ -242,9 +244,9 @@ def __str__(self): for row in self.rows: table.append(str(row)) - table.append('
    ') + table.append("") - return '\n'.join(table) + return "\n".join(table) def __iter__(self): """Iterate through table rows""" @@ -285,14 +287,16 @@ def __str__(self): page.append('' % self.css) # Set encoding - page.append('' % self.encoding) + page.append( + '' % self.encoding + ) for table in self.tables: page.append(str(table)) - page.append('
    ') + page.append("
    ") - return '\n'.join(page) + return "\n".join(page) def __iter__(self): """Iterate through tables""" @@ -301,7 +305,7 @@ def __iter__(self): def save(self, filename): """Save HTML page to a file using the proper encoding""" - with codecs.open(filename, 'w', self.encoding) as outfile: + with codecs.open(filename, "w", self.encoding) as outfile: for line in str(self): outfile.write(line) @@ -324,4 +328,4 @@ def fit_data_to_columns(data, num_cols): if len(data) % num_cols != 0: num_iterations += 1 - return [data[num_cols * i:num_cols * i + num_cols] for i in range(num_iterations)] + return [data[num_cols * i : num_cols * i + num_cols] for i in range(num_iterations)] diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 6296bd31..9d60bb34 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -102,11 +102,15 @@ # TODO: Lazy-loading backends would improve startup performance. However, this requires removing # some of the re-exported types above from the public API. AVAILABLE_BACKENDS: Dict[str, Type] = { - backend.BACKEND_NAME: backend for backend in filter(None, [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - ]) + backend.BACKEND_NAME: backend + for backend in filter( + None, + [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + ], + ) } """All available backends that :func:`scenedetect.open_video` can consider for the `backend` parameter. These backends must support construction with the following signature: diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index e0c4a92b..62c97294 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -29,13 +29,15 @@ from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure from scenedetect.backends.opencv import VideoStreamCv2 -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: bool = False): + def __init__( + self, path: AnyStr, framerate: Optional[float] = None, print_infos: bool = False + ): """Open a video or device. Arguments: @@ -53,7 +55,8 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: # TODO: Add framerate override. if framerate is not None: raise NotImplementedError( - "VideoStreamMoviePy does not support the `framerate` argument yet.") + "VideoStreamMoviePy does not support the `framerate` argument yet." + ) self._path = path # TODO: Need to map errors based on the strings, since several failure @@ -77,7 +80,7 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: # VideoStream Methods/Properties # - BACKEND_NAME = 'moviepy' + BACKEND_NAME = "moviepy" """Unique name used to identify this backend.""" @property @@ -103,13 +106,13 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return tuple(self._reader.infos['video_size']) + return tuple(self._reader.infos["video_size"]) @property def duration(self) -> Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - assert isinstance(self._reader.infos['duration'], float) - return self.base_timecode + self._reader.infos['duration'] + assert isinstance(self._reader.infos["duration"], float) + return self.base_timecode + self._reader.infos["duration"] @property def aspect_ratio(self) -> float: @@ -192,13 +195,15 @@ def seek(self, target: Union[FrameTimecode, float, int]): self._frame_number = target.frame_num def reset(self): - """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._reader.initialize() self._last_frame = self._reader.read_frame() self._frame_number = 0 self._eof = False - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read( + self, decode: bool = True, advance: bool = True + ) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -213,7 +218,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b if self._last_frame_rgb is None: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) return self._last_frame_rgb - if not hasattr(self._reader, 'lastread'): + if not hasattr(self._reader, "lastread"): return False self._last_frame = self._reader.lastread self._reader.read_frame() diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 4ab9a897..893c54bf 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -28,23 +28,28 @@ from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.platform import get_file_name -from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure, FrameRateUnavailable +from scenedetect.video_stream import ( + VideoStream, + SeekError, + VideoOpenFailure, + FrameRateUnavailable, +) -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") -IMAGE_SEQUENCE_IDENTIFIER = '%' +IMAGE_SEQUENCE_IDENTIFIER = "%" NON_VIDEO_FILE_INPUT_IDENTIFIERS = ( - IMAGE_SEQUENCE_IDENTIFIER, # image sequence - '://', # URL/network stream - ' ! ', # gstreamer pipe + IMAGE_SEQUENCE_IDENTIFIER, # image sequence + "://", # URL/network stream + " ! ", # gstreamer pipe ) def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float: """Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels).""" # Versions of OpenCV < 3.4.1 do not support this, so we fall back to 1.0. - if not 'CAP_PROP_SAR_NUM' in dir(cv2): + if not "CAP_PROP_SAR_NUM" in dir(cv2): return 1.0 num: float = cap.get(cv2.CAP_PROP_SAR_NUM) den: float = cap.get(cv2.CAP_PROP_SAR_DEN) @@ -86,21 +91,24 @@ def __init__( super().__init__() # TODO(v0.7): Replace with DeprecationWarning that `path_or_device` will be removed in v0.8. if path_or_device is not None: - logger.error('path_or_device is deprecated, use path or VideoCaptureAdapter instead.') + logger.error( + "path_or_device is deprecated, use path or VideoCaptureAdapter instead." + ) path = path_or_device if path is None: - raise ValueError('Path must be specified!') + raise ValueError("Path must be specified!") if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError('Specified framerate (%f) is invalid!' % framerate) + raise ValueError("Specified framerate (%f) is invalid!" % framerate) if max_decode_attempts < 0: - raise ValueError('Maximum decode attempts must be >= 0!') + raise ValueError("Maximum decode attempts must be >= 0!") self._path_or_device = path self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: - self._cap: Optional[ - cv2.VideoCapture] = None # Reference to underlying cv2.VideoCapture object. + self._cap: Optional[cv2.VideoCapture] = ( + None # Reference to underlying cv2.VideoCapture object. + ) self._frame_rate: Optional[float] = None # VideoCapture state @@ -130,7 +138,7 @@ def capture(self) -> cv2.VideoCapture: # VideoStream Methods/Properties # - BACKEND_NAME = 'opencv' + BACKEND_NAME = "opencv" """Unique name used to identify this backend.""" @property @@ -157,7 +165,7 @@ def name(self) -> str: if IMAGE_SEQUENCE_IDENTIFIER in file_name: # file_name is an image sequence, trim everything including/after the %. # TODO: This excludes any suffix after the sequence identifier. - file_name = file_name[:file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] + file_name = file_name[: file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] return file_name @property @@ -170,8 +178,10 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) + return ( + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) @property def duration(self) -> Optional[FrameTimecode]: @@ -258,11 +268,13 @@ def seek(self, target: Union[FrameTimecode, float, int]): self._has_grabbed = self._cap.grab() def reset(self): - """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._cap.release() self._open_capture(self._frame_rate) - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read( + self, decode: bool = True, advance: bool = True + ) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. @@ -289,9 +301,11 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b # Report previous failure in debug mode. if has_grabbed: self._decode_failures += 1 - logger.debug('Frame failed to decode.') + logger.debug("Frame failed to decode.") if not self._warning_displayed and self._decode_failures > 1: - logger.warning('Failed to decode some frames, results may be inaccurate.') + logger.warning( + "Failed to decode some frames, results may be inaccurate." + ) # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: return False @@ -309,34 +323,41 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b def _open_capture(self, framerate: Optional[float] = None): """Opens capture referenced by this object and resets internal state.""" if self._is_device and self._path_or_device < 0: - raise ValueError('Invalid/negative device ID specified.') + raise ValueError("Invalid/negative device ID specified.") input_is_video_file = not self._is_device and not any( - identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS) + identifier in self._path_or_device + for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS + ) # We don't have a way of querying why opening a video fails (errors are logged at least), # so provide a better error message if we try to open a file that doesn't exist. if input_is_video_file: if not os.path.exists(self._path_or_device): - raise OSError('Video file not found.') + raise OSError("Video file not found.") cap = cv2.VideoCapture(self._path_or_device) if not cap.isOpened(): raise VideoOpenFailure( - 'Ensure file is valid video and system dependencies are up to date.\n') + "Ensure file is valid video and system dependencies are up to date.\n" + ) # Display an error if the video codec type seems unsupported (#86) as this indicates # potential video corruption, or may explain missing frames. We only perform this check # for video files on-disk (skipped for devices, image sequences, streams, etc...). - codec_unsupported: bool = (int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0) + codec_unsupported: bool = int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0 if codec_unsupported and input_is_video_file: - logger.error('Video codec detection failed. If output is incorrect:\n' - ' - Re-encode the input video with ffmpeg\n' - ' - Update OpenCV (pip install --upgrade opencv-python)\n' - ' - Use the PyAV backend (--backend pyav)\n' - 'For details, see https://github.com/Breakthrough/PySceneDetect/issues/86') + logger.error( + "Video codec detection failed. If output is incorrect:\n" + " - Re-encode the input video with ffmpeg\n" + " - Update OpenCV (pip install --upgrade opencv-python)\n" + " - Use the PyAV backend (--backend pyav)\n" + "For details, see https://github.com/Breakthrough/PySceneDetect/issues/86" + ) # Ensure the framerate is correct to avoid potential divide by zero errors. This can be # addressed in the PyAV backend if required since it supports integer timebases. - assert framerate is None or framerate > MAX_FPS_DELTA, "Framerate must be validated if set!" + assert ( + framerate is None or framerate > MAX_FPS_DELTA + ), "Framerate must be validated if set!" if framerate is None: framerate = cap.get(cv2.CAP_PROP_FPS) if framerate < MAX_FPS_DELTA: @@ -380,11 +401,11 @@ def __init__( super().__init__() if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError('Specified framerate (%f) is invalid!' % framerate) + raise ValueError("Specified framerate (%f) is invalid!" % framerate) if max_read_attempts < 0: - raise ValueError('Maximum decode attempts must be >= 0!') + raise ValueError("Maximum decode attempts must be >= 0!") if not cap.isOpened(): - raise ValueError('Specified VideoCapture must already be opened!') + raise ValueError("Specified VideoCapture must already be opened!") if framerate is None: framerate = cap.get(cv2.CAP_PROP_FPS) if framerate < MAX_FPS_DELTA: @@ -417,7 +438,7 @@ def capture(self) -> cv2.VideoCapture: # VideoStream Methods/Properties # - BACKEND_NAME = 'opencv_adapter' + BACKEND_NAME = "opencv_adapter" """Unique name used to identify this backend.""" @property @@ -429,12 +450,12 @@ def frame_rate(self) -> float: @property def path(self) -> str: """Always 'CAP_ADAPTER'.""" - return 'CAP_ADAPTER' + return "CAP_ADAPTER" @property def name(self) -> str: """Always 'CAP_ADAPTER'.""" - return 'CAP_ADAPTER' + return "CAP_ADAPTER" @property def is_seekable(self) -> bool: @@ -444,8 +465,10 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Reported size of each video frame in pixels as a tuple of (width, height).""" - return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) + return ( + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) @property def duration(self) -> Optional[FrameTimecode]: @@ -500,7 +523,9 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read( + self, decode: bool = True, advance: bool = True + ) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. @@ -526,9 +551,11 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b # Report previous failure in debug mode. if has_grabbed: self._decode_failures += 1 - logger.debug('Frame failed to decode.') + logger.debug("Frame failed to decode.") if not self._warning_displayed and self._decode_failures > 1: - logger.warning('Failed to decode some frames, results may be inaccurate.') + logger.warning( + "Failed to decode some frames, results may be inaccurate." + ) # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: return False diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 07647818..7db5881a 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -23,7 +23,7 @@ from scenedetect.platform import get_file_name from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") VALID_THREAD_MODES = [ av.codec.context.ThreadType.NONE, @@ -82,26 +82,28 @@ def __init__( # Ensure specified framerate is valid if set. if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError('Specified framerate (%f) is invalid!' % framerate) + raise ValueError("Specified framerate (%f) is invalid!" % framerate) - self._name = '' if name is None else name - self._path = '' + self._name = "" if name is None else name + self._path = "" self._frame = None self._reopened = True if threading_mode: threading_mode = threading_mode.upper() if not threading_mode in VALID_THREAD_MODES: - raise ValueError('Invalid threading mode! Must be one of: %s' % VALID_THREAD_MODES) + raise ValueError( + "Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES + ) if not suppress_output: - logger.debug('Restoring default ffmpeg log callbacks.') + logger.debug("Restoring default ffmpeg log callbacks.") av.logging.restore_default_callback() try: if isinstance(path_or_io, (str, bytes)): self._path = path_or_io - self._io = open(path_or_io, 'rb') + self._io = open(path_or_io, "rb") if not self._name: self._name = get_file_name(self.path, include_extension=False) else: @@ -111,7 +113,7 @@ def __init__( if threading_mode is not None: self._video_stream.thread_type = threading_mode self._reopened = False - logger.debug('Threading mode set: %s', threading_mode) + logger.debug("Threading mode set: %s", threading_mode) except OSError: raise except Exception as ex: @@ -119,8 +121,11 @@ def __init__( if framerate is None: # Calculate framerate from video container. `guessed_rate` below appears in PyAV 9. - frame_rate = self._video_stream.guessed_rate if hasattr( - self._video_stream, 'guessed_rate') else self._codec_context.framerate + frame_rate = ( + self._video_stream.guessed_rate + if hasattr(self._video_stream, "guessed_rate") + else self._codec_context.framerate + ) if frame_rate is None or frame_rate == 0: raise FrameRateUnavailable() # TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations. @@ -144,7 +149,7 @@ def __del__(self): # VideoStream Methods/Properties # - BACKEND_NAME = 'pyav' + BACKEND_NAME = "pyav" """Unique name used to identify this backend.""" @property @@ -207,13 +212,17 @@ def frame_number(self) -> int: @property def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" - if not hasattr(self._codec_context, - "display_aspect_ratio") or self._codec_context.display_aspect_ratio is None: + if ( + not hasattr(self._codec_context, "display_aspect_ratio") + or self._codec_context.display_aspect_ratio is None + ): return 1.0 ar_denom = self._codec_context.display_aspect_ratio.denominator if ar_denom <= 0: return 1.0 - display_aspect_ratio = self._codec_context.display_aspect_ratio.numerator / ar_denom + display_aspect_ratio = ( + self._codec_context.display_aspect_ratio.numerator / ar_denom + ) assert self.frame_size[0] > 0 and self.frame_size[1] > 0 frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio @@ -238,12 +247,13 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: """ if target < 0: raise ValueError("Target cannot be negative!") - beginning = (target == 0) - target = (self.base_timecode + target) + beginning = target == 0 + target = self.base_timecode + target if target >= 1: target = target - 1 target_pts = self._video_stream.start_time + int( - (self.base_timecode + target).get_seconds() / self._video_stream.time_base) + (self.base_timecode + target).get_seconds() / self._video_stream.time_base + ) self._frame = None self._container.seek(target_pts, stream=self._video_stream) if not beginning: @@ -253,7 +263,7 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: break def reset(self): - """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._container.close() self._frame = None try: @@ -261,7 +271,9 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read( + self, decode: bool = True, advance: bool = True + ) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -286,7 +298,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b return False has_advanced = True if decode: - return self._frame.to_ndarray(format='bgr24') + return self._frame.to_ndarray(format="bgr24") return has_advanced # @@ -307,7 +319,9 @@ def _get_duration(self) -> int: """Get video duration as number of frames based on the video and set framerate.""" # See https://pyav.org/docs/develop/api/time.html for details on how ffmpeg/PyAV # handle time calculations internally and which time base to use. - assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!" + assert ( + self.frame_rate is not None + ), "Frame rate must be set before calling _get_duration!" # See if we can obtain the number of frames directly from the stream itself. if self._video_stream.frames > 0: return self._video_stream.frames @@ -320,14 +334,15 @@ def _get_duration(self) -> int: # Lastly, if that calculation fails, try to calculate it based on the stream duration. if duration_sec is None or duration_sec < MAX_FPS_DELTA: if self._video_stream.duration is None: - logger.warning('Video duration unavailable.') + logger.warning("Video duration unavailable.") return 0 # Streams use stream `time_base` as the time base. time_base = self._video_stream.time_base if time_base.denominator == 0: logger.warning( - 'Unable to calculate video duration: time_base (%s) has zero denominator!', - str(time_base)) + "Unable to calculate video duration: time_base (%s) has zero denominator!", + str(time_base), + ) return 0 duration_sec = float(self._video_stream.duration / time_base) return round(duration_sec * self.frame_rate) @@ -341,7 +356,10 @@ def _handle_eof(self): return False self._reopened = True # Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode. - if not self.is_seekable or not self._video_stream.thread_type in ('AUTO', 'FRAME'): + if not self.is_seekable or not self._video_stream.thread_type in ( + "AUTO", + "FRAME", + ): return False last_frame = self.frame_number orig_pos = self._io.tell() diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 064255f5..d9c19429 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -24,7 +24,7 @@ from scenedetect.detectors import ContentDetector -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") class AdaptiveDetector(ContentDetector): @@ -71,12 +71,12 @@ def __init__( # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will # be removed in v0.8. if video_manager is not None: - logger.error('video_manager is deprecated, use video instead.') + logger.error("video_manager is deprecated, use video instead.") if min_delta_hsv is not None: - logger.error('min_delta_hsv is deprecated, use min_content_val instead.') + logger.error("min_delta_hsv is deprecated, use min_content_val instead.") min_content_val = min_delta_hsv if window_width < 1: - raise ValueError('window_width must be at least 1.') + raise ValueError("window_width must be at least 1.") super().__init__( threshold=255.0, @@ -93,7 +93,8 @@ def __init__( self.window_width = window_width self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( - window_width=window_width, luma_only='' if not luma_only else '_lum') + window_width=window_width, luma_only="" if not luma_only else "_lum" + ) self._first_frame_num = None # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. @@ -114,7 +115,9 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: + def process_frame( + self, frame_num: int, frame_img: Optional[np.ndarray] + ) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -141,9 +144,11 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List return [] self._buffer = self._buffer[-required_frames:] (target_frame, target_score) = self._buffer[self.window_width] - average_window_score = ( - sum(score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width) / - (2.0 * self.window_width)) + average_window_score = sum( + score + for i, (_frame, score) in enumerate(self._buffer) + if i != self.window_width + ) / (2.0 * self.window_width) average_is_zero = abs(average_window_score) < 0.00001 @@ -154,12 +159,16 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: - self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio}) + self.stats_manager.set_metrics( + target_frame, {self._adaptive_ratio_key: adaptive_ratio} + ) # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut threshold_met: bool = ( - adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val) + adaptive_ratio >= self.adaptive_threshold + and target_score >= self.min_content_val + ) min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: self._last_cut = target_frame @@ -169,10 +178,14 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List def get_content_val(self, frame_num: int) -> Optional[float]: """Returns the average content change for a frame.""" # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. - logger.error("get_content_val is deprecated and will be removed. Lookup the value" - " using a StatsManager with ContentDetector.FRAME_SCORE_KEY.") + logger.error( + "get_content_val is deprecated and will be removed. Lookup the value" + " using a StatsManager with ContentDetector.FRAME_SCORE_KEY." + ) if self.stats_manager is not None: - return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] + return self.stats_manager.get_metrics( + frame_num, [ContentDetector.FRAME_SCORE_KEY] + )[0] return 0.0 def post_process(self, _unused_frame_num: int): diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 954a91d7..89d99b3e 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -15,6 +15,7 @@ This detector is available from the command-line as the `detect-content` command. """ + from dataclasses import dataclass import math from typing import List, NamedTuple, Optional @@ -32,7 +33,10 @@ def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: assert len(left.shape) == 2 and len(right.shape) == 2 assert left.shape == right.shape num_pixels: float = float(left.shape[0] * left.shape[1]) - return (numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels) + return ( + numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) + / num_pixels + ) def _estimated_kernel_size(frame_width: int, frame_height: int) -> int: @@ -56,6 +60,7 @@ class ContentDetector(SceneDetector): # a wider variety of test cases. class Components(NamedTuple): """Components that make up a frame's score, and their default values.""" + delta_hue: float = 1.0 """Difference between pixel hue values of adjacent frames.""" delta_sat: float = 1.0 @@ -80,7 +85,7 @@ class Components(NamedTuple): ) """Component weights to use if `luma_only` is set.""" - FRAME_SCORE_KEY = 'content_val' + FRAME_SCORE_KEY = "content_val" """Key in statsfile representing the final frame score after weighed by specified components.""" METRIC_KEYS = [FRAME_SCORE_KEY, *Components._fields] @@ -89,6 +94,7 @@ class Components(NamedTuple): @dataclass class _FrameData: """Data calculated for a given frame.""" + hue: numpy.ndarray """Frame hue map [2D 8-bit].""" sat: numpy.ndarray @@ -102,7 +108,7 @@ def __init__( self, threshold: float = 27.0, min_scene_len: int = 15, - weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, + weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: Optional[int] = None, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, @@ -133,7 +139,7 @@ def __init__( if kernel_size is not None: print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: - raise ValueError('kernel_size must be odd integer >= 3') + raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: Optional[float] = None self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) @@ -155,8 +161,9 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl hue, sat, lum = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) # Performance: Only calculate edges if we have to. - calculate_edges: bool = ((self._weights.delta_edges > 0.0) - or self.stats_manager is not None) + calculate_edges: bool = ( + self._weights.delta_edges > 0.0 + ) or self.stats_manager is not None edges = self._detect_edges(lum) if calculate_edges else None if self._last_frame is None: @@ -168,13 +175,17 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl delta_hue=_mean_pixel_distance(hue, self._last_frame.hue), delta_sat=_mean_pixel_distance(sat, self._last_frame.sat), delta_lum=_mean_pixel_distance(lum, self._last_frame.lum), - delta_edges=(0.0 if edges is None else _mean_pixel_distance( - edges, self._last_frame.edges)), + delta_edges=( + 0.0 + if edges is None + else _mean_pixel_distance(edges, self._last_frame.edges) + ), ) - frame_score: float = ( - sum(component * weight for (component, weight) in zip(score_components, self._weights)) - / sum(abs(weight) for weight in self._weights)) + frame_score: float = sum( + component * weight + for (component, weight) in zip(score_components, self._weights) + ) / sum(abs(weight) for weight in self._weights) # Record components and frame score if needed for analysis. if self.stats_manager is not None: @@ -203,7 +214,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return [] above_threshold: bool = self._frame_score >= self._threshold - return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) + return self._flash_filter.filter( + frame_num=frame_num, above_threshold=above_threshold + ) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 1ec508a7..690159c6 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -112,14 +112,18 @@ def process_frame(self, frame_num, frame_img): if self._last_frame is not None: # We obtain the change in hash value between subsequent frames. curr_hash = self.hash_frame( - frame_img=frame_img, hash_size=self._size, factor=self._factor) + frame_img=frame_img, hash_size=self._size, factor=self._factor + ) last_hash = self._last_hash if last_hash.size == 0: # Calculate hash of last frame last_hash = self.hash_frame( - frame_img=self._last_frame, hash_size=self._size, factor=self._factor) + frame_img=self._last_frame, + hash_size=self._size, + factor=self._factor, + ) # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) @@ -128,14 +132,17 @@ def process_frame(self, frame_num, frame_img): hash_dist_norm = hash_dist / self._size_sq if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_key: hash_dist_norm}) + self.stats_manager.set_metrics( + frame_num, {self._metric_key: hash_dist_norm} + ) self._last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - if hash_dist_norm >= self._threshold and ((frame_num - self._last_scene_cut) - >= self._min_scene_len): + if hash_dist_norm >= self._threshold and ( + (frame_num - self._last_scene_cut) >= self._min_scene_len + ): cut_list.append(frame_num) self._last_scene_cut = frame_num @@ -154,7 +161,9 @@ def hash_frame(frame_img, hash_size, factor) -> numpy.ndarray: # Resize image to square to help with DCT imsize = hash_size * factor - resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) + resized_img = cv2.resize( + gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA + ) # Check to avoid dividing by zero max_value = numpy.max(numpy.max(resized_img)) diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index ad469489..4904cfd3 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -29,9 +29,11 @@ class HistogramDetector(SceneDetector): """Compares the difference in the Y channel of YUV histograms for adjacent frames. When the difference exceeds a given threshold, a cut is detected.""" - METRIC_KEYS = ['hist_diff'] + METRIC_KEYS = ["hist_diff"] - def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): + def __init__( + self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15 + ): """ Arguments: threshold: maximum relative difference between 0.0 and 1.0 that the histograms can @@ -71,10 +73,12 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: np_data_type = frame_img.dtype if np_data_type != numpy.uint8: - raise ValueError('Image must be 8-bit rgb for HistogramDetector') + raise ValueError("Image must be 8-bit rgb for HistogramDetector") if frame_img.shape[2] != 3: - raise ValueError('Image must have three color channels for HistogramDetector') + raise ValueError( + "Image must have three color channels for HistogramDetector" + ) # Initialize last scene cut point at the beginning of the frames of interest. if not self._last_scene_cut: @@ -84,7 +88,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # We can only start detecting once we have a frame to compare with. if self._last_hist is not None: - #TODO: We can have EMA of histograms to make it more robust + # TODO: We can have EMA of histograms to make it more robust # ema_hist = alpha * hist + (1 - alpha) * ema_hist # Compute histogram difference between frames @@ -97,8 +101,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # Values close to 1 indicate very similar frames, while lower values suggest changes. # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation # less than 0.8 between histograms will be considered significant enough to denote a scene change. - if hist_diff <= self._threshold and ((frame_num - self._last_scene_cut) - >= self._min_scene_len): + if hist_diff <= self._threshold and ( + (frame_num - self._last_scene_cut) >= self._min_scene_len + ): cut_list.append(frame_num) self._last_scene_cut = frame_num @@ -111,9 +116,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return cut_list @staticmethod - def calculate_histogram(frame_img: numpy.ndarray, - bins: int = 256, - normalize: bool = True) -> numpy.ndarray: + def calculate_histogram( + frame_img: numpy.ndarray, bins: int = 256, normalize: bool = True + ) -> numpy.ndarray: """ Calculates and optionally normalizes the histogram of the luma (Y) channel of an image converted from BGR to YUV color space. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 784bd1f9..3a6e6ebd 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -24,7 +24,7 @@ from scenedetect.scene_detector import SceneDetector -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") ## ## ThresholdDetector Helper Functions @@ -62,12 +62,13 @@ class ThresholdDetector(SceneDetector): class Method(Enum): """Method for ThresholdDetector to use when comparing frame brightness to the threshold.""" + FLOOR = 0 """Fade out happens when frame brightness falls below threshold.""" CEILING = 1 """Fade out happens when frame brightness rises above threshold.""" - THRESHOLD_VALUE_KEY = 'average_rgb' + THRESHOLD_VALUE_KEY = "average_rgb" def __init__( self, @@ -95,7 +96,7 @@ def __init__( """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. if block_size is not None: - logger.error('block_size is deprecated.') + logger.error("block_size is deprecated.") super().__init__() self.threshold = int(threshold) @@ -109,8 +110,8 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - 'frame': 0, # frame number where the last detected fade is - 'type': None # type of fade, can be either 'in' or 'out' + "frame": 0, # frame number where the last detected fade is + "type": None, # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] @@ -145,42 +146,61 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this # frame metric instead (to assist with manually selecting a threshold) - if (self.stats_manager is not None) and (self.stats_manager.metrics_exist( - frame_num, self._metric_keys)): + if (self.stats_manager is not None) and ( + self.stats_manager.metrics_exist(frame_num, self._metric_keys) + ): frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] else: frame_avg = _compute_frame_average(frame_img) if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) + self.stats_manager.set_metrics( + frame_num, {self._metric_keys[0]: frame_avg} + ) if self.processed_frame: - if self.last_fade['type'] == 'in' and (( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): + if self.last_fade["type"] == "in" and ( + ( + self.method == ThresholdDetector.Method.FLOOR + and frame_avg < self.threshold + ) + or ( + self.method == ThresholdDetector.Method.CEILING + and frame_avg >= self.threshold + ) + ): # Just faded out of a scene, wait for next fade in. - self.last_fade['type'] = 'out' - self.last_fade['frame'] = frame_num - - elif self.last_fade['type'] == 'out' and ( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): + self.last_fade["type"] = "out" + self.last_fade["frame"] = frame_num + + elif self.last_fade["type"] == "out" and ( + ( + self.method == ThresholdDetector.Method.FLOOR + and frame_avg >= self.threshold + ) + or ( + self.method == ThresholdDetector.Method.CEILING + and frame_avg < self.threshold + ) + ): # Only add the scene if min_scene_len frames have passed. if (frame_num - self.last_scene_cut) >= self.min_scene_len: # Just faded into a new scene, compute timecode for the scene # split based on the fade bias. - f_out = self.last_fade['frame'] + f_out = self.last_fade["frame"] f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) + (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) + / 2 + ) cut_list.append(f_split) self.last_scene_cut = frame_num - self.last_fade['type'] = 'in' - self.last_fade['frame'] = frame_num + self.last_fade["type"] = "in" + self.last_fade["frame"] = frame_num else: - self.last_fade['frame'] = 0 + self.last_fade["frame"] = 0 if frame_avg < self.threshold: - self.last_fade['type'] = 'out' + self.last_fade["type"] = "out" else: - self.last_fade['type'] = 'in' + self.last_fade["type"] = "in" self.processed_frame = True return cut_list @@ -197,8 +217,13 @@ def post_process(self, frame_num: int): # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cut_times = [] - if self.last_fade['type'] == 'out' and self.add_final_scene and ( - (self.last_scene_cut is None and frame_num >= self.min_scene_len) or - (frame_num - self.last_scene_cut) >= self.min_scene_len): - cut_times.append(self.last_fade['frame']) + if ( + self.last_fade["type"] == "out" + and self.add_final_scene + and ( + (self.last_scene_cut is None and frame_num >= self.min_scene_len) + or (frame_num - self.last_scene_cut) >= self.min_scene_len + ) + ): + cut_times.append(self.last_fade["frame"]) return cut_times diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 5c009f52..d843df25 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -88,9 +88,11 @@ class FrameTimecode: 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) """ - def __init__(self, - timecode: Union[int, float, str, 'FrameTimecode'] = None, - fps: Union[int, float, str, 'FrameTimecode'] = None): + 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 @@ -112,20 +114,23 @@ def __init__(self, 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.') + 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.') + 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.') + 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. @@ -197,7 +202,7 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: # Compute hours and minutes based off of seconds, and update seconds. secs = self.get_seconds() hrs = int(secs / _SECONDS_PER_HOUR) - secs -= (hrs * _SECONDS_PER_HOUR) + secs -= hrs * _SECONDS_PER_HOUR mins = int(secs / _SECONDS_PER_MINUTE) secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) if use_rounding: @@ -211,15 +216,15 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: mins = 0 hrs += 1 # We have to extend the precision by 1 here, since `format` will round up. - msec = format(secs, '.%df' % (precision + 1)) if precision else '' + msec = format(secs, ".%df" % (precision + 1)) if precision else "" # Need to include decimal place in `msec_str`. - msec_str = msec[-(2 + precision):-1] + msec_str = msec[-(2 + precision) : -1] secs_str = f"{int(secs):02d}{msec_str}" # Return hours, minutes, and seconds as a formatted timecode string. - return '%02d:%02d:%s' % (hrs, mins, secs_str) + return "%02d:%02d:%s" % (hrs, mins, secs_str) # TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter. - def previous_frame(self) -> 'FrameTimecode': + 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) @@ -236,7 +241,7 @@ def _seconds_to_frames(self, seconds: float) -> int: 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. + """Parse a timecode number, storing it as the exact number of frames. Can be passed as frame number (int), seconds (float) Raises: @@ -246,20 +251,24 @@ def _parse_timecode_number(self, timecode: Union[int, float]) -> int: # Exact number of frames N if isinstance(timecode, int): if timecode < 0: - raise ValueError('Timecode frame number must be positive and greater than zero.') + 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.') + 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!') + raise TypeError("Timecode/frame number must be specified!") else: - raise TypeError('Timecode format/type unrecognized.') + raise TypeError("Timecode format/type unrecognized.") def _parse_timecode_string(self, input: str) -> int: """Parses a string based on the three possible forms (in timecode format, @@ -279,77 +288,97 @@ def _parse_timecode_string(self, input: str) -> int: if input.isdigit(): timecode = int(input) if timecode < 0: - raise ValueError('Timecode frame number must be positive.') + raise ValueError("Timecode frame number must be positive.") return timecode # Timecode in string format 'HH:MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") hrs, mins = int(values[0]), int(values[1]) - secs = float(values[2]) if '.' in values[2] else int(values[2]) + secs = float(values[2]) if "." in values[2] else int(values[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).') + raise ValueError( + "Invalid timecode range (values outside allowed range)." + ) secs += (hrs * 60 * 60) + (mins * 60) return self._seconds_to_frames(secs) # Try to parse the number as seconds in the format 1234.5 or 1234s - if input.endswith('s'): + if input.endswith("s"): input = input[:-1] - if not input.replace('.', '').isdigit(): - raise ValueError('All characters in timecode seconds string must be digits.') + if not input.replace(".", "").isdigit(): + raise ValueError( + "All characters in timecode seconds string must be digits." + ) as_float = float(input) if as_float < 0.0: - raise ValueError('Timecode seconds value must be positive.') + raise ValueError("Timecode seconds value must be positive.") return self._seconds_to_frames(as_float) - def __iadd__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode': + 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.') + 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. + 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': + 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': + 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.') + 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)) + 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': + 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': + def __eq__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): return self.frame_num == other elif isinstance(other, float): @@ -361,17 +390,20 @@ def __eq__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimeco return self.frame_num == other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "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)) + raise TypeError( + "Unsupported type for performing == with FrameTimecode: %s" + % type(other) + ) - def __ne__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __ne__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: return not self == other - def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num < other elif isinstance(other, float): @@ -383,12 +415,14 @@ def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num < other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing < with FrameTimecode: %s' % - type(other)) + raise TypeError( + "Unsupported type for performing < with FrameTimecode: %s" % type(other) + ) - def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num <= other elif isinstance(other, float): @@ -400,12 +434,15 @@ def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num <= other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing <= with FrameTimecode: %s' % - type(other)) + raise TypeError( + "Unsupported type for performing <= with FrameTimecode: %s" + % type(other) + ) - def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num > other elif isinstance(other, float): @@ -417,12 +454,14 @@ def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num > other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing > with FrameTimecode: %s' % - type(other)) + raise TypeError( + "Unsupported type for performing > with FrameTimecode: %s" % type(other) + ) - def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __ge__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num >= other elif isinstance(other, float): @@ -434,10 +473,13 @@ def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num >= other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing >= with FrameTimecode: %s' % - type(other)) + 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. @@ -452,7 +494,11 @@ 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) + return "%s [frame=%d, fps=%.3f]" % ( + self.get_timecode(), + self.frame_num, + self.framerate, + ) def __hash__(self) -> int: return self.frame_num diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 38c86bf3..b9b0bf7e 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -88,7 +88,7 @@ def __exit__(self, type, value, traceback): # TODO: Move this into scene_manager. def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: - """ Get OpenCV imwrite Params: Returns a dict of supported image formats and + """Get OpenCV imwrite Params: Returns a dict of supported image formats and their associated quality/compression parameter index, or None if that format is not supported. @@ -100,7 +100,7 @@ def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: """ def _get_cv2_param(param_name: str) -> Union[int, None]: - if param_name.startswith('CV_'): + if param_name.startswith("CV_"): param_name = param_name[3:] try: return getattr(cv2, param_name) @@ -108,9 +108,9 @@ def _get_cv2_param(param_name: str) -> Union[int, None]: return None return { - 'jpg': _get_cv2_param('IMWRITE_JPEG_QUALITY'), - 'png': _get_cv2_param('IMWRITE_PNG_COMPRESSION'), - 'webp': _get_cv2_param('IMWRITE_WEBP_QUALITY') + "jpg": _get_cv2_param("IMWRITE_JPEG_QUALITY"), + "png": _get_cv2_param("IMWRITE_PNG_COMPRESSION"), + "webp": _get_cv2_param("IMWRITE_WEBP_QUALITY"), } @@ -128,14 +128,16 @@ def get_file_name(file_path: AnyStr, include_extension=True) -> AnyStr: file_name = os.path.basename(file_path) if not include_extension: file_name = str(file_name) - last_dot_pos = file_name.rfind('.') + last_dot_pos = file_name.rfind(".") if last_dot_pos >= 0: file_name = file_name[:last_dot_pos] return file_name -def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = None) -> AnyStr: - """ Get & Create Path: Gets and returns the full/absolute path to file_path +def get_and_create_path( + file_path: AnyStr, output_directory: Optional[AnyStr] = None +) -> AnyStr: + """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories along the way. @@ -167,9 +169,11 @@ def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = ## -def init_logger(log_level: int = logging.INFO, - show_stdout: bool = False, - log_file: Optional[str] = None): +def init_logger( + log_level: int = logging.INFO, + show_stdout: bool = False, + log_file: Optional[str] = None, +): """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'. By default the logger has no handlers to suppress output. All existing log handlers are replaced every time this function is invoked. @@ -181,10 +185,10 @@ def init_logger(log_level: int = logging.INFO, log_file: If set, add handler to dump debug log messages to given file path. """ # Format of log messages depends on verbosity. - INFO_TEMPLATE = '[PySceneDetect] %(message)s' - DEBUG_TEMPLATE = '%(levelname)s: %(module)s.%(funcName)s(): %(message)s' + INFO_TEMPLATE = "[PySceneDetect] %(message)s" + DEBUG_TEMPLATE = "%(levelname)s: %(module)s.%(funcName)s(): %(message)s" # Get the named logger and remove any existing handlers. - logger_instance = logging.getLogger('pyscenedetect') + logger_instance = logging.getLogger("pyscenedetect") logger_instance.handlers = [] logger_instance.setLevel(log_level) # Add stdout handler if required. @@ -192,7 +196,10 @@ def init_logger(log_level: int = logging.INFO, handler = logging.StreamHandler(stream=sys.stdout) handler.setLevel(log_level) handler.setFormatter( - logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE)) + logging.Formatter( + fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE + ) + ) logger_instance.addHandler(handler) # Add debug log handler if required. if log_file: @@ -230,12 +237,12 @@ def invoke_command(args: List[str]) -> int: try: return subprocess.call(args) except OSError as err: - if os.name != 'nt': + if os.name != "nt": raise exception_string = str(err) # Error 206: The filename or extension is too long # Error 87: The parameter is incorrect - to_match = ('206', '87') + to_match = ("206", "87") if any([x in exception_string for x in to_match]): raise CommandTooLong() from err raise @@ -247,8 +254,8 @@ def get_ffmpeg_path() -> Optional[str]: """ # Try invoking ffmpeg with the current environment. try: - subprocess.call(['ffmpeg', '-v', 'quiet']) - return 'ffmpeg' + subprocess.call(["ffmpeg", "-v", "quiet"]) + return "ffmpeg" except OSError: pass # Failed to invoke ffmpeg with current environment, try another possibility. @@ -256,8 +263,9 @@ def get_ffmpeg_path() -> Optional[str]: try: # pylint: disable=import-outside-toplevel from imageio_ffmpeg import get_ffmpeg_exe + # pylint: enable=import-outside-toplevel - subprocess.call([get_ffmpeg_exe(), '-v', 'quiet']) + subprocess.call([get_ffmpeg_exe(), "-v", "quiet"]) return get_ffmpeg_exe() # Gracefully handle case where imageio_ffmpeg is not available. except ModuleNotFoundError: @@ -278,9 +286,9 @@ def get_ffmpeg_version() -> Optional[str]: if ffmpeg_path is None: return None # If get_ffmpeg_path() returns a value, the path it returns should be invocable. - output = subprocess.check_output(args=[ffmpeg_path, '-version'], text=True) + output = subprocess.check_output(args=[ffmpeg_path, "-version"], text=True) output_split = output.split() - if len(output_split) >= 3 and output_split[1] == 'version': + if len(output_split) >= 3 and output_split[1] == "version": return output_split[2] # If parsing the version fails, return the entire first line of output. return output.splitlines()[0] @@ -288,15 +296,15 @@ def get_ffmpeg_version() -> Optional[str]: def get_mkvmerge_version() -> Optional[str]: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" - tool_name = 'mkvmerge' + tool_name = "mkvmerge" try: - output = subprocess.check_output(args=[tool_name, '--version'], text=True) + output = subprocess.check_output(args=[tool_name, "--version"], text=True) except FileNotFoundError: # mkvmerge doesn't exist on the system return None output_split = output.split() if len(output_split) >= 1 and output_split[0] == tool_name: - return ' '.join(output_split[1:]) + return " ".join(output_split[1:]) # If parsing the version fails, return the entire first line of output. return output.splitlines()[0] @@ -307,31 +315,32 @@ def get_system_version_info() -> str: Used for the `scenedetect version -a` command. """ - output_template = '{:<12} {}' - line_separator = '-' * 60 - not_found_str = 'Not Installed' + output_template = "{:<12} {}" + line_separator = "-" * 60 + not_found_str = "Not Installed" out_lines = [] # System (Python, OS) - out_lines += ['System Info', line_separator] + out_lines += ["System Info", line_separator] out_lines += [ - output_template.format(name, version) for name, version in ( - ('OS', '%s' % platform.platform()), - ('Python', '%d.%d.%d' % sys.version_info[0:3]), + output_template.format(name, version) + for name, version in ( + ("OS", "%s" % platform.platform()), + ("Python", "%d.%d.%d" % sys.version_info[0:3]), ) ] # Third-Party Packages - out_lines += ['', 'Packages', line_separator] + out_lines += ["", "Packages", line_separator] third_party_packages = ( - 'av', - 'click', - 'cv2', - 'moviepy', - 'numpy', - 'platformdirs', - 'scenedetect', - 'tqdm', + "av", + "click", + "cv2", + "moviepy", + "numpy", + "platformdirs", + "scenedetect", + "tqdm", ) for module_name in third_party_packages: try: @@ -341,21 +350,25 @@ def get_system_version_info() -> str: out_lines.append(output_template.format(module_name, not_found_str)) # External Tools - out_lines += ['', 'Tools', line_separator] + out_lines += ["", "Tools", line_separator] tool_version_info = ( - ('ffmpeg', get_ffmpeg_version()), - ('mkvmerge', get_mkvmerge_version()), + ("ffmpeg", get_ffmpeg_version()), + ("mkvmerge", get_mkvmerge_version()), ) - for (tool_name, tool_version) in tool_version_info: + for tool_name, tool_version in tool_version_info: out_lines.append( - output_template.format(tool_name, tool_version if tool_version else not_found_str)) + output_template.format( + tool_name, tool_version if tool_version else not_found_str + ) + ) - return '\n'.join(out_lines) + return "\n".join(out_lines) class Template(string.Template): """Template matcher used to replace instances of $TEMPLATES in filenames.""" - idpattern = '[A-Z0-9_]+' + + idpattern = "[A-Z0-9_]+" flags = re.ASCII diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index ded5d35d..72bc7a5c 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -35,7 +35,7 @@ # pylint: disable=unused-argument, no-self-use class SceneDetector: - """ Base class to inherit from when implementing a scene detection algorithm. + """Base class to inherit from when implementing a scene detection algorithm. This API is not yet stable and subject to change. @@ -45,6 +45,7 @@ class SceneDetector: Also see the implemented scene detectors in the scenedetect.detectors module to get an idea of how a particular detector can be created. """ + # TODO(v0.7): Make this a proper abstract base class. stats_manager: ty.Optional[StatsManager] = None @@ -67,8 +68,10 @@ def is_processing_required(self, frame_num: int) -> bool: to be passed to process_frame for the given frame_num). """ metric_keys = self.get_metrics() - return not metric_keys or not (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys)) + return not metric_keys or not ( + self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys) + ) def stats_manager_required(self) -> bool: """Stats Manager Required: Prototype indicating if detector requires stats. @@ -133,8 +136,9 @@ class SparseSceneDetector(SceneDetector): An example of a SparseSceneDetector is the MotionDetector. """ - def process_frame(self, frame_num: int, - frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: + def process_frame( + self, frame_num: int, frame_img: numpy.ndarray + ) -> ty.List[ty.Tuple[int, int]]: """Process Frame: Computes/stores metrics and detects any scene changes. Prototype method, no actual detection. @@ -158,7 +162,6 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: - class Mode(Enum): MERGE = 0 """Merge consecutive cuts shorter than filter length.""" @@ -167,11 +170,15 @@ class Mode(Enum): def __init__(self, mode: Mode, length: int): self._mode = mode - self._filter_length = length # Number of frames to use for activating the filter. - self._last_above = None # Last frame above threshold. - self._merge_enabled = False # Used to disable merging until at least one cut was found. - self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filte. + self._filter_length = ( + length # Number of frames to use for activating the filter. + ) + self._last_above = None # Last frame above threshold. + self._merge_enabled = ( + False # Used to disable merging until at least one cut was found. + ) + self._merge_triggered = False # True when the merge filter is active. + self._merge_start = None # Frame number where we started the merge filte. def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if not self._filter_length > 0: @@ -179,9 +186,13 @@ def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if self._last_above is None: self._last_above = frame_num if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) + return self._filter_merge( + frame_num=frame_num, above_threshold=above_threshold + ) if self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) + return self._filter_suppress( + frame_num=frame_num, above_threshold=above_threshold + ) def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length @@ -200,7 +211,11 @@ def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. num_merged_frames = self._last_above - self._merge_start - if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + if ( + min_length_met + and not above_threshold + and num_merged_frames >= self._filter_length + ): self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index bbada707..0e4d01aa 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -91,16 +91,26 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import cv2 import numpy as np -from scenedetect._thirdparty.simpletable import (SimpleTableCell, SimpleTableImage, SimpleTableRow, - SimpleTable, HTMLPage) - -from scenedetect.platform import (tqdm, get_and_create_path, get_cv2_imwrite_params, Template) +from scenedetect._thirdparty.simpletable import ( + SimpleTableCell, + SimpleTableImage, + SimpleTableRow, + SimpleTable, + HTMLPage, +) + +from scenedetect.platform import ( + tqdm, + get_and_create_path, + get_cv2_imwrite_params, + Template, +) from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream from scenedetect.scene_detector import SceneDetector, SparseSceneDetector from scenedetect.stats_manager import StatsManager, FrameMetricRegistered -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current @@ -114,12 +124,13 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): MAX_FRAME_SIZE_ERRORS: int = 16 """Maximum number of frame size error messages that can be logged.""" -PROGRESS_BAR_DESCRIPTION = ' Detected: %d | Progress' +PROGRESS_BAR_DESCRIPTION = " Detected: %d | Progress" """Template to use for progress bar.""" class Interpolation(Enum): """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" + NEAREST = cv2.INTER_NEAREST """Nearest neighbor interpolation.""" LINEAR = cv2.INTER_LINEAR @@ -132,7 +143,9 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> int: +def compute_downscale_factor( + frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH +) -> int: """Get the optimal default downscale factor based on a video's resolution (currently only the width in pixels is considered). @@ -181,7 +194,7 @@ def get_scenes_from_cuts( """ # TODO(v0.7): Use the warnings module to turn this into a warning. if base_timecode is not None: - logger.error('`base_timecode` argument is deprecated has no effect.') + logger.error("`base_timecode` argument is deprecated has no effect.") # Scene list, where scenes are tuples of (Start FrameTimecode, End FrameTimecode). scene_list = [] @@ -200,10 +213,12 @@ def get_scenes_from_cuts( return scene_list -def write_scene_list(output_csv_file: TextIO, - scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], - include_cut_list: bool = True, - cut_list: Optional[Iterable[FrameTimecode]] = None) -> None: +def write_scene_list( + output_csv_file: TextIO, + scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], + include_cut_list: bool = True, + cut_list: Optional[Iterable[FrameTimecode]] = None, +) -> None: """Writes the given list of scenes to an output file handle in CSV format. Arguments: @@ -215,41 +230,56 @@ def write_scene_list(output_csv_file: TextIO, in the video that need to be split to generate individual scenes). If not specified, the cut list is generated using the start times of each scene following the first one. """ - csv_writer = csv.writer(output_csv_file, lineterminator='\n') + csv_writer = csv.writer(output_csv_file, lineterminator="\n") # If required, output the cutting list as the first row (i.e. before the header row). if include_cut_list: csv_writer.writerow( - ["Timecode List:"] + - cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) - csv_writer.writerow([ - "Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)", "End Frame", - "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", - "Length (seconds)" - ]) + ["Timecode List:"] + cut_list + if cut_list + else [start.get_timecode() for start, _ in scene_list[1:]] + ) + csv_writer.writerow( + [ + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", + ] + ) for i, (start, end) in enumerate(scene_list): duration = end - start - csv_writer.writerow([ - '%d' % (i + 1), - '%d' % (start.get_frames() + 1), - start.get_timecode(), - '%.3f' % start.get_seconds(), - '%d' % end.get_frames(), - end.get_timecode(), - '%.3f' % end.get_seconds(), - '%d' % duration.get_frames(), - duration.get_timecode(), - '%.3f' % duration.get_seconds() - ]) - - -def write_scene_list_html(output_html_filename, - scene_list, - cut_list=None, - css=None, - css_class='mytable', - image_filenames=None, - image_width=None, - image_height=None): + csv_writer.writerow( + [ + "%d" % (i + 1), + "%d" % (start.get_frames() + 1), + start.get_timecode(), + "%.3f" % start.get_seconds(), + "%d" % end.get_frames(), + end.get_timecode(), + "%.3f" % end.get_seconds(), + "%d" % duration.get_frames(), + duration.get_timecode(), + "%.3f" % duration.get_seconds(), + ] + ) + + +def write_scene_list_html( + output_html_filename, + scene_list, + cut_list=None, + css=None, + css_class="mytable", + image_filenames=None, + image_width=None, + image_height=None, +): """Writes the given list of scenes to an output file handle in html format. Arguments: @@ -306,40 +336,60 @@ def write_scene_list_html(output_html_filename, # Output Timecode list timecode_table = SimpleTable( - [["Timecode List:"] + - (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]])], - css_class=css_class) + [ + ["Timecode List:"] + + ( + cut_list + if cut_list + else [start.get_timecode() for start, _ in scene_list[1:]] + ) + ], + css_class=css_class, + ) # Output list of scenes header_row = [ - "Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)", "End Frame", - "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", - "Length (seconds)" + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", ] for i, (start, end) in enumerate(scene_list): duration = end - start - row = SimpleTableRow([ - '%d' % (i + 1), - '%d' % (start.get_frames() + 1), - start.get_timecode(), - '%.3f' % start.get_seconds(), - '%d' % end.get_frames(), - end.get_timecode(), - '%.3f' % end.get_seconds(), - '%d' % duration.get_frames(), - duration.get_timecode(), - '%.3f' % duration.get_seconds() - ]) + row = SimpleTableRow( + [ + "%d" % (i + 1), + "%d" % (start.get_frames() + 1), + start.get_timecode(), + "%.3f" % start.get_seconds(), + "%d" % end.get_frames(), + end.get_timecode(), + "%.3f" % end.get_seconds(), + "%d" % duration.get_frames(), + duration.get_timecode(), + "%.3f" % duration.get_seconds(), + ] + ) if image_filenames: for image in image_filenames[i]: row.add_cell( SimpleTableCell( - SimpleTableImage(image, width=image_width, height=image_height))) + SimpleTableImage(image, width=image_width, height=image_height) + ) + ) if i == 0: - scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) + scene_table = SimpleTable( + rows=[row], header_row=header_row, css_class=css_class + ) else: scene_table.add_row(row=row) @@ -355,20 +405,22 @@ def write_scene_list_html(output_html_filename, # TODO(v1.0): Refactor to take a SceneList object; consider moving this and save scene list # to a better spot, or just move them to scene_list.py. # -def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - video: VideoStream, - num_images: int = 3, - frame_margin: int = 1, - image_extension: str = 'jpg', - encoder_param: int = 95, - image_name_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', - output_dir: Optional[str] = None, - show_progress: Optional[bool] = False, - scale: Optional[float] = None, - height: Optional[int] = None, - width: Optional[int] = None, - interpolation: Interpolation = Interpolation.CUBIC, - video_manager=None) -> Dict[int, List[str]]: +def save_images( + scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + video: VideoStream, + num_images: int = 3, + frame_margin: int = 1, + image_extension: str = "jpg", + encoder_param: int = 95, + image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + output_dir: Optional[str] = None, + show_progress: Optional[bool] = False, + scale: Optional[float] = None, + height: Optional[int] = None, + width: Optional[int] = None, + interpolation: Interpolation = Interpolation.CUBIC, + video_manager=None, +) -> Dict[int, List[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -418,7 +470,7 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], """ # TODO(v0.7): Add DeprecationWarning that `video_manager` will be removed in v0.8. if video_manager is not None: - logger.error('`video_manager` argument is deprecated, use `video` instead.') + logger.error("`video_manager` argument is deprecated, use `video` instead.") video = video_manager if not scene_list: @@ -428,56 +480,70 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], # TODO: Validate that encoder_param is within the proper range. # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. - imwrite_param = [get_cv2_imwrite_params()[image_extension], encoder_param - ] if encoder_param is not None else [] + imwrite_param = ( + [get_cv2_imwrite_params()[image_extension], encoder_param] + if encoder_param is not None + else [] + ) video.reset() # Setup flags and init progress bar if available. completed = True - logger.info('Generating output images (%d per scene)...', num_images) + logger.info("Generating output images (%d per scene)...", num_images) progress_bar = None if show_progress: - progress_bar = tqdm(total=len(scene_list) * num_images, unit='images', dynamic_ncols=True) + progress_bar = tqdm( + total=len(scene_list) * num_images, unit="images", dynamic_ncols=True + ) filename_template = Template(image_name_template) - scene_num_format = '%0' - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' - image_num_format = '%0' - image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + 'd' + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" framerate = scene_list[0][0].framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. timecode_list = [ [ - FrameTimecode(int(f), fps=framerate) for f in [ - # middle frames - a[len(a) // 2] if (0 < j < num_images - 1) or num_images == 1 - - # first frame - else min(a[0] + frame_margin, a[-1]) if j == 0 - - # last frame + FrameTimecode(int(f), fps=framerate) + for f in [ + # middle frames + a[len(a) // 2] + if (0 < j < num_images - 1) or num_images == 1 + # first frame + else min(a[0] + frame_margin, a[-1]) + if j == 0 + # last frame else max(a[-1] - frame_margin, a[0]) - - # for each evenly-split array of frames in the scene list + # for each evenly-split array of frames in the scene list for j, a in enumerate(np.array_split(r, num_images)) ] - ] for i, r in enumerate([ - # pad ranges to number of images - r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.get_frames(), - start.get_frames() + max( - 1, # guard against zero length scenes - end.get_frames() - start.get_frames())) - # for each scene in scene list - for start, end in scene_list) - ]) + ] + for i, r in enumerate( + [ + # pad ranges to number of images + r + if 1 + r[-1] - r[0] >= num_images + else list(r) + [r[-1]] * (num_images - len(r)) + # create range of frames in scene + for r in ( + range( + start.get_frames(), + start.get_frames() + + max( + 1, # guard against zero length scenes + end.get_frames() - start.get_frames(), + ), + ) + # for each scene in scene list + for start, end in scene_list + ) + ] + ) ] image_filenames = {i: [] for i in range(len(timecode_list))} @@ -485,31 +551,34 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], if abs(aspect_ratio - 1.0) < 0.01: aspect_ratio = None - logger.debug('Writing images with template %s', filename_template.template) + logger.debug("Writing images with template %s", filename_template.template) for i, scene_timecodes in enumerate(timecode_list): for j, image_timecode in enumerate(scene_timecodes): video.seek(image_timecode) frame_im = video.read() if frame_im is not None: # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = '%s.%s' % ( + file_path = "%s.%s" % ( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), IMAGE_NUMBER=image_num_format % (j + 1), FRAME_NUMBER=image_timecode.get_frames(), TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), - TIMECODE=image_timecode.get_timecode().replace(":", ";")), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), image_extension, ) image_filenames[i].append(file_path) # TODO: Combine this resize with the ones below. if aspect_ratio is not None: frame_im = cv2.resize( - frame_im, (0, 0), + frame_im, + (0, 0), fx=aspect_ratio, fy=1.0, - interpolation=interpolation.value) + interpolation=interpolation.value, + ) frame_height = frame_im.shape[0] frame_width = frame_im.shape[1] @@ -523,12 +592,20 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], height = int(factor * frame_height) assert height > 0 and width > 0 frame_im = cv2.resize( - frame_im, (width, height), interpolation=interpolation.value) + frame_im, (width, height), interpolation=interpolation.value + ) elif scale: frame_im = cv2.resize( - frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) - - cv2.imwrite(get_and_create_path(file_path, output_dir), frame_im, imwrite_param) + frame_im, + (0, 0), + fx=scale, + fy=scale, + interpolation=interpolation.value, + ) + + cv2.imwrite( + get_and_create_path(file_path, output_dir), frame_im, imwrite_param + ) else: completed = False break @@ -539,7 +616,7 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], progress_bar.close() if not completed: - logger.error('Could not generate all output images.') + logger.error("Could not generate all output images.") return image_filenames @@ -625,7 +702,9 @@ def downscale(self, value: int): if value < 1: raise ValueError("Downscale factor must be a positive integer >= 1!") if self.auto_downscale: - logger.warning("Downscale factor will be ignored because auto_downscale=True!") + logger.warning( + "Downscale factor will be ignored because auto_downscale=True!" + ) if value is not None and not isinstance(value, int): logger.warning("Downscale factor will be truncated to integer!") value = int(value) @@ -665,10 +744,12 @@ def add_detector(self, detector: SceneDetector) -> None: else: self._sparse_detector_list.append(detector) - self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) + self._frame_buffer_size = max( + detector.event_buffer_length, self._frame_buffer_size + ) def get_num_detectors(self) -> int: - """Get number of registered scene detectors added via add_detector. """ + """Get number of registered scene detectors added via add_detector.""" return len(self._detector_list) def clear(self) -> None: @@ -687,13 +768,15 @@ def clear(self) -> None: self.clear_detectors() def clear_detectors(self) -> None: - """Remove all scene detectors added to the SceneManager via add_detector(). """ + """Remove all scene detectors added to the SceneManager via add_detector().""" self._detector_list.clear() self._sparse_detector_list.clear() - def get_scene_list(self, - base_timecode: Optional[FrameTimecode] = None, - start_in_scene: bool = False) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def get_scene_list( + self, + base_timecode: Optional[FrameTimecode] = None, + start_in_scene: bool = False, + ) -> List[Tuple[FrameTimecode, FrameTimecode]]: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: @@ -711,12 +794,13 @@ def get_scene_list(self, """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error('`base_timecode` argument is deprecated and has no effect.') + logger.error("`base_timecode` argument is deprecated and has no effect.") if self._base_timecode is None: return [] cut_list = self._get_cutting_list() scene_list = get_scenes_from_cuts( - cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1) + cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1 + ) # If we didn't actually detect any cuts, make sure the resulting scene_list is empty # unless start_in_scene is True. if not cut_list and not start_in_scene: @@ -735,13 +819,17 @@ def _get_event_list(self) -> List[Tuple[FrameTimecode, FrameTimecode]]: if not self._event_list: return [] assert self._base_timecode is not None - return [(self._base_timecode + start, self._base_timecode + end) - for start, end in self._event_list] + return [ + (self._base_timecode + start, self._base_timecode + end) + for start, end in self._event_list + ] - def _process_frame(self, - frame_num: int, - frame_im: np.ndarray, - callback: Optional[Callable[[np.ndarray, int], None]] = None) -> bool: + def _process_frame( + self, + frame_num: int, + frame_im: np.ndarray, + callback: Optional[Callable[[np.ndarray, int], None]] = None, + ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" new_cuts = False @@ -751,7 +839,7 @@ def _process_frame(self, self._frame_buffer.append(frame_im) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] - self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1):] + self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] for detector in self._detector_list: cuts = detector.process_frame(frame_num, frame_im) self._cutting_list += cuts @@ -778,14 +866,16 @@ def stop(self) -> None: """Stop the current :meth:`detect_scenes` call, if any. Thread-safe.""" self._stop.set() - def detect_scenes(self, - video: VideoStream = None, - duration: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, - frame_skip: int = 0, - show_progress: bool = False, - callback: Optional[Callable[[np.ndarray, int], None]] = None, - frame_source: Optional[VideoStream] = None) -> int: + def detect_scenes( + self, + video: VideoStream = None, + duration: Optional[FrameTimecode] = None, + end_time: Optional[FrameTimecode] = None, + frame_skip: int = 0, + show_progress: bool = False, + callback: Optional[Callable[[np.ndarray, int], None]] = None, + frame_source: Optional[VideoStream] = None, + ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the number of frames processed. Results can be obtained by calling :meth:`get_scene_list` or :meth:`get_cut_list`. @@ -821,16 +911,18 @@ def detect_scenes(self, video = frame_source # TODO(v0.8): Remove default value for `video` after `frame_source` is removed. if video is None: - raise TypeError("detect_scenes() missing 1 required positional argument: 'video'") + raise TypeError( + "detect_scenes() missing 1 required positional argument: 'video'" + ) if frame_skip > 0 and self.stats_manager is not None: - raise ValueError('frame_skip must be 0 when using a StatsManager.') + raise ValueError("frame_skip must be 0 when using a StatsManager.") if duration is not None and end_time is not None: - raise ValueError('duration and end_time cannot be set at the same time!') + raise ValueError("duration and end_time cannot be set at the same time!") # TODO: These checks should be handled by the FrameTimecode constructor. if duration is not None and isinstance(duration, (int, float)) and duration < 0: - raise ValueError('duration must be greater than or equal to 0!') + raise ValueError("duration must be greater than or equal to 0!") if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: - raise ValueError('end_time must be greater than or equal to 0!') + raise ValueError("end_time must be greater than or equal to 0!") self._base_timecode = video.base_timecode @@ -847,9 +939,9 @@ def detect_scenes(self, total_frames = 0 if video.duration is not None: if end_time is not None and end_time < video.duration: - total_frames = (end_time - start_frame_num) + total_frames = end_time - start_frame_num else: - total_frames = (video.duration.get_frames() - start_frame_num) + total_frames = video.duration.get_frames() - start_frame_num # Calculate the desired downscale factor and log the effective resolution. if self.auto_downscale: @@ -857,15 +949,18 @@ def detect_scenes(self, else: downscale_factor = self.downscale if downscale_factor > 1: - logger.info('Downscale factor set to %d, effective resolution: %d x %d', - downscale_factor, video.frame_size[0] // downscale_factor, - video.frame_size[1] // downscale_factor) + logger.info( + "Downscale factor set to %d, effective resolution: %d x %d", + downscale_factor, + video.frame_size[0] // downscale_factor, + video.frame_size[1] // downscale_factor, + ) progress_bar = None if show_progress: progress_bar = tqdm( total=int(total_frames), - unit='frames', + unit="frames", desc=PROGRESS_BAR_DESCRIPTION % 0, dynamic_ncols=True, ) @@ -875,11 +970,12 @@ def detect_scenes(self, decode_thread = threading.Thread( target=SceneManager._decode_thread, args=(self, video, frame_skip, downscale_factor, end_time, frame_queue), - daemon=True) + daemon=True, + ) decode_thread.start() frame_im = None - logger.info('Detecting scenes...') + logger.info("Detecting scenes...") while not self._stop.is_set(): next_frame, position = frame_queue.get() if next_frame is None and position is None: @@ -890,12 +986,15 @@ def detect_scenes(self, if progress_bar is not None: if new_cuts: progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False) + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), + refresh=False, + ) progress_bar.update(1 + frame_skip) if progress_bar is not None: progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True) + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True + ) progress_bar.close() # Unblock any puts in the decode thread before joining. This can happen if the main # processing thread stops before the decode thread. @@ -938,25 +1037,32 @@ def _decode_thread( if video.frame_size != decoded_size: logger.warn( f"WARNING: Decoded frame size ({decoded_size}) does not match " - f" video resolution {video.frame_size}, possible corrupt input.") + f" video resolution {video.frame_size}, possible corrupt input." + ) elif self._frame_size != decoded_size: self._frame_size_errors += 1 if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: logger.error( f"ERROR: Frame at {str(video.position)} has incorrect size and " f"cannot be processed: decoded size = {decoded_size}, " - f"expected = {self._frame_size}. Video may be corrupt.") + f"expected = {self._frame_size}. Video may be corrupt." + ) if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: logger.warn( - f"WARNING: Too many errors emitted, skipping future messages.") + f"WARNING: Too many errors emitted, skipping future messages." + ) # Skip processing frames that have an incorrect size. continue if downscale_factor > 1: frame_im = cv2.resize( - frame_im, (round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor)), - interpolation=self._interpolation.value) + frame_im, + ( + round(frame_im.shape[1] / downscale_factor), + round(frame_im.shape[0] / downscale_factor), + ), + interpolation=self._interpolation.value, + ) else: if video.read(decode=False) is False: break @@ -982,7 +1088,7 @@ def _decode_thread( logger.debug("Received KeyboardInterrupt.") self._stop.set() except BaseException: - logger.critical('Fatal error: Exception raised in decode thread.') + logger.critical("Fatal error: Exception raised in decode thread.") self._exception_info = sys.exc_info() self._stop.set() @@ -1001,9 +1107,9 @@ def _decode_thread( # pylint: disable=unused-argument - def get_cut_list(self, - base_timecode: Optional[FrameTimecode] = None, - show_warning: bool = True) -> List[FrameTimecode]: + def get_cut_list( + self, base_timecode: Optional[FrameTimecode] = None, show_warning: bool = True + ) -> List[FrameTimecode]: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing @@ -1026,12 +1132,13 @@ def get_cut_list(self, """ # TODO(v0.7): Use the warnings module to turn this into a warning. if show_warning: - logger.error('`get_cut_list()` is deprecated and will be removed in a future release.') + logger.error( + "`get_cut_list()` is deprecated and will be removed in a future release." + ) return self._get_cutting_list() def get_event_list( - self, - base_timecode: Optional[FrameTimecode] = None + self, base_timecode: Optional[FrameTimecode] = None ) -> List[Tuple[FrameTimecode, FrameTimecode]]: """[DEPRECATED] DO NOT USE. @@ -1048,7 +1155,9 @@ def get_event_list( List of pairs of FrameTimecode objects denoting the detected scenes. """ # TODO(v0.7): Use the warnings module to turn this into a warning. - logger.error('`get_event_list()` is deprecated and will be removed in a future release.') + logger.error( + "`get_event_list()` is deprecated and will be removed in a future release." + ) return self._get_event_list() # pylint: enable=unused-argument @@ -1057,4 +1166,9 @@ def _is_processing_required(self, frame_num: int) -> bool: """True if frame metrics not in StatsManager, False otherwise.""" if self.stats_manager is None: return True - return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) + return all( + [ + detector.is_processing_required(frame_num) + for detector in self._detector_list + ] + ) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 8bb8b9ec..9ac7e69e 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -25,13 +25,14 @@ import csv from logging import getLogger import typing as ty + # TODO: Replace below imports with `ty.` prefix. from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union import os.path from scenedetect.frame_timecode import FrameTimecode -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") ## ## StatsManager CSV File Column Names (Header Row) @@ -50,19 +51,23 @@ class FrameMetricRegistered(Exception): """[DEPRECATED - DO NOT USE] No longer used.""" + pass class FrameMetricNotRegistered(Exception): """[DEPRECATED - DO NOT USE] No longer used.""" + pass class StatsFileCorrupt(Exception): """Raised when frame metrics/stats could not be loaded from a provided CSV file.""" - def __init__(self, - message: str = "Could not load frame metric data data from passed CSV file."): + def __init__( + self, + message: str = "Could not load frame metric data data from passed CSV file.", + ): super().__init__(message) @@ -98,8 +103,12 @@ def __init__(self, base_timecode: FrameTimecode = None): # of each frame metric key and the value it represents (usually float). self._frame_metrics: Dict[FrameTimecode, Dict[str, float]] = dict() self._metric_keys: Set[str] = set() - self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: Optional[FrameTimecode] = base_timecode # Used for timing calculations. + self._metrics_updated: bool = ( + False # Flag indicating if metrics require saving. + ) + self._base_timecode: Optional[FrameTimecode] = ( + base_timecode # Used for timing calculations. + ) @property def metric_keys(self) -> ty.Iterable[str]: @@ -124,10 +133,12 @@ def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any in the same order as the input list of metric keys. If a metric could not be found, None is returned for that particular metric. """ - return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] + return [ + self._get_metric(frame_number, metric_key) for metric_key in metric_keys + ] def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None: - """ Set Metrics: Sets the provided statistics/metrics for a given frame. + """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: frame_number: Frame number to retrieve metrics for. @@ -138,15 +149,20 @@ def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) def metrics_exist(self, frame_number: int, metric_keys: Iterable[str]) -> bool: - """ Metrics Exist: Checks if the given metrics/stats exist for the given frame. + """Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: bool: True if the given metric keys exist for the frame, False otherwise. """ - return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys]) + return all( + [ + self._metric_exists(frame_number, metric_key) + for metric_key in metric_keys + ] + ) def is_save_required(self) -> bool: - """ Is Save Required: Checks if the stats have been updated since loading. + """Is Save Required: Checks if the stats have been updated since loading. Returns: bool: True if there are frame metrics/statistics not yet written to disk, @@ -154,11 +170,13 @@ def is_save_required(self) -> bool: """ return self._metrics_updated - def save_to_csv(self, - csv_file: Union[str, bytes, TextIO], - base_timecode: Optional[FrameTimecode] = None, - force_save=True) -> None: - """ Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. + def save_to_csv( + self, + csv_file: Union[str, bytes, TextIO], + base_timecode: Optional[FrameTimecode] = None, + force_save=True, + ) -> None: + """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. Arguments: csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str. @@ -170,7 +188,7 @@ def save_to_csv(self, """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error('base_timecode is deprecated and has no effect.') + logger.error("base_timecode is deprecated and has no effect.") if not (force_save or self.is_save_required()): logger.info("No metrics to write.") @@ -179,21 +197,23 @@ def save_to_csv(self, # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. if isinstance(csv_file, (str, bytes)): - with open(csv_file, 'w') as file: + with open(csv_file, "w") as file: self.save_to_csv(csv_file=file, force_save=force_save) return - csv_writer = csv.writer(csv_file, lineterminator='\n') + csv_writer = csv.writer(csv_file, lineterminator="\n") metric_keys = sorted(list(self._metric_keys)) - csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) + csv_writer.writerow( + [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys + ) frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: frame_timecode = self._base_timecode + frame_key csv_writer.writerow( - [frame_timecode.get_frames() + - 1, frame_timecode.get_timecode()] + - [str(metric) for metric in self.get_metrics(frame_key, metric_keys)]) + [frame_timecode.get_frames() + 1, frame_timecode.get_timecode()] + + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] + ) @staticmethod def valid_header(row: List[str]) -> bool: @@ -231,19 +251,21 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: was specified. """ # TODO: Make this an error, then make load_from_csv() a no-op, and finally, remove it. - logger.warning("load_from_csv() is deprecated and will be removed in a future release.") + logger.warning( + "load_from_csv() is deprecated and will be removed in a future release." + ) # If we get a path instead of an open file handle, check that it exists, and if so, # recursively call ourselves again but with file set instead of path. if isinstance(csv_file, (str, bytes)): if os.path.exists(csv_file): - with open(csv_file, 'r') as file: + with open(csv_file, "r") as file: return self.load_from_csv(csv_file=file) # Path doesn't exist. return None # If we get here, file is a valid file handle in read-only text mode. - csv_reader = csv.reader(csv_file, lineterminator='\n') + csv_reader = csv.reader(csv_file, lineterminator="\n") num_cols = None num_metrics = None num_frames = None @@ -262,28 +284,31 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: num_cols = len(row) num_metrics = num_cols - 2 if not num_metrics > 0: - raise StatsFileCorrupt('No metrics defined in CSV file.') + raise StatsFileCorrupt("No metrics defined in CSV file.") loaded_metrics = list(row[2:]) num_frames = 0 for row in csv_reader: metric_dict = {} if not len(row) == num_cols: - raise StatsFileCorrupt('Wrong number of columns detected in stats file row.') + raise StatsFileCorrupt( + "Wrong number of columns detected in stats file row." + ) frame_number = int(row[0]) # Switch from 1-based to 0-based frame numbers. if frame_number > 0: frame_number -= 1 self.set_metrics(frame_number, metric_dict) for i, metric in enumerate(row[2:]): - if metric and metric != 'None': + if metric and metric != "None": try: self._set_metric(frame_number, loaded_metrics[i], float(metric)) except ValueError: - raise StatsFileCorrupt('Corrupted value in stats file: %s' % - metric) from ValueError + raise StatsFileCorrupt( + "Corrupted value in stats file: %s" % metric + ) from ValueError num_frames += 1 self._metric_keys = self._metric_keys.union(set(loaded_metrics)) - logger.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) + logger.info("Loaded %d metrics for %d frames.", num_metrics, num_frames) self._metrics_updated = False return num_frames @@ -294,12 +319,16 @@ def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]: return self._frame_metrics[frame_number][metric_key] return None - def _set_metric(self, frame_number: int, metric_key: str, metric_value: Any) -> None: + def _set_metric( + self, frame_number: int, metric_key: str, metric_value: Any + ) -> None: self._metrics_updated = True if not frame_number in self._frame_metrics: self._frame_metrics[frame_number] = dict() self._frame_metrics[frame_number][metric_key] = metric_value def _metric_exists(self, frame_number: int, metric_key: str) -> bool: - return (frame_number in self._frame_metrics - and metric_key in self._frame_metrics[frame_number]) + return ( + frame_number in self._frame_metrics + and metric_key in self._frame_metrics[frame_number] + ) diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index a927bc95..5650d7ad 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -38,12 +38,14 @@ class VideoParameterMismatch(Exception): - """ VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some - of the video parameters (frame height, frame width, and framerate/FPS) do not match. """ - - def __init__(self, - file_list=None, - message="OpenCV VideoCapture object parameters do not match."): + """VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some + of the video parameters (frame height, frame width, and framerate/FPS) do not match.""" + + def __init__( + self, + file_list=None, + message="OpenCV VideoCapture object parameters do not match.", + ): # type: (Iterable[Tuple[int, float, float, str, str]], str) -> None # Pass message string to base Exception class. super(VideoParameterMismatch, self).__init__(message) @@ -54,13 +56,13 @@ def __init__(self, class VideoDecodingInProgress(RuntimeError): - """ VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that - must be called *before* start() has been called. """ + """VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that + must be called *before* start() has been called.""" class InvalidDownscaleFactor(ValueError): - """ InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, - i.e. the supplied downscale factor was not a positive integer greater than zero. """ + """InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, + i.e. the supplied downscale factor was not a positive integer greater than zero.""" ## @@ -75,12 +77,12 @@ def get_video_name(video_file: str) -> Tuple[str, str]: Tuple of the form [name, video_file]. """ if isinstance(video_file, int): - return ('Device %d' % video_file, video_file) + return ("Device %d" % video_file, video_file) return (os.path.split(video_file)[1], video_file) def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int: - """ Get Number of Frames: Returns total number of frames in the cap_list. + """Get Number of Frames: Returns total number of frames in the cap_list. Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. """ @@ -92,7 +94,7 @@ def open_captures( framerate: Optional[float] = None, validate_parameters: bool = True, ) -> Tuple[List[cv2.VideoCapture], float, Tuple[int, int]]: - """ Open Captures - helper function to open all capture objects, set the framerate, + """Open Captures - helper function to open all capture objects, set the framerate, and ensure that all open captures have been opened and the framerates match on a list of video file paths, or a list containing a single device ID. @@ -128,38 +130,52 @@ def open_captures( raise ValueError("Expected at least 1 video file or device ID.") if isinstance(video_files[0], int): if len(video_files) > 1: - raise ValueError("If device ID is specified, no video sources may be appended.") + raise ValueError( + "If device ID is specified, no video sources may be appended." + ) elif video_files[0] < 0: raise ValueError("Invalid/negative device ID specified.") is_device = True elif not all([isinstance(video_file, (str, bytes)) for video_file in video_files]): print(video_files) - raise ValueError("Unexpected element type in video_files list (expected str(s)/int).") + raise ValueError( + "Unexpected element type in video_files list (expected str(s)/int)." + ) elif framerate is not None and not isinstance(framerate, float): raise TypeError("Expected type float for parameter framerate.") # Check if files exist if passed video file is not an image sequence # (checked with presence of % in filename) or not a URL (://). - if not is_device and any([ + if not is_device and any( + [ not os.path.exists(video_file) for video_file in video_files - if not ('%' in video_file or '://' in video_file) - ]): + if not ("%" in video_file or "://" in video_file) + ] + ): raise IOError("Video file(s) not found.") cap_list = [] try: cap_list = [cv2.VideoCapture(video_file) for video_file in video_files] video_names = [get_video_name(video_file) for video_file in video_files] - closed_caps = [video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened()] + closed_caps = [ + video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened() + ] if closed_caps: raise VideoOpenFailure(str(closed_caps)) cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list] - cap_framerate, check_framerate = validate_capture_framerate(video_names, cap_framerates, - framerate) + cap_framerate, check_framerate = validate_capture_framerate( + video_names, cap_framerates, framerate + ) # Store frame sizes as integers (VideoCapture.get() returns float). - cap_frame_sizes = [(math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) for cap in cap_list] + cap_frame_sizes = [ + ( + math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) + for cap in cap_list + ] cap_frame_size = cap_frame_sizes[0] # If we need to validate the parameters, we check that the FPS and width/height @@ -169,7 +185,8 @@ def open_captures( video_names=video_names, cap_frame_sizes=cap_frame_sizes, check_framerate=check_framerate, - cap_framerates=cap_framerates) + cap_framerates=cap_framerates, + ) except: for cap in cap_list: @@ -197,15 +214,21 @@ def validate_capture_framerate( if framerate is not None: if isinstance(framerate, float): if framerate < MAX_FPS_DELTA: - raise ValueError("Invalid framerate (must be a positive non-zero value).") + raise ValueError( + "Invalid framerate (must be a positive non-zero value)." + ) cap_framerate = framerate check_framerate = False else: - raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__) + raise TypeError( + "Expected float for framerate, got %s." % type(framerate).__name__ + ) else: - unavailable_framerates = [(video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if fps < MAX_FPS_DELTA] + unavailable_framerates = [ + (video_names[i][0], video_names[i][1]) + for i, fps in enumerate(cap_framerates) + if fps < MAX_FPS_DELTA + ] if unavailable_framerates: raise FrameRateUnavailable() return (cap_framerate, check_framerate) @@ -217,7 +240,7 @@ def validate_capture_parameters( check_framerate: bool = False, cap_framerates: Optional[List[float]] = None, ) -> None: - """ Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) + """Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) framerates are equal. Raises VideoParameterMismatch if there is a mismatch. Raises: @@ -226,20 +249,41 @@ def validate_capture_parameters( bad_params = [] max_framerate_delta = MAX_FPS_DELTA # Check heights/widths match. - bad_params += [(cv2.CAP_PROP_FRAME_WIDTH, frame_size[0], cap_frame_sizes[0][0], - video_names[i][0], video_names[i][1]) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0] - bad_params += [(cv2.CAP_PROP_FRAME_HEIGHT, frame_size[1], cap_frame_sizes[0][1], - video_names[i][0], video_names[i][1]) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0] + bad_params += [ + ( + cv2.CAP_PROP_FRAME_WIDTH, + frame_size[0], + cap_frame_sizes[0][0], + video_names[i][0], + video_names[i][1], + ) + for i, frame_size in enumerate(cap_frame_sizes) + if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0 + ] + bad_params += [ + ( + cv2.CAP_PROP_FRAME_HEIGHT, + frame_size[1], + cap_frame_sizes[0][1], + video_names[i][0], + video_names[i][1], + ) + for i, frame_size in enumerate(cap_frame_sizes) + if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0 + ] # Check framerates if required. if check_framerate: - bad_params += [(cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], - video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if math.fabs(fps - cap_framerates[0]) > max_framerate_delta] + bad_params += [ + ( + cv2.CAP_PROP_FPS, + fps, + cap_framerates[0], + video_names[i][0], + video_names[i][1], + ) + for i, fps in enumerate(cap_framerates) + if math.fabs(fps - cap_framerates[0]) > max_framerate_delta + ] if bad_params: raise VideoParameterMismatch(bad_params) @@ -256,12 +300,14 @@ class VideoManager(VideoStream): Provides a cv2.VideoCapture-like interface to a set of one or more video files, or a single device ID. Supports seeking and setting end time/duration.""" - BACKEND_NAME = 'video_manager_do_not_use' + BACKEND_NAME = "video_manager_do_not_use" - def __init__(self, - video_files: List[str], - framerate: Optional[float] = None, - logger=getLogger('pyscenedetect')): + def __init__( + self, + video_files: List[str], + framerate: Optional[float] = None, + logger=getLogger("pyscenedetect"), + ): """[DEPRECATED] DO NOT USE. Arguments: @@ -285,14 +331,17 @@ def __init__(self, # will be removed in PySceneDetect v0.8. Use VideoStreamCv2 or VideoCaptureAdapter instead.' logger.error("VideoManager is deprecated and will be removed.") if not video_files: - raise ValueError("At least one string/integer must be passed in the video_files list.") + raise ValueError( + "At least one string/integer must be passed in the video_files list." + ) # Need to support video_files as a single str too for compatibility. if isinstance(video_files, str): video_files = [video_files] # These VideoCaptures are only open in this process. self._is_device = isinstance(video_files[0], int) self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=video_files, framerate=framerate) + video_files=video_files, framerate=framerate + ) self._path = video_files[0] if not self._is_device else video_files self._end_of_video = False self._start_time = self.get_base_timecode() @@ -303,12 +352,18 @@ def __init__(self, self._video_file_paths = video_files self._logger = logger if self._logger is not None: - self._logger.info('Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d', - len(self._cap_list), 's' if len(self._cap_list) > 1 else '', - self.get_framerate(), *self.get_framesize()) + self._logger.info( + "Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d", + len(self._cap_list), + "s" if len(self._cap_list) > 1 else "", + self.get_framerate(), + *self.get_framesize(), + ) self._started = False self._frame_length = self.get_base_timecode() + get_num_frames(self._cap_list) - self._first_cap_len = self.get_base_timecode() + get_num_frames([self._cap_list[0]]) + self._first_cap_len = self.get_base_timecode() + get_num_frames( + [self._cap_list[0]] + ) self._aspect_ratio = _get_aspect_ratio(self._cap_list[0]) def set_downscale_factor(self, downscale_factor=None): @@ -340,10 +395,10 @@ def get_video_name(self) -> str: """ video_paths = self.get_video_paths() if not video_paths: - return '' + return "" video_name = os.path.basename(video_paths[0]) - if video_name.rfind('.') >= 0: - video_name = video_name[:video_name.rfind('.')] + if video_name.rfind(".") >= 0: + video_name = video_name[: video_name.rfind(".")] return video_name def get_framerate(self) -> float: @@ -380,7 +435,7 @@ def get_base_timecode(self) -> FrameTimecode: return FrameTimecode(timecode=0, fps=self._cap_framerate) def get_current_timecode(self) -> FrameTimecode: - """ Get Current Timecode - returns a FrameTimecode object at current VideoManager position. + """Get Current Timecode - returns a FrameTimecode object at current VideoManager position. Returns: Timecode at the current VideoManager position. @@ -396,7 +451,7 @@ def get_framesize(self) -> Tuple[int, int]: return self._cap_framesize def get_framesize_effective(self) -> Tuple[int, int]: - """ Get Frame Size - returns the frame size of the video(s) open in the + """Get Frame Size - returns the frame size of the video(s) open in the VideoManager's capture objects. Returns: @@ -404,11 +459,13 @@ def get_framesize_effective(self) -> Tuple[int, int]: """ return self._cap_framesize - def set_duration(self, - duration: Optional[FrameTimecode] = None, - start_time: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None) -> None: - """ Set Duration - sets the duration/length of the video(s) to decode, as well as + def set_duration( + self, + duration: Optional[FrameTimecode] = None, + start_time: Optional[FrameTimecode] = None, + end_time: Optional[FrameTimecode] = None, + ) -> None: + """Set Duration - sets the duration/length of the video(s) to decode, as well as the start/end times. Must be called before :meth:`start()` is called, otherwise a VideoDecodingInProgress exception will be thrown. May be called after :meth:`reset()` as well. @@ -432,13 +489,23 @@ def set_duration(self, raise VideoDecodingInProgress() # Ensure any passed timecodes have the proper framerate. - if ((duration is not None and not duration.equal_framerate(self._cap_framerate)) - or (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) - or (end_time is not None and not end_time.equal_framerate(self._cap_framerate))): + if ( + (duration is not None and not duration.equal_framerate(self._cap_framerate)) + or ( + start_time is not None + and not start_time.equal_framerate(self._cap_framerate) + ) + or ( + end_time is not None + and not end_time.equal_framerate(self._cap_framerate) + ) + ): raise ValueError("FrameTimecode framerate does not match.") if duration is not None and end_time is not None: - raise TypeError("Only one of duration and end_time may be specified, not both.") + raise TypeError( + "Only one of duration and end_time may be specified, not both." + ) if start_time is not None: self._start_time = start_time @@ -455,13 +522,15 @@ def set_duration(self, self._frame_length -= self._start_time if self._logger is not None: - self._logger.info('Duration set, start: %s, duration: %s, end: %s.', - start_time.get_timecode() if start_time is not None else start_time, - duration.get_timecode() if duration is not None else duration, - end_time.get_timecode() if end_time is not None else end_time) + self._logger.info( + "Duration set, start: %s, duration: %s, end: %s.", + start_time.get_timecode() if start_time is not None else start_time, + duration.get_timecode() if duration is not None else duration, + end_time.get_timecode() if end_time is not None else end_time, + ) def get_duration(self) -> FrameTimecode: - """ Get Duration - gets the duration/length of the video(s) to decode, + """Get Duration - gets the duration/length of the video(s) to decode, as well as the start/end times. If the end time was not set by :meth:`set_duration()`, the end timecode @@ -477,7 +546,7 @@ def get_duration(self) -> FrameTimecode: return (self._frame_length, self._start_time, end_time) def start(self) -> None: - """ Start - starts video decoding and seeks to start time. Raises + """Start - starts video decoding and seeks to start time. Raises exception VideoDecodingInProgress if the method is called after the decoder process has already been started. @@ -498,7 +567,9 @@ def start(self) -> None: # from `timecode` to `target`. For compatibility, we allow calling seek with the form # seek(0), seek(timecode=0), and seek(target=0). Specifying both arguments is an error. # pylint: disable=arguments-differ - def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> bool: + def seek( + self, timecode: FrameTimecode = None, target: FrameTimecode = None + ) -> bool: """Seek forwards to the passed timecode. Only supports seeking forwards (i.e. timecode must be greater than the @@ -516,9 +587,9 @@ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> ValueError: Either none or both `timecode` and `target` were set. """ if timecode is None and target is None: - raise ValueError('`target` must be set.') + raise ValueError("`target` must be set.") if timecode is not None and target is not None: - raise ValueError('Only one of `timecode` or `target` can be set.') + raise ValueError("Only one of `timecode` or `target` can be set.") if target is not None: timecode = target assert timecode is not None @@ -539,8 +610,10 @@ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> # TODO: This should throw an exception instead of potentially failing silently # if no logger was provided. if self._logger is not None: - self._logger.error('Seeking past the first input video is not currently supported.') - self._logger.warning('Seeking to end of first input.') + self._logger.error( + "Seeking past the first input video is not currently supported." + ) + self._logger.warning("Seeking to end of first input.") timecode = self._first_cap_len if self._curr_cap is not None and self._end_of_video is not True: self._curr_cap.set(cv2.CAP_PROP_POS_FRAMES, timecode.get_frames() - 1) @@ -554,14 +627,14 @@ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> # pylint: enable=arguments-differ def release(self) -> None: - """ Release (cv2.VideoCapture method), releases all open capture(s). """ + """Release (cv2.VideoCapture method), releases all open capture(s).""" for cap in self._cap_list: cap.release() self._cap_list = [] self._started = False def reset(self) -> None: - """ Reset - Reopens captures passed to the constructor of the VideoManager. + """Reset - Reopens captures passed to the constructor of the VideoManager. Can only be called after the :meth:`release()` method has been called. @@ -575,11 +648,13 @@ def reset(self) -> None: self._end_of_video = False self._curr_time = self.get_base_timecode() self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=self._video_file_paths, framerate=self._curr_time.get_framerate()) + video_files=self._video_file_paths, + framerate=self._curr_time.get_framerate(), + ) self._curr_cap, self._curr_cap_idx = None, None def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, int]: - """ Get (cv2.VideoCapture method) - obtains capture properties from the current + """Get (cv2.VideoCapture method) - obtains capture properties from the current VideoCapture object in use. Index represents the same index as the original video_files list passed to the constructor. Getting/setting the position (POS) properties has no effect; seeking is implemented using VideoDecoder methods. @@ -607,7 +682,7 @@ def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, in return self._cap_list[index].get(capture_prop) def grab(self) -> bool: - """ Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. + """Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. Returns: bool: True if a frame was grabbed, False otherwise. @@ -631,7 +706,7 @@ def grab(self) -> bool: return grabbed def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: - """ Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. + """Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. Frame returned corresponds to last call to :meth:`grab()`. @@ -653,8 +728,10 @@ def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: self._last_frame = None return (retrieved, self._last_frame) - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: - """ Return next frame (or current if advance = False), or False if end of video. + def read( + self, decode: bool = True, advance: bool = True + ) -> Union[np.ndarray, bool]: + """Return next frame (or current if advance = False), or False if end of video. Arguments: decode: Decode and return the frame. @@ -690,7 +767,7 @@ def _get_next_cap(self) -> bool: return True def _correct_frame_length(self) -> None: - """ Checks if the current frame position exceeds that originally calculated, + """Checks if the current frame position exceeds that originally calculated, and adjusts the internally calculated frame length accordingly. Called after exhausting all input frames from the video source(s). """ @@ -749,8 +826,10 @@ def frame_rate(self) -> float: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return (math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT))) + return ( + math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) @property def is_seekable(self) -> bool: diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index a4bce715..ee32e232 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -41,10 +41,16 @@ import time import typing as ty -from scenedetect.platform import (tqdm, invoke_command, CommandTooLong, get_ffmpeg_path, Template) +from scenedetect.platform import ( + tqdm, + invoke_command, + CommandTooLong, + get_ffmpeg_path, + Template, +) from scenedetect.frame_timecode import FrameTimecode -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" @@ -62,7 +68,8 @@ """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" DEFAULT_FFMPEG_ARGS = ( - "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac") + "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac" +) """Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" ## @@ -71,14 +78,14 @@ def is_mkvmerge_available() -> bool: - """ Is mkvmerge Available: Gracefully checks if mkvmerge command is available. + """Is mkvmerge Available: Gracefully checks if mkvmerge command is available. Returns: True if `mkvmerge` can be invoked, False otherwise. """ ret_val = None try: - ret_val = subprocess.call(['mkvmerge', '--quiet']) + ret_val = subprocess.call(["mkvmerge", "--quiet"]) except OSError: return False if ret_val is not None and ret_val != 2: @@ -87,7 +94,7 @@ def is_mkvmerge_available() -> bool: def is_ffmpeg_available() -> bool: - """ Is ffmpeg Available: Gracefully checks if ffmpeg command is available. + """Is ffmpeg Available: Gracefully checks if ffmpeg command is available. Returns: True if `ffmpeg` can be invoked, False otherwise. @@ -103,6 +110,7 @@ def is_ffmpeg_available() -> bool: @dataclass class VideoMetadata: """Information about the video being split.""" + name: str """Expected name of the video. May differ from `path`.""" path: Path @@ -114,6 +122,7 @@ class VideoMetadata: @dataclass class SceneMetadata: """Information about the scene being extracted.""" + index: int """0-based index of this scene.""" start: FrameTimecode @@ -128,20 +137,25 @@ class SceneMetadata: def default_formatter(template: str) -> PathFormatter: """Formats filenames using a template string which allows the following variables: - `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` """ MIN_DIGITS = 3 format_scene_number: PathFormatter = lambda video, scene: ( - ('%0' + str(max(MIN_DIGITS, - math.floor(math.log(video.total_scenes, 10)) + 1)) + 'd') % - (scene.index + 1)) + ( + "%0" + + str(max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1)) + + "d" + ) + % (scene.index + 1) + ) formatter: PathFormatter = lambda video, scene: Template(template).safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=format_scene_number(video, scene), START_TIME=str(scene.start.get_timecode().replace(":", ";")), END_TIME=str(scene.end.get_timecode().replace(":", ";")), START_FRAME=str(scene.start.get_frames()), - END_FRAME=str(scene.end.get_frames())) + END_FRAME=str(scene.end.get_frames()), + ) return formatter @@ -154,12 +168,12 @@ def split_video_mkvmerge( input_video_path: str, scene_list: ty.Iterable[TimecodePair], output_dir: ty.Optional[Path] = None, - output_file_template: str = '$VIDEO_NAME.mkv', + output_file_template: str = "$VIDEO_NAME.mkv", video_name: ty.Optional[str] = None, show_output: bool = False, suppress_output=None, ) -> int: - """ Calls the mkvmerge command on the input video, splitting it at the + """Calls the mkvmerge command on the input video, splitting it at the passed timecodes, where each scene is written in sequence from 001. Arguments: @@ -179,19 +193,21 @@ def split_video_mkvmerge( """ # Handle backwards compatibility with v0.5 API. if isinstance(input_video_path, list): - logger.error('Using a list of paths is deprecated. Pass a single path instead.') + logger.error("Using a list of paths is deprecated. Pass a single path instead.") if len(input_video_path) > 1: - raise ValueError('Concatenating multiple input videos is not supported.') + raise ValueError("Concatenating multiple input videos is not supported.") input_video_path = input_video_path[0] if suppress_output is not None: - logger.error('suppress_output is deprecated, use show_output instead.') + logger.error("suppress_output is deprecated, use show_output instead.") show_output = not suppress_output if not scene_list: return 0 - logger.info('Splitting input video using mkvmerge, output path template:\n %s', - output_file_template) + logger.info( + "Splitting input video using mkvmerge, output path template:\n %s", + output_file_template, + ) if video_name is None: video_name = Path(input_video_path).stem @@ -207,31 +223,40 @@ def split_video_mkvmerge( output_path.parent.mkdir(parents=True, exist_ok=True) try: - call_list = ['mkvmerge'] + call_list = ["mkvmerge"] if not show_output: - call_list.append('--quiet') + call_list.append("--quiet") call_list += [ - '-o', - str(output_path), '--split', - 'parts:%s' % ','.join([ - '%s-%s' % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list - ]), input_video_path + "-o", + str(output_path), + "--split", + "parts:%s" + % ",".join( + [ + "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) + for start_time, end_time in scene_list + ] + ), + input_video_path, ] total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() processing_start_time = time.time() # TODO: Capture stdout/stderr and show that if the command fails. ret_val = invoke_command(call_list) if show_output: - logger.info('Average processing speed %.2f frames/sec.', - float(total_frames) / (time.time() - processing_start_time)) + logger.info( + "Average processing speed %.2f frames/sec.", + float(total_frames) / (time.time() - processing_start_time), + ) except CommandTooLong: logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logger.error('mkvmerge could not be found on the system.' - ' Please install mkvmerge to enable video output support.') + logger.error( + "mkvmerge could not be found on the system." + " Please install mkvmerge to enable video output support." + ) if ret_val != 0: - logger.error('Error splitting video (mkvmerge returned %d).', ret_val) + logger.error("Error splitting video (mkvmerge returned %d).", ret_val) return ret_val @@ -239,7 +264,7 @@ def split_video_ffmpeg( input_video_path: str, scene_list: ty.Iterable[TimecodePair], output_dir: ty.Optional[Path] = None, - output_file_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4', + output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", video_name: ty.Optional[str] = None, arg_override: str = DEFAULT_FFMPEG_ARGS, show_progress: bool = False, @@ -248,7 +273,7 @@ def split_video_ffmpeg( hide_progress=None, formatter: ty.Optional[PathFormatter] = None, ) -> int: - """ Calls the ffmpeg command on the input video, generating a new video for + """Calls the ffmpeg command on the input video, generating a new video for each scene based on the start/end timecodes. Arguments: @@ -274,22 +299,24 @@ def split_video_ffmpeg( """ # Handle backwards compatibility with v0.5 API. if isinstance(input_video_path, list): - logger.error('Using a list of paths is deprecated. Pass a single path instead.') + logger.error("Using a list of paths is deprecated. Pass a single path instead.") if len(input_video_path) > 1: - raise ValueError('Concatenating multiple input videos is not supported.') + raise ValueError("Concatenating multiple input videos is not supported.") input_video_path = input_video_path[0] if suppress_output is not None: - logger.error('suppress_output is deprecated, use show_output instead.') + logger.error("suppress_output is deprecated, use show_output instead.") show_output = not suppress_output if hide_progress is not None: - logger.error('hide_progress is deprecated, use show_progress instead.') + logger.error("hide_progress is deprecated, use show_progress instead.") show_progress = not hide_progress if not scene_list: return 0 - logger.info('Splitting input video using ffmpeg, output path template:\n %s', - output_file_template) + logger.info( + "Splitting input video using ffmpeg, output path template:\n %s", + output_file_template, + ) if video_name is None: video_name = Path(input_video_path).stem @@ -297,23 +324,26 @@ def split_video_ffmpeg( arg_override = arg_override.replace('\\"', '"') ret_val = 0 - arg_override = arg_override.split(' ') - scene_num_format = '%0' - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' + arg_override = arg_override.split(" ") + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" if formatter is None: formatter = default_formatter(output_file_template) video_metadata = VideoMetadata( - name=video_name, path=input_video_path, total_scenes=len(scene_list)) + name=video_name, path=input_video_path, total_scenes=len(scene_list) + ) try: progress_bar = None total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() if show_progress: - progress_bar = tqdm(total=total_frames, unit='frame', miniters=1, dynamic_ncols=True) + progress_bar = tqdm( + total=total_frames, unit="frame", miniters=1, dynamic_ncols=True + ) processing_start_time = time.time() for i, (start_time, end_time) in enumerate(scene_list): - duration = (end_time - start_time) + duration = end_time - start_time scene_metadata = SceneMetadata(index=i, start=start_time, end=end_time) output_path = Path(formatter(scene=scene_metadata, video=video_metadata)) if output_dir: @@ -321,29 +351,35 @@ def split_video_ffmpeg( output_path.parent.mkdir(parents=True, exist_ok=True) # Gracefully handle case where FFMPEG_PATH might be unset. - call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else 'ffmpeg'] + call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else "ffmpeg"] if not show_output: - call_list += ['-v', 'quiet'] + call_list += ["-v", "quiet"] elif i > 0: # Only show ffmpeg output for the first call, which will display any # errors if it fails, and then break the loop. We only show error messages # for the remaining calls. - call_list += ['-v', 'error'] + call_list += ["-v", "error"] call_list += [ - '-nostdin', '-y', '-ss', - str(start_time.get_seconds()), '-i', input_video_path, '-t', - str(duration.get_seconds()) + "-nostdin", + "-y", + "-ss", + str(start_time.get_seconds()), + "-i", + input_video_path, + "-t", + str(duration.get_seconds()), ] call_list += arg_override - call_list += ['-sn'] + call_list += ["-sn"] call_list += [str(output_path)] ret_val = invoke_command(call_list) if show_output and i == 0 and len(scene_list) > 1: logger.info( - 'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...') + "Output from ffmpeg for Scene 1 shown above, splitting remaining scenes..." + ) if ret_val != 0: # TODO: Capture stdout/stderr and display it on any failed calls. - logger.error('Error splitting video (ffmpeg returned %d).', ret_val) + logger.error("Error splitting video (ffmpeg returned %d).", ret_val) break if progress_bar: progress_bar.update(duration.get_frames()) @@ -351,12 +387,16 @@ def split_video_ffmpeg( if progress_bar: progress_bar.close() if show_output: - logger.info('Average processing speed %.2f frames/sec.', - float(total_frames) / (time.time() - processing_start_time)) + logger.info( + "Average processing speed %.2f frames/sec.", + float(total_frames) / (time.time() - processing_start_time), + ) except CommandTooLong: logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logger.error('ffmpeg could not be found on the system.' - ' Please install ffmpeg to enable video output support.') + logger.error( + "ffmpeg could not be found on the system." + " Please install ffmpeg to enable video output support." + ) return ret_val diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index bfdcbbf0..f157b972 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -70,8 +70,10 @@ class FrameRateUnavailable(VideoOpenFailure): rate is unavailable or cannot be calculated. Subclass of VideoOpenFailure.""" def __init__(self): - super().__init__('Unable to obtain video framerate! Specify `framerate` manually, or' - ' re-encode/re-mux the video and try again.') + super().__init__( + "Unable to obtain video framerate! Specify `framerate` manually, or" + " re-encode/re-mux the video and try again." + ) ## @@ -80,7 +82,7 @@ def __init__(self): class VideoStream(ABC): - """ Interface which all video backends must implement. """ + """Interface which all video backends must implement.""" # # Default Implementations @@ -177,7 +179,9 @@ def frame_number(self) -> int: # @abstractmethod - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read( + self, decode: bool = True, advance: bool = True + ) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -192,7 +196,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b @abstractmethod def reset(self) -> None: - """ Close and re-open the VideoStream (equivalent to seeking back to beginning). """ + """Close and re-open the VideoStream (equivalent to seeking back to beginning).""" raise NotImplementedError @abstractmethod diff --git a/setup.py b/setup.py index 2d8b2415..498f577c 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ # # Copyright (C) 2014-2024 Brandon Castellano . # -""" PySceneDetect setup.py - DEPRECATED. +"""PySceneDetect setup.py - DEPRECATED. Build using `python -m build` and installing the resulting .whl using `pip`. """ diff --git a/tests/__init__.py b/tests/__init__.py index 5a618310..6ff20946 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect Unit Test Suite +"""PySceneDetect Unit Test Suite To run all available tests run `pytest -v` from the parent directory (i.e. the root project folder of PySceneDetect containing the scenedetect/ diff --git a/tests/conftest.py b/tests/conftest.py index f7e8a25a..3e3e47a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect Test Configuration +"""PySceneDetect Test Configuration This file includes all pytest configuration for running PySceneDetect's tests. @@ -39,19 +39,22 @@ def check_exists(path: AnyStr) -> AnyStr: - """ Returns the absolute path to a (relative) path of a file that + """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ if not os.path.exists(path): - raise FileNotFoundError(""" + raise FileNotFoundError( + """ Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" % path) +""" + % path + ) return path @@ -84,13 +87,16 @@ def pytest_assertrepr_compare(op, left, right): def no_logs_gte_error(caplog): """Ensure no log messages with error severity or higher were reported during test execution.""" # TODO: Remove exclusion for VideoManager module when removed from codebase. - EXCLUDED_MODULES = {'video_manager'} + EXCLUDED_MODULES = {"video_manager"} yield errors = [ - record for record in caplog.get_records('call') + record + for record in caplog.get_records("call") if record.levelno >= logging.ERROR and not record.module in EXCLUDED_MODULES ] - assert not errors, "Test failed due to presence of one or more logs with ERROR severity." + assert ( + not errors + ), "Test failed due to presence of one or more logs with ERROR severity." @pytest.fixture diff --git a/tests/test_api.py b/tests/test_api.py index 1ddb5596..50fad8c5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -24,59 +24,79 @@ def test_api_detect(test_video_file: str): """Demonstrate usage of the `detect()` function to process a complete video.""" from scenedetect import detect, ContentDetector + scene_list = detect(test_video_file, ContentDetector()) for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print( + "Scene %d: %s - %s" + % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) + ) def test_api_detect_start_end_time(test_video_file: str): """Demonstrate usage of the `detect()` function to process a subset of a video.""" from scenedetect import detect, ContentDetector + # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). # See test_api_timecode_types() for examples of each format. - scene_list = detect(test_video_file, ContentDetector(), start_time=10.5, end_time=15.9) + scene_list = detect( + test_video_file, ContentDetector(), start_time=10.5, end_time=15.9 + ) for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print( + "Scene %d: %s - %s" + % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) + ) def test_api_detect_stats(test_video_file: str): """Demonstrate usage of the `detect()` function to generate a statsfile.""" from scenedetect import detect, ContentDetector + detect(test_video_file, ContentDetector(), stats_file_path="frame_metrics.csv") def test_api_scene_manager(test_video_file: str): """Demonstrate how to use a SceneManager to implement a function similar to `detect()`.""" from scenedetect import SceneManager, ContentDetector, open_video + video = open_video(test_video_file) scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print( + "Scene %d: %s - %s" + % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) + ) def test_api_scene_manager_start_end_time(test_video_file: str): """Demonstrate how to use a SceneManager to process a subset of the input video.""" from scenedetect import SceneManager, ContentDetector, open_video + video = open_video(test_video_file) scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). # See test_api_timecode_types() for examples of each format. - start_time = 200 # Start at frame (int) 200 + start_time = 200 # Start at frame (int) 200 end_time = 15.0 # End at 15 seconds (float) video.seek(start_time) scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print( + "Scene %d: %s - %s" + % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) + ) def test_api_timecode_types(): """Demonstrate all different types of timecodes that can be used.""" from scenedetect import FrameTimecode + base_timecode = FrameTimecode(timecode=0, fps=10.0) # Frames (int) timecode = base_timecode + 1 @@ -85,22 +105,23 @@ def test_api_timecode_types(): timecode = base_timecode + 1.0 assert timecode.get_frames() == 10 # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') - timecode = base_timecode + '00:00:01.500' + timecode = base_timecode + "00:00:01.500" assert timecode.get_frames() == 15 # Seconds (str, 'SSSs' or 'SSSS.SSSs') - timecode = base_timecode + '1.5s' + timecode = base_timecode + "1.5s" assert timecode.get_frames() == 15 def test_api_stats_manager(test_video_file: str): """Demonstrate using a StatsManager to save per-frame statistics to disk.""" from scenedetect import SceneManager, StatsManager, ContentDetector, open_video + video = open_video(test_video_file) scene_manager = SceneManager(stats_manager=StatsManager()) scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. - filename = '%s.stats.csv' % test_video_file + filename = "%s.stats.csv" % test_video_file scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -139,4 +160,6 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): total_frames = 1000 scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) - scene_manager.detect_scenes(video=video, duration=total_frames, callback=on_new_scene) + scene_manager.detect_scenes( + video=video, duration=total_frames, callback=on_new_scene + ) diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index eeae7620..0b383463 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.backend.opencv Tests +"""PySceneDetect scenedetect.backend.opencv Tests This file includes unit tests for the scenedetect.backend.opencv module that implements the VideoStreamCv2 ('opencv') backend. These tests validate behaviour specific to this backend. @@ -47,10 +47,14 @@ def test_capture_adapter(test_movie_clip: str): scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) - assert scene_manager.detect_scenes(video=adapter, duration=adapter.base_timecode + 10.0) + assert scene_manager.detect_scenes( + video=adapter, duration=adapter.base_timecode + 10.0 + ) scenes = scene_manager.get_scene_list() assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST) - assert [start.get_frames() for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST + assert [ + start.get_frames() for (start, _) in scenes + ] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST def test_capture_adapter_callback(test_video_file: str): diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index bfcc4bfb..5f8243e8 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.backend.pyav Tests +"""PySceneDetect scenedetect.backend.pyav Tests This file includes unit tests for the scenedetect.backend.pyav module that implements the VideoStreamAv ('pyav') backend. These tests validate behaviour specific to this backend. @@ -24,7 +24,7 @@ def test_video_stream_pyav_bytesio(test_video_file: str): """Test that VideoStreamAv works with a BytesIO input in addition to a path.""" # Mode must be binary! - video_file = open(test_video_file, mode='rb') + video_file = open(test_video_file, mode="rb") stream = VideoStreamAv(path_or_io=video_file, threading_mode=None) assert stream.is_seekable stream.seek(50) diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py index 2c7b5064..fdca641f 100644 --- a/tests/test_backwards_compat.py +++ b/tests/test_backwards_compat.py @@ -35,49 +35,59 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) # Suppress errors generated by using deprecated classes/arguments below. init_logger(log_level=logging.CRITICAL) video_manager = VideoManager([test_video_file]) - stats_file_path = test_video_file + '.csv' + stats_file_path = test_video_file + ".csv" stats_manager = StatsManager() scene_manager = SceneManager(stats_manager) scene_manager.add_detector(ContentDetector()) base_timecode = video_manager.get_base_timecode() scene_list = [] try: - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 10.0 # 00:00:10.000 + start_time = base_timecode + 20 # 00:00:00.667 + end_time = base_timecode + 10.0 # 00:00:10.000 if os.path.exists(stats_file_path): - with open(stats_file_path, 'r') as stats_file: + with open(stats_file_path, "r") as stats_file: stats_manager.load_from_csv(stats_file) # ContentDetector requires at least 1 frame before it can calculate any metrics. - assert stats_manager.metrics_exist(start_time.get_frames() + 1, - [ContentDetector.FRAME_SCORE_KEY]) + assert stats_manager.metrics_exist( + start_time.get_frames() + 1, [ContentDetector.FRAME_SCORE_KEY] + ) # Correct end frame # for presentation duration. - assert stats_manager.metrics_exist(end_time.get_frames() - 1, - [ContentDetector.FRAME_SCORE_KEY]) + assert stats_manager.metrics_exist( + end_time.get_frames() - 1, [ContentDetector.FRAME_SCORE_KEY] + ) video_manager.set_duration(start_time=start_time, end_time=end_time) video_manager.set_downscale_factor() video_manager.start() - assert video_manager.get_current_timecode().get_frames() == start_time.get_frames() + assert ( + video_manager.get_current_timecode().get_frames() == start_time.get_frames() + ) scene_manager.detect_scenes(frame_source=video_manager) scene_list = scene_manager.get_scene_list() # Correct end frame # for presentation duration. - assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1 + assert ( + video_manager.get_current_timecode().get_frames() + == end_time.get_frames() + 1 + ) - print('List of scenes obtained:') + print("List of scenes obtained:") for i, scene in enumerate(scene_list): - print(' Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( - i + 1, - scene[0].get_timecode(), - scene[0].get_frames(), - scene[1].get_timecode(), - scene[1].get_frames(), - )) + print( + " Scene %2d: Start %s / Frame %d, End %s / Frame %d" + % ( + i + 1, + scene[0].get_timecode(), + scene[0].get_frames(), + scene[1].get_timecode(), + scene[1].get_frames(), + ) + ) if stats_manager.is_save_required(): - with open(stats_file_path, 'w') as stats_file: + with open(stats_file_path, "w") as stats_file: stats_manager.save_to_csv(stats_file, base_timecode=base_timecode) finally: video_manager.release() @@ -87,7 +97,7 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) def test_backwards_compatibility_with_stats(test_video_file: str): """Runs equivalent code to `tests/api_test.py` from v0.5 twice to also exercise loading a statsfile from disk.""" - stats_file_path = test_video_file + '.csv' + stats_file_path = test_video_file + ".csv" if os.path.exists(stats_file_path): os.remove(stats_file_path) scenes = validate_backwards_compatibility(test_video_file, stats_file_path) diff --git a/tests/test_cli.py b/tests/test_cli.py index fffa0a56..43710c4f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,24 +42,28 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. -SCENEDETECT_CMD = 'python -m scenedetect' +SCENEDETECT_CMD = "python -m scenedetect" ALL_DETECTORS = [ - 'detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist', 'detect-hash' + "detect-content", + "detect-threshold", + "detect-adaptive", + "detect-hist", + "detect-hash", ] -ALL_BACKENDS = ['opencv', 'pyav'] +ALL_BACKENDS = ["opencv", "pyav"] -DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' +DEFAULT_VIDEO_PATH = "tests/resources/goldeneye.mp4" DEFAULT_VIDEO_NAME = Path(DEFAULT_VIDEO_PATH).stem -DEFAULT_BACKEND = 'opencv' -DEFAULT_STATSFILE = 'statsfile.csv' -DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. -DEFAULT_DETECTOR = 'detect-content' -DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. -DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. +DEFAULT_BACKEND = "opencv" +DEFAULT_STATSFILE = "statsfile.csv" +DEFAULT_TIME = "-s 2s -d 4s" # Seek forward a bit but limit the amount we process. +DEFAULT_DETECTOR = "detect-content" +DEFAULT_CONFIG_FILE = "scenedetect.cfg" # Ensure we default to a "blank" config file. +DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. def invoke_scenedetect( - args: str = '', + args: str = "", output_dir: ty.Optional[str] = None, config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, **kwargs, @@ -91,11 +95,11 @@ def invoke_scenedetect( value_dict.update(**kwargs) command = SCENEDETECT_CMD if output_dir: - command += ' -o %s' % output_dir + command += " -o %s" % output_dir if config_file: - command += ' -c %s' % config_file - command += ' ' + args.format(**value_dict) - return subprocess.call(command.strip().split(' ')) + command += " -c %s" % config_file + command += " " + args.format(**value_dict) + return subprocess.call(command.strip().split(" ")) def test_cli_no_args(): @@ -105,10 +109,10 @@ def test_cli_no_args(): def test_cli_default_detector(): """Test `scenedetect` command invoked without a detector.""" - assert invoke_scenedetect('-i {VIDEO} time {TIME}', config_file=None) == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME}", config_file=None) == 0 -@pytest.mark.parametrize('info_command', ['help', 'about', 'version']) +@pytest.mark.parametrize("info_command", ["help", "about", "version"]) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about).""" assert invoke_scenedetect(info_command) == 0 @@ -116,10 +120,10 @@ def test_cli_info_command(info_command): def test_cli_time_validate_options(): """Validate behavior of setting parameters via the `time` command.""" - base_command = '-i {VIDEO} time {TIME} {DETECTOR}' + base_command = "-i {VIDEO} time {TIME} {DETECTOR}" # Ensure cannot set end and duration together. - assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 6.0 -e 8.0') != 0 - assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 8.0 -d 6.0 ') != 0 + assert invoke_scenedetect(base_command, TIME="-s 2.0 -d 6.0 -e 8.0") != 0 + assert invoke_scenedetect(base_command, TIME="-s 2.0 -e 8.0 -d 6.0 ") != 0 def test_cli_time_end(): @@ -142,10 +146,19 @@ def test_cli_time_end(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + - test_case.split(), - text=True) + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + ] + + test_case.split(), + text=True, + ) assert EXPECTED in output, test_case @@ -169,10 +182,19 @@ def test_cli_time_start(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + - test_case.split(), - text=True) + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + ] + + test_case.split(), + text=True, + ) assert EXPECTED in output, test_case @@ -213,10 +235,19 @@ def test_cli_time_scene_boundary(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + - test_case.split(), - text=True) + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + ] + + test_case.split(), + text=True, + ) assert EXPECTED in output, test_case @@ -224,10 +255,21 @@ def test_cli_time_end_of_video(): """Validate frame number/timecode alignment at the end of the video. The end timecode includes presentation time and therefore should represent the full length of the video.""" output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ['-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], - text=True) - assert """ + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "detect-content", + "list-scenes", + "-n", + "time", + "-s", + "1872", + ], + text=True, + ) + assert ( + """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -235,31 +277,44 @@ def test_cli_time_end_of_video(): | 2 | 1917 | 00:01:19.913 | 1966 | 00:01:21.999 | | 3 | 1967 | 00:01:21.999 | 1980 | 00:01:22.582 | ----------------------------------------------------------------------- -""" in output +""" + in output + ) assert "00:01:19.913,00:01:21.999" in output -@pytest.mark.parametrize('detector_command', ALL_DETECTORS) +@pytest.mark.parametrize("detector_command", ALL_DETECTORS) def test_cli_detector(detector_command: str): """Test each detection algorithm.""" # Ensure all detectors work without a statsfile. - assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR}', DETECTOR=detector_command) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR}", DETECTOR=detector_command + ) + == 0 + ) -@pytest.mark.parametrize('detector_command', ALL_DETECTORS) +@pytest.mark.parametrize("detector_command", ALL_DETECTORS) def test_cli_detector_with_stats(tmp_path, detector_command: str): """Test each detection algorithm with a statsfile.""" # Run with a statsfile twice to ensure the file is populated with those metrics and reloaded. - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}', - output_dir=tmp_path, - DETECTOR=detector_command, - ) == 0 - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}', - output_dir=tmp_path, - DETECTOR=detector_command, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", + output_dir=tmp_path, + DETECTOR=detector_command, + ) + == 0 + ) + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", + output_dir=tmp_path, + DETECTOR=detector_command, + ) + == 0 + ) # TODO: Check for existence of statsfile by trying to load it with the library, # and ensuring that we got some frames. @@ -267,78 +322,126 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" # Regular invocation - assert invoke_scenedetect( - '-i {VIDEO} time {TIME} {DETECTOR} list-scenes', - output_dir=tmp_path, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", + output_dir=tmp_path, + ) + == 0 + ) # Add statsfile - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes', - output_dir=tmp_path, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes", + output_dir=tmp_path, + ) + == 0 + ) # Suppress output file - assert invoke_scenedetect( - '-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n', - output_dir=tmp_path, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n", + output_dir=tmp_path, + ) + == 0 + ) # TODO: Check for output files from regular invocation. # TODO: Delete scene list and ensure is not recreated using -n. -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +@pytest.mark.skipif( + condition=not is_ffmpeg_available(), reason="ffmpeg is not available" +) def test_cli_split_video_ffmpeg(tmp_path: Path): """Test `split-video` command using ffmpeg.""" # Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER. - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video', output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video", + output_dir=tmp_path, + ) + == 0 + ) entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) - assert (len(entries) == DEFAULT_NUM_SCENES), entries + assert len(entries) == DEFAULT_NUM_SCENES, entries [entry.unlink() for entry in entries] - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c', output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c", + output_dir=tmp_path, + ) + == 0 + ) entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) - assert (len(entries) == DEFAULT_NUM_SCENES) + assert len(entries) == DEFAULT_NUM_SCENES [entry.unlink() for entry in entries] - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER', - output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER", + output_dir=tmp_path, + ) + == 0 + ) entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*")) - assert (len(entries) == DEFAULT_NUM_SCENES), entries + assert len(entries) == DEFAULT_NUM_SCENES, entries [entry.unlink() for entry in entries] # -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero) assert invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a \"-c:v libx264\"", - output_dir=tmp_path) + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a "-c:v libx264"', + output_dir=tmp_path, + ) -@pytest.mark.skipif(condition=not is_mkvmerge_available(), reason="mkvmerge is not available") +@pytest.mark.skipif( + condition=not is_mkvmerge_available(), reason="mkvmerge is not available" +) def test_cli_split_video_mkvmerge(tmp_path: Path): """Test `split-video` command using mkvmerge.""" - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m', output_dir=tmp_path) == 0 - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c', output_dir=tmp_path) == 0 - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', - output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m", + output_dir=tmp_path, + ) + == 0 + ) + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c", + output_dir=tmp_path, + ) + == 0 + ) + assert ( + invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', + output_dir=tmp_path, + ) + == 0 + ) # -a/--args and -m/--mkvmerge are mutually exclusive assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -a "-c:v libx264"', - output_dir=tmp_path) + output_dir=tmp_path, + ) # TODO: Check for existence of split video files. def test_cli_save_images(tmp_path: Path): """Test `save-images` command.""" - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images', output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images", + output_dir=tmp_path, + ) + == 0 + ) # Open one of the created images and make sure it has the correct resolution. # TODO: Also need to test that the right number of images was generated, and compare with # expected frames from the actual video. - images = glob.glob(os.path.join(tmp_path, '*.jpg')) + images = glob.glob(os.path.join(tmp_path, "*.jpg")) assert images image = cv2.imread(images[0]) assert image.shape == (544, 1280, 3) @@ -347,11 +450,15 @@ def test_cli_save_images(tmp_path: Path): # TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. def test_cli_save_images_rotation(rotated_video_file, tmp_path): """Test that `save-images` command rotates images correctly with the default backend.""" - assert invoke_scenedetect( - '-i {VIDEO} {DETECTOR} time {TIME} save-images', - VIDEO=rotated_video_file, - output_dir=tmp_path) == 0 - images = glob.glob(os.path.join(tmp_path, '*.jpg')) + assert ( + invoke_scenedetect( + "-i {VIDEO} {DETECTOR} time {TIME} save-images", + VIDEO=rotated_video_file, + output_dir=tmp_path, + ) + == 0 + ) + images = glob.glob(os.path.join(tmp_path, "*.jpg")) assert images image = cv2.imread(images[0]) # Note same resolution as in test_cli_save_images but rotated 90 degrees. @@ -360,42 +467,69 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path): def test_cli_export_html(tmp_path: Path): """Test `export-html` command.""" - base_command = '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}' - assert invoke_scenedetect( - base_command, COMMAND='save-images export-html', output_dir=tmp_path) == 0 - assert invoke_scenedetect( - base_command, COMMAND='export-html --no-images', output_dir=tmp_path) == 0 + base_command = "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}" + assert ( + invoke_scenedetect( + base_command, COMMAND="save-images export-html", output_dir=tmp_path + ) + == 0 + ) + assert ( + invoke_scenedetect( + base_command, COMMAND="export-html --no-images", output_dir=tmp_path + ) + == 0 + ) # TODO: Check for existence of HTML & image files. -@pytest.mark.parametrize('backend_type', ALL_BACKENDS) +@pytest.mark.parametrize("backend_type", ALL_BACKENDS) def test_cli_backend(backend_type: str): """Test setting the `-b`/`--backend` argument.""" - assert invoke_scenedetect( - '-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}', BACKEND=backend_type) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}", BACKEND=backend_type + ) + == 0 + ) def test_cli_backend_unsupported(): """Ensure setting an invalid backend returns an error.""" - assert invoke_scenedetect( - '-i {VIDEO} -b {BACKEND} {DETECTOR}', BACKEND='unknown_backend_type') != 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -b {BACKEND} {DETECTOR}", BACKEND="unknown_backend_type" + ) + != 0 + ) def test_cli_load_scenes(): """Ensure we can load scenes both with and without the cut row.""" - assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes') == 0 - assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes") == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv" + ) + == 0 + ) # Specifying a detector with load-scenes should be disallowed. assert invoke_scenedetect( - '-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv') + "-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv" + ) # Specifying load-scenes several times should be disallowed. assert invoke_scenedetect( - '-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv' + "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv" ) # If `-s`/`--skip-cuts` is specified, the resulting scene list should still be compatible with # the `load-scenes` command. - assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s') == 0 - assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s") == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv" + ) + == 0 + ) def test_cli_load_scenes_with_time_frames(): @@ -406,24 +540,27 @@ def test_cli_load_scenes_with_time_frames(): 2,91 3,211 """ - with open('test_scene_list.csv', 'w') as f: + with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + [ - '-i', + SCENEDETECT_CMD.split(" ") + + [ + "-i", DEFAULT_VIDEO_PATH, - 'load-scenes', - '-i', - 'test_scene_list.csv', - 'time', - '-s', - '2s', - '-e', - '10s', - 'list-scenes', + "load-scenes", + "-i", + "test_scene_list.csv", + "time", + "-s", + "2s", + "-e", + "10s", + "list-scenes", ], - text=True) - assert """ + text=True, + ) + assert ( + """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -431,7 +568,9 @@ def test_cli_load_scenes_with_time_frames(): | 2 | 91 | 00:00:03.754 | 210 | 00:00:08.759 | | 3 | 211 | 00:00:08.759 | 240 | 00:00:10.010 | ----------------------------------------------------------------------- -""" in output +""" + in output + ) assert "00:00:03.754,00:00:08.759" in output @@ -443,21 +582,47 @@ def test_cli_load_scenes_round_trip(): 2,91 3,211 """ - with open('test_scene_list.csv', 'w') as f: + with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) ground_truth = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + [ - '-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-f', 'testout.csv', 'time', - '-s', '200', '-e', '400' + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "detect-content", + "list-scenes", + "-f", + "testout.csv", + "time", + "-s", + "200", + "-e", + "400", ], - text=True) + text=True, + ) loaded_first_pass = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + [ - '-i', DEFAULT_VIDEO_PATH, 'load-scenes', '-i', 'testout.csv', 'time', '-s', '200', '-e', - '400', 'list-scenes', '-f', 'testout2.csv' + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "load-scenes", + "-i", + "testout.csv", + "time", + "-s", + "200", + "-e", + "400", + "list-scenes", + "-f", + "testout2.csv", ], - text=True) - SPLIT_POINT = ' | Scene # | Start Frame | Start Time | End Frame | End Time |' + text=True, + ) + SPLIT_POINT = ( + " | Scene # | Start Frame | Start Time | End Frame | End Time |" + ) assert ground_truth.split(SPLIT_POINT)[1] == loaded_first_pass.split(SPLIT_POINT)[1] - with open('testout.csv') as first, open('testout2.csv') as second: + with open("testout.csv") as first, open("testout2.csv") as second: assert first.readlines() == second.readlines() diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 38152b01..7d1d5323 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect Scene Detection Tests +"""PySceneDetect Scene Detection Tests These tests ensure that the detection algorithms deliver consistent results by using known ground truths of scene cut locations in the @@ -34,7 +34,10 @@ HistogramDetector, ) -ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) +ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( + *FAST_CUT_DETECTORS, + ThresholdDetector, +) # TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores # regardless of resolution. This will ensure that threshold values will hold true for different @@ -44,20 +47,23 @@ # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: - """ Returns the absolute path to a (relative) path of a file that + """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError(""" + raise FileNotFoundError( + """ Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" % relative_path) +""" + % relative_path + ) return abs_path @@ -82,7 +88,8 @@ def detect(self): video_path=self.path, detector=self.detector, start_time=self.start_time, - end_time=self.end_time) + end_time=self.end_time, + ) def get_fast_cut_test_cases(): @@ -96,8 +103,11 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=15), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), - id="%s/default" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], + ), + id="%s/default" % detector_type.__name__, + ) + for detector_type in FAST_CUT_DETECTORS ] # goldeneye.mp4 with min_scene_len = 30 test_cases += [ @@ -107,8 +117,11 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=30), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365]), - id="%s/m=30" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS + scene_boundaries=[1199, 1260, 1334, 1365], + ), + id="%s/m=30" % detector_type.__name__, + ) + for detector_type in FAST_CUT_DETECTORS ] return test_cases @@ -124,16 +137,20 @@ def get_fade_in_out_test_cases(): detector=ThresholdDetector(), start_time=0, end_time=500, - scene_boundaries=[0, 15, 198, 376]), - id="threshold_testvideo_default"), + scene_boundaries=[0, 15, 198, 376], + ), + id="threshold_testvideo_default", + ), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), detector=ThresholdDetector(), start_time=0, end_time=250, - scene_boundaries=[0, 84, 167]), - id="threshold_fades_default"), + scene_boundaries=[0, 84, 167], + ), + id="threshold_fades_default", + ), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), @@ -144,8 +161,10 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 84, 167, 245]), - id="threshold_fades_floor"), + scene_boundaries=[0, 84, 167, 245], + ), + id="threshold_fades_floor", + ), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), @@ -156,8 +175,10 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 42, 125, 209]), - id="threshold_fades_ceil"), + scene_boundaries=[0, 42, 125, 209], + ), + id="threshold_fades_ceil", + ), ] @@ -181,7 +202,7 @@ def test_detect_fades(test_case: TestCase): def test_detectors_with_stats(test_video_file): - """ Test all detectors functionality with a StatsManager. """ + """Test all detectors functionality with a StatsManager.""" # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). for detector in ALL_DETECTORS: video = VideoStreamCv2(test_video_file) @@ -189,7 +210,7 @@ def test_detectors_with_stats(test_video_file): scene_manager = SceneManager(stats_manager=stats) scene_manager.add_detector(detector()) scene_manager.auto_downscale = True - end_time = FrameTimecode('00:00:08', video.frame_rate) + end_time = FrameTimecode("00:00:08", video.frame_rate) scene_manager.detect_scenes(video=video, end_time=end_time) initial_scene_len = len(scene_manager.get_scene_list()) assert initial_scene_len > 0, "Test case must have at least one scene." diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index aa5c5386..f6728cab 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.timecode Tests +"""PySceneDetect scenedetect.timecode Tests This file includes unit tests for the scenedetect.timecode module (specifically, the FrameTimecode object, used for representing frame-accurate timestamps and time values). @@ -32,7 +32,7 @@ def test_framerate(): - ''' Test FrameTimecode constructor argument "fps". ''' + """Test FrameTimecode constructor argument "fps".""" # Not passing fps results in TypeError. with pytest.raises(TypeError): FrameTimecode() @@ -65,7 +65,7 @@ def test_framerate(): def test_timecode_numeric(): - ''' Test FrameTimecode constructor argument "timecode" with numeric arguments. ''' + """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" with pytest.raises(ValueError): FrameTimecode(timecode=-1, fps=1) with pytest.raises(ValueError): @@ -81,113 +81,131 @@ def test_timecode_numeric(): def test_timecode_string(): - ''' Test FrameTimecode constructor argument "timecode" with string arguments. ''' + """Test FrameTimecode constructor argument "timecode" with string arguments.""" # Invalid strings: with pytest.raises(ValueError): - FrameTimecode(timecode='-1', fps=1) + FrameTimecode(timecode="-1", fps=1) with pytest.raises(ValueError): - FrameTimecode(timecode='-1.0', fps=1.0) + FrameTimecode(timecode="-1.0", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='-0.1', fps=1.0) + FrameTimecode(timecode="-0.1", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='1.9x', fps=1) + FrameTimecode(timecode="1.9x", fps=1) with pytest.raises(ValueError): - FrameTimecode(timecode='1x', fps=1.0) + FrameTimecode(timecode="1x", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='1.9.9', fps=1.0) + FrameTimecode(timecode="1.9.9", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='1.0-', fps=1.0) + FrameTimecode(timecode="1.0-", fps=1.0) # Frame number integer [int->str] ('%d', integer number as string) - assert FrameTimecode(timecode='0', fps=1).frame_num == 0 - assert FrameTimecode(timecode='1', fps=1).frame_num == 1 - assert FrameTimecode(timecode='10', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="0", fps=1).frame_num == 0 + assert FrameTimecode(timecode="1", fps=1).frame_num == 1 + assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 # Seconds format [float->str] ('%f', number as string) - assert FrameTimecode(timecode='0.0', fps=1).frame_num == 0 - assert FrameTimecode(timecode='1.0', fps=1).frame_num == 1 - assert FrameTimecode(timecode='10.0', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.0000000000', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.100', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='1.100', fps=10.0).frame_num == 11 + assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0 + assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1 + assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) - assert FrameTimecode(timecode='0s', fps=1).frame_num == 0 - assert FrameTimecode(timecode='1s', fps=1).frame_num == 1 - assert FrameTimecode(timecode='10s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.0s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.0000000000s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.100s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='1.100s', fps=10.0).frame_num == 11 + assert FrameTimecode(timecode="0s", fps=1).frame_num == 0 + assert FrameTimecode(timecode="1s", fps=1).frame_num == 1 + assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) - assert FrameTimecode(timecode='00:00:01', fps=1).frame_num == 1 - assert FrameTimecode(timecode='00:00:01.9999', fps=1).frame_num == 2 - assert FrameTimecode(timecode='00:00:02.0000', fps=1).frame_num == 2 - assert FrameTimecode(timecode='00:00:02.0001', fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1 + assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 - assert FrameTimecode(timecode='00:00:01', fps=10).frame_num == 10 - assert FrameTimecode(timecode='00:00:00.5', fps=10).frame_num == 5 - assert FrameTimecode(timecode='00:00:00.100', fps=10).frame_num == 1 - assert FrameTimecode(timecode='00:00:00.001', fps=1000).frame_num == 1 + assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 + assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 + assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 + assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1 - assert FrameTimecode(timecode='00:00:59.999', fps=1).frame_num == 60 - assert FrameTimecode(timecode='00:01:00.000', fps=1).frame_num == 60 - assert FrameTimecode(timecode='00:01:00.001', fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60 - assert FrameTimecode(timecode='00:59:59.999', fps=1).frame_num == 3600 - assert FrameTimecode(timecode='01:00:00.000', fps=1).frame_num == 3600 - assert FrameTimecode(timecode='01:00:00.001', fps=1).frame_num == 3600 + assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 def test_get_frames(): - ''' Test FrameTimecode get_frames() method. ''' + """Test FrameTimecode get_frames() method.""" assert FrameTimecode(timecode=1, fps=1.0).get_frames(), 1 assert FrameTimecode(timecode=1000, fps=60.0).get_frames(), 1000 assert FrameTimecode(timecode=1000000000, fps=29.97).get_frames(), 1000000000 assert FrameTimecode(timecode=1.0, fps=1.0).get_frames(), int(1.0 / 1.0) assert FrameTimecode(timecode=1000.0, fps=60.0).get_frames(), int(1000.0 * 60.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int(1000000000.0 * 29.97) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int( + 1000000000.0 * 29.97 + ) - assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_frames(), 2 - assert FrameTimecode(timecode='00:00:00.5', fps=10).get_frames(), 5 - assert FrameTimecode(timecode='00:00:01', fps=10).get_frames(), 10 - assert FrameTimecode(timecode='00:01:00.000', fps=1).get_frames(), 60 + assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_frames(), 2 + assert FrameTimecode(timecode="00:00:00.5", fps=10).get_frames(), 5 + assert FrameTimecode(timecode="00:00:01", fps=10).get_frames(), 10 + assert FrameTimecode(timecode="00:01:00.000", fps=1).get_frames(), 60 def test_get_seconds(): - ''' Test FrameTimecode get_seconds() method. ''' + """Test FrameTimecode get_seconds() method.""" assert FrameTimecode(timecode=1, fps=1.0).get_seconds(), pytest.approx(1.0 / 1.0) - assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx(1000 / 60.0) - assert FrameTimecode( - timecode=1000000000, fps=29.97).get_seconds(), pytest.approx(1000000000 / 29.97) + assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx( + 1000 / 60.0 + ) + assert FrameTimecode(timecode=1000000000, fps=29.97).get_seconds(), pytest.approx( + 1000000000 / 29.97 + ) assert FrameTimecode(timecode=1.0, fps=1.0).get_seconds(), pytest.approx(1.0) assert FrameTimecode(timecode=1000.0, fps=60.0).get_seconds(), pytest.approx(1000.0) - assert FrameTimecode( - timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx(1000000000.0) - - assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_seconds(), pytest.approx(2.0) - assert FrameTimecode(timecode='00:00:00.5', fps=10).get_seconds(), pytest.approx(0.5) - assert FrameTimecode(timecode='00:00:01', fps=10).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode='00:01:00.000', fps=1).get_seconds(), pytest.approx(60.0) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx( + 1000000000.0 + ) + + assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_seconds(), pytest.approx( + 2.0 + ) + assert FrameTimecode(timecode="00:00:00.5", fps=10).get_seconds(), pytest.approx( + 0.5 + ) + assert FrameTimecode(timecode="00:00:01", fps=10).get_seconds(), pytest.approx(1.0) + assert FrameTimecode(timecode="00:01:00.000", fps=1).get_seconds(), pytest.approx( + 60.0 + ) def test_get_timecode(): - ''' Test FrameTimecode get_timecode() method. ''' - assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == '00:00:01.000' - assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == '00:01:00.117' - assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == '01:00:00.234' - - assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_timecode() == '00:00:02.000' - assert FrameTimecode(timecode='00:00:00.5', fps=10).get_timecode() == '00:00:00.500' - assert FrameTimecode(timecode='00:00:01.501', fps=10).get_timecode() == '00:00:01.500' - assert FrameTimecode(timecode='00:01:00.000', fps=1).get_timecode() == '00:01:00.000' + """Test FrameTimecode get_timecode() method.""" + assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == "00:00:01.000" + assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117" + assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234" + + assert ( + FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" + ) + assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.500" + ) + assert ( + FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" + ) def test_equality(): - ''' Test FrameTimecode equality (==, __eq__) operator. ''' + """Test FrameTimecode equality (==, __eq__) operator.""" x = FrameTimecode(timecode=1.0, fps=10.0) assert x == x assert x == FrameTimecode(timecode=1.0, fps=10.0) @@ -203,19 +221,19 @@ def test_equality(): assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) - assert x == '00:00:01' - assert x == '00:00:01.0' - assert x == '00:00:01.00' - assert x == '00:00:01.000' - assert x == '00:00:01.0000' - assert x == '00:00:01.00000' + assert x == "00:00:01" + assert x == "00:00:01.0" + assert x == "00:00:01.00" + assert x == "00:00:01.000" + assert x == "00:00:01.0000" + assert x == "00:00:01.00000" assert x == 10 assert x == 1.0 with pytest.raises(ValueError): - x == '0x' + x == "0x" with pytest.raises(ValueError): - x == 'x00:00:00.000' + x == "x00:00:00.000" with pytest.raises(TypeError): x == [0] with pytest.raises(TypeError): @@ -225,31 +243,31 @@ def test_equality(): with pytest.raises(TypeError): x == {0: 0} - assert FrameTimecode(timecode='00:00:00.5', fps=10) == '00:00:00.500' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.500' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.501' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.502' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.508' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.509' - assert FrameTimecode(timecode='00:00:01.519', fps=10) == '00:00:01.510' + assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.501" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.502" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.508" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.509" + assert FrameTimecode(timecode="00:00:01.519", fps=10) == "00:00:01.510" def test_addition(): - ''' Test FrameTimecode addition (+/+=, __add__/__iadd__) operator. ''' + """Test FrameTimecode addition (+/+=, __add__/__iadd__) operator.""" x = FrameTimecode(timecode=1.0, fps=10.0) assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) assert x + 1 == FrameTimecode(1.1, x) assert x + 10 == 20 assert x + 10 == 2.0 - assert x + 10 == '00:00:02.000' + assert x + 10 == "00:00:02.000" with pytest.raises(TypeError): - FrameTimecode('00:00:02.000', fps=20.0) == x + 10 + FrameTimecode("00:00:02.000", fps=20.0) == x + 10 def test_subtraction(): - ''' Test FrameTimecode subtraction (-/-=, __sub__) operator. ''' + """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" x = FrameTimecode(timecode=1.0, fps=10.0) assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) assert x - 2 == FrameTimecode(0.8, x) @@ -264,12 +282,14 @@ def test_subtraction(): assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) with pytest.raises(TypeError): - FrameTimecode('00:00:02.000', fps=20.0) == x - 10 + FrameTimecode("00:00:02.000", fps=20.0) == x - 10 -@pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) +@pytest.mark.parametrize( + "frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)] +) def test_identity(frame_num, fps): - ''' Test FrameTimecode values, when used in init return the same values ''' + """Test FrameTimecode values, when used in init return the same values""" frame_time_code = FrameTimecode(frame_num, fps=fps) assert FrameTimecode(frame_time_code) == frame_time_code assert FrameTimecode(frame_time_code.get_frames(), fps=fps) == frame_time_code @@ -282,16 +302,52 @@ def test_precision(): fps = 1000.0 - assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.11" - assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.11" - assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) == "00:00:00.1" - assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.1" - assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) == "00:00:00" - assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" - - assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.99" - assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.99" - assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) == "00:00:01.0" - assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" - assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" - assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + assert ( + FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) + == "00:00:00.11" + ) + assert ( + FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) + == "00:00:00.11" + ) + assert ( + FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) + == "00:00:00.1" + ) + assert ( + FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) + == "00:00:00.1" + ) + assert ( + FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) + == "00:00:00" + ) + assert ( + FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) + == "00:00:00" + ) + + assert ( + FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) + == "00:00:00.99" + ) + assert ( + FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) + == "00:00:00.99" + ) + assert ( + FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) + == "00:00:01.0" + ) + assert ( + FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) + == "00:00:00.9" + ) + assert ( + FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) + == "00:00:01" + ) + assert ( + FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) + == "00:00:00" + ) diff --git a/tests/test_platform.py b/tests/test_platform.py index 4f90ff1e..767aeecb 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.platform Tests +"""PySceneDetect scenedetect.platform Tests This file includes unit tests for the scenedetect.platform module, containing all platform/library/OS-specific compatibility fixes. @@ -23,18 +23,18 @@ def test_invoke_command(): - """ Ensures the function exists and is callable without throwing - an exception. """ - if platform.system() == 'Windows': - invoke_command(['cmd']) + """Ensures the function exists and is callable without throwing + an exception.""" + if platform.system() == "Windows": + invoke_command(["cmd"]) else: - invoke_command(['echo']) + invoke_command(["echo"]) def test_long_command(): - """ [Windows Only] Ensures that a command string too large to be handled + """[Windows Only] Ensures that a command string too large to be handled is translated to the correct exception for error handling. """ - if platform.system() == 'Windows': + if platform.system() == "Windows": with pytest.raises(CommandTooLong): - invoke_command('x' * 2**15) + invoke_command("x" * 2**15) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index c974398d..a6f0234f 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -38,8 +38,8 @@ def test_scene_list(test_video_file): sm.add_detector(ContentDetector()) video_fps = video.frame_rate - start_time = FrameTimecode('00:00:05', video_fps) - end_time = FrameTimecode('00:00:15', video_fps) + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) assert end_time.get_frames() > start_time.get_frames() @@ -93,22 +93,27 @@ def test_save_images(test_video_file): sm = SceneManager() sm.add_detector(ContentDetector()) - image_name_glob = 'scenedetect.tempfile.*.jpg' - image_name_template = ('scenedetect.tempfile.' - '$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.' - '$TIMESTAMP_MS.$TIMECODE') + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile." + "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." + "$TIMESTAMP_MS.$TIMECODE" + ) try: video_fps = video.frame_rate - scene_list = [(FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)]] + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] image_filenames = save_images( scene_list=scene_list, video=video, num_images=3, - image_extension='jpg', - image_name_template=image_name_template) + image_extension="jpg", + image_name_template=image_name_template, + ) # Ensure images got created, and the proper number got created. total_images = 0 @@ -128,21 +133,26 @@ def test_save_images(test_video_file): def test_save_images_zero_width_scene(test_video_file): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" video = VideoStreamCv2(test_video_file) - image_name_glob = 'scenedetect.tempfile.*.jpg' - image_name_template = 'scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER' + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" try: video_fps = video.frame_rate - scene_list = [(FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)]] + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)] + ] NUM_IMAGES = 10 image_filenames = save_images( scene_list=scene_list, video=video, num_images=10, - image_extension='jpg', - image_name_template=image_name_template) + image_extension="jpg", + image_name_template=image_name_template, + ) assert len(image_filenames) == 3 - assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) + assert all( + len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames + ) total_images = 0 for scene_number in image_filenames: for path in image_filenames[scene_number]: @@ -195,13 +205,14 @@ def test_detect_scenes_callback(test_video_file): fake_callback = FakeCallback() video_fps = video.frame_rate - start_time = FrameTimecode('00:00:05', video_fps) - end_time = FrameTimecode('00:00:15', video_fps) + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) video.seek(start_time) sm.auto_downscale = True _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_lambda()) + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() + ) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -212,7 +223,9 @@ def test_detect_scenes_callback(test_video_file): fake_callback = FakeCallback() video.seek(start_time) - _ = sm.detect_scenes(video=video, end_time=end_time, callback=fake_callback.get_callback_func()) + _ = sm.detect_scenes( + video=video, end_time=end_time, callback=fake_callback.get_callback_func() + ) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -231,13 +244,14 @@ def test_detect_scenes_callback_adaptive(test_video_file): fake_callback = FakeCallback() video_fps = video.frame_rate - start_time = FrameTimecode('00:00:05', video_fps) - end_time = FrameTimecode('00:00:15', video_fps) + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) video.seek(start_time) sm.auto_downscale = True _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_lambda()) + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() + ) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -248,7 +262,9 @@ def test_detect_scenes_callback_adaptive(test_video_file): fake_callback = FakeCallback() video.seek(start_time) - _ = sm.detect_scenes(video=video, end_time=end_time, callback=fake_callback.get_callback_func()) + _ = sm.detect_scenes( + video=video, end_time=end_time, callback=fake_callback.get_callback_func() + ) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 9c2f0af6..91bcadb3 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.stats_manager Tests +"""PySceneDetect scenedetect.stats_manager Tests This file includes unit tests for the scenedetect.stats_manager module (specifically, the StatsManager object, used to coordinate caching of frame metrics to/from a CSV @@ -27,7 +27,7 @@ These files will be deleted, if possible, after the tests are completed running. """ -#pylint: disable=protected-access +# pylint: disable=protected-access import csv import os @@ -47,24 +47,25 @@ from scenedetect.stats_manager import COLUMN_NAME_TIMECODE # TODO(v1.0): use https://docs.pytest.org/en/6.2.x/tmpdir.html -TEST_STATS_FILES = ['TEST_STATS_FILE'] * 4 +TEST_STATS_FILES = ["TEST_STATS_FILE"] * 4 TEST_STATS_FILES = [ - '%s_%012d.csv' % (stats_file, random.randint(0, 10**12)) for stats_file in TEST_STATS_FILES + "%s_%012d.csv" % (stats_file, random.randint(0, 10**12)) + for stats_file in TEST_STATS_FILES ] def teardown_module(): - """ Removes any created stats files, if any. """ + """Removes any created stats files, if any.""" for stats_file in TEST_STATS_FILES: if os.path.exists(stats_file): os.remove(stats_file) def test_metrics(): - """ Test StatsManager metric registration/setting/getting with a set of pre-defined + """Test StatsManager metric registration/setting/getting with a set of pre-defined key-value pairs (metric_dict). """ - metric_dict = {'some_metric': 1.2345, 'another_metric': 6.7890} + metric_dict = {"some_metric": 1.2345, "another_metric": 6.7890} metric_keys = list(metric_dict.keys()) stats = StatsManager() @@ -85,12 +86,13 @@ def test_metrics(): assert stats.metrics_exist(frame_key, metric_keys) assert stats.metrics_exist(frame_key, metric_keys[1:]) - assert stats.get_metrics( - frame_key, metric_keys) == [metric_dict[metric_key] for metric_key in metric_keys] + assert stats.get_metrics(frame_key, metric_keys) == [ + metric_dict[metric_key] for metric_key in metric_keys + ] def test_detector_metrics(test_video_file): - """ Test passing StatsManager to a SceneManager and using it for storing the frame metrics + """Test passing StatsManager to a SceneManager and using it for storing the frame metrics from a ContentDetector. """ video = VideoStreamCv2(test_video_file) @@ -98,7 +100,7 @@ def test_detector_metrics(test_video_file): scene_manager = SceneManager(stats_manager) scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode('00:00:05', video_fps) + duration = FrameTimecode("00:00:05", video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video=video, duration=duration) # Check that metrics were written to the StatsManager. @@ -106,8 +108,8 @@ def test_detector_metrics(test_video_file): def test_load_empty_stats(): - """ Test loading an empty stats file, ensuring it results in no errors. """ - open(TEST_STATS_FILES[0], 'w').close() + """Test loading an empty stats file, ensuring it results in no errors.""" + open(TEST_STATS_FILES[0], "w").close() stats_manager = StatsManager() stats_manager.load_from_csv(TEST_STATS_FILES[0]) @@ -119,36 +121,41 @@ def test_save_no_detect_scenes(): def test_load_hardcoded_file(): - """ Test loading a stats file with some hard-coded data generated by this test case. """ + """Test loading a stats file with some hard-coded data generated by this test case.""" stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], 'w') as stats_file: + with open(TEST_STATS_FILES[0], "w") as stats_file: + stats_writer = csv.writer(stats_file, lineterminator="\n") - stats_writer = csv.writer(stats_file, lineterminator='\n') - - some_metric_key = 'some_metric' + some_metric_key = "some_metric" some_metric_value = 1.2 some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) some_frame_timecode = base_timecode + some_frame_key # Write out a valid file. - stats_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) stats_writer.writerow( - [some_frame_key + 1, - some_frame_timecode.get_timecode(), - str(some_metric_value)]) + [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key] + ) + stats_writer.writerow( + [ + some_frame_key + 1, + some_frame_timecode.get_timecode(), + str(some_metric_value), + ] + ) stats_manager.load_from_csv(TEST_STATS_FILES[0]) # Check that we decoded the correct values. assert stats_manager.metrics_exist(some_frame_key, [some_metric_key]) - assert stats_manager.get_metrics(some_frame_key, - [some_metric_key])[0] == pytest.approx(some_metric_value) + assert stats_manager.get_metrics(some_frame_key, [some_metric_key])[ + 0 + ] == pytest.approx(some_metric_value) def test_save_load_from_video(test_video_file): - """ Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and + """Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and loading the file back to ensure the loaded frame metrics agree with those that were saved. """ video = VideoStreamCv2(test_video_file) @@ -158,7 +165,7 @@ def test_save_load_from_video(test_video_file): scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode('00:00:05', video_fps) + duration = FrameTimecode("00:00:05", video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video, duration=duration) @@ -181,14 +188,14 @@ def test_save_load_from_video(test_video_file): def test_load_corrupt_stats(): - """ Test loading a corrupted stats file created by outputting data in the wrong format. """ + """Test loading a corrupted stats file created by outputting data in the wrong format.""" stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], 'wt') as stats_file: - stats_writer = csv.writer(stats_file, lineterminator='\n') + with open(TEST_STATS_FILES[0], "wt") as stats_file: + stats_writer = csv.writer(stats_file, lineterminator="\n") - some_metric_key = 'some_metric' + some_metric_key = "some_metric" some_metric_value = str(1.2) some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) @@ -198,9 +205,12 @@ def test_load_corrupt_stats(): # File #0: Wrong Header Names [StatsFileCorrupt] # Swapped timecode & frame number. - stats_writer.writerow([COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key]) stats_writer.writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) + [COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key] + ) + stats_writer.writerow( + [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value] + ) stats_file.close() diff --git a/tests/test_video_splitter.py b/tests/test_video_splitter.py index 2cd77cbb..f13008f2 100644 --- a/tests/test_video_splitter.py +++ b/tests/test_video_splitter.py @@ -18,11 +18,17 @@ import pytest from scenedetect import open_video -from scenedetect.video_splitter import (split_video_ffmpeg, is_ffmpeg_available, SceneMetadata, - VideoMetadata) +from scenedetect.video_splitter import ( + split_video_ffmpeg, + is_ffmpeg_available, + SceneMetadata, + VideoMetadata, +) -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +@pytest.mark.skipif( + condition=not is_ffmpeg_available(), reason="ffmpeg is not available" +) def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): video = open_video(test_movie_clip) # Extract three hard-coded scenes for testing, each 60 frames. @@ -35,10 +41,12 @@ def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) - assert (len(entries) == len(scenes)) + assert len(entries) == len(scenes) -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +@pytest.mark.skipif( + condition=not is_ffmpeg_available(), reason="ffmpeg is not available" +) def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): video = open_video(test_movie_clip) # Extract three hard-coded scenes for testing, each 60 frames. @@ -52,10 +60,13 @@ def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): def name_formatter(video: VideoMetadata, scene: SceneMetadata): return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" - assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) == 0 + assert ( + split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) + == 0 + ) video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) - assert (len(entries) == len(scenes)) + assert len(entries) == len(scenes) # TODO: Add tests for `split_video_mkvmerge`. diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 7e952881..a139a0ab 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.video_stream Tests +"""PySceneDetect scenedetect.video_stream Tests This file includes unit tests for the scenedetect.video_stream module, as well as the video backends implemented in scenedetect.backends. These tests enforce a consistent interface across @@ -48,7 +48,7 @@ def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: if roi: - assert False # TODO + assert False # TODO assert frame_a.shape == frame_b.shape num_pixels = frame_a.shape[0] * frame_a.shape[1] return numpy.sum(numpy.abs(frame_b - frame_a)) / num_pixels @@ -56,26 +56,30 @@ def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: - """ Returns the absolute path to a (relative) path of a file that + """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError(""" + raise FileNotFoundError( + """ Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" % relative_path) +""" + % relative_path + ) return abs_path @dataclass class VideoParameters: """Properties for each input a VideoStream is tested against.""" + path: str height: int width: int @@ -120,12 +124,17 @@ def get_test_video_params() -> List[VideoParameters]: pytest.mark.parametrize( "vs_type", list( - filter(lambda x: x is not None, [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - VideoManager, - ]))), + filter( + lambda x: x is not None, + [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + VideoManager, + ], + ) + ), + ), pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), ] @@ -138,13 +147,16 @@ def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameter """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) - assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) + assert stream.frame_rate == pytest.approx( + test_video.frame_rate, FRAMERATE_TOLERANCE + ) assert stream.duration.get_frames() == test_video.total_frames file_name = os.path.basename(test_video.path) - last_dot_pos = file_name.rfind('.') + last_dot_pos = file_name.rfind(".") assert stream.name == file_name[:last_dot_pos] - assert stream.aspect_ratio == pytest.approx(test_video.aspect_ratio, - PIXEL_ASPECT_RATIO_TOLERANCE) + assert stream.aspect_ratio == pytest.approx( + test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE + ) def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" @@ -154,7 +166,9 @@ def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_advance(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read_no_advance( + self, vs_type: Type[VideoStream], test_video: VideoParameters + ): """Validate invoking `read` with `advance` set to False.""" stream = vs_type(test_video.path) frame = stream.read().copy() @@ -163,7 +177,9 @@ def test_read_no_advance(self, vs_type: Type[VideoStream], test_video: VideoPara assert stream.frame_number == 1 assert calculate_frame_delta(frame, frame_copy) == pytest.approx(0.0) - def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read_no_decode( + self, vs_type: Type[VideoStream], test_video: VideoParameters + ): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True @@ -171,7 +187,9 @@ def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParam stream.read(decode=False, advance=False) assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_time_invariants( + self, vs_type: Type[VideoStream], test_video: VideoParameters + ): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -191,7 +209,8 @@ def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoPara assert stream.frame_number == i assert stream.position == stream.base_timecode + (i - 1) assert stream.position_ms == pytest.approx( - 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS) + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS + ) def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" @@ -214,12 +233,14 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.frame_number == 200 assert stream.position == stream.base_timecode + 199 assert stream.position_ms == pytest.approx( - 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS) + 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS + ) stream.read() assert stream.frame_number == 201 assert stream.position == stream.base_timecode + 200 assert stream.position_ms == pytest.approx( - 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS) + 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS + ) # Seek to a time in seconds (float). stream.seek(2.0) @@ -228,11 +249,14 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): # starts counting from zero. This should eventually be changed. assert stream.position == (stream.base_timecode + 2.0) - 1 assert stream.position_ms == pytest.approx( - 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate) + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate + ) stream.read() assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) assert stream.position == stream.base_timecode + 2.0 - assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) + assert stream.position_ms == pytest.approx( + 2000.0, abs=1000.0 / stream.frame_rate + ) # Seek to a FrameTimecode. stream.seek(stream.base_timecode + 2.0) @@ -241,11 +265,14 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): # starts counting from zero. This should eventually be changed. assert stream.position == (stream.base_timecode + 2.0) - 1 assert stream.position_ms == pytest.approx( - 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate) + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate + ) stream.read() assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) assert stream.position == stream.base_timecode + 2.0 - assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) + assert stream.position_ms == pytest.approx( + 2000.0, abs=1000.0 / stream.frame_rate + ) def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" @@ -265,7 +292,8 @@ def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameter assert stream.frame_number == i assert stream.position == stream.base_timecode + (i - 1) assert stream.position_ms == pytest.approx( - 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS) + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS + ) stream.seek(0) assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -290,14 +318,21 @@ def test_read_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters) pass # TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs. if vs_type == VideoStreamAv: - assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1) + assert stream.frame_number in ( + test_video.total_frames, + test_video.total_frames - 1, + ) else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof( + self, vs_type: Type[VideoStream], test_video: VideoParameters + ): """Validate calling `seek()` to offset past end of video.""" if vs_type == VideoManager: - pytest.skip(reason='VideoManager does not have compliant end-of-video seek behaviour.') + pytest.skip( + reason="VideoManager does not have compliant end-of-video seek behaviour." + ) stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit # frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF, @@ -312,11 +347,16 @@ def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParame assert stream.read(advance=False) is not False # TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs. if vs_type == VideoStreamAv: - assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1) + assert stream.frame_number in ( + test_video.total_frames, + test_video.total_frames - 1, + ) else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_invalid( + self, vs_type: Type[VideoStream], test_video: VideoParameters + ): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -335,13 +375,13 @@ def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParamet def test_invalid_path(vs_type: Type[VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): - _ = vs_type('this_path_should_not_exist.mp4') + _ = vs_type("this_path_should_not_exist.mp4") def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoManager: - pytest.skip(reason='VideoManager does not support handling corrupt videos.') + pytest.skip(reason="VideoManager does not support handling corrupt videos.") stream = vs_type(corrupt_video_file) From 53d2441d060117d440857f260710fc6d7ad67c2a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 4 Sep 2024 21:43:15 -0400 Subject: [PATCH 122/407] Revert "[build] Update workflow actions." Mistaken merge commit. This reverts commit c23eee83b17d0b51e4e55dad852abcf0441d4b91. --- docs/conf.py | 118 ++- docs/generate_cli_docs.py | 158 ++-- scenedetect/__init__.py | 37 +- scenedetect/__main__.py | 19 +- scenedetect/_cli/__init__.py | 933 +++++++++----------- scenedetect/_cli/config.py | 188 ++-- scenedetect/_cli/context.py | 726 ++++++--------- scenedetect/_cli/controller.py | 208 ++--- scenedetect/_thirdparty/simpletable.py | 36 +- scenedetect/backends/__init__.py | 14 +- scenedetect/backends/moviepy.py | 25 +- scenedetect/backends/opencv.py | 113 +-- scenedetect/backends/pyav.py | 68 +- scenedetect/detectors/adaptive_detector.py | 41 +- scenedetect/detectors/content_detector.py | 37 +- scenedetect/detectors/hash_detector.py | 21 +- scenedetect/detectors/histogram_detector.py | 25 +- scenedetect/detectors/threshold_detector.py | 81 +- scenedetect/frame_timecode.py | 162 ++-- scenedetect/platform.py | 109 +-- scenedetect/scene_detector.py | 43 +- scenedetect/scene_manager.py | 458 ++++------ scenedetect/stats_manager.py | 97 +- scenedetect/video_manager.py | 257 ++---- scenedetect/video_splitter.py | 156 ++-- scenedetect/video_stream.py | 14 +- setup.py | 2 +- tests/__init__.py | 2 +- tests/conftest.py | 20 +- tests/test_api.py | 43 +- tests/test_backend_opencv.py | 10 +- tests/test_backend_pyav.py | 4 +- tests/test_backwards_compat.py | 50 +- tests/test_cli.py | 433 +++------ tests/test_detectors.py | 61 +- tests/test_frame_timecode.py | 262 +++--- tests/test_platform.py | 18 +- tests/test_scene_manager.py | 66 +- tests/test_stats_manager.py | 74 +- tests/test_video_splitter.py | 25 +- tests/test_video_stream.py | 106 +-- 41 files changed, 2052 insertions(+), 3268 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fcfbe6ef..0cb4f243 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,15 +15,15 @@ import os import sys -sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath('..')) from scenedetect import __version__ as scenedetect_version # -- Project information ----------------------------------------------------- -project = "PySceneDetect" -copyright = "2014-2024, Brandon Castellano" -author = "Brandon Castellano" +project = 'PySceneDetect' +copyright = '2014-2024, Brandon Castellano' +author = 'Brandon Castellano' # The short X.Y version version = scenedetect_version @@ -36,49 +36,49 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - "sphinx.ext.napoleon", - "sphinx.ext.autodoc", + 'sphinx.ext.napoleon', + 'sphinx.ext.autodoc', ] autoclass_content = "both" autodoc_member_order = "groupwise" -autodoc_typehints = "description" -autodoc_typehints_format = "short" +autodoc_typehints = 'description' +autodoc_typehints_format = 'short' # Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = ".rst" +source_suffix = '.rst' # The root toctree document. -root_doc = "index" +root_doc = 'index' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = "en" +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" +pygments_style = 'sphinx' # -- Options for HTML output ------------------------------------------------- # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] -html_css_files = ["pyscenedetect.css"] +html_static_path = ['_static'] +html_css_files = ['pyscenedetect.css'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -93,43 +93,40 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = "PySceneDetectdoc" +htmlhelp_basename = 'PySceneDetectdoc' # -- Options for LaTeX output ------------------------------------------------ latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ( - root_doc, - "PySceneDetect.tex", - "PySceneDetect Documentation", - "Brandon Castellano", - "manual", - ), + (root_doc, 'PySceneDetect.tex', 'PySceneDetect Documentation', 'Brandon Castellano', 'manual'), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(root_doc, "pyscenedetect", "PySceneDetect Documentation", [author], 1)] +man_pages = [(root_doc, 'pyscenedetect', 'PySceneDetect Documentation', [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -137,38 +134,31 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ( - root_doc, - "PySceneDetect", - "PySceneDetect Documentation", - author, - "PySceneDetect", - "Python API and `scenedetect` command reference.", - "Miscellaneous", - ), + (root_doc, 'PySceneDetect', 'PySceneDetect Documentation', author, 'PySceneDetect', + 'Python API and `scenedetect` command reference.', 'Miscellaneous'), ] # -- Theme ------------------------------------------------- # TODO: Consider switching to sphinx_material. -html_theme = "alabaster" +html_theme = 'alabaster' html_theme_options = { - "sidebar_width": "235px", - "description": "Version: [%s]" % (release), - "show_relbar_bottom": True, - "show_relbar_top": False, - "github_user": "Breakthrough", - "github_repo": "PySceneDetect", - "github_type": "star", - "tip_bg": "#f0f6fa", - "tip_border": "#c2dcf2", - "hint_bg": "#f0faf0", - "hint_border": "#d3ebdc", - "warn_bg": "#f5ebd0", - "warn_border": "#f2caa2", - "attention_bg": "#f5dcdc", - "attention_border": "#ffaaaa", - "logo": "pyscenedetect_logo.png", - "logo_name": False, + 'sidebar_width': '235px', + 'description': 'Version: [%s]' % (release), + 'show_relbar_bottom': True, + 'show_relbar_top': False, + 'github_user': 'Breakthrough', + 'github_repo': 'PySceneDetect', + 'github_type': 'star', + 'tip_bg': '#f0f6fa', + 'tip_border': '#c2dcf2', + 'hint_bg': '#f0faf0', + 'hint_border': '#d3ebdc', + 'warn_bg': '#f5ebd0', + 'warn_border': '#f2caa2', + 'attention_bg': '#f5dcdc', + 'attention_border': '#ffaaaa', + 'logo': 'pyscenedetect_logo.png', + 'logo_name': False, } diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index 82fc297f..cd5c6f6f 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -28,21 +28,22 @@ StrGenerator = ty.Generator[str, None, None] -INDENT = " " * 4 +INDENT = ' ' * 4 -PAGE_SEP = "*" * 72 -TITLE_SEP = "=" * 72 -HEADING_SEP = "-" * 72 +PAGE_SEP = '*' * 72 +TITLE_SEP = '=' * 72 +HEADING_SEP = '-' * 72 OPTION_HELP_OVERRIDES = { - "scenedetect": { - "config": "Path to config file. See :ref:`config file reference ` for details." + 'scenedetect': { + 'config': + 'Path to config file. See :ref:`config file reference ` for details.' }, } -TITLE_LEVELS = ["*", "=", "-"] +TITLE_LEVELS = ['*', '=', '-'] -INFO_COMMANDS = ["help", "about", "version"] +INFO_COMMANDS = ['help', 'about', 'version'] INFO_COMMAND_OVERRIDE = """ .. _command-help: @@ -72,28 +73,26 @@ def patch_help(s: str, commands: ty.List[str]) -> str: # Patch some TODOs still not handled correctly below. pos = 0 while True: - pos = s.find("global option :option:", pos) + pos = s.find('global option :option:', pos) if pos < 0: break - pos = s.find("<-", pos) + pos = s.find('<-', pos) assert pos > 0 - s = s[: pos + 1] + "scenedetect " + s[pos + 1 :] + s = s[:pos + 1] + 'scenedetect ' + s[pos + 1:] for command in [command for command in commands if not command in INFO_COMMANDS]: - def add_link(_match: re.Match) -> str: - return ":ref:`%s `" % (command, command) - - s = re.sub("``%s``(?!\\n)" % command, add_link, s) + return ':ref:`%s `' % (command, command) + s = re.sub('``%s``(?!\\n)' % command, add_link, s) return s def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator: - yield "\n" + yield '\n' if level == 0: - yield TITLE_LEVELS[level] * len + "\n" - yield s + "\n" - yield TITLE_LEVELS[level] * len + "\n\n" + yield TITLE_LEVELS[level] * len + '\n' + yield s + '\n' + yield TITLE_LEVELS[level] * len + '\n\n' @dataclass @@ -104,11 +103,11 @@ class ReplaceWithReference: def transform_backquotes(s: str) -> str: - return s.replace("``", "`").replace("`", "``") + return s.replace('``', '`').replace('`', '``') def add_backquotes(match: re.Match) -> str: - return "``%s``" % match.string[match.start() : match.end()] + return '``%s``' % match.string[match.start():match.end()] def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: @@ -116,13 +115,13 @@ def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: references to any found options.""" def _add_backquotes(s: re.Match) -> str: - to_add: str = s.string[s.start() : s.end()] - flag = re.search("-+[\w-]+[^\.\=\s\/]*", to_add) - if flag is not None and flag.string[flag.start() : flag.end()] in refs: + to_add: str = s.string[s.start():s.end()] + flag = re.search('-+[\w-]+[^\.\=\s\/]*', to_add) + if flag is not None and flag.string[flag.start():flag.end()] in refs: # add cross reference - cross_ref = flag.string[flag.start() : flag.end()] - option = s.string[s.start() : s.end()] - return ":option:`%s <%s>`" % (option, cross_ref) + cross_ref = flag.string[flag.start():flag.end()] + option = s.string[s.start():s.end()] + return ':option:`%s <%s>`' % (option, cross_ref) else: return add_backquotes(s) @@ -130,13 +129,13 @@ def _add_backquotes(s: re.Match) -> str: def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: - default = re.search("\[default: .*\]", s) + default = re.search('\[default: .*\]', s) if default is not None: span = default.span() assert span[1] == len(s) - s, default = s[: span[0]].strip(), s[span[0] : span[1]][len("[default: ") : -1] + s, default = s[:span[0]].strip(), s[span[0]:span[1]][len('[default: '):-1] # Double-quote any default values that contain spaces. - if " " in default and not '"' in default and not "," in default: + if ' ' in default and not '"' in default and not ',' in default: default = '"%s"' % default return (s, default) @@ -146,68 +145,57 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. # -c/--command - s = re.sub("-\w/--\w[\w-]*", transform, s) + s = re.sub('-\w/--\w[\w-]*', transform, s) # --arg=value, --arg=1.2.3, --arg=1,2,3 s = re.sub('-+[\w-]+=[^"\s\)]+(? StrGenerator: +def format_option(command: click.Command, opt: click.Option, flags: ty.List[str]) -> StrGenerator: if isinstance(opt, click.Argument): - yield "\n.. option:: %s\n" % opt.name + yield '\n.. option:: %s\n' % opt.name return - yield "\n.. option:: %s\n" % ", ".join( - arg if opt.metavar is None else "%s %s" % (arg, opt.metavar) - for arg in sorted(opt.opts, reverse=True) - ) - - help = ( - OPTION_HELP_OVERRIDES[command.name][opt.name] - if command.name in OPTION_HELP_OVERRIDES - and opt.name in OPTION_HELP_OVERRIDES[command.name] - else opt.help.strip() - ) + yield '\n.. option:: %s\n' % ', '.join(arg if opt.metavar is None else '%s %s' % + (arg, opt.metavar) + for arg in sorted(opt.opts, reverse=True)) + + help = OPTION_HELP_OVERRIDES[command.name][ + opt.name] if command.name in OPTION_HELP_OVERRIDES and opt.name in OPTION_HELP_OVERRIDES[ + command.name] else opt.help.strip() # TODO: Make metavars link to the option as well. help, default = extract_default_value(help) help = transform_add_option_refs(help, flags) - yield "\n %s\n" % help + yield '\n %s\n' % help if default is not None: - yield "\n Default: ``%s``\n" % default + yield '\n Default: ``%s``\n' % default -def generate_command_help( - ctx: click.Context, command: click.Command, parent_name: ty.Optional[str] = None -) -> StrGenerator: +def generate_command_help(ctx: click.Context, + command: click.Command, + parent_name: ty.Optional[str] = None) -> StrGenerator: # TODO: Add references to long options. Requires splitting out examples. # TODO: Add references to subcommands. Need to add actual refs, since programs can't be ref'd. # TODO: Handle dollar signs in examples by having both escaped and unescaped versions - yield "\n.. _command-%s:\n" % command.name - yield "\n.. program:: %s\n\n" % ( - command.name if parent_name is None else "%s %s" % (parent_name, command.name) - ) + yield '\n.. _command-%s:\n' % command.name + yield '\n.. program:: %s\n\n' % ( + command.name if parent_name is None else '%s %s' % (parent_name, command.name)) if parent_name: - yield from generate_title("``%s``" % command.name, 1) + yield from generate_title('``%s``' % command.name, 1) replacements = [ - opt - for opts in [param.opts for param in command.params if hasattr(param, "opts")] + opt for opts in [param.opts for param in command.params if hasattr(param, 'opts')] for opt in opts ] help = command.help - help = help.replace( - "Examples:\n", "".join(generate_title("Examples", 0 if not parent_name else 2)) - ) - help = help.replace("\b\n", "") - help = help.format( - scenedetect="scenedetect", scenedetect_with_video="scenedetect -i video.mp4" - ) + help = help.replace('Examples:\n', + ''.join(generate_title('Examples', 0 if not parent_name else 2))) + help = help.replace('\b\n', '') + help = help.format(scenedetect='scenedetect', scenedetect_with_video='scenedetect -i video.mp4') help = transform_backquotes(help) help = transform_add_option_refs(help, replacements) @@ -215,19 +203,20 @@ def generate_command_help( if line.startswith(INDENT): indent = line.count(INDENT) line = line.strip() - yield "%s``%s``\n" % (indent * INDENT, line) if line else "\n" + yield '%s``%s``\n' % (indent * INDENT, line) if line else '\n' else: - yield "%s\n" % line + yield '%s\n' % line if command.params: - yield "\n" - yield from generate_title("Options", 0 if not parent_name else 2) + yield '\n' + yield from generate_title('Options', 0 if not parent_name else 2) for param in command.params: yield from format_option(command, param, replacements) - yield "\n" + yield '\n' def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGenerator: + processed = set() for info_command in INFO_COMMANDS: @@ -235,24 +224,19 @@ def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGener processed.add(info_command) yield INFO_COMMAND_OVERRIDE - yield from generate_title("Detectors", 0) - detectors = [command for command in commands if command.startswith("detect-")] + yield from generate_title('Detectors', 0) + detectors = [command for command in commands if command.startswith('detect-')] for detector in detectors: - yield from generate_command_help( - ctx, ctx.command.get_command(ctx, detector), ctx.info_name - ) + yield from generate_command_help(ctx, ctx.command.get_command(ctx, detector), ctx.info_name) processed.add(detector) - yield from generate_title("Commands", 0) + yield from generate_title('Commands', 0) output_commands = [ - command - for command in commands - if (not command.startswith("detect-") and not command in INFO_COMMANDS) + command for command in commands + if (not command.startswith('detect-') and not command in INFO_COMMANDS) ] for command in output_commands: - yield from generate_command_help( - ctx, ctx.command.get_command(ctx, command), ctx.info_name - ) + yield from generate_command_help(ctx, ctx.command.get_command(ctx, command), ctx.info_name) processed.add(command) assert set(commands) == processed @@ -262,22 +246,22 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) - # ctx.to_info_dict lacks metavar so we have to use the context directly. + #ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ - generate_title("``scenedetect`` 🎬 Command", level=0), + generate_title('``scenedetect`` 🎬 Command', level=0), generate_command_help(ctx, ctx.command), generate_subcommands(ctx, commands), ] lines = [] for action in actions: lines.extend(action) - return "".join(lines), commands + return ''.join(lines), commands def main(): help, commands = create_help() help = patch_help(help, commands) - with open("docs/cli.rst", "wb") as f: + with open('docs/cli.rst', 'wb') as f: f.write(help.encode()) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index a3b7402f..160bee61 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -27,7 +27,7 @@ except ModuleNotFoundError as ex: raise ModuleNotFoundError( "OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python", - name="cv2", + name='cv2', ) from ex # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. @@ -36,20 +36,9 @@ from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.scene_detector import SceneDetector -from scenedetect.detectors import ( - ContentDetector, - AdaptiveDetector, - ThresholdDetector, - HistogramDetector, - HashDetector, -) -from scenedetect.backends import ( - AVAILABLE_BACKENDS, - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - VideoCaptureAdapter, -) +from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HistogramDetector, HashDetector +from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, + VideoStreamMoviePy, VideoCaptureAdapter) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt from scenedetect.scene_manager import SceneManager, save_images @@ -58,16 +47,16 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.4" +__version__ = '0.6.4' init_logger() -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') def open_video( path: str, framerate: Optional[float] = None, - backend: str = "opencv", + backend: str = 'opencv', **kwargs, ) -> VideoStream: """Open a video at the given path. If `backend` is specified but not available on the current @@ -94,24 +83,22 @@ def open_video( if backend in AVAILABLE_BACKENDS: backend_type = AVAILABLE_BACKENDS[backend] try: - logger.debug("Opening video with %s...", backend_type.BACKEND_NAME) + logger.debug('Opening video with %s...', backend_type.BACKEND_NAME) return backend_type(path, framerate, **kwargs) except VideoOpenFailure as ex: - logger.warning( - "Failed to open video with %s: %s", backend_type.BACKEND_NAME, str(ex) - ) + logger.warning('Failed to open video with %s: %s', backend_type.BACKEND_NAME, str(ex)) if backend == VideoStreamCv2.BACKEND_NAME: raise last_error = ex else: - logger.warning("Backend %s not available.", backend) + logger.warning('Backend %s not available.', backend) # Fallback to OpenCV if `backend` is unavailable, or specified backend failed to open `path`. backend_type = VideoStreamCv2 - logger.warning("Trying another backend: %s", backend_type.BACKEND_NAME) + logger.warning('Trying another backend: %s', backend_type.BACKEND_NAME) try: return backend_type(path, framerate) except VideoOpenFailure as ex: - logger.debug("Failed to open video: %s", str(ex)) + logger.debug('Failed to open video: %s', str(ex)) if last_error is None: last_error = ex # Propagate any exceptions raised from specified backend, instead of errors from the fallback. diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 23481eee..7a8cfb9a 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -27,38 +27,35 @@ def main(): cli_ctx = CliContext() try: # Process command line arguments and subcommands to initialize the context. - scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. + scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. except SystemExit as exit: - help_command = any(arg in sys.argv for arg in ["-h", "--help"]) + help_command = any(arg in sys.argv for arg in ['-h', '--help']) if help_command or exit.code != 0: raise # If we get here, processing the command line and loading the context worked. Let's run # the controller if we didn't process any help requests. - logger = getLogger("pyscenedetect") + logger = getLogger('pyscenedetect') # Ensure log messages don't conflict with any progress bars. If we're in quiet mode, where # no progress bars get created, we instead create a fake context manager. This is done here # to avoid needing a separate context manager at each point a progress bar is created. - log_redirect = ( - FakeTqdmLoggingRedirect() - if cli_ctx.quiet_mode - else logging_redirect_tqdm(loggers=[logger]) - ) + log_redirect = FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm( + loggers=[logger]) with log_redirect: try: run_scenedetect(cli_ctx) except KeyboardInterrupt: - logger.info("Stopped.") + logger.info('Stopped.') if __debug__: raise except BaseException as ex: if __debug__: raise else: - logger.critical("Unhandled exception:", exc_info=ex) + logger.critical('Unhandled exception:', exc_info=ex) raise SystemExit(1) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index e85e44da..1890b9b5 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,13 +27,8 @@ import click import scenedetect -from scenedetect.detectors import ( - AdaptiveDetector, - ContentDetector, - HashDetector, - HistogramDetector, - ThresholdDetector, -) +from scenedetect.detectors import (AdaptiveDetector, ContentDetector, HashDetector, + HistogramDetector, ThresholdDetector) from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.platform import get_system_version_info @@ -43,9 +38,9 @@ _PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" -logger = logging.getLogger("pyscenedetect") +logger = logging.getLogger('pyscenedetect') -_LINE_SEPARATOR = "-" * 72 +_LINE_SEPARATOR = '-' * 72 # About & copyright message string shown for the 'about' CLI command (scenedetect about). _ABOUT_STRING = """ @@ -88,16 +83,16 @@ class _Command(click.Command): def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help into the formatter if it exists.""" if ctx.parent: - formatter.write(click.style("`%s` Command" % ctx.command.name, fg="cyan")) + formatter.write(click.style('`%s` Command' % ctx.command.name, fg='cyan')) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg="cyan")) + formatter.write(click.style(_LINE_SEPARATOR, fg='cyan')) formatter.write_paragraph() else: - formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) + formatter.write(click.style(_LINE_SEPARATOR, fg='yellow')) formatter.write_paragraph() - formatter.write(click.style("PySceneDetect Help", fg="yellow")) + formatter.write(click.style('PySceneDetect Help', fg='yellow')) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) + formatter.write(click.style(_LINE_SEPARATOR, fg='yellow')) formatter.write_paragraph() self.format_usage(ctx, formatter) @@ -105,18 +100,12 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) - def format_help_text( - self, ctx: click.Context, formatter: click.HelpFormatter - ) -> None: + def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help text to the formatter if it exists.""" if self.help: - base_command = ( - ctx.parent.info_name if ctx.parent is not None else ctx.info_name - ) + base_command = (ctx.parent.info_name if ctx.parent is not None else ctx.info_name) formatted_help = self.help.format( - scenedetect=base_command, - scenedetect_with_video="%s -i video.mp4" % base_command, - ) + scenedetect=base_command, scenedetect_with_video='%s -i video.mp4' % base_command) text = inspect.cleandoc(formatted_help).partition("\f")[0] formatter.write_paragraph() formatter.write_text(text) @@ -131,7 +120,6 @@ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> N class _CommandGroup(_Command, click.Group): """Custom formatting for command groups.""" - pass @@ -139,139 +127,133 @@ def _print_command_help(ctx: click.Context, command: click.Command): """Print help/usage for a given command. Modifies `ctx` in-place.""" ctx.info_name = command.name ctx.command = command - click.echo("") + click.echo('') click.echo(command.get_help(ctx)) @click.group( cls=_CommandGroup, chain=True, - context_settings=dict(help_option_names=["-h", "--help"]), + context_settings=dict(help_option_names=['-h', '--help']), invoke_without_command=True, - epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""", + epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""" ) # *NOTE*: Although input is required, we cannot mark it as `required=True`, otherwise we will reject # commands of the form `scenedetect detect-content --help`. @click.option( - "--input", - "-i", + '--input', + '-i', multiple=False, required=False, - metavar="VIDEO", + metavar='VIDEO', type=click.STRING, - help="[REQUIRED] Input video file. Image sequences and URLs are supported.", + help='[REQUIRED] Input video file. Image sequences and URLs are supported.', ) @click.option( - "--output", - "-o", + '--output', + '-o', multiple=False, required=False, - metavar="DIR", + metavar='DIR', type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s" + help='Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s' % (USER_CONFIG.get_help_string("global", "output", show_default=False)), ) @click.option( - "--config", - "-c", - metavar="FILE", + '--config', + '-c', + metavar='FILE', type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help="Path to config file. If unset, tries to load config from %s" - % (CONFIG_FILE_PATH), + help='Path to config file. If unset, tries to load config from %s' % (CONFIG_FILE_PATH), ) @click.option( - "--stats", - "-s", - metavar="CSV", + '--stats', + '-s', + metavar='CSV', type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help="Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.", + help='Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.', ) @click.option( - "--framerate", - "-f", - metavar="FPS", + '--framerate', + '-f', + metavar='FPS', type=click.FLOAT, default=None, - help="Override framerate with value as frames/sec.", + help='Override framerate with value as frames/sec.', ) @click.option( - "--min-scene-len", - "-m", - metavar="TIMECODE", + '--min-scene-len', + '-m', + metavar='TIMECODE', type=click.STRING, default=None, - help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s" + help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s' % USER_CONFIG.get_help_string("global", "min-scene-len"), ) @click.option( - "--drop-short-scenes", + '--drop-short-scenes', is_flag=True, flag_value=True, - help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" - % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), + help='Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s' % + (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), ) @click.option( - "--merge-last-scene", + '--merge-last-scene', is_flag=True, flag_value=True, - help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" - % (USER_CONFIG.get_help_string("global", "merge-last-scene")), + help='Merge last scene with previous if shorter than -m/--min-scene-len.%s' % + (USER_CONFIG.get_help_string('global', 'merge-last-scene')), ) @click.option( - "--backend", - "-b", - metavar="BACKEND", + '--backend', + '-b', + metavar='BACKEND', type=click.Choice(CHOICE_MAP["global"]["backend"]), default=None, - help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" - % ( - ", ".join(AVAILABLE_BACKENDS.keys()), - USER_CONFIG.get_help_string("global", "backend"), - ), + help='Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s' + % (', '.join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), ) @click.option( - "--downscale", - "-d", - metavar="N", + '--downscale', + '-d', + metavar='N', type=click.INT, default=None, - help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s" + help='Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s' % (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), ) @click.option( - "--frame-skip", - "-fs", - metavar="N", + '--frame-skip', + '-fs', + metavar='N', type=click.INT, default=None, - help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s" + help='Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s' % USER_CONFIG.get_help_string("global", "frame-skip"), ) @click.option( - "--verbosity", - "-v", - metavar="LEVEL", - type=click.Choice(CHOICE_MAP["global"]["verbosity"], False), + '--verbosity', + '-v', + metavar='LEVEL', + type=click.Choice(CHOICE_MAP['global']['verbosity'], False), default=None, - help="Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s" - % ( - ", ".join(CHOICE_MAP["global"]["verbosity"]), - USER_CONFIG.get_help_string("global", "verbosity"), - ), + help='Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s' % + (', '.join(CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string( + "global", "verbosity")), ) @click.option( - "--logfile", - "-l", - metavar="FILE", + '--logfile', + '-l', + metavar='FILE', type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help="Save debug log to FILE. Appends to existing file if present.", + help='Save debug log to FILE. Appends to existing file if present.', ) @click.option( - "--quiet", - "-q", + '--quiet', + '-q', is_flag=True, flag_value=True, - help="Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.", + help='Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.', ) @click.pass_context # pylint: disable=redefined-builtin @@ -294,30 +276,30 @@ def scenedetect( ): """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: - {scenedetect_with_video} [detector] [commands] + {scenedetect_with_video} [detector] [commands] - For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. +For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. - Examples: +Examples: - Split video wherever a new scene is detected: +Split video wherever a new scene is detected: - {scenedetect_with_video} split-video + {scenedetect_with_video} split-video - Save scene list in CSV format with images at the start, middle, and end of each scene: +Save scene list in CSV format with images at the start, middle, and end of each scene: - {scenedetect_with_video} list-scenes save-images + {scenedetect_with_video} list-scenes save-images - Skip the first 10 seconds of the input video: +Skip the first 10 seconds of the input video: - {scenedetect_with_video} time --start 10s detect-content + {scenedetect_with_video} time --start 10s detect-content - Show summary of all options and commands: +Show summary of all options and commands: - {scenedetect} --help + {scenedetect} --help - Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. - """ +Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. +""" assert isinstance(ctx.obj, CliContext) ctx.obj.handle_options( input_path=input, @@ -341,9 +323,9 @@ def scenedetect( # pylint: enable=redefined-builtin -@click.command("help", cls=_Command) +@click.command('help', cls=_Command) @click.argument( - "command_name", + 'command_name', required=False, type=click.STRING, ) @@ -357,11 +339,11 @@ def help_command(ctx: click.Context, command_name: str): if command_name is not None: if not command_name in all_commands: error_strs = [ - "unknown command. List of valid commands:", - " %s" % ", ".join(sorted(all_commands)), + 'unknown command. List of valid commands:', + ' %s' % ', '.join(sorted(all_commands)) ] - raise click.BadParameter("\n".join(error_strs), param_hint="command") - click.echo("") + raise click.BadParameter('\n'.join(error_strs), param_hint='command') + click.echo('') _print_command_help(ctx, parent_command.get_command(ctx, command_name)) else: click.echo(ctx.parent.get_help()) @@ -370,53 +352,53 @@ def help_command(ctx: click.Context, command_name: str): ctx.exit() -@click.command("about", cls=_Command, add_help_option=False) +@click.command('about', cls=_Command, add_help_option=False) @click.pass_context def about_command(ctx: click.Context): """Print license/copyright info.""" assert isinstance(ctx.obj, CliContext) - click.echo("") - click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) - click.echo(click.style(" About PySceneDetect %s" % _PROGRAM_VERSION, fg="yellow")) - click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) + click.echo('') + click.echo(click.style(_LINE_SEPARATOR, fg='cyan')) + click.echo(click.style(' About PySceneDetect %s' % _PROGRAM_VERSION, fg='yellow')) + click.echo(click.style(_LINE_SEPARATOR, fg='cyan')) click.echo(_ABOUT_STRING) ctx.exit() -@click.command("version", cls=_Command, add_help_option=False) +@click.command('version', cls=_Command, add_help_option=False) @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" assert isinstance(ctx.obj, CliContext) - click.echo("") + click.echo('') click.echo(get_system_version_info()) ctx.exit() -@click.command("time", cls=_Command) +@click.command('time', cls=_Command) @click.option( - "--start", - "-s", - metavar="TIMECODE", + '--start', + '-s', + metavar='TIMECODE', type=click.STRING, default=None, - help="Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).", + help='Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).', ) @click.option( - "--duration", - "-d", - metavar="TIMECODE", + '--duration', + '-d', + metavar='TIMECODE', type=click.STRING, default=None, - help="Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.", + help='Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.', ) @click.option( - "--end", - "-e", - metavar="TIMECODE", + '--end', + '-e', + metavar='TIMECODE', type=click.STRING, default=None, - help="Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration", + help='Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration', ) @click.pass_context def time_command( @@ -427,16 +409,16 @@ def time_command( ): """Set start/end/duration of input video. - Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: +Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - {scenedetect_with_video} time --end 00:01:00 + {scenedetect_with_video} time --end 00:01:00 - {scenedetect_with_video} time --duration 60.0 + {scenedetect_with_video} time --duration 60.0 - Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: +Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: - {scenedetect_with_video} time --start 0 --end 1000 - """ + {scenedetect_with_video} time --start 0 --end 1000 +""" assert isinstance(ctx.obj, CliContext) ctx.obj.handle_time( start=start, @@ -450,12 +432,10 @@ def time_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-content"]["threshold"].min_val, - CONFIG_MAP["detect-content"]["threshold"].max_val, - ), + type=click.FloatRange(CONFIG_MAP["detect-content"]["threshold"].min_val, + CONFIG_MAP["detect-content"]["threshold"].max_val), default=None, - help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.%s' + help="The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to \"content_val\" in stats file.%s" % (USER_CONFIG.get_help_string("detect-content", "threshold")), ) @click.option( @@ -472,7 +452,7 @@ def time_command( "-l", is_flag=True, flag_value=True, - help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w="0 0 1 0".%s' + help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w=\"0 0 1 0\".%s" % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( @@ -490,12 +470,9 @@ def time_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" - % ( - "" - if USER_CONFIG.is_default("detect-content", "min-scene-len") - else USER_CONFIG.get_help_string("detect-content", "min-scene-len") - ), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" % + ("" if USER_CONFIG.is_default("detect-content", "min-scene-len") else + USER_CONFIG.get_help_string("detect-content", "min-scene-len")), ) @click.option( "--filter-mode", @@ -503,11 +480,9 @@ def time_command( metavar="MODE", type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), default=None, - help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" - % ( - ", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), - USER_CONFIG.get_help_string("detect-content", "filter-mode"), - ), + help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" % + (", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), + USER_CONFIG.get_help_string("detect-content", "filter-mode")), ) @click.pass_context def detect_content_command( @@ -521,26 +496,26 @@ def detect_content_command( ): """Find fast cuts using differences in HSL (filtered). - For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. +For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. - Scores are calculated from several components which are also recorded in the statsfile: +Scores are calculated from several components which are also recorded in the statsfile: - - *delta_hue*: Difference between pixel hue values of adjacent frames. + - *delta_hue*: Difference between pixel hue values of adjacent frames. - - *delta_sat*: Difference between pixel saturation values of adjacent frames. + - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. - Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. +Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. - Examples: +Examples: - {scenedetect_with_video} detect-content + {scenedetect_with_video} detect-content - {scenedetect_with_video} detect-content --threshold 27.5 - """ + {scenedetect_with_video} detect-content --threshold 27.5 +""" assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_content_params( threshold=threshold, @@ -548,87 +523,83 @@ def detect_content_command( min_scene_len=min_scene_len, weights=weights, kernel_size=kernel_size, - filter_mode=filter_mode, - ) - logger.debug("Adding detector: ContentDetector(%s)", detector_args) + filter_mode=filter_mode) + logger.debug('Adding detector: ContentDetector(%s)', detector_args) ctx.obj.add_detector(ContentDetector(**detector_args)) -@click.command("detect-adaptive", cls=_Command) +@click.command('detect-adaptive', cls=_Command) @click.option( - "--threshold", - "-t", - metavar="VAL", + '--threshold', + '-t', + metavar='VAL', type=click.FLOAT, default=None, help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-adaptive", "threshold")), + % (USER_CONFIG.get_help_string('detect-adaptive', 'threshold')), ) @click.option( - "--min-content-val", - "-c", - metavar="VAL", + '--min-content-val', + '-c', + metavar='VAL', type=click.FLOAT, default=None, - help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' - % (USER_CONFIG.get_help_string("detect-adaptive", "min-content-val")), + help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' % + (USER_CONFIG.get_help_string('detect-adaptive', 'min-content-val')), ) @click.option( - "--min-delta-hsv", - "-d", - metavar="VAL", + '--min-delta-hsv', + '-d', + metavar='VAL', type=click.FLOAT, default=None, - help="[DEPRECATED] Use -c/--min-content-val instead.%s" - % (USER_CONFIG.get_help_string("detect-adaptive", "min-delta-hsv")), + help='[DEPRECATED] Use -c/--min-content-val instead.%s' % + (USER_CONFIG.get_help_string('detect-adaptive', 'min-delta-hsv')), hidden=True, ) @click.option( - "--frame-window", - "-f", - metavar="VAL", + '--frame-window', + '-f', + metavar='VAL', type=click.INT, default=None, - help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s" - % (USER_CONFIG.get_help_string("detect-adaptive", "frame-window")), + help='Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s' + % (USER_CONFIG.get_help_string('detect-adaptive', 'frame-window')), ) @click.option( - "--weights", - "-w", + '--weights', + '-w', type=(float, float, float, float), default=None, help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".%s' % (USER_CONFIG.get_help_string("detect-content", "weights")), ) @click.option( - "--luma-only", - "-l", + '--luma-only', + '-l', is_flag=True, flag_value=True, help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".%s' % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( - "--kernel-size", - "-k", - metavar="N", + '--kernel-size', + '-k', + metavar='N', type=click.INT, default=None, - help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s" + help='Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s' % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), ) @click.option( - "--min-scene-len", - "-m", - metavar="TIMECODE", + '--min-scene-len', + '-m', + metavar='TIMECODE', type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" - % ( - "" - if USER_CONFIG.is_default("detect-adaptive", "min-scene-len") - else USER_CONFIG.get_help_string("detect-adaptive", "min-scene-len") - ), + help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' + % ('' if USER_CONFIG.is_default('detect-adaptive', 'min-scene-len') else + USER_CONFIG.get_help_string('detect-adaptive', 'min-scene-len')), ) @click.pass_context def detect_adaptive_command( @@ -644,14 +615,14 @@ def detect_adaptive_command( ): """Find fast cuts using diffs in HSL colorspace (rolling average). - Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. +Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. - Examples: +Examples: - {scenedetect_with_video} detect-adaptive + {scenedetect_with_video} detect-adaptive - {scenedetect_with_video} detect-adaptive --threshold 3.2 - """ + {scenedetect_with_video} detect-adaptive --threshold 3.2 +""" assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_adaptive_params( threshold=threshold, @@ -663,55 +634,48 @@ def detect_adaptive_command( weights=weights, kernel_size=kernel_size, ) - logger.debug("Adding detector: AdaptiveDetector(%s)", detector_args) + logger.debug('Adding detector: AdaptiveDetector(%s)', detector_args) ctx.obj.add_detector(AdaptiveDetector(**detector_args)) -@click.command("detect-threshold", cls=_Command) +@click.command('detect-threshold', cls=_Command) @click.option( - "--threshold", - "-t", - metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-threshold"]["threshold"].min_val, - CONFIG_MAP["detect-threshold"]["threshold"].max_val, - ), + '--threshold', + '-t', + metavar='VAL', + type=click.FloatRange(CONFIG_MAP['detect-threshold']['threshold'].min_val, + CONFIG_MAP['detect-threshold']['threshold'].max_val), default=None, help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-threshold", "threshold")), + % (USER_CONFIG.get_help_string('detect-threshold', 'threshold')), ) @click.option( - "--fade-bias", - "-f", - metavar="PERCENT", - type=click.FloatRange( - CONFIG_MAP["detect-threshold"]["fade-bias"].min_val, - CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, - ), + '--fade-bias', + '-f', + metavar='PERCENT', + type=click.FloatRange(CONFIG_MAP['detect-threshold']['fade-bias'].min_val, + CONFIG_MAP['detect-threshold']['fade-bias'].max_val), default=None, - help="Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s" - % (USER_CONFIG.get_help_string("detect-threshold", "fade-bias")), + help='Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s' + % (USER_CONFIG.get_help_string('detect-threshold', 'fade-bias')), ) @click.option( - "--add-last-scene", - "-l", + '--add-last-scene', + '-l', is_flag=True, flag_value=True, - help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s" - % (USER_CONFIG.get_help_string("detect-threshold", "add-last-scene")), + help='If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s' + % (USER_CONFIG.get_help_string('detect-threshold', 'add-last-scene')), ) @click.option( - "--min-scene-len", - "-m", - metavar="TIMECODE", + '--min-scene-len', + '-m', + metavar='TIMECODE', type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" - % ( - "" - if USER_CONFIG.is_default("detect-threshold", "min-scene-len") - else USER_CONFIG.get_help_string("detect-threshold", "min-scene-len") - ), + help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' + % ('' if USER_CONFIG.is_default('detect-threshold', 'min-scene-len') else + USER_CONFIG.get_help_string('detect-threshold', 'min-scene-len')), ) @click.pass_context def detect_threshold_command( @@ -723,14 +687,14 @@ def detect_threshold_command( ): """Find fade in/out using averaging. - Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. +Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. - Examples: +Examples: - {scenedetect_with_video} detect-threshold + {scenedetect_with_video} detect-threshold - {scenedetect_with_video} detect-threshold --threshold 15 - """ + {scenedetect_with_video} detect-threshold --threshold 15 +""" assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_threshold_params( threshold=threshold, @@ -738,7 +702,7 @@ def detect_threshold_command( add_last_scene=add_last_scene, min_scene_len=min_scene_len, ) - logger.debug("Adding detector: ThresholdDetector(%s)", detector_args) + logger.debug('Adding detector: ThresholdDetector(%s)', detector_args) ctx.obj.add_detector(ThresholdDetector(**detector_args)) @@ -747,27 +711,21 @@ def detect_threshold_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-hist"]["threshold"].min_val, - CONFIG_MAP["detect-hist"]["threshold"].max_val, - ), + type=click.FloatRange(CONFIG_MAP["detect-hist"]["threshold"].min_val, + CONFIG_MAP["detect-hist"]["threshold"].max_val), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " - "values are more sensitive to changes.%s" - % (USER_CONFIG.get_help_string("detect-hist", "threshold")), -) + "values are more sensitive to changes.%s" % + (USER_CONFIG.get_help_string("detect-hist", "threshold"))) @click.option( "--bins", "-b", metavar="NUM", - type=click.IntRange( - CONFIG_MAP["detect-hist"]["bins"].min_val, - CONFIG_MAP["detect-hist"]["bins"].max_val, - ), + type=click.IntRange(CONFIG_MAP["detect-hist"]["bins"].min_val, + CONFIG_MAP["detect-hist"]["bins"].max_val), default=None, - help="The number of bins to use for the histogram calculation.%s" - % (USER_CONFIG.get_help_string("detect-hist", "bins")), -) + help="The number of bins to use for the histogram calculation.%s" % + (USER_CONFIG.get_help_string("detect-hist", "bins"))) @click.option( "--min-scene-len", "-m", @@ -776,38 +734,29 @@ def detect_threshold_command( default=None, help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," - " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" - % ( - "" - if USER_CONFIG.is_default("detect-hist", "min-scene-len") - else USER_CONFIG.get_help_string("detect-hist", "min-scene-len") - ), -) + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % + ("" if USER_CONFIG.is_default("detect-hist", "min-scene-len") else USER_CONFIG.get_help_string( + "detect-hist", "min-scene-len"))) @click.pass_context -def detect_hist_command( - ctx: click.Context, - threshold: Optional[float], - bins: Optional[int], - min_scene_len: Optional[str], -): +def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], + min_scene_len: Optional[str]): """Find fast cuts by differencing YUV histograms. - Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. +Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. - Saved as the `hist_diff` metric in a statsfile. +Saved as the `hist_diff` metric in a statsfile. - Examples: +Examples: - {scenedetect_with_video} detect-hist + {scenedetect_with_video} detect-hist - {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 + {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hist_params( - threshold=threshold, bins=bins, min_scene_len=min_scene_len - ) + threshold=threshold, bins=bins, min_scene_len=min_scene_len) logger.debug("Adding detector: HistogramDetector(%s)", detector_args) ctx.obj.add_detector(HistogramDetector(**detector_args)) @@ -817,44 +766,31 @@ def detect_hist_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-hash"]["threshold"].min_val, - CONFIG_MAP["detect-hash"]["threshold"].max_val, - ), + type=click.FloatRange(CONFIG_MAP["detect-hash"]["threshold"].min_val, + CONFIG_MAP["detect-hash"]["threshold"].max_val), default=None, - help=( - "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " - "more sensitive to changes.%s" - % (USER_CONFIG.get_help_string("detect-hash", "threshold")) - ), -) + help=("Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " + "more sensitive to changes.%s" % + (USER_CONFIG.get_help_string("detect-hash", "threshold")))) @click.option( "--size", "-s", metavar="SIZE", - type=click.IntRange( - CONFIG_MAP["detect-hash"]["size"].min_val, - CONFIG_MAP["detect-hash"]["size"].max_val, - ), + type=click.IntRange(CONFIG_MAP["detect-hash"]["size"].min_val, + CONFIG_MAP["detect-hash"]["size"].max_val), default=None, - help="Size of square of low frequency data to include from the discrete cosine transform.%s" - % (USER_CONFIG.get_help_string("detect-hash", "size")), -) + help="Size of square of low frequency data to include from the discrete cosine transform.%s" % + (USER_CONFIG.get_help_string("detect-hash", "size"))) @click.option( "--lowpass", "-l", metavar="FRAC", - type=click.IntRange( - CONFIG_MAP["detect-hash"]["lowpass"].min_val, - CONFIG_MAP["detect-hash"]["lowpass"].max_val, - ), + type=click.IntRange(CONFIG_MAP["detect-hash"]["lowpass"].min_val, + CONFIG_MAP["detect-hash"]["lowpass"].max_val), default=None, - help=( - "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " - "the frequency data, 4 means only keep 1/4, etc...%s" - % (USER_CONFIG.get_help_string("detect-hash", "lowpass")) - ), -) + help=("How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " + "the frequency data, 4 means only keep 1/4, etc...%s" % + (USER_CONFIG.get_help_string("detect-hash", "lowpass")))) @click.option( "--min-scene-len", "-m", @@ -863,111 +799,97 @@ def detect_hist_command( default=None, help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," - " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" - % ( - "" - if USER_CONFIG.is_default("detect-hash", "min-scene-len") - else USER_CONFIG.get_help_string("detect-hash", "min-scene-len") - ), -) + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % + ("" if USER_CONFIG.is_default("detect-hash", "min-scene-len") else USER_CONFIG.get_help_string( + "detect-hash", "min-scene-len"))) @click.pass_context -def detect_hash_command( - ctx: click.Context, - threshold: Optional[float], - size: Optional[int], - lowpass: Optional[int], - min_scene_len: Optional[str], -): +def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], + lowpass: Optional[int], min_scene_len: Optional[str]): """Find fast cuts using perceptual hashing. - The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. +The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. - Saved as the `hash_dist` metric in a statsfile. +Saved as the `hash_dist` metric in a statsfile. - Examples: +Examples: - {scenedetect_with_video} detect-hash + {scenedetect_with_video} detect-hash - {scenedetect_with_video} detect-hash --size 32 --lowpass 3 + {scenedetect_with_video} detect-hash --size 32 --lowpass 3 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hash_params( - threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len - ) + threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len) logger.debug("Adding detector: HashDetector(%s)", detector_args) ctx.obj.add_detector(HashDetector(**detector_args)) -@click.command("load-scenes", cls=_Command) +@click.command('load-scenes', cls=_Command) @click.option( - "--input", - "-i", + '--input', + '-i', multiple=False, - metavar="FILE", + metavar='FILE', required=True, type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=True), - help="Scene list to read cut information from.", -) + help='Scene list to read cut information from.') @click.option( - "--start-col-name", - "-c", - metavar="STRING", + '--start-col-name', + '-c', + metavar='STRING', type=click.STRING, default=None, - help="Name of column used to mark scene cuts.%s" - % (USER_CONFIG.get_help_string("load-scenes", "start-col-name")), -) + help='Name of column used to mark scene cuts.%s' % + (USER_CONFIG.get_help_string('load-scenes', 'start-col-name'))) @click.pass_context -def load_scenes_command( - ctx: click.Context, input: Optional[str], start_col_name: Optional[str] -): +def load_scenes_command(ctx: click.Context, input: Optional[str], start_col_name: Optional[str]): """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). - Examples: +Examples: - {scenedetect_with_video} load-scenes -i scenes.csv + {scenedetect_with_video} load-scenes -i scenes.csv - {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" - """ + {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" +""" assert isinstance(ctx.obj, CliContext) - logger.debug("Loading scenes from %s (start_col_name = %s)", input, start_col_name) + logger.debug('Loading scenes from %s (start_col_name = %s)', input, start_col_name) ctx.obj.handle_load_scenes(input=input, start_col_name=start_col_name) -@click.command("export-html", cls=_Command) +@click.command('export-html', cls=_Command) @click.option( - "--filename", - "-f", - metavar="NAME", - default="$VIDEO_NAME-Scenes.html", + '--filename', + '-f', + metavar='NAME', + default='$VIDEO_NAME-Scenes.html', type=click.STRING, - help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s" - % (USER_CONFIG.get_help_string("export-html", "filename")), + help='Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s' + % (USER_CONFIG.get_help_string('export-html', 'filename')), ) @click.option( - "--no-images", + '--no-images', is_flag=True, flag_value=True, - help="Export the scene list including or excluding the saved images.%s" - % (USER_CONFIG.get_help_string("export-html", "no-images")), + help='Export the scene list including or excluding the saved images.%s' % + (USER_CONFIG.get_help_string('export-html', 'no-images')), ) @click.option( - "--image-width", - "-w", - metavar="pixels", + '--image-width', + '-w', + metavar='pixels', type=click.INT, - help="Width in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("export-html", "image-width", show_default=False)), + help='Width in pixels of the images in the resulting HTML table.%s' % + (USER_CONFIG.get_help_string('export-html', 'image-width', show_default=False)), ) @click.option( - "--image-height", - "-h", - metavar="pixels", + '--image-height', + '-h', + metavar='pixels', type=click.INT, - help="Height in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("export-html", "image-height", show_default=False)), + help='Height in pixels of the images in the resulting HTML table.%s' % + (USER_CONFIG.get_help_string('export-html', 'image-height', show_default=False)), ) @click.pass_context def export_html_command( @@ -987,47 +909,46 @@ def export_html_command( ) -@click.command("list-scenes", cls=_Command) +@click.command('list-scenes', cls=_Command) @click.option( - "--output", - "-o", - metavar="DIR", + '--output', + '-o', + metavar='DIR', type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output if set.%s" - % (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)), + help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % + (USER_CONFIG.get_help_string('list-scenes', 'output', show_default=False)), ) @click.option( - "--filename", - "-f", - metavar="NAME", - default="$VIDEO_NAME-Scenes.csv", + '--filename', + '-f', + metavar='NAME', + default='$VIDEO_NAME-Scenes.csv', type=click.STRING, - help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\$VIDEO_NAME-Scenes.csv).%s" - % (USER_CONFIG.get_help_string("list-scenes", "filename")), + help='Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\$VIDEO_NAME-Scenes.csv).%s' + % (USER_CONFIG.get_help_string('list-scenes', 'filename')), ) @click.option( - "--no-output-file", - "-n", + '--no-output-file', + '-n', is_flag=True, flag_value=True, - help="Only print scene list.%s" - % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), + help='Only print scene list.%s' % + (USER_CONFIG.get_help_string('list-scenes', 'no-output-file')), ) @click.option( - "--quiet", - "-q", + '--quiet', + '-q', is_flag=True, flag_value=True, - help="Suppress printing scene list.%s" - % (USER_CONFIG.get_help_string("list-scenes", "quiet")), + help='Suppress printing scene list.%s' % (USER_CONFIG.get_help_string('list-scenes', 'quiet')), ) @click.option( - "--skip-cuts", - "-s", + '--skip-cuts', + '-s', is_flag=True, flag_value=True, - help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s" - % (USER_CONFIG.get_help_string("list-scenes", "skip-cuts")), + help='Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s' % + (USER_CONFIG.get_help_string('list-scenes', 'skip-cuts')), ) @click.pass_context def list_scenes_command( @@ -1049,88 +970,84 @@ def list_scenes_command( ) -@click.command("split-video", cls=_Command) +@click.command('split-video', cls=_Command) @click.option( - "--output", - "-o", - metavar="DIR", + '--output', + '-o', + metavar='DIR', type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output if set.%s" - % (USER_CONFIG.get_help_string("split-video", "output", show_default=False)), + help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % + (USER_CONFIG.get_help_string('split-video', 'output', show_default=False)), ) @click.option( - "--filename", - "-f", - metavar="NAME", + '--filename', + '-f', + metavar='NAME', default=None, type=click.STRING, - help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" - % (USER_CONFIG.get_help_string("split-video", "filename")), + help='File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s' + % (USER_CONFIG.get_help_string('split-video', 'filename')), ) @click.option( - "--quiet", - "-q", + '--quiet', + '-q', is_flag=True, flag_value=True, - help="Hide output from external video splitting tool.%s" - % (USER_CONFIG.get_help_string("split-video", "quiet")), + help='Hide output from external video splitting tool.%s' % + (USER_CONFIG.get_help_string('split-video', 'quiet')), ) @click.option( - "--copy", - "-c", + '--copy', + '-c', is_flag=True, flag_value=True, - help="Copy instead of re-encode. Faster but less precise.%s" - % (USER_CONFIG.get_help_string("split-video", "copy")), + help="Copy instead of re-encode. Faster but less precise.%s" % + (USER_CONFIG.get_help_string('split-video', 'copy')), ) @click.option( - "--high-quality", - "-hq", + '--high-quality', + '-hq', is_flag=True, flag_value=True, - help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s" - % (USER_CONFIG.get_help_string("split-video", "high-quality")), + help='Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s' + % (USER_CONFIG.get_help_string('split-video', 'high-quality')), ) @click.option( - "--rate-factor", - "-crf", - metavar="RATE", + '--rate-factor', + '-crf', + metavar='RATE', default=None, - type=click.IntRange( - CONFIG_MAP["split-video"]["rate-factor"].min_val, - CONFIG_MAP["split-video"]["rate-factor"].max_val, - ), - help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s" - % (USER_CONFIG.get_help_string("split-video", "rate-factor")), + type=click.IntRange(CONFIG_MAP['split-video']['rate-factor'].min_val, + CONFIG_MAP['split-video']['rate-factor'].max_val), + help='Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s' + % (USER_CONFIG.get_help_string('split-video', 'rate-factor')), ) @click.option( - "--preset", - "-p", - metavar="LEVEL", + '--preset', + '-p', + metavar='LEVEL', default=None, - type=click.Choice(CHOICE_MAP["split-video"]["preset"]), - help="Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s" - % ( - ", ".join(CHOICE_MAP["split-video"]["preset"]), - USER_CONFIG.get_help_string("split-video", "preset"), - ), + type=click.Choice(CHOICE_MAP['split-video']['preset']), + help='Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s' + % (', '.join( + CHOICE_MAP['split-video']['preset']), USER_CONFIG.get_help_string('split-video', 'preset')), ) @click.option( - "--args", - "-a", - metavar="ARGS", + '--args', + '-a', + metavar='ARGS', type=click.STRING, default=None, help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.%s' - % (USER_CONFIG.get_help_string("split-video", "args")), + % (USER_CONFIG.get_help_string('split-video', 'args')), ) @click.option( - "--mkvmerge", - "-m", + '--mkvmerge', + '-m', is_flag=True, flag_value=True, - help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s" - % (USER_CONFIG.get_help_string("split-video", "mkvmerge")), + help='Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s' + % (USER_CONFIG.get_help_string('split-video', 'mkvmerge')), ) @click.pass_context def split_video_command( @@ -1147,14 +1064,14 @@ def split_video_command( ): """Split input video using ffmpeg or mkvmerge. - Examples: +Examples: - {scenedetect_with_video} split-video + {scenedetect_with_video} split-video - {scenedetect_with_video} split-video --copy + {scenedetect_with_video} split-video --copy - {scenedetect_with_video} split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER - """ + {scenedetect_with_video} split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER +""" assert isinstance(ctx.obj, CliContext) ctx.obj.handle_split_video( output=output, @@ -1169,108 +1086,108 @@ def split_video_command( ) -@click.command("save-images", cls=_Command) +@click.command('save-images', cls=_Command) @click.option( - "--output", - "-o", - metavar="DIR", + '--output', + '-o', + metavar='DIR', type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory for images. Overrides global option -o/--output if set.%s" - % (USER_CONFIG.get_help_string("save-images", "output", show_default=False)), + help='Output directory for images. Overrides global option -o/--output if set.%s' % + (USER_CONFIG.get_help_string('save-images', 'output', show_default=False)), ) @click.option( - "--filename", - "-f", - metavar="NAME", + '--filename', + '-f', + metavar='NAME', default=None, type=click.STRING, - help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" - % (USER_CONFIG.get_help_string("save-images", "filename")), + help='Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s' + % (USER_CONFIG.get_help_string('save-images', 'filename')), ) @click.option( - "--num-images", - "-n", - metavar="N", + '--num-images', + '-n', + metavar='N', default=None, type=click.INT, - help="Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s" - % (USER_CONFIG.get_help_string("save-images", "num-images")), + help='Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s' + % (USER_CONFIG.get_help_string('save-images', 'num-images')), ) @click.option( - "--jpeg", - "-j", + '--jpeg', + '-j', is_flag=True, flag_value=True, - help="Set output format to JPEG (default).%s" - % (USER_CONFIG.get_help_string("save-images", "format", show_default=False)), + help='Set output format to JPEG (default).%s' % + (USER_CONFIG.get_help_string('save-images', 'format', show_default=False)), ) @click.option( - "--webp", - "-w", + '--webp', + '-w', is_flag=True, flag_value=True, - help="Set output format to WebP", + help='Set output format to WebP', ) @click.option( - "--quality", - "-q", - metavar="Q", + '--quality', + '-q', + metavar='Q', default=None, type=click.IntRange(0, 100), - help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s" - % (USER_CONFIG.get_help_string("save-images", "quality", show_default=False)), + help='JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s' + % (USER_CONFIG.get_help_string('save-images', 'quality', show_default=False)), ) @click.option( - "--png", - "-p", + '--png', + '-p', is_flag=True, flag_value=True, - help="Set output format to PNG.", + help='Set output format to PNG.', ) @click.option( - "--compression", - "-c", - metavar="C", + '--compression', + '-c', + metavar='C', default=None, type=click.IntRange(0, 9), - help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s" - % (USER_CONFIG.get_help_string("save-images", "compression")), + help='PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s' + % (USER_CONFIG.get_help_string('save-images', 'compression')), ) @click.option( - "-m", - "--frame-margin", - metavar="N", + '-m', + '--frame-margin', + metavar='N', default=None, type=click.INT, - help="Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s" - % (USER_CONFIG.get_help_string("save-images", "num-images")), + help='Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s' + % (USER_CONFIG.get_help_string('save-images', 'num-images')), ) @click.option( - "--scale", - "-s", - metavar="S", + '--scale', + '-s', + metavar='S', default=None, type=click.FLOAT, - help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s" - % (USER_CONFIG.get_help_string("save-images", "scale", show_default=False)), + help='Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s' % + (USER_CONFIG.get_help_string('save-images', 'scale', show_default=False)), ) @click.option( - "--height", - "-H", - metavar="H", + '--height', + '-H', + metavar='H', default=None, type=click.INT, - help="Height (pixels) of images.%s" - % (USER_CONFIG.get_help_string("save-images", "height", show_default=False)), + help='Height (pixels) of images.%s' % + (USER_CONFIG.get_help_string('save-images', 'height', show_default=False)), ) @click.option( - "--width", - "-W", - metavar="W", + '--width', + '-W', + metavar='W', default=None, type=click.INT, - help="Width (pixels) of images.%s" - % (USER_CONFIG.get_help_string("save-images", "width", show_default=False)), + help='Width (pixels) of images.%s' % + (USER_CONFIG.get_help_string('save-images', 'width', show_default=False)), ) @click.pass_context def save_images_command( @@ -1290,16 +1207,16 @@ def save_images_command( ): """Create images for each detected scene. - Images can be resized +Images can be resized - Examples: +Examples: - {scenedetect_with_video} save-images + {scenedetect_with_video} save-images - {scenedetect_with_video} save-images --width 1024 + {scenedetect_with_video} save-images --width 1024 - {scenedetect_with_video} save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER - """ + {scenedetect_with_video} save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER +""" assert isinstance(ctx.obj, CliContext) ctx.obj.handle_save_images( num_images=num_images, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index fcb8ab38..3407b2a5 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -31,7 +31,7 @@ from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS -VALID_PYAV_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO'] class OptionParseFailure(Exception): @@ -53,7 +53,7 @@ def value(self) -> Any: @staticmethod @abstractmethod - def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue": + def from_config(config_value: str, default: 'ValidatedValue') -> 'ValidatedValue': """Validate and get the user-specified configuration option. Raises: @@ -83,13 +83,12 @@ def __str__(self) -> str: return str(self.value) @staticmethod - def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": + def from_config(config_value: str, default: 'TimecodeValue') -> 'TimecodeValue': try: return TimecodeValue(config_value) except ValueError as ex: raise OptionParseFailure( - "Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS." - ) from ex + 'Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS.') from ex class RangeValue(ValidatedValue): @@ -129,25 +128,22 @@ def __str__(self) -> str: return str(self.value) @staticmethod - def from_config(config_value: str, default: "RangeValue") -> "RangeValue": + def from_config(config_value: str, default: 'RangeValue') -> 'RangeValue': try: return RangeValue( - value=int(config_value) - if isinstance(default.value, int) - else float(config_value), + value=int(config_value) if isinstance(default.value, int) else float(config_value), min_val=default.min_val, max_val=default.max_val, ) except ValueError as ex: - raise OptionParseFailure( - "Value must be between %s and %s." % (default.min_val, default.max_val) - ) from ex + raise OptionParseFailure('Value must be between %s and %s.' % + (default.min_val, default.max_val)) from ex class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" - _IGNORE_CHARS = [",", "/", "(", ")"] + _IGNORE_CHARS = [',', '/', '(', ')'] """Characters to ignore.""" def __init__(self, value: Union[str, ContentDetector.Components]): @@ -155,8 +151,7 @@ def __init__(self, value: Union[str, ContentDetector.Components]): self._value = value else: translation_table = str.maketrans( - {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} - ) + {char: ' ' for char in ScoreWeightsValue._IGNORE_CHARS}) values = value.translate(translation_table).split() if not len(values) == 4: raise ValueError("Score weights must be specified as four numbers!") @@ -170,19 +165,16 @@ def __repr__(self) -> str: return str(self.value) def __str__(self) -> str: - return "%.3f, %.3f, %.3f, %.3f" % self.value + return '%.3f, %.3f, %.3f, %.3f' % self.value @staticmethod - def from_config( - config_value: str, default: "ScoreWeightsValue" - ) -> "ScoreWeightsValue": + def from_config(config_value: str, default: 'ScoreWeightsValue') -> 'ScoreWeightsValue': try: return ScoreWeightsValue(config_value) except ValueError as ex: raise OptionParseFailure( - "Score weights must be specified as four numbers in the form (H,S,L,E)," - " e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored." - ) from ex + 'Score weights must be specified as four numbers in the form (H,S,L,E),' + ' e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored.') from ex class KernelSizeValue(ValidatedValue): @@ -209,22 +201,21 @@ def __repr__(self) -> str: def __str__(self) -> str: if self.value is None: - return "auto" + return 'auto' return str(self.value) @staticmethod - def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue": + def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeValue': try: return KernelSizeValue(int(config_value)) except ValueError as ex: raise OptionParseFailure( - "Value must be an odd integer greater than 1, or set to -1 for auto kernel size." + 'Value must be an odd integer greater than 1, or set to -1 for auto kernel size.' ) from ex class TimecodeFormat(Enum): """Format to display timecodes.""" - FRAMES = 0 """Print timecodes as exact frame number.""" TIMECODE = 1 @@ -238,18 +229,16 @@ def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return "%.3f" % timecode.get_seconds() + return '%.3f' % timecode.get_seconds() assert False ConfigValue = Union[bool, int, float, str] ConfigDict = Dict[str, Dict[str, ConfigValue]] -_CONFIG_FILE_NAME: AnyStr = "scenedetect.cfg" +_CONFIG_FILE_NAME: AnyStr = 'scenedetect.cfg' _CONFIG_FILE_DIR: AnyStr = user_config_dir("PySceneDetect", False) -_PLACEHOLDER = ( - 0 # Placeholder for image quality default, as the value depends on output format -) +_PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format CONFIG_FILE_PATH: AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) DEFAULT_JPG_QUALITY = 95 @@ -360,36 +349,29 @@ def format(self, timecode: FrameTimecode) -> str: certain string options are stored in `CHOICE_MAP`.""" CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { - "backend-pyav": { - "threading_mode": [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + 'backend-pyav': { + 'threading_mode': [mode.lower() for mode in VALID_PYAV_THREAD_MODES], }, - "detect-content": { - "filter-mode": [mode.name.lower() for mode in FlashFilter.Mode], + 'detect-content': { + 'filter-mode': [mode.name.lower() for mode in FlashFilter.Mode], }, - "global": { - "backend": ["opencv", "pyav", "moviepy"], - "default-detector": ["detect-adaptive", "detect-content", "detect-threshold"], - "downscale-method": [value.name.lower() for value in Interpolation], - "verbosity": ["debug", "info", "warning", "error", "none"], + 'global': { + 'backend': ['opencv', 'pyav', 'moviepy'], + 'default-detector': ['detect-adaptive', 'detect-content', 'detect-threshold'], + 'downscale-method': [value.name.lower() for value in Interpolation], + 'verbosity': ['debug', 'info', 'warning', 'error', 'none'], }, - "list-scenes": { - "cut-format": [value.name.lower() for value in TimecodeFormat], + 'list-scenes': { + 'cut-format': [value.name.lower() for value in TimecodeFormat], }, - "save-images": { - "format": ["jpeg", "png", "webp"], - "scale-method": [value.name.lower() for value in Interpolation], + 'save-images': { + 'format': ['jpeg', 'png', 'webp'], + 'scale-method': [value.name.lower() for value in Interpolation], }, - "split-video": { - "preset": [ - "ultrafast", - "superfast", - "veryfast", - "faster", - "fast", - "medium", - "slow", - "slower", - "veryslow", + 'split-video': { + 'preset': [ + 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', + 'veryslow' ], }, } @@ -409,13 +391,11 @@ def _validate_structure(config: ConfigParser) -> List[str]: errors: List[str] = [] for section in config.sections(): if not section in CONFIG_MAP.keys(): - errors.append("Unsupported config section: [%s]" % (section)) + errors.append('Unsupported config section: [%s]' % (section)) continue - for option_name, _ in config.items(section): + for (option_name, _) in config.items(section): if not option_name in CONFIG_MAP[section].keys(): - errors.append( - "Unsupported config option in [%s]: %s" % (section, option_name) - ) + errors.append('Unsupported config option in [%s]: %s' % (section, option_name)) return errors @@ -434,22 +414,20 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: try: value_type = None if isinstance(CONFIG_MAP[command][option], bool): - value_type = "yes/no value" + value_type = 'yes/no value' out_map[command][option] = config.getboolean(command, option) continue elif isinstance(CONFIG_MAP[command][option], int): - value_type = "integer" + value_type = 'integer' out_map[command][option] = config.getint(command, option) continue elif isinstance(CONFIG_MAP[command][option], float): - value_type = "number" + value_type = 'number' out_map[command][option] = config.getfloat(command, option) continue except ValueError as _: - errors.append( - "Invalid [%s] value for %s: %s is not a valid %s." - % (command, option, config.get(command, option), value_type) - ) + errors.append('Invalid [%s] value for %s: %s is not a valid %s.' % + (command, option, config.get(command, option), value_type)) continue # Handle custom validation types. @@ -459,34 +437,21 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: if issubclass(option_type, ValidatedValue): try: out_map[command][option] = option_type.from_config( - config_value=config_value, default=default - ) + config_value=config_value, default=default) except OptionParseFailure as ex: - errors.append( - "Invalid [%s] value for %s:\n %s\n%s" - % (command, option, config_value, ex.error) - ) + errors.append('Invalid [%s] value for %s:\n %s\n%s' % + (command, option, config_value, ex.error)) continue # If we didn't process the value as a given type, handle it as a string. We also # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: - config_value = ( - config.get(command, option).replace("\n", " ").strip() - ) + config_value = config.get(command, option).replace('\n', ' ').strip() if command in CHOICE_MAP and option in CHOICE_MAP[command]: if config_value.lower() not in CHOICE_MAP[command][option]: - errors.append( - "Invalid [%s] value for %s: %s. Must be one of: %s." - % ( - command, - option, - config.get(command, option), - ", ".join( - choice for choice in CHOICE_MAP[command][option] - ), - ) - ) + errors.append('Invalid [%s] value for %s: %s. Must be one of: %s.' % + (command, option, config.get(command, option), ', '.join( + choice for choice in CHOICE_MAP[command][option]))) continue out_map[command][option] = config_value continue @@ -504,8 +469,9 @@ def __init__(self, init_log: Tuple[int, str], reason: Optional[Exception] = None class ConfigRegistry: + def __init__(self, path: Optional[str] = None, throw_exception: bool = True): - self._config: ConfigDict = {} # Options set in the loaded config file. + self._config: ConfigDict = {} # Options set in the loaded config file. self._init_log: List[Tuple[int, str]] = [] self._initialized = False @@ -521,7 +487,7 @@ def __init__(self, path: Optional[str] = None, throw_exception: bool = True): self._init_log = ex.init_log if ex.reason is not None: self._init_log += [ - (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")), + (logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' ')), ] self._initialized = False @@ -547,9 +513,7 @@ def _log(self, log_level, log_str): def _load_from_disk(self, path=None): # Validate `path`, or if not provided, use CONFIG_FILE_PATH if it exists. if path: - self._init_log.append( - (logging.INFO, "Loading config from file:\n %s" % path) - ) + self._init_log.append((logging.INFO, "Loading config from file:\n %s" % path)) if not os.path.exists(path): self._init_log.append((logging.ERROR, "File not found: %s" % (path))) raise ConfigLoadFailure(self._init_log) @@ -559,13 +523,11 @@ def _load_from_disk(self, path=None): self._init_log.append((logging.DEBUG, "User config file not found.")) return path = CONFIG_FILE_PATH - self._init_log.append( - (logging.INFO, "Loading user config file:\n %s" % path) - ) + self._init_log.append((logging.INFO, "Loading user config file:\n %s" % path)) # Try to load and parse the config file at `path`. config = ConfigParser() try: - with open(path, "r") as config_file: + with open(path, 'r') as config_file: config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) except ParsingError as ex: @@ -586,13 +548,11 @@ def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" return not (command in self._config and option in self._config[command]) - def get_value( - self, - command: str, - option: str, - override: Optional[ConfigValue] = None, - ignore_default: bool = False, - ) -> ConfigValue: + def get_value(self, + command: str, + option: str, + override: Optional[ConfigValue] = None, + ignore_default: bool = False) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: @@ -607,9 +567,10 @@ def get_value( return value.value return value - def get_help_string( - self, command: str, option: str, show_default: Optional[bool] = None - ) -> str: + def get_help_string(self, + command: str, + option: str, + show_default: Optional[bool] = None) -> str: """Get a string to specify for the help text indicating the current command option value, if set, or the default. @@ -623,12 +584,11 @@ def get_help_string( is_flag = isinstance(CONFIG_MAP[command][option], bool) if command in self._config and option in self._config[command]: if is_flag: - value_str = "on" if self._config[command][option] else "off" + value_str = 'on' if self._config[command][option] else 'off' else: value_str = str(self._config[command][option]) - return " [setting: %s]" % (value_str) - if show_default is False or ( - show_default is None and is_flag and CONFIG_MAP[command][option] is False - ): - return "" - return " [default: %s]" % (str(CONFIG_MAP[command][option])) + return ' [setting: %s]' % (value_str) + if show_default is False or (show_default is None and is_flag + and CONFIG_MAP[command][option] is False): + return '' + return ' [default: %s]' % (str(CONFIG_MAP[command][option])) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 806dabe7..ee583727 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -24,47 +24,32 @@ from scenedetect import open_video, AVAILABLE_BACKENDS from scenedetect.scene_detector import SceneDetector, FlashFilter -from scenedetect.platform import ( - get_and_create_path, - get_cv2_imwrite_params, - init_logger, -) +from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available -from scenedetect.detectors import ( - AdaptiveDetector, - ContentDetector, - ThresholdDetector, - HistogramDetector, -) +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.stats_manager import StatsManager from scenedetect.scene_manager import SceneManager, Interpolation -from scenedetect._cli.config import ( - ConfigRegistry, - ConfigLoadFailure, - TimecodeFormat, - CHOICE_MAP, - DEFAULT_JPG_QUALITY, - DEFAULT_WEBP_QUALITY, -) +from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP, + DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY) -logger = logging.getLogger("pyscenedetect") +logger = logging.getLogger('pyscenedetect') USER_CONFIG = ConfigRegistry(throw_exception=False) -def parse_timecode( - value: ty.Optional[str], frame_rate: float, correct_pts: bool = False -) -> FrameTimecode: +def parse_timecode(value: ty.Optional[str], + frame_rate: float, + correct_pts: bool = False) -> FrameTimecode: """Parses a user input string into a FrameTimecode assuming the given framerate. If value is None, None will be returned instead of processing the value. Raises: click.BadParameter - """ + """ if value is None: return None try: @@ -75,17 +60,16 @@ def parse_timecode( return FrameTimecode(timecode=value, fps=frame_rate) except ValueError as ex: raise click.BadParameter( - "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" - ) from ex + 'timecode must be in seconds (100.0), frames (100), or HH:MM:SS') from ex def contains_sequence_or_url(video_path: str) -> bool: """Checks if the video path is a URL or image sequence.""" - return "%" in video_path or "://" in video_path + return '%' in video_path or '://' in video_path def check_split_video_requirements(use_mkvmerge: bool) -> None: - """Validates that the proper tool is available on the system to perform the + """ Validates that the proper tool is available on the system to perform the `split-video` command. Arguments: @@ -97,19 +81,16 @@ def check_split_video_requirements(use_mkvmerge: bool) -> None: if (use_mkvmerge and not is_mkvmerge_available()) or not is_ffmpeg_available(): error_strs = [ "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format( - EXTERN_TOOL="mkvmerge" if use_mkvmerge else "ffmpeg", - EXTRA_ARGS=" when mkvmerge (-m) is set" if use_mkvmerge else "", - ) + EXTERN_TOOL='mkvmerge' if use_mkvmerge else 'ffmpeg', + EXTRA_ARGS=' when mkvmerge (-m) is set' if use_mkvmerge else '') ] - error_strs += ["Ensure the program is available on your system and try again."] + error_strs += ['Ensure the program is available on your system and try again.'] if not use_mkvmerge and is_mkvmerge_available(): - error_strs += [ - "You can specify mkvmerge (-m) to use mkvmerge for splitting." - ] + error_strs += ['You can specify mkvmerge (-m) to use mkvmerge for splitting.'] elif use_mkvmerge and is_ffmpeg_available(): - error_strs += ["You can specify copy (-c) to use ffmpeg stream copying."] - error_str = "\n".join(error_strs) - raise click.BadParameter(error_str, param_hint="split-video") + error_strs += ['You can specify copy (-c) to use ffmpeg stream copying.'] + error_str = '\n'.join(error_strs) + raise click.BadParameter(error_str, param_hint='split-video') # pylint: disable=too-many-instance-attributes,too-many-arguments,too-many-locals @@ -132,68 +113,65 @@ def __init__(self): self.added_detector: bool = False # Global `scenedetect` Options - self.output_dir: str = None # -o/--output - self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet - self.stats_file_path: str = None # -s/--stats - self.drop_short_scenes: bool = None # --drop-short-scenes - self.merge_last_scene: bool = None # --merge-last-scene - self.min_scene_len: FrameTimecode = None # -m/--min-scene-len - self.frame_skip: int = None # -fs/--frame-skip - self.default_detector: Tuple[Type[SceneDetector], Dict[str, Any]] = ( - None # [global] default-detector - ) + self.output_dir: str = None # -o/--output + self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet + self.stats_file_path: str = None # -s/--stats + self.drop_short_scenes: bool = None # --drop-short-scenes + self.merge_last_scene: bool = None # --merge-last-scene + self.min_scene_len: FrameTimecode = None # -m/--min-scene-len + self.frame_skip: int = None # -fs/--frame-skip + self.default_detector: Tuple[Type[SceneDetector], + Dict[str, Any]] = None # [global] default-detector # `time` Command Options self.time: bool = False - self.start_time: FrameTimecode = None # time -s/--start - self.end_time: FrameTimecode = None # time -e/--end - self.duration: FrameTimecode = None # time -d/--duration + self.start_time: FrameTimecode = None # time -s/--start + self.end_time: FrameTimecode = None # time -e/--end + self.duration: FrameTimecode = None # time -d/--duration # `save-images` Command Options self.save_images: bool = False - self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_dir: str = None # save-images -o/--output - self.image_param: int = None # save-images -q/--quality if -j/-w, - # otherwise -c/--compression if -p - self.image_name_format: str = None # save-images -f/--name-format - self.num_images: int = None # save-images -n/--num-images - self.frame_margin: int = 1 # save-images -m/--frame-margin - self.scale: float = None # save-images -s/--scale - self.height: int = None # save-images -h/--height - self.width: int = None # save-images -w/--width - self.scale_method: Interpolation = None # [save-images] scale-method + self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png + self.image_dir: str = None # save-images -o/--output + self.image_param: int = None # save-images -q/--quality if -j/-w, + # otherwise -c/--compression if -p + self.image_name_format: str = None # save-images -f/--name-format + self.num_images: int = None # save-images -n/--num-images + self.frame_margin: int = 1 # save-images -m/--frame-margin + self.scale: float = None # save-images -s/--scale + self.height: int = None # save-images -h/--height + self.width: int = None # save-images -w/--width + self.scale_method: Interpolation = None # [save-images] scale-method # `split-video` Command Options self.split_video: bool = False - self.split_mkvmerge: bool = None # split-video -m/--mkvmerge - self.split_args: str = None # split-video -a/--args, -c/--copy - self.split_dir: str = None # split-video -o/--output - self.split_name_format: str = None # split-video -f/--filename - self.split_quiet: bool = None # split-video -q/--quiet + self.split_mkvmerge: bool = None # split-video -m/--mkvmerge + self.split_args: str = None # split-video -a/--args, -c/--copy + self.split_dir: str = None # split-video -o/--output + self.split_name_format: str = None # split-video -f/--filename + self.split_quiet: bool = None # split-video -q/--quiet # `list-scenes` Command Options self.list_scenes: bool = False - self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_dir: str = None # list-scenes -o/--output - self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output-file - self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = True # [list-scenes] display-cuts - self.display_scenes: bool = True # [list-scenes] display-scenes - self.cut_format: TimecodeFormat = ( - TimecodeFormat.TIMECODE - ) # [list-scenes] cut-format + self.list_scenes_quiet: bool = None # list-scenes -q/--quiet + self.scene_list_dir: str = None # list-scenes -o/--output + self.scene_list_name_format: str = None # list-scenes -f/--filename + self.scene_list_output: bool = None # list-scenes -n/--no-output-file + self.skip_cuts: bool = None # list-scenes -s/--skip-cuts + self.display_cuts: bool = True # [list-scenes] display-cuts + self.display_scenes: bool = True # [list-scenes] display-scenes + self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format # `export-html` Command Options self.export_html: bool = False - self.html_name_format: str = None # export-html -f/--filename - self.html_include_images: bool = None # export-html --no-images - self.image_width: int = None # export-html -w/--image-width - self.image_height: int = None # export-html -h/--image-height + self.html_name_format: str = None # export-html -f/--filename + self.html_include_images: bool = None # export-html --no-images + self.image_width: int = None # export-html -w/--image-width + self.image_height: int = None # export-html -h/--image-height # `load-scenes` Command Options - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name # # Command Handlers @@ -240,11 +218,9 @@ def handle_options( self.config = ConfigRegistry(config) init_log += self.config.get_init_log() # Re-initialize logger with the correct verbosity. - if verbosity is None and not self.config.is_default( - "global", "verbosity" - ): - verbosity_str = self.config.get_value("global", "verbosity") - assert verbosity_str in CHOICE_MAP["global"]["verbosity"] + if verbosity is None and not self.config.is_default('global', 'verbosity'): + verbosity_str = self.config.get_value('global', 'verbosity') + assert verbosity_str in CHOICE_MAP['global']['verbosity'] self.quiet_mode = False self._initialize_logging(verbosity=verbosity_str, logfile=logfile) @@ -252,13 +228,11 @@ def handle_options( init_failure = True init_log += ex.init_log if ex.reason is not None: - init_log += [ - (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")) - ] + init_log += [(logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' '))] finally: # Make sure we print the version number even on any kind of init failure. - logger.info("PySceneDetect %s", scenedetect.__version__) - for log_level, log_str in init_log: + logger.info('PySceneDetect %s', scenedetect.__version__) + for (log_level, log_str) in init_log: logger.log(log_level, log_str) if init_failure: logger.critical("Error processing configuration file.") @@ -267,17 +241,16 @@ def handle_options( if self.config.config_dict: logger.debug("Current configuration:\n%s", str(self.config.config_dict)) - logger.debug("Parsing program options.") + logger.debug('Parsing program options.') if stats is not None and frame_skip: error_strs = [ - "Unable to detect scenes with stats file if frame skip is not 0.", - " Either remove the -fs/--frame-skip option, or the -s/--stats file.\n", + 'Unable to detect scenes with stats file if frame skip is not 0.', + ' Either remove the -fs/--frame-skip option, or the -s/--stats file.\n' ] - logger.error("\n".join(error_strs)) + logger.error('\n'.join(error_strs)) raise click.BadParameter( - "Combining the -s/--stats and -fs/--frame-skip options is not supported.", - param_hint="frame skip + stats file", - ) + 'Combining the -s/--stats and -fs/--frame-skip options is not supported.', + param_hint='frame skip + stats file') # Handle the case where -i/--input was not specified (e.g. for the `help` command). if input_path is None: @@ -287,29 +260,19 @@ def handle_options( self._open_video_stream( input_path=input_path, framerate=framerate, - backend=self.config.get_value( - "global", "backend", backend, ignore_default=True - ), - ) - - self.output_dir = ( - output if output else self.config.get_value("global", "output") - ) + backend=self.config.get_value("global", "backend", backend, ignore_default=True)) + + self.output_dir = output if output else self.config.get_value("global", "output") if self.output_dir: - logger.info("Output directory set:\n %s", self.output_dir) + logger.info('Output directory set:\n %s', self.output_dir) self.min_scene_len = parse_timecode( - min_scene_len - if min_scene_len is not None - else self.config.get_value("global", "min-scene-len"), - self.video_stream.frame_rate, - ) + min_scene_len if min_scene_len is not None else self.config.get_value( + "global", "min-scene-len"), self.video_stream.frame_rate) self.drop_short_scenes = drop_short_scenes or self.config.get_value( - "global", "drop-short-scenes" - ) + "global", "drop-short-scenes") self.merge_last_scene = merge_last_scene or self.config.get_value( - "global", "merge-last-scene" - ) + "global", "merge-last-scene") self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) # Create StatsManager if --stats is specified. @@ -319,28 +282,20 @@ def handle_options( # Initialize default detector with values in the config file. default_detector = self.config.get_value("global", "default-detector") - if default_detector == "detect-adaptive": - self.default_detector = ( - AdaptiveDetector, - self.get_detect_adaptive_params(), - ) - elif default_detector == "detect-content": + if default_detector == 'detect-adaptive': + self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) + elif default_detector == 'detect-content': self.default_detector = (ContentDetector, self.get_detect_content_params()) - elif default_detector == "detect-hash": + elif default_detector == 'detect-hash': self.default_detector = (HashDetector, self.get_detect_hash_params()) - elif default_detector == "detect-hist": + elif default_detector == 'detect-hist': self.default_detector = (HistogramDetector, self.get_detect_hist_params()) - elif default_detector == "detect-threshold": - self.default_detector = ( - ThresholdDetector, - self.get_detect_threshold_params(), - ) + elif default_detector == 'detect-threshold': + self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) else: - raise click.BadParameter( - "Unknown detector type!", param_hint="default-detector" - ) + raise click.BadParameter("Unknown detector type!", param_hint='default-detector') - logger.debug("Initializing SceneManager.") + logger.debug('Initializing SceneManager.') scene_manager = SceneManager(self.stats_manager) if downscale is None and self.config.is_default("global", "downscale"): @@ -352,10 +307,9 @@ def handle_options( scene_manager.downscale = downscale except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="downscale factor") - scene_manager.interpolation = Interpolation[ - self.config.get_value("global", "downscale-method").upper() - ] + raise click.BadParameter(str(ex), param_hint='downscale factor') + scene_manager.interpolation = Interpolation[self.config.get_value( + 'global', 'downscale-method').upper()] self.scene_manager = scene_manager def get_detect_content_params( @@ -374,39 +328,33 @@ def get_detect_content_params( min_scene_len = 0 else: if min_scene_len is None: - if self.config.is_default("detect-content", "min-scene-len"): + if self.config.is_default('detect-content', 'min-scene-len'): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value( - "detect-content", "min-scene-len" - ) - min_scene_len = parse_timecode( - min_scene_len, self.video_stream.frame_rate - ).frame_num + min_scene_len = self.config.get_value('detect-content', 'min-scene-len') + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="weights") + raise click.BadParameter(str(ex), param_hint='weights') return { - "weights": self.config.get_value("detect-content", "weights", weights), - "kernel_size": self.config.get_value( - "detect-content", "kernel-size", kernel_size - ), - "luma_only": luma_only - or self.config.get_value("detect-content", "luma-only"), - "min_scene_len": min_scene_len, - "threshold": self.config.get_value( - "detect-content", "threshold", threshold - ), - "filter_mode": FlashFilter.Mode[ - self.config.get_value( - "detect-content", "filter-mode", filter_mode - ).upper() - ], + 'weights': + self.config.get_value('detect-content', 'weights', weights), + 'kernel_size': + self.config.get_value('detect-content', 'kernel-size', kernel_size), + 'luma_only': + luma_only or self.config.get_value('detect-content', 'luma-only'), + 'min_scene_len': + min_scene_len, + 'threshold': + self.config.get_value('detect-content', 'threshold', threshold), + 'filter_mode': + FlashFilter.Mode[self.config.get_value("detect-content", "filter-mode", + filter_mode).upper()], } def get_detect_adaptive_params( @@ -425,21 +373,16 @@ def get_detect_adaptive_params( # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: - logger.error( - "-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead." - ) + logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.') if min_content_val is None: min_content_val = min_delta_hsv # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error( - "[detect-adaptive] config file option `min-delta-hsv` is deprecated" - ", use `min-delta-hsv` instead." - ) + logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated' + ', use `min-delta-hsv` instead.') if self.config.is_default("detect-adaptive", "min-content-val"): self.config.config_dict["detect-adaptive"]["min-content-val"] = ( - self.config.config_dict["detect-adaptive"]["min-deleta-hsv"] - ) + self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) if self.drop_short_scenes: min_scene_len = 0 @@ -448,36 +391,30 @@ def get_detect_adaptive_params( if self.config.is_default("detect-adaptive", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value( - "detect-adaptive", "min-scene-len" - ) - min_scene_len = parse_timecode( - min_scene_len, self.video_stream.frame_rate - ).frame_num + min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num if weights is not None: try: weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="weights") + raise click.BadParameter(str(ex), param_hint='weights') return { - "adaptive_threshold": self.config.get_value( - "detect-adaptive", "threshold", threshold - ), - "weights": self.config.get_value("detect-adaptive", "weights", weights), - "kernel_size": self.config.get_value( - "detect-adaptive", "kernel-size", kernel_size - ), - "luma_only": luma_only - or self.config.get_value("detect-adaptive", "luma-only"), - "min_content_val": self.config.get_value( - "detect-adaptive", "min-content-val", min_content_val - ), - "min_scene_len": min_scene_len, - "window_width": self.config.get_value( - "detect-adaptive", "frame-window", frame_window - ), + 'adaptive_threshold': + self.config.get_value("detect-adaptive", "threshold", threshold), + 'weights': + self.config.get_value("detect-adaptive", "weights", weights), + 'kernel_size': + self.config.get_value("detect-adaptive", "kernel-size", kernel_size), + 'luma_only': + luma_only or self.config.get_value("detect-adaptive", "luma-only"), + 'min_content_val': + self.config.get_value("detect-adaptive", "min-content-val", min_content_val), + 'min_scene_len': + min_scene_len, + 'window_width': + self.config.get_value("detect-adaptive", "frame-window", frame_window), } def get_detect_threshold_params( @@ -497,53 +434,37 @@ def get_detect_threshold_params( if self.config.is_default("detect-threshold", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value( - "detect-threshold", "min-scene-len" - ) - min_scene_len = parse_timecode( - min_scene_len, self.video_stream.frame_rate - ).frame_num + min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { - "add_final_scene": add_last_scene - or self.config.get_value("detect-threshold", "add-last-scene"), - "fade_bias": self.config.get_value( - "detect-threshold", "fade-bias", fade_bias - ), - "min_scene_len": min_scene_len, - "threshold": self.config.get_value( - "detect-threshold", "threshold", threshold - ), + 'add_final_scene': + add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"), + 'fade_bias': + self.config.get_value("detect-threshold", "fade-bias", fade_bias), + 'min_scene_len': + min_scene_len, + 'threshold': + self.config.get_value("detect-threshold", "threshold", threshold), } def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): """Handle `load-scenes` command options.""" self._ensure_input_open() if self.added_detector: - raise click.ClickException( - "The load-scenes command cannot be used with detectors." - ) + raise click.ClickException("The load-scenes command cannot be used with detectors.") if self.load_scenes_input: - raise click.ClickException( - "The load-scenes command must only be specified once." - ) + raise click.ClickException("The load-scenes command must only be specified once.") input = os.path.abspath(input) if not os.path.exists(input): raise click.BadParameter( - f"Could not load scenes, file does not exist: {input}", - param_hint="-i/--input", - ) + f'Could not load scenes, file does not exist: {input}', param_hint='-i/--input') self.load_scenes_input = input - self.load_scenes_column_name = self.config.get_value( - "load-scenes", "start-col-name", start_col_name - ) + self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", + start_col_name) - def get_detect_hist_params( - self, - threshold: Optional[float], - bins: Optional[int], - min_scene_len: Optional[str], - ) -> Dict[str, Any]: + def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int], + min_scene_len: Optional[str]) -> Dict[str, Any]: """Handle detect-hist command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -553,25 +474,17 @@ def get_detect_hist_params( if self.config.is_default("detect-hist", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value( - "detect-hist", "min-scene-len" - ) - min_scene_len = parse_timecode( - min_scene_len, self.video_stream.frame_rate - ).frame_num + min_scene_len = self.config.get_value("detect-hist", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num return { - "bins": self.config.get_value("detect-hist", "bins", bins), - "min_scene_len": min_scene_len, - "threshold": self.config.get_value("detect-hist", "threshold", threshold), + 'bins': self.config.get_value("detect-hist", "bins", bins), + 'min_scene_len': min_scene_len, + 'threshold': self.config.get_value("detect-hist", "threshold", threshold), } - def get_detect_hash_params( - self, - threshold: Optional[float], - size: Optional[int], - lowpass: Optional[int], - min_scene_len: Optional[str], - ) -> Dict[str, Any]: + def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int], + lowpass: Optional[int], + min_scene_len: Optional[str]) -> Dict[str, Any]: """Handle detect-hash command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -581,12 +494,8 @@ def get_detect_hash_params( if self.config.is_default("detect-hash", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value( - "detect-hash", "min-scene-len" - ) - min_scene_len = parse_timecode( - min_scene_len, self.video_stream.frame_rate - ).frame_num + min_scene_len = self.config.get_value("detect-hash", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), "min_scene_len": min_scene_len, @@ -604,27 +513,20 @@ def handle_export_html( """Handle `export-html` command options.""" self._ensure_input_open() if self.export_html: - self._on_duplicate_command("export_html") + self._on_duplicate_command('export_html') - no_images = no_images or self.config.get_value("export-html", "no-images") + no_images = no_images or self.config.get_value('export-html', 'no-images') self.html_include_images = not no_images - self.html_name_format = self.config.get_value( - "export-html", "filename", filename - ) - self.image_width = self.config.get_value( - "export-html", "image-width", image_width - ) - self.image_height = self.config.get_value( - "export-html", "image-height", image_height - ) + self.html_name_format = self.config.get_value('export-html', 'filename', filename) + self.image_width = self.config.get_value('export-html', 'image-width', image_width) + self.image_height = self.config.get_value('export-html', 'image-height', image_height) if not self.save_images and not no_images: raise click.BadArgumentUsage( - "The export-html command requires that the save-images command\n" - "is specified before it, unless --no-images is specified." - ) - logger.info("HTML file name format:\n %s", filename) + 'The export-html command requires that the save-images command\n' + 'is specified before it, unless --no-images is specified.') + logger.info('HTML file name format:\n %s', filename) self.export_html = True @@ -644,24 +546,15 @@ def handle_list_scenes( self.display_cuts = self.config.get_value("list-scenes", "display-cuts") self.display_scenes = self.config.get_value("list-scenes", "display-scenes") self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts") - self.cut_format = TimecodeFormat[ - self.config.get_value("list-scenes", "cut-format").upper() - ] + self.cut_format = TimecodeFormat[self.config.get_value("list-scenes", "cut-format").upper()] self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") - no_output_file = no_output_file or self.config.get_value( - "list-scenes", "no-output-file" - ) + no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") self.scene_list_dir = self.config.get_value( - "list-scenes", "output", output, ignore_default=True - ) - self.scene_list_name_format = self.config.get_value( - "list-scenes", "filename", filename - ) + "list-scenes", "output", output, ignore_default=True) + self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) if self.scene_list_name_format is not None and not no_output_file: - logger.info( - "Scene list filename format:\n %s", self.scene_list_name_format - ) + logger.info("Scene list filename format:\n %s", self.scene_list_name_format) self.scene_list_output = not no_output_file if self.scene_list_dir is not None: logger.info("Scene list output directory:\n %s", self.scene_list_dir) @@ -683,74 +576,62 @@ def handle_split_video( """Handle `split-video` command options.""" self._ensure_input_open() if self.split_video: - self._on_duplicate_command("split-video") + self._on_duplicate_command('split-video') check_split_video_requirements(use_mkvmerge=mkvmerge) if contains_sequence_or_url(self.video_stream.path): - error_str = ( - "The split-video command is incompatible with image sequences/URLs." - ) - raise click.BadParameter(error_str, param_hint="split-video") + error_str = 'The split-video command is incompatible with image sequences/URLs.' + raise click.BadParameter(error_str, param_hint='split-video') ## ## Common Arguments/Options ## self.split_video = True - self.split_quiet = quiet or self.config.get_value("split-video", "quiet") - self.split_dir = self.config.get_value( - "split-video", "output", output, ignore_default=True - ) + self.split_quiet = quiet or self.config.get_value('split-video', 'quiet') + self.split_dir = self.config.get_value('split-video', 'output', output, ignore_default=True) if self.split_dir is not None: - logger.info("Video output path set: \n%s", self.split_dir) - self.split_name_format = self.config.get_value( - "split-video", "filename", filename - ) + logger.info('Video output path set: \n%s', self.split_dir) + self.split_name_format = self.config.get_value('split-video', 'filename', filename) # We only load the config values for these flags/options if none of the other # encoder flags/options were set via the CLI to avoid any conflicting options # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). if not (mkvmerge or copy or high_quality or args or rate_factor or preset): - mkvmerge = self.config.get_value("split-video", "mkvmerge") - copy = self.config.get_value("split-video", "copy") - high_quality = self.config.get_value("split-video", "high-quality") - rate_factor = self.config.get_value("split-video", "rate-factor") - preset = self.config.get_value("split-video", "preset") - args = self.config.get_value("split-video", "args") + mkvmerge = self.config.get_value('split-video', 'mkvmerge') + copy = self.config.get_value('split-video', 'copy') + high_quality = self.config.get_value('split-video', 'high-quality') + rate_factor = self.config.get_value('split-video', 'rate-factor') + preset = self.config.get_value('split-video', 'preset') + args = self.config.get_value('split-video', 'args') # Disallow certain combinations of flags/options. if mkvmerge or copy: - command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" + command = 'mkvmerge (-m)' if mkvmerge else 'copy (-c)' if high_quality: raise click.BadParameter( - "high-quality (-hq) cannot be used with %s" % (command), - param_hint="split-video", - ) + 'high-quality (-hq) cannot be used with %s' % (command), + param_hint='split-video') if args: raise click.BadParameter( - "args (-a) cannot be used with %s" % (command), - param_hint="split-video", - ) + 'args (-a) cannot be used with %s' % (command), param_hint='split-video') if rate_factor: raise click.BadParameter( - "rate-factor (crf) cannot be used with %s" % (command), - param_hint="split-video", - ) + 'rate-factor (crf) cannot be used with %s' % (command), + param_hint='split-video') if preset: raise click.BadParameter( - "preset (-p) cannot be used with %s" % (command), - param_hint="split-video", - ) + 'preset (-p) cannot be used with %s' % (command), param_hint='split-video') ## ## mkvmerge-Specific Arguments/Options ## if mkvmerge: if copy: - logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") + logger.warning('copy mode (-c) ignored due to mkvmerge mode (-m).') self.split_mkvmerge = True - logger.info("Using mkvmerge for video splitting.") + logger.info('Using mkvmerge for video splitting.') return ## @@ -763,15 +644,13 @@ def handle_split_video( rate_factor = 22 if not high_quality else 17 if preset is None: preset = "veryfast" if not high_quality else "slow" - args = ( - "-map 0:v:0 -map 0:a? -map 0:s? " - f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" - ) + args = ("-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac") - logger.info("ffmpeg arguments: %s", args) + logger.info('ffmpeg arguments: %s', args) self.split_args = args if filename: - logger.info("Output file name format: %s", filename) + logger.info('Output file name format: %s', filename) def handle_save_images( self, @@ -791,86 +670,70 @@ def handle_save_images( """Handle `save-images` command options.""" self._ensure_input_open() if self.save_images: - self._on_duplicate_command("save-images") + self._on_duplicate_command('save-images') - if "://" in self.video_stream.path: - error_str = "\nThe save-images command is incompatible with URLs." + if '://' in self.video_stream.path: + error_str = '\nThe save-images command is incompatible with URLs.' logger.error(error_str) - raise click.BadParameter(error_str, param_hint="save-images") + raise click.BadParameter(error_str, param_hint='save-images') num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) if num_flags > 1: - logger.error("Multiple image type flags set for save-images command.") + logger.error('Multiple image type flags set for save-images command.') raise click.BadParameter( - "Only one image type (JPG/PNG/WEBP) can be specified.", - param_hint="save-images", - ) + 'Only one image type (JPG/PNG/WEBP) can be specified.', param_hint='save-images') # Only use config params for image format if one wasn't specified. elif num_flags == 0: - image_format = self.config.get_value("save-images", "format").lower() - jpeg = image_format == "jpeg" - webp = image_format == "webp" - png = image_format == "png" + image_format = self.config.get_value('save-images', 'format').lower() + jpeg = image_format == 'jpeg' + webp = image_format == 'webp' + png = image_format == 'png' # Only use config params for scale/height/width if none of them are specified explicitly. if scale is None and height is None and width is None: - self.scale = self.config.get_value("save-images", "scale") - self.height = self.config.get_value("save-images", "height") - self.width = self.config.get_value("save-images", "width") + self.scale = self.config.get_value('save-images', 'scale') + self.height = self.config.get_value('save-images', 'height') + self.width = self.config.get_value('save-images', 'width') else: self.scale = scale self.height = height self.width = width - self.scale_method = Interpolation[ - self.config.get_value("save-images", "scale-method").upper() - ] + self.scale_method = Interpolation[self.config.get_value('save-images', + 'scale-method').upper()] default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY quality = ( - default_quality - if self.config.is_default("save-images", "quality") - else self.config.get_value("save-images", "quality") - ) + default_quality if self.config.is_default('save-images', 'quality') else + self.config.get_value('save-images', 'quality')) - compression = self.config.get_value("save-images", "compression", compression) + compression = self.config.get_value('save-images', 'compression', compression) self.image_param = compression if png else quality - self.image_extension = "jpg" if jpeg else "png" if png else "webp" + self.image_extension = 'jpg' if jpeg else 'png' if png else 'webp' valid_params = get_cv2_imwrite_params() - if ( - not self.image_extension in valid_params - or valid_params[self.image_extension] is None - ): + if not self.image_extension in valid_params or valid_params[self.image_extension] is None: error_strs = [ - "Image encoder type `%s` not supported." % self.image_extension.upper(), - "The specified encoder type could not be found in the current OpenCV module.", - "To enable this output format, please update the installed version of OpenCV.", - "If you build OpenCV, ensure the the proper dependencies are enabled. ", + 'Image encoder type `%s` not supported.' % self.image_extension.upper(), + 'The specified encoder type could not be found in the current OpenCV module.', + 'To enable this output format, please update the installed version of OpenCV.', + 'If you build OpenCV, ensure the the proper dependencies are enabled. ' ] - logger.debug("\n".join(error_strs)) - raise click.BadParameter("\n".join(error_strs), param_hint="save-images") - - self.image_dir = self.config.get_value( - "save-images", "output", output, ignore_default=True - ) - - self.image_name_format = self.config.get_value( - "save-images", "filename", filename - ) - self.num_images = self.config.get_value("save-images", "num-images", num_images) - self.frame_margin = self.config.get_value( - "save-images", "frame-margin", frame_margin - ) - - image_type = ("jpeg" if jpeg else self.image_extension).upper() - image_param_type = "Compression" if png else "Quality" - image_param_type = " [%s: %d]" % (image_param_type, self.image_param) - logger.info("Image output format set: %s%s", image_type, image_param_type) + logger.debug('\n'.join(error_strs)) + raise click.BadParameter('\n'.join(error_strs), param_hint='save-images') + + self.image_dir = self.config.get_value('save-images', 'output', output, ignore_default=True) + + self.image_name_format = self.config.get_value('save-images', 'filename', filename) + self.num_images = self.config.get_value('save-images', 'num-images', num_images) + self.frame_margin = self.config.get_value('save-images', 'frame-margin', frame_margin) + + image_type = ('jpeg' if jpeg else self.image_extension).upper() + image_param_type = 'Compression' if png else 'Quality' + image_param_type = ' [%s: %d]' % (image_param_type, self.image_param) + logger.info('Image output format set: %s%s', image_type, image_param_type) if self.image_dir is not None: - logger.info( - "Image output directory set:\n %s", os.path.abspath(self.image_dir) - ) + logger.info('Image output directory set:\n %s', os.path.abspath(self.image_dir)) self.save_images = True @@ -878,24 +741,17 @@ def handle_time(self, start, duration, end): """Handle `time` command options.""" self._ensure_input_open() if self.time: - self._on_duplicate_command("time") + self._on_duplicate_command('time') if duration is not None and end is not None: raise click.BadParameter( - "Only one of --duration/-d or --end/-e can be specified, not both.", - param_hint="time", - ) - logger.debug( - "Setting video time:\n start: %s, duration: %s, end: %s", - start, - duration, - end, - ) + 'Only one of --duration/-d or --end/-e can be specified, not both.', + param_hint='time') + logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration, + end) # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to # match the default start number used by `ffmpeg` when saving frames as images. As such, # we must correct start time if set as frames. See the test_cli_time* tests for for details. - self.start_time = parse_timecode( - start, self.video_stream.frame_rate, correct_pts=True - ) + self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True) self.end_time = parse_timecode(end, self.video_stream.frame_rate) self.duration = parse_timecode(duration, self.video_stream.frame_rate) if self.start_time and self.end_time and (self.start_time + 1) > self.end_time: @@ -918,35 +774,31 @@ def _initialize_logging( curr_verbosity = logging.INFO # Convert verbosity into it's log level enum, and override quiet mode if set. if verbosity is not None: - assert verbosity in CHOICE_MAP["global"]["verbosity"] - if verbosity.lower() == "none": + assert verbosity in CHOICE_MAP['global']['verbosity'] + if verbosity.lower() == 'none': self.quiet_mode = True - verbosity = "info" + verbosity = 'info' else: # Override quiet mode if verbosity is set. self.quiet_mode = False curr_verbosity = getattr(logging, verbosity.upper()) else: - verbosity_str = USER_CONFIG.get_value("global", "verbosity") - assert verbosity_str in CHOICE_MAP["global"]["verbosity"] - if verbosity_str.lower() == "none": + verbosity_str = USER_CONFIG.get_value('global', 'verbosity') + assert verbosity_str in CHOICE_MAP['global']['verbosity'] + if verbosity_str.lower() == 'none': self.quiet_mode = True else: curr_verbosity = getattr(logging, verbosity_str.upper()) # Override quiet mode if verbosity is set. - if not USER_CONFIG.is_default("global", "verbosity"): + if not USER_CONFIG.is_default('global', 'verbosity'): self.quiet_mode = False # Initialize logger with the set CLI args / user configuration. - init_logger( - log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile - ) + init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) def add_detector(self, detector): - """Add Detector: Adds a detection algorithm to the CliContext's SceneManager.""" + """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """ if self.load_scenes_input: - raise click.ClickException( - "The load-scenes command cannot be used with detectors." - ) + raise click.ClickException("The load-scenes command cannot be used with detectors.") self._ensure_input_open() self.scene_manager.add_detector(detector) self.added_detector = True @@ -960,51 +812,40 @@ def _ensure_input_open(self) -> None: click.BadParameter: self.video_stream was not initialized. """ if self.video_stream is None: - raise click.ClickException("No input video (-i/--input) was specified.") + raise click.ClickException('No input video (-i/--input) was specified.') - def _open_video_stream( - self, input_path: AnyStr, framerate: Optional[float], backend: Optional[str] - ): - if "%" in input_path and backend != "opencv": + def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float], + backend: Optional[str]): + if '%' in input_path and backend != 'opencv': raise click.BadParameter( - "The OpenCV backend (`--backend opencv`) must be used to process image sequences.", - param_hint="-i/--input", - ) + 'The OpenCV backend (`--backend opencv`) must be used to process image sequences.', + param_hint='-i/--input') if framerate is not None and framerate < MAX_FPS_DELTA: - raise click.BadParameter( - "Invalid framerate specified!", param_hint="-f/--framerate" - ) + raise click.BadParameter('Invalid framerate specified!', param_hint='-f/--framerate') try: if backend is None: - backend = self.config.get_value("global", "backend") + backend = self.config.get_value('global', 'backend') else: if not backend in AVAILABLE_BACKENDS: raise click.BadParameter( - "Specified backend %s is not available on this system!" - % backend, - param_hint="-b/--backend", - ) + 'Specified backend %s is not available on this system!' % backend, + param_hint='-b/--backend') # Open the video with the specified backend, loading any required config settings. - if backend == "pyav": + if backend == 'pyav': self.video_stream = open_video( path=input_path, framerate=framerate, backend=backend, - threading_mode=self.config.get_value( - "backend-pyav", "threading-mode" - ), - suppress_output=self.config.get_value( - "backend-pyav", "suppress-output" - ), + threading_mode=self.config.get_value('backend-pyav', 'threading-mode'), + suppress_output=self.config.get_value('backend-pyav', 'suppress-output'), ) - elif backend == "opencv": + elif backend == 'opencv': self.video_stream = open_video( path=input_path, framerate=framerate, backend=backend, - max_decode_attempts=self.config.get_value( - "backend-opencv", "max-decode-attempts" - ), + max_decode_attempts=self.config.get_value('backend-opencv', + 'max-decode-attempts'), ) # Handle backends without any config options. else: @@ -1013,25 +854,19 @@ def _open_video_stream( framerate=framerate, backend=backend, ) - logger.debug( - "Video opened using backend %s", type(self.video_stream).__name__ - ) + logger.debug('Video opened using backend %s', type(self.video_stream).__name__) except FrameRateUnavailable as ex: raise click.BadParameter( - "Failed to obtain framerate for input video. Manually specify framerate with the" - " -f/--framerate option, or try re-encoding the file.", - param_hint="-i/--input", - ) from ex + 'Failed to obtain framerate for input video. Manually specify framerate with the' + ' -f/--framerate option, or try re-encoding the file.', + param_hint='-i/--input') from ex except VideoOpenFailure as ex: raise click.BadParameter( - "Failed to open input video%s: %s" - % (" using %s backend" % backend if backend else "", str(ex)), - param_hint="-i/--input", - ) from ex + 'Failed to open input video%s: %s' % + (' using %s backend' % backend if backend else '', str(ex)), + param_hint='-i/--input') from ex except OSError as ex: - raise click.BadParameter( - "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" - ) + raise click.BadParameter('Input error:\n\n\t%s\n' % str(ex), param_hint='-i/--input') def _on_duplicate_command(self, command: str) -> None: """Called when a command is duplicated to stop parsing and raise an error. @@ -1043,11 +878,10 @@ def _on_duplicate_command(self, command: str) -> None: click.BadParameter """ error_strs = [] - error_strs.append("Error: Command %s specified multiple times." % command) - error_strs.append("The %s command may appear only one time.") + error_strs.append('Error: Command %s specified multiple times.' % command) + error_strs.append('The %s command may appear only one time.') - logger.error("\n".join(error_strs)) + logger.error('\n'.join(error_strs)) raise click.BadParameter( - "\n Command %s may only be specified once." % command, - param_hint="%s command" % command, - ) + '\n Command %s may only be specified once.' % command, + param_hint='%s command' % command) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 0c86b19b..d7180542 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -23,18 +23,13 @@ from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path -from scenedetect.scene_manager import ( - get_scenes_from_cuts, - save_images, - write_scene_list, - write_scene_list_html, -) +from scenedetect.scene_manager import get_scenes_from_cuts, save_images, write_scene_list, write_scene_list_html from scenedetect.video_splitter import split_video_mkvmerge, split_video_ffmpeg from scenedetect.video_stream import SeekError from scenedetect._cli.context import CliContext, check_split_video_requirements -logger = logging.getLogger("pyscenedetect") +logger = logging.getLogger('pyscenedetect') def run_scenedetect(context: CliContext): @@ -52,9 +47,7 @@ def run_scenedetect(context: CliContext): if context.load_scenes_input: # Skip detection if load-scenes was used. - logger.info( - "Skipping detection, loading scenes from: %s", context.load_scenes_input - ) + logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) if context.stats_file_path: logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") scene_list, cut_list = _load_scenes(context) @@ -68,18 +61,11 @@ def run_scenedetect(context: CliContext): _save_stats(context) if scene_list: logger.info( - "Detected %d scenes, average shot length %.1f seconds.", - len(scene_list), - sum( - [ - (end_time - start_time).get_seconds() - for start_time, end_time in scene_list - ] - ) - / float(len(scene_list)), - ) + 'Detected %d scenes, average shot length %.1f seconds.', len(scene_list), + sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) + / float(len(scene_list))) else: - logger.info("No scenes detected.") + logger.info('No scenes detected.') # Handle list-scenes command. _list_scenes(context, scene_list, cut_list) @@ -98,23 +84,18 @@ def _detect(context: CliContext): # Use default detector if one was not specified. if context.scene_manager.get_num_detectors() == 0: detector_type, detector_args = context.default_detector - logger.debug( - "Using default detector: %s(%s)" % (detector_type.__name__, detector_args) - ) + logger.debug('Using default detector: %s(%s)' % (detector_type.__name__, detector_args)) context.scene_manager.add_detector(detector_type(**detector_args)) perf_start_time = time.time() if context.start_time is not None: - logger.debug("Seeking to start time...") + logger.debug('Seeking to start time...') try: context.video_stream.seek(target=context.start_time) except SeekError as ex: - logger.critical( - "Failed to seek to %s / frame %d: %s", - context.start_time.get_timecode(), - context.start_time.get_frames(), - str(ex), - ) + logger.critical('Failed to seek to %s / frame %d: %s', + context.start_time.get_timecode(), context.start_time.get_frames(), + str(ex)) return num_frames = context.scene_manager.detect_scenes( @@ -122,30 +103,25 @@ def _detect(context: CliContext): duration=context.duration, end_time=context.end_time, frame_skip=context.frame_skip, - show_progress=not context.quiet_mode, - ) + show_progress=not context.quiet_mode) # Handle case where video failure is most likely due to multiple audio tracks (#179). # TODO(#380): Ensure this does not erroneusly fire. - if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv": + if num_frames <= 0 and context.video_stream.BACKEND_NAME == 'opencv': logger.critical( - "Failed to read any frames from video file. This could be caused by the video" - " having multiple audio tracks. If so, try installing the PyAV backend:\n" - " pip install av\n" - "Or remove the audio tracks by running either:\n" - " ffmpeg -i input.mp4 -c copy -an output.mp4\n" - " mkvmerge -o output.mkv input.mp4\n" - "For details, see https://scenedetect.com/faq/" - ) + 'Failed to read any frames from video file. This could be caused by the video' + ' having multiple audio tracks. If so, try installing the PyAV backend:\n' + ' pip install av\n' + 'Or remove the audio tracks by running either:\n' + ' ffmpeg -i input.mp4 -c copy -an output.mp4\n' + ' mkvmerge -o output.mkv input.mp4\n' + 'For details, see https://scenedetect.com/faq/') return perf_duration = time.time() - perf_start_time - logger.info( - "Processed %d frames in %.1f seconds (average %.2f FPS).", - num_frames, - perf_duration, - float(num_frames) / perf_duration, - ) + logger.info('Processed %d frames in %.1f seconds (average %.2f FPS).', num_frames, + perf_duration, + float(num_frames) / perf_duration) # Get list of detected cuts/scenes from the SceneManager to generate the required output # files, based on the given commands (list-scenes, split-video, save-images, etc...). @@ -161,42 +137,34 @@ def _save_stats(context: CliContext) -> None: return if context.stats_manager.is_save_required(): path = get_and_create_path(context.stats_file_path, context.output_dir) - logger.info("Saving frame metrics to stats file: %s", path) + logger.info('Saving frame metrics to stats file: %s', path) with open(path, mode="w") as file: context.stats_manager.save_to_csv(csv_file=file) else: - logger.debug("No frame metrics updated, skipping update of the stats file.") + logger.debug('No frame metrics updated, skipping update of the stats file.') -def _list_scenes( - context: CliContext, - scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - cut_list: List[FrameTimecode], -) -> None: +def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + cut_list: List[FrameTimecode]) -> None: """Handles the `list-scenes` command.""" if not context.list_scenes: return # Write scene list CSV to if required. if context.scene_list_output: - scene_list_filename = Template(context.scene_list_name_format).safe_substitute( - VIDEO_NAME=context.video_stream.name - ) - if not scene_list_filename.lower().endswith(".csv"): - scene_list_filename += ".csv" + scene_list_filename = Template( + context.scene_list_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + if not scene_list_filename.lower().endswith('.csv'): + scene_list_filename += '.csv' scene_list_path = get_and_create_path( scene_list_filename, - context.scene_list_dir - if context.scene_list_dir is not None - else context.output_dir, - ) - logger.info("Writing scene list to CSV file:\n %s", scene_list_path) - with open(scene_list_path, "wt") as scene_list_file: + context.scene_list_dir if context.scene_list_dir is not None else context.output_dir) + logger.info('Writing scene list to CSV file:\n %s', scene_list_path) + with open(scene_list_path, 'wt') as scene_list_file: write_scene_list( output_csv_file=scene_list_file, scene_list=scene_list, include_cut_list=not context.skip_cuts, - cut_list=cut_list, - ) + cut_list=cut_list) # Suppress output if requested. if context.list_scenes_quiet: return @@ -208,37 +176,26 @@ def _list_scenes( | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s ------------------------------------------------------------------------""", - "\n".join( - [ - " | %5d | %11d | %s | %11d | %s |" - % ( - i + 1, - start_time.get_frames() + 1, - start_time.get_timecode(), - end_time.get_frames(), - end_time.get_timecode(), - ) - for i, (start_time, end_time) in enumerate(scene_list) - ] - ), - ) +-----------------------------------------------------------------------""", '\n'.join([ + " | %5d | %11d | %s | %11d | %s |" % + (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), + end_time.get_frames(), end_time.get_timecode()) + for i, (start_time, end_time) in enumerate(scene_list) + ])) # Print cut list. if cut_list and context.display_cuts: - logger.info( - "Comma-separated timecode list:\n %s", - ",".join([context.cut_format.format(cut) for cut in cut_list]), - ) + logger.info("Comma-separated timecode list:\n %s", + ",".join([context.cut_format.format(cut) for cut in cut_list])) def _save_images( - context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]] -) -> Optional[Dict[int, List[str]]]: + context: CliContext, + scene_list: List[Tuple[FrameTimecode, FrameTimecode]]) -> Optional[Dict[int, List[str]]]: """Handles the `save-images` command.""" if not context.save_images: return None # Command can override global output directory setting. - output_dir = context.output_dir if context.image_dir is None else context.image_dir + output_dir = (context.output_dir if context.image_dir is None else context.image_dir) return save_images( scene_list=scene_list, video=context.video_stream, @@ -252,28 +209,23 @@ def _save_images( scale=context.scale, height=context.height, width=context.width, - interpolation=context.scale_method, - ) + interpolation=context.scale_method) -def _export_html( - context: CliContext, - scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - cut_list: List[FrameTimecode], - image_filenames: Optional[Dict[int, List[str]]], -) -> None: +def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + cut_list: List[FrameTimecode], image_filenames: Optional[Dict[int, + List[str]]]) -> None: """Handles the `export-html` command.""" if not context.export_html: return # Command can override global output directory setting. - output_dir = context.output_dir if context.image_dir is None else context.image_dir - html_filename = Template(context.html_name_format).safe_substitute( - VIDEO_NAME=context.video_stream.name - ) - if not html_filename.lower().endswith(".html"): - html_filename += ".html" + output_dir = (context.output_dir if context.image_dir is None else context.image_dir) + html_filename = Template( + context.html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + if not html_filename.lower().endswith('.html'): + html_filename += '.html' html_path = get_and_create_path(html_filename, output_dir) - logger.info("Exporting to html file:\n %s:", html_path) + logger.info('Exporting to html file:\n %s:', html_path) if not context.html_include_images: image_filenames = None write_scene_list_html( @@ -282,26 +234,24 @@ def _export_html( cut_list, image_filenames=image_filenames, image_width=context.image_width, - image_height=context.image_height, - ) + image_height=context.image_height) -def _split_video( - context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]] -) -> None: +def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, + FrameTimecode]]) -> None: """Handles the `split-video` command.""" if not context.split_video: return output_path_template = context.split_name_format # Add proper extension to filename template if required. - dot_pos = output_path_template.rfind(".") + dot_pos = output_path_template.rfind('.') extension_length = 0 if dot_pos < 0 else len(output_path_template) - (dot_pos + 1) # If using mkvmerge, force extension to .mkv. - if context.split_mkvmerge and not output_path_template.endswith(".mkv"): - output_path_template += ".mkv" + if context.split_mkvmerge and not output_path_template.endswith('.mkv'): + output_path_template += '.mkv' # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. elif not 2 <= extension_length <= 4: - output_path_template += ".mp4" + output_path_template += '.mp4' # Ensure the appropriate tool is available before handling split-video. check_split_video_requirements(context.split_mkvmerge) # Command can override global output directory setting. @@ -325,32 +275,29 @@ def _split_video( show_output=not (context.quiet_mode or context.split_quiet), ) if scene_list: - logger.info("Video splitting completed, scenes written to disk.") + logger.info('Video splitting completed, scenes written to disk.') def _load_scenes( - context: CliContext, -) -> ty.Tuple[ - ty.Iterable[ty.Tuple[FrameTimecode, FrameTimecode]], ty.Iterable[FrameTimecode] -]: + context: CliContext +) -> ty.Tuple[ty.Iterable[ty.Tuple[FrameTimecode, FrameTimecode]], ty.Iterable[FrameTimecode]]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) - with open(context.load_scenes_input, "r") as input_file: + with open(context.load_scenes_input, 'r') as input_file: file_reader = csv.reader(input_file) csv_headers = next(file_reader) if not context.load_scenes_column_name in csv_headers: csv_headers = next(file_reader) # Check to make sure column headers are present if context.load_scenes_column_name not in csv_headers: - raise ValueError("specified column header for scene start is not present") + raise ValueError('specified column header for scene start is not present') col_idx = csv_headers.index(context.load_scenes_column_name) cut_list = sorted( FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 - for row in file_reader - ) + for row in file_reader) # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` # but this part of the API is being reworked and hasn't been used by any detectors yet. @@ -372,24 +319,17 @@ def _load_scenes( cut_list = [cut for cut in cut_list if cut < end_time] return get_scenes_from_cuts( - cut_list=cut_list, start_pos=start_time, end_pos=end_time - ), cut_list + cut_list=cut_list, start_pos=start_time, end_pos=end_time), cut_list def _postprocess_scene_list( context: CliContext, scene_list: ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] ) -> ty.List[ty.Tuple[FrameTimecode, FrameTimecode]]: + # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, # it will be merged with the previous one. - if ( - context.merge_last_scene - and context.min_scene_len is not None - and context.min_scene_len > 0 - ): - if ( - len(scene_list) > 1 - and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len - ): + if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: + if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: new_last_scene = (scene_list[-2][0], scene_list[-1][1]) scene_list = scene_list[:-2] + [new_last_scene] diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py index a000f972..e940a432 100644 --- a/scenedetect/_thirdparty/simpletable.py +++ b/scenedetect/_thirdparty/simpletable.py @@ -56,11 +56,9 @@ def quote(string): try: from urllib.parse import quote - return quote(string) except ModuleNotFoundError: from urllib import pathname2url - return pathname2url(string) @@ -84,9 +82,9 @@ def __init__(self, text, header=False): def __str__(self): """Return the HTML code for the table cell.""" if self.header: - return "%s" % (self.text) + return '%s' % (self.text) else: - return "%s" % (self.text) + return '%s' % (self.text) class SimpleTableImage(object): @@ -123,7 +121,7 @@ def __str__(self): output += ' height="%s"' % (self.height) if self.width: output += ' width="%s"' % (self.width) - output += ">" + output += '>' return output @@ -163,14 +161,14 @@ def __str__(self): """Return the HTML code for the table row and its cells as a string.""" row = [] - row.append("") + row.append('') for cell in self.cells: row.append(str(cell)) - row.append("") + row.append('') - return "\n".join(row) + return '\n'.join(row) def __iter__(self): """Iterate through row cells""" @@ -234,9 +232,9 @@ def __str__(self): table = [] if self.css_class: - table.append("" % self.css_class) + table.append('
    ' % self.css_class) else: - table.append("
    ") + table.append('
    ') if self.header_row: table.append(str(self.header_row)) @@ -244,9 +242,9 @@ def __str__(self): for row in self.rows: table.append(str(row)) - table.append("
    ") + table.append('') - return "\n".join(table) + return '\n'.join(table) def __iter__(self): """Iterate through table rows""" @@ -287,16 +285,14 @@ def __str__(self): page.append('' % self.css) # Set encoding - page.append( - '' % self.encoding - ) + page.append('' % self.encoding) for table in self.tables: page.append(str(table)) - page.append("
    ") + page.append('
    ') - return "\n".join(page) + return '\n'.join(page) def __iter__(self): """Iterate through tables""" @@ -305,7 +301,7 @@ def __iter__(self): def save(self, filename): """Save HTML page to a file using the proper encoding""" - with codecs.open(filename, "w", self.encoding) as outfile: + with codecs.open(filename, 'w', self.encoding) as outfile: for line in str(self): outfile.write(line) @@ -328,4 +324,4 @@ def fit_data_to_columns(data, num_cols): if len(data) % num_cols != 0: num_iterations += 1 - return [data[num_cols * i : num_cols * i + num_cols] for i in range(num_iterations)] + return [data[num_cols * i:num_cols * i + num_cols] for i in range(num_iterations)] diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 9d60bb34..6296bd31 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -102,15 +102,11 @@ # TODO: Lazy-loading backends would improve startup performance. However, this requires removing # some of the re-exported types above from the public API. AVAILABLE_BACKENDS: Dict[str, Type] = { - backend.BACKEND_NAME: backend - for backend in filter( - None, - [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - ], - ) + backend.BACKEND_NAME: backend for backend in filter(None, [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + ]) } """All available backends that :func:`scenedetect.open_video` can consider for the `backend` parameter. These backends must support construction with the following signature: diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 62c97294..e0c4a92b 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -29,15 +29,13 @@ from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure from scenedetect.backends.opencv import VideoStreamCv2 -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__( - self, path: AnyStr, framerate: Optional[float] = None, print_infos: bool = False - ): + def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: bool = False): """Open a video or device. Arguments: @@ -55,8 +53,7 @@ def __init__( # TODO: Add framerate override. if framerate is not None: raise NotImplementedError( - "VideoStreamMoviePy does not support the `framerate` argument yet." - ) + "VideoStreamMoviePy does not support the `framerate` argument yet.") self._path = path # TODO: Need to map errors based on the strings, since several failure @@ -80,7 +77,7 @@ def __init__( # VideoStream Methods/Properties # - BACKEND_NAME = "moviepy" + BACKEND_NAME = 'moviepy' """Unique name used to identify this backend.""" @property @@ -106,13 +103,13 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return tuple(self._reader.infos["video_size"]) + return tuple(self._reader.infos['video_size']) @property def duration(self) -> Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - assert isinstance(self._reader.infos["duration"], float) - return self.base_timecode + self._reader.infos["duration"] + assert isinstance(self._reader.infos['duration'], float) + return self.base_timecode + self._reader.infos['duration'] @property def aspect_ratio(self) -> float: @@ -195,15 +192,13 @@ def seek(self, target: Union[FrameTimecode, float, int]): self._frame_number = target.frame_num def reset(self): - """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ self._reader.initialize() self._last_frame = self._reader.read_frame() self._frame_number = 0 self._eof = False - def read( - self, decode: bool = True, advance: bool = True - ) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -218,7 +213,7 @@ def read( if self._last_frame_rgb is None: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) return self._last_frame_rgb - if not hasattr(self._reader, "lastread"): + if not hasattr(self._reader, 'lastread'): return False self._last_frame = self._reader.lastread self._reader.read_frame() diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 893c54bf..4ab9a897 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -28,28 +28,23 @@ from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA from scenedetect.platform import get_file_name -from scenedetect.video_stream import ( - VideoStream, - SeekError, - VideoOpenFailure, - FrameRateUnavailable, -) +from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure, FrameRateUnavailable -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') -IMAGE_SEQUENCE_IDENTIFIER = "%" +IMAGE_SEQUENCE_IDENTIFIER = '%' NON_VIDEO_FILE_INPUT_IDENTIFIERS = ( - IMAGE_SEQUENCE_IDENTIFIER, # image sequence - "://", # URL/network stream - " ! ", # gstreamer pipe + IMAGE_SEQUENCE_IDENTIFIER, # image sequence + '://', # URL/network stream + ' ! ', # gstreamer pipe ) def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float: """Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels).""" # Versions of OpenCV < 3.4.1 do not support this, so we fall back to 1.0. - if not "CAP_PROP_SAR_NUM" in dir(cv2): + if not 'CAP_PROP_SAR_NUM' in dir(cv2): return 1.0 num: float = cap.get(cv2.CAP_PROP_SAR_NUM) den: float = cap.get(cv2.CAP_PROP_SAR_DEN) @@ -91,24 +86,21 @@ def __init__( super().__init__() # TODO(v0.7): Replace with DeprecationWarning that `path_or_device` will be removed in v0.8. if path_or_device is not None: - logger.error( - "path_or_device is deprecated, use path or VideoCaptureAdapter instead." - ) + logger.error('path_or_device is deprecated, use path or VideoCaptureAdapter instead.') path = path_or_device if path is None: - raise ValueError("Path must be specified!") + raise ValueError('Path must be specified!') if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError('Specified framerate (%f) is invalid!' % framerate) if max_decode_attempts < 0: - raise ValueError("Maximum decode attempts must be >= 0!") + raise ValueError('Maximum decode attempts must be >= 0!') self._path_or_device = path self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: - self._cap: Optional[cv2.VideoCapture] = ( - None # Reference to underlying cv2.VideoCapture object. - ) + self._cap: Optional[ + cv2.VideoCapture] = None # Reference to underlying cv2.VideoCapture object. self._frame_rate: Optional[float] = None # VideoCapture state @@ -138,7 +130,7 @@ def capture(self) -> cv2.VideoCapture: # VideoStream Methods/Properties # - BACKEND_NAME = "opencv" + BACKEND_NAME = 'opencv' """Unique name used to identify this backend.""" @property @@ -165,7 +157,7 @@ def name(self) -> str: if IMAGE_SEQUENCE_IDENTIFIER in file_name: # file_name is an image sequence, trim everything including/after the %. # TODO: This excludes any suffix after the sequence identifier. - file_name = file_name[: file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] + file_name = file_name[:file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] return file_name @property @@ -178,10 +170,8 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return ( - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) + return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) @property def duration(self) -> Optional[FrameTimecode]: @@ -268,13 +258,11 @@ def seek(self, target: Union[FrameTimecode, float, int]): self._has_grabbed = self._cap.grab() def reset(self): - """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ self._cap.release() self._open_capture(self._frame_rate) - def read( - self, decode: bool = True, advance: bool = True - ) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. @@ -301,11 +289,9 @@ def read( # Report previous failure in debug mode. if has_grabbed: self._decode_failures += 1 - logger.debug("Frame failed to decode.") + logger.debug('Frame failed to decode.') if not self._warning_displayed and self._decode_failures > 1: - logger.warning( - "Failed to decode some frames, results may be inaccurate." - ) + logger.warning('Failed to decode some frames, results may be inaccurate.') # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: return False @@ -323,41 +309,34 @@ def read( def _open_capture(self, framerate: Optional[float] = None): """Opens capture referenced by this object and resets internal state.""" if self._is_device and self._path_or_device < 0: - raise ValueError("Invalid/negative device ID specified.") + raise ValueError('Invalid/negative device ID specified.') input_is_video_file = not self._is_device and not any( - identifier in self._path_or_device - for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS - ) + identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS) # We don't have a way of querying why opening a video fails (errors are logged at least), # so provide a better error message if we try to open a file that doesn't exist. if input_is_video_file: if not os.path.exists(self._path_or_device): - raise OSError("Video file not found.") + raise OSError('Video file not found.') cap = cv2.VideoCapture(self._path_or_device) if not cap.isOpened(): raise VideoOpenFailure( - "Ensure file is valid video and system dependencies are up to date.\n" - ) + 'Ensure file is valid video and system dependencies are up to date.\n') # Display an error if the video codec type seems unsupported (#86) as this indicates # potential video corruption, or may explain missing frames. We only perform this check # for video files on-disk (skipped for devices, image sequences, streams, etc...). - codec_unsupported: bool = int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0 + codec_unsupported: bool = (int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0) if codec_unsupported and input_is_video_file: - logger.error( - "Video codec detection failed. If output is incorrect:\n" - " - Re-encode the input video with ffmpeg\n" - " - Update OpenCV (pip install --upgrade opencv-python)\n" - " - Use the PyAV backend (--backend pyav)\n" - "For details, see https://github.com/Breakthrough/PySceneDetect/issues/86" - ) + logger.error('Video codec detection failed. If output is incorrect:\n' + ' - Re-encode the input video with ffmpeg\n' + ' - Update OpenCV (pip install --upgrade opencv-python)\n' + ' - Use the PyAV backend (--backend pyav)\n' + 'For details, see https://github.com/Breakthrough/PySceneDetect/issues/86') # Ensure the framerate is correct to avoid potential divide by zero errors. This can be # addressed in the PyAV backend if required since it supports integer timebases. - assert ( - framerate is None or framerate > MAX_FPS_DELTA - ), "Framerate must be validated if set!" + assert framerate is None or framerate > MAX_FPS_DELTA, "Framerate must be validated if set!" if framerate is None: framerate = cap.get(cv2.CAP_PROP_FPS) if framerate < MAX_FPS_DELTA: @@ -401,11 +380,11 @@ def __init__( super().__init__() if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError('Specified framerate (%f) is invalid!' % framerate) if max_read_attempts < 0: - raise ValueError("Maximum decode attempts must be >= 0!") + raise ValueError('Maximum decode attempts must be >= 0!') if not cap.isOpened(): - raise ValueError("Specified VideoCapture must already be opened!") + raise ValueError('Specified VideoCapture must already be opened!') if framerate is None: framerate = cap.get(cv2.CAP_PROP_FPS) if framerate < MAX_FPS_DELTA: @@ -438,7 +417,7 @@ def capture(self) -> cv2.VideoCapture: # VideoStream Methods/Properties # - BACKEND_NAME = "opencv_adapter" + BACKEND_NAME = 'opencv_adapter' """Unique name used to identify this backend.""" @property @@ -450,12 +429,12 @@ def frame_rate(self) -> float: @property def path(self) -> str: """Always 'CAP_ADAPTER'.""" - return "CAP_ADAPTER" + return 'CAP_ADAPTER' @property def name(self) -> str: """Always 'CAP_ADAPTER'.""" - return "CAP_ADAPTER" + return 'CAP_ADAPTER' @property def is_seekable(self) -> bool: @@ -465,10 +444,8 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Reported size of each video frame in pixels as a tuple of (width, height).""" - return ( - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) + return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) @property def duration(self) -> Optional[FrameTimecode]: @@ -523,9 +500,7 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read( - self, decode: bool = True, advance: bool = True - ) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. @@ -551,11 +526,9 @@ def read( # Report previous failure in debug mode. if has_grabbed: self._decode_failures += 1 - logger.debug("Frame failed to decode.") + logger.debug('Frame failed to decode.') if not self._warning_displayed and self._decode_failures > 1: - logger.warning( - "Failed to decode some frames, results may be inaccurate." - ) + logger.warning('Failed to decode some frames, results may be inaccurate.') # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: return False diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 7db5881a..07647818 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -23,7 +23,7 @@ from scenedetect.platform import get_file_name from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') VALID_THREAD_MODES = [ av.codec.context.ThreadType.NONE, @@ -82,28 +82,26 @@ def __init__( # Ensure specified framerate is valid if set. if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError('Specified framerate (%f) is invalid!' % framerate) - self._name = "" if name is None else name - self._path = "" + self._name = '' if name is None else name + self._path = '' self._frame = None self._reopened = True if threading_mode: threading_mode = threading_mode.upper() if not threading_mode in VALID_THREAD_MODES: - raise ValueError( - "Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES - ) + raise ValueError('Invalid threading mode! Must be one of: %s' % VALID_THREAD_MODES) if not suppress_output: - logger.debug("Restoring default ffmpeg log callbacks.") + logger.debug('Restoring default ffmpeg log callbacks.') av.logging.restore_default_callback() try: if isinstance(path_or_io, (str, bytes)): self._path = path_or_io - self._io = open(path_or_io, "rb") + self._io = open(path_or_io, 'rb') if not self._name: self._name = get_file_name(self.path, include_extension=False) else: @@ -113,7 +111,7 @@ def __init__( if threading_mode is not None: self._video_stream.thread_type = threading_mode self._reopened = False - logger.debug("Threading mode set: %s", threading_mode) + logger.debug('Threading mode set: %s', threading_mode) except OSError: raise except Exception as ex: @@ -121,11 +119,8 @@ def __init__( if framerate is None: # Calculate framerate from video container. `guessed_rate` below appears in PyAV 9. - frame_rate = ( - self._video_stream.guessed_rate - if hasattr(self._video_stream, "guessed_rate") - else self._codec_context.framerate - ) + frame_rate = self._video_stream.guessed_rate if hasattr( + self._video_stream, 'guessed_rate') else self._codec_context.framerate if frame_rate is None or frame_rate == 0: raise FrameRateUnavailable() # TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations. @@ -149,7 +144,7 @@ def __del__(self): # VideoStream Methods/Properties # - BACKEND_NAME = "pyav" + BACKEND_NAME = 'pyav' """Unique name used to identify this backend.""" @property @@ -212,17 +207,13 @@ def frame_number(self) -> int: @property def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" - if ( - not hasattr(self._codec_context, "display_aspect_ratio") - or self._codec_context.display_aspect_ratio is None - ): + if not hasattr(self._codec_context, + "display_aspect_ratio") or self._codec_context.display_aspect_ratio is None: return 1.0 ar_denom = self._codec_context.display_aspect_ratio.denominator if ar_denom <= 0: return 1.0 - display_aspect_ratio = ( - self._codec_context.display_aspect_ratio.numerator / ar_denom - ) + display_aspect_ratio = self._codec_context.display_aspect_ratio.numerator / ar_denom assert self.frame_size[0] > 0 and self.frame_size[1] > 0 frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio @@ -247,13 +238,12 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: """ if target < 0: raise ValueError("Target cannot be negative!") - beginning = target == 0 - target = self.base_timecode + target + beginning = (target == 0) + target = (self.base_timecode + target) if target >= 1: target = target - 1 target_pts = self._video_stream.start_time + int( - (self.base_timecode + target).get_seconds() / self._video_stream.time_base - ) + (self.base_timecode + target).get_seconds() / self._video_stream.time_base) self._frame = None self._container.seek(target_pts, stream=self._video_stream) if not beginning: @@ -263,7 +253,7 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: break def reset(self): - """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ self._container.close() self._frame = None try: @@ -271,9 +261,7 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read( - self, decode: bool = True, advance: bool = True - ) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -298,7 +286,7 @@ def read( return False has_advanced = True if decode: - return self._frame.to_ndarray(format="bgr24") + return self._frame.to_ndarray(format='bgr24') return has_advanced # @@ -319,9 +307,7 @@ def _get_duration(self) -> int: """Get video duration as number of frames based on the video and set framerate.""" # See https://pyav.org/docs/develop/api/time.html for details on how ffmpeg/PyAV # handle time calculations internally and which time base to use. - assert ( - self.frame_rate is not None - ), "Frame rate must be set before calling _get_duration!" + assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!" # See if we can obtain the number of frames directly from the stream itself. if self._video_stream.frames > 0: return self._video_stream.frames @@ -334,15 +320,14 @@ def _get_duration(self) -> int: # Lastly, if that calculation fails, try to calculate it based on the stream duration. if duration_sec is None or duration_sec < MAX_FPS_DELTA: if self._video_stream.duration is None: - logger.warning("Video duration unavailable.") + logger.warning('Video duration unavailable.') return 0 # Streams use stream `time_base` as the time base. time_base = self._video_stream.time_base if time_base.denominator == 0: logger.warning( - "Unable to calculate video duration: time_base (%s) has zero denominator!", - str(time_base), - ) + 'Unable to calculate video duration: time_base (%s) has zero denominator!', + str(time_base)) return 0 duration_sec = float(self._video_stream.duration / time_base) return round(duration_sec * self.frame_rate) @@ -356,10 +341,7 @@ def _handle_eof(self): return False self._reopened = True # Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode. - if not self.is_seekable or not self._video_stream.thread_type in ( - "AUTO", - "FRAME", - ): + if not self.is_seekable or not self._video_stream.thread_type in ('AUTO', 'FRAME'): return False last_frame = self.frame_number orig_pos = self._io.tell() diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index d9c19429..064255f5 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -24,7 +24,7 @@ from scenedetect.detectors import ContentDetector -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') class AdaptiveDetector(ContentDetector): @@ -71,12 +71,12 @@ def __init__( # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will # be removed in v0.8. if video_manager is not None: - logger.error("video_manager is deprecated, use video instead.") + logger.error('video_manager is deprecated, use video instead.') if min_delta_hsv is not None: - logger.error("min_delta_hsv is deprecated, use min_content_val instead.") + logger.error('min_delta_hsv is deprecated, use min_content_val instead.') min_content_val = min_delta_hsv if window_width < 1: - raise ValueError("window_width must be at least 1.") + raise ValueError('window_width must be at least 1.') super().__init__( threshold=255.0, @@ -93,8 +93,7 @@ def __init__( self.window_width = window_width self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( - window_width=window_width, luma_only="" if not luma_only else "_lum" - ) + window_width=window_width, luma_only='' if not luma_only else '_lum') self._first_frame_num = None # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. @@ -115,9 +114,7 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame( - self, frame_num: int, frame_img: Optional[np.ndarray] - ) -> List[int]: + def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -144,11 +141,9 @@ def process_frame( return [] self._buffer = self._buffer[-required_frames:] (target_frame, target_score) = self._buffer[self.window_width] - average_window_score = sum( - score - for i, (_frame, score) in enumerate(self._buffer) - if i != self.window_width - ) / (2.0 * self.window_width) + average_window_score = ( + sum(score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width) / + (2.0 * self.window_width)) average_is_zero = abs(average_window_score) < 0.00001 @@ -159,16 +154,12 @@ def process_frame( # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: - self.stats_manager.set_metrics( - target_frame, {self._adaptive_ratio_key: adaptive_ratio} - ) + self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio}) # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut threshold_met: bool = ( - adaptive_ratio >= self.adaptive_threshold - and target_score >= self.min_content_val - ) + adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val) min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: self._last_cut = target_frame @@ -178,14 +169,10 @@ def process_frame( def get_content_val(self, frame_num: int) -> Optional[float]: """Returns the average content change for a frame.""" # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. - logger.error( - "get_content_val is deprecated and will be removed. Lookup the value" - " using a StatsManager with ContentDetector.FRAME_SCORE_KEY." - ) + logger.error("get_content_val is deprecated and will be removed. Lookup the value" + " using a StatsManager with ContentDetector.FRAME_SCORE_KEY.") if self.stats_manager is not None: - return self.stats_manager.get_metrics( - frame_num, [ContentDetector.FRAME_SCORE_KEY] - )[0] + return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] return 0.0 def post_process(self, _unused_frame_num: int): diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 89d99b3e..954a91d7 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -15,7 +15,6 @@ This detector is available from the command-line as the `detect-content` command. """ - from dataclasses import dataclass import math from typing import List, NamedTuple, Optional @@ -33,10 +32,7 @@ def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: assert len(left.shape) == 2 and len(right.shape) == 2 assert left.shape == right.shape num_pixels: float = float(left.shape[0] * left.shape[1]) - return ( - numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) - / num_pixels - ) + return (numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels) def _estimated_kernel_size(frame_width: int, frame_height: int) -> int: @@ -60,7 +56,6 @@ class ContentDetector(SceneDetector): # a wider variety of test cases. class Components(NamedTuple): """Components that make up a frame's score, and their default values.""" - delta_hue: float = 1.0 """Difference between pixel hue values of adjacent frames.""" delta_sat: float = 1.0 @@ -85,7 +80,7 @@ class Components(NamedTuple): ) """Component weights to use if `luma_only` is set.""" - FRAME_SCORE_KEY = "content_val" + FRAME_SCORE_KEY = 'content_val' """Key in statsfile representing the final frame score after weighed by specified components.""" METRIC_KEYS = [FRAME_SCORE_KEY, *Components._fields] @@ -94,7 +89,6 @@ class Components(NamedTuple): @dataclass class _FrameData: """Data calculated for a given frame.""" - hue: numpy.ndarray """Frame hue map [2D 8-bit].""" sat: numpy.ndarray @@ -108,7 +102,7 @@ def __init__( self, threshold: float = 27.0, min_scene_len: int = 15, - weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, + weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: Optional[int] = None, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, @@ -139,7 +133,7 @@ def __init__( if kernel_size is not None: print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: - raise ValueError("kernel_size must be odd integer >= 3") + raise ValueError('kernel_size must be odd integer >= 3') self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: Optional[float] = None self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) @@ -161,9 +155,8 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl hue, sat, lum = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) # Performance: Only calculate edges if we have to. - calculate_edges: bool = ( - self._weights.delta_edges > 0.0 - ) or self.stats_manager is not None + calculate_edges: bool = ((self._weights.delta_edges > 0.0) + or self.stats_manager is not None) edges = self._detect_edges(lum) if calculate_edges else None if self._last_frame is None: @@ -175,17 +168,13 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl delta_hue=_mean_pixel_distance(hue, self._last_frame.hue), delta_sat=_mean_pixel_distance(sat, self._last_frame.sat), delta_lum=_mean_pixel_distance(lum, self._last_frame.lum), - delta_edges=( - 0.0 - if edges is None - else _mean_pixel_distance(edges, self._last_frame.edges) - ), + delta_edges=(0.0 if edges is None else _mean_pixel_distance( + edges, self._last_frame.edges)), ) - frame_score: float = sum( - component * weight - for (component, weight) in zip(score_components, self._weights) - ) / sum(abs(weight) for weight in self._weights) + frame_score: float = ( + sum(component * weight for (component, weight) in zip(score_components, self._weights)) + / sum(abs(weight) for weight in self._weights)) # Record components and frame score if needed for analysis. if self.stats_manager is not None: @@ -214,9 +203,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return [] above_threshold: bool = self._frame_score >= self._threshold - return self._flash_filter.filter( - frame_num=frame_num, above_threshold=above_threshold - ) + return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 690159c6..1ec508a7 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -112,18 +112,14 @@ def process_frame(self, frame_num, frame_img): if self._last_frame is not None: # We obtain the change in hash value between subsequent frames. curr_hash = self.hash_frame( - frame_img=frame_img, hash_size=self._size, factor=self._factor - ) + frame_img=frame_img, hash_size=self._size, factor=self._factor) last_hash = self._last_hash if last_hash.size == 0: # Calculate hash of last frame last_hash = self.hash_frame( - frame_img=self._last_frame, - hash_size=self._size, - factor=self._factor, - ) + frame_img=self._last_frame, hash_size=self._size, factor=self._factor) # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) @@ -132,17 +128,14 @@ def process_frame(self, frame_num, frame_img): hash_dist_norm = hash_dist / self._size_sq if self.stats_manager is not None: - self.stats_manager.set_metrics( - frame_num, {self._metric_key: hash_dist_norm} - ) + self.stats_manager.set_metrics(frame_num, {self._metric_key: hash_dist_norm}) self._last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - if hash_dist_norm >= self._threshold and ( - (frame_num - self._last_scene_cut) >= self._min_scene_len - ): + if hash_dist_norm >= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num @@ -161,9 +154,7 @@ def hash_frame(frame_img, hash_size, factor) -> numpy.ndarray: # Resize image to square to help with DCT imsize = hash_size * factor - resized_img = cv2.resize( - gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA - ) + resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) # Check to avoid dividing by zero max_value = numpy.max(numpy.max(resized_img)) diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 4904cfd3..ad469489 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -29,11 +29,9 @@ class HistogramDetector(SceneDetector): """Compares the difference in the Y channel of YUV histograms for adjacent frames. When the difference exceeds a given threshold, a cut is detected.""" - METRIC_KEYS = ["hist_diff"] + METRIC_KEYS = ['hist_diff'] - def __init__( - self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15 - ): + def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): """ Arguments: threshold: maximum relative difference between 0.0 and 1.0 that the histograms can @@ -73,12 +71,10 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: np_data_type = frame_img.dtype if np_data_type != numpy.uint8: - raise ValueError("Image must be 8-bit rgb for HistogramDetector") + raise ValueError('Image must be 8-bit rgb for HistogramDetector') if frame_img.shape[2] != 3: - raise ValueError( - "Image must have three color channels for HistogramDetector" - ) + raise ValueError('Image must have three color channels for HistogramDetector') # Initialize last scene cut point at the beginning of the frames of interest. if not self._last_scene_cut: @@ -88,7 +84,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # We can only start detecting once we have a frame to compare with. if self._last_hist is not None: - # TODO: We can have EMA of histograms to make it more robust + #TODO: We can have EMA of histograms to make it more robust # ema_hist = alpha * hist + (1 - alpha) * ema_hist # Compute histogram difference between frames @@ -101,9 +97,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # Values close to 1 indicate very similar frames, while lower values suggest changes. # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation # less than 0.8 between histograms will be considered significant enough to denote a scene change. - if hist_diff <= self._threshold and ( - (frame_num - self._last_scene_cut) >= self._min_scene_len - ): + if hist_diff <= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num @@ -116,9 +111,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return cut_list @staticmethod - def calculate_histogram( - frame_img: numpy.ndarray, bins: int = 256, normalize: bool = True - ) -> numpy.ndarray: + def calculate_histogram(frame_img: numpy.ndarray, + bins: int = 256, + normalize: bool = True) -> numpy.ndarray: """ Calculates and optionally normalizes the histogram of the luma (Y) channel of an image converted from BGR to YUV color space. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 3a6e6ebd..784bd1f9 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -24,7 +24,7 @@ from scenedetect.scene_detector import SceneDetector -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') ## ## ThresholdDetector Helper Functions @@ -62,13 +62,12 @@ class ThresholdDetector(SceneDetector): class Method(Enum): """Method for ThresholdDetector to use when comparing frame brightness to the threshold.""" - FLOOR = 0 """Fade out happens when frame brightness falls below threshold.""" CEILING = 1 """Fade out happens when frame brightness rises above threshold.""" - THRESHOLD_VALUE_KEY = "average_rgb" + THRESHOLD_VALUE_KEY = 'average_rgb' def __init__( self, @@ -96,7 +95,7 @@ def __init__( """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. if block_size is not None: - logger.error("block_size is deprecated.") + logger.error('block_size is deprecated.') super().__init__() self.threshold = int(threshold) @@ -110,8 +109,8 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - "frame": 0, # frame number where the last detected fade is - "type": None, # type of fade, can be either 'in' or 'out' + 'frame': 0, # frame number where the last detected fade is + 'type': None # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] @@ -146,61 +145,42 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this # frame metric instead (to assist with manually selecting a threshold) - if (self.stats_manager is not None) and ( - self.stats_manager.metrics_exist(frame_num, self._metric_keys) - ): + if (self.stats_manager is not None) and (self.stats_manager.metrics_exist( + frame_num, self._metric_keys)): frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] else: frame_avg = _compute_frame_average(frame_img) if self.stats_manager is not None: - self.stats_manager.set_metrics( - frame_num, {self._metric_keys[0]: frame_avg} - ) + self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) if self.processed_frame: - if self.last_fade["type"] == "in" and ( - ( - self.method == ThresholdDetector.Method.FLOOR - and frame_avg < self.threshold - ) - or ( - self.method == ThresholdDetector.Method.CEILING - and frame_avg >= self.threshold - ) - ): + if self.last_fade['type'] == 'in' and (( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): # Just faded out of a scene, wait for next fade in. - self.last_fade["type"] = "out" - self.last_fade["frame"] = frame_num - - elif self.last_fade["type"] == "out" and ( - ( - self.method == ThresholdDetector.Method.FLOOR - and frame_avg >= self.threshold - ) - or ( - self.method == ThresholdDetector.Method.CEILING - and frame_avg < self.threshold - ) - ): + self.last_fade['type'] = 'out' + self.last_fade['frame'] = frame_num + + elif self.last_fade['type'] == 'out' and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or + (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): # Only add the scene if min_scene_len frames have passed. if (frame_num - self.last_scene_cut) >= self.min_scene_len: # Just faded into a new scene, compute timecode for the scene # split based on the fade bias. - f_out = self.last_fade["frame"] + f_out = self.last_fade['frame'] f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) - / 2 - ) + (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) cut_list.append(f_split) self.last_scene_cut = frame_num - self.last_fade["type"] = "in" - self.last_fade["frame"] = frame_num + self.last_fade['type'] = 'in' + self.last_fade['frame'] = frame_num else: - self.last_fade["frame"] = 0 + self.last_fade['frame'] = 0 if frame_avg < self.threshold: - self.last_fade["type"] = "out" + self.last_fade['type'] = 'out' else: - self.last_fade["type"] = "in" + self.last_fade['type'] = 'in' self.processed_frame = True return cut_list @@ -217,13 +197,8 @@ def post_process(self, frame_num: int): # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cut_times = [] - if ( - self.last_fade["type"] == "out" - and self.add_final_scene - and ( - (self.last_scene_cut is None and frame_num >= self.min_scene_len) - or (frame_num - self.last_scene_cut) >= self.min_scene_len - ) - ): - cut_times.append(self.last_fade["frame"]) + if self.last_fade['type'] == 'out' and self.add_final_scene and ( + (self.last_scene_cut is None and frame_num >= self.min_scene_len) or + (frame_num - self.last_scene_cut) >= self.min_scene_len): + cut_times.append(self.last_fade['frame']) return cut_times diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index d843df25..5c009f52 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -88,11 +88,9 @@ class FrameTimecode: 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) """ - def __init__( - self, - timecode: Union[int, float, str, "FrameTimecode"] = None, - fps: Union[int, float, str, "FrameTimecode"] = None, - ): + 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 @@ -114,23 +112,20 @@ def __init__( 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." - ) + 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.") + 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.") + 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. @@ -202,7 +197,7 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: # Compute hours and minutes based off of seconds, and update seconds. secs = self.get_seconds() hrs = int(secs / _SECONDS_PER_HOUR) - secs -= hrs * _SECONDS_PER_HOUR + secs -= (hrs * _SECONDS_PER_HOUR) mins = int(secs / _SECONDS_PER_MINUTE) secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) if use_rounding: @@ -216,15 +211,15 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: mins = 0 hrs += 1 # We have to extend the precision by 1 here, since `format` will round up. - msec = format(secs, ".%df" % (precision + 1)) if precision else "" + msec = format(secs, '.%df' % (precision + 1)) if precision else '' # Need to include decimal place in `msec_str`. - msec_str = msec[-(2 + precision) : -1] + msec_str = msec[-(2 + precision):-1] secs_str = f"{int(secs):02d}{msec_str}" # Return hours, minutes, and seconds as a formatted timecode string. - return "%02d:%02d:%s" % (hrs, mins, secs_str) + return '%02d:%02d:%s' % (hrs, mins, secs_str) # TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter. - def previous_frame(self) -> "FrameTimecode": + 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) @@ -241,7 +236,7 @@ def _seconds_to_frames(self, seconds: float) -> int: 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. + """ Parse a timecode number, storing it as the exact number of frames. Can be passed as frame number (int), seconds (float) Raises: @@ -251,24 +246,20 @@ def _parse_timecode_number(self, timecode: Union[int, float]) -> int: # Exact number of frames N if isinstance(timecode, int): if timecode < 0: - raise ValueError( - "Timecode frame number must be positive and greater than zero." - ) + 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." - ) + 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!") + raise TypeError('Timecode/frame number must be specified!') else: - raise TypeError("Timecode format/type unrecognized.") + raise TypeError('Timecode format/type unrecognized.') def _parse_timecode_string(self, input: str) -> int: """Parses a string based on the three possible forms (in timecode format, @@ -288,97 +279,77 @@ def _parse_timecode_string(self, input: str) -> int: if input.isdigit(): timecode = int(input) if timecode < 0: - raise ValueError("Timecode frame number must be positive.") + raise ValueError('Timecode frame number must be positive.') return timecode # Timecode in string format 'HH:MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") hrs, mins = int(values[0]), int(values[1]) - secs = float(values[2]) if "." in values[2] else int(values[2]) + secs = float(values[2]) if '.' in values[2] else int(values[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)." - ) + raise ValueError('Invalid timecode range (values outside allowed range).') secs += (hrs * 60 * 60) + (mins * 60) return self._seconds_to_frames(secs) # Try to parse the number as seconds in the format 1234.5 or 1234s - if input.endswith("s"): + if input.endswith('s'): input = input[:-1] - if not input.replace(".", "").isdigit(): - raise ValueError( - "All characters in timecode seconds string must be digits." - ) + if not input.replace('.', '').isdigit(): + raise ValueError('All characters in timecode seconds string must be digits.') as_float = float(input) if as_float < 0.0: - raise ValueError("Timecode seconds value must be positive.") + raise ValueError('Timecode seconds value must be positive.') return self._seconds_to_frames(as_float) - def __iadd__( - self, other: Union[int, float, str, "FrameTimecode"] - ) -> "FrameTimecode": + 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." - ) + 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. + 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": + 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": + 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." - ) + 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) - ) + 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": + 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": + def __eq__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode': if isinstance(other, int): return self.frame_num == other elif isinstance(other, float): @@ -390,20 +361,17 @@ def __eq__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimeco return self.frame_num == other.frame_num else: raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) + '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) - ) + raise TypeError('Unsupported type for performing == with FrameTimecode: %s' % + type(other)) - def __ne__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __ne__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return not self == other - def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: if isinstance(other, int): return self.frame_num < other elif isinstance(other, float): @@ -415,14 +383,12 @@ def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num < other.frame_num else: raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) + 'FrameTimecode objects must have the same framerate to be compared.') else: - raise TypeError( - "Unsupported type for performing < with FrameTimecode: %s" % type(other) - ) + raise TypeError('Unsupported type for performing < with FrameTimecode: %s' % + type(other)) - def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: if isinstance(other, int): return self.frame_num <= other elif isinstance(other, float): @@ -434,15 +400,12 @@ def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num <= other.frame_num else: raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) + 'FrameTimecode objects must have the same framerate to be compared.') else: - raise TypeError( - "Unsupported type for performing <= with FrameTimecode: %s" - % type(other) - ) + raise TypeError('Unsupported type for performing <= with FrameTimecode: %s' % + type(other)) - def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: if isinstance(other, int): return self.frame_num > other elif isinstance(other, float): @@ -454,14 +417,12 @@ def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num > other.frame_num else: raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) + 'FrameTimecode objects must have the same framerate to be compared.') else: - raise TypeError( - "Unsupported type for performing > with FrameTimecode: %s" % type(other) - ) + raise TypeError('Unsupported type for performing > with FrameTimecode: %s' % + type(other)) - def __ge__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: if isinstance(other, int): return self.frame_num >= other elif isinstance(other, float): @@ -473,13 +434,10 @@ def __ge__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num >= other.frame_num else: raise TypeError( - "FrameTimecode objects must have the same framerate to be compared." - ) + 'FrameTimecode objects must have the same framerate to be compared.') else: - raise TypeError( - "Unsupported type for performing >= with FrameTimecode: %s" - % type(other) - ) + 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. @@ -494,11 +452,7 @@ 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, - ) + return '%s [frame=%d, fps=%.3f]' % (self.get_timecode(), self.frame_num, self.framerate) def __hash__(self) -> int: return self.frame_num diff --git a/scenedetect/platform.py b/scenedetect/platform.py index b9b0bf7e..38c86bf3 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -88,7 +88,7 @@ def __exit__(self, type, value, traceback): # TODO: Move this into scene_manager. def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: - """Get OpenCV imwrite Params: Returns a dict of supported image formats and + """ Get OpenCV imwrite Params: Returns a dict of supported image formats and their associated quality/compression parameter index, or None if that format is not supported. @@ -100,7 +100,7 @@ def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: """ def _get_cv2_param(param_name: str) -> Union[int, None]: - if param_name.startswith("CV_"): + if param_name.startswith('CV_'): param_name = param_name[3:] try: return getattr(cv2, param_name) @@ -108,9 +108,9 @@ def _get_cv2_param(param_name: str) -> Union[int, None]: return None return { - "jpg": _get_cv2_param("IMWRITE_JPEG_QUALITY"), - "png": _get_cv2_param("IMWRITE_PNG_COMPRESSION"), - "webp": _get_cv2_param("IMWRITE_WEBP_QUALITY"), + 'jpg': _get_cv2_param('IMWRITE_JPEG_QUALITY'), + 'png': _get_cv2_param('IMWRITE_PNG_COMPRESSION'), + 'webp': _get_cv2_param('IMWRITE_WEBP_QUALITY') } @@ -128,16 +128,14 @@ def get_file_name(file_path: AnyStr, include_extension=True) -> AnyStr: file_name = os.path.basename(file_path) if not include_extension: file_name = str(file_name) - last_dot_pos = file_name.rfind(".") + last_dot_pos = file_name.rfind('.') if last_dot_pos >= 0: file_name = file_name[:last_dot_pos] return file_name -def get_and_create_path( - file_path: AnyStr, output_directory: Optional[AnyStr] = None -) -> AnyStr: - """Get & Create Path: Gets and returns the full/absolute path to file_path +def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = None) -> AnyStr: + """ Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories along the way. @@ -169,11 +167,9 @@ def get_and_create_path( ## -def init_logger( - log_level: int = logging.INFO, - show_stdout: bool = False, - log_file: Optional[str] = None, -): +def init_logger(log_level: int = logging.INFO, + show_stdout: bool = False, + log_file: Optional[str] = None): """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'. By default the logger has no handlers to suppress output. All existing log handlers are replaced every time this function is invoked. @@ -185,10 +181,10 @@ def init_logger( log_file: If set, add handler to dump debug log messages to given file path. """ # Format of log messages depends on verbosity. - INFO_TEMPLATE = "[PySceneDetect] %(message)s" - DEBUG_TEMPLATE = "%(levelname)s: %(module)s.%(funcName)s(): %(message)s" + INFO_TEMPLATE = '[PySceneDetect] %(message)s' + DEBUG_TEMPLATE = '%(levelname)s: %(module)s.%(funcName)s(): %(message)s' # Get the named logger and remove any existing handlers. - logger_instance = logging.getLogger("pyscenedetect") + logger_instance = logging.getLogger('pyscenedetect') logger_instance.handlers = [] logger_instance.setLevel(log_level) # Add stdout handler if required. @@ -196,10 +192,7 @@ def init_logger( handler = logging.StreamHandler(stream=sys.stdout) handler.setLevel(log_level) handler.setFormatter( - logging.Formatter( - fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE - ) - ) + logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE)) logger_instance.addHandler(handler) # Add debug log handler if required. if log_file: @@ -237,12 +230,12 @@ def invoke_command(args: List[str]) -> int: try: return subprocess.call(args) except OSError as err: - if os.name != "nt": + if os.name != 'nt': raise exception_string = str(err) # Error 206: The filename or extension is too long # Error 87: The parameter is incorrect - to_match = ("206", "87") + to_match = ('206', '87') if any([x in exception_string for x in to_match]): raise CommandTooLong() from err raise @@ -254,8 +247,8 @@ def get_ffmpeg_path() -> Optional[str]: """ # Try invoking ffmpeg with the current environment. try: - subprocess.call(["ffmpeg", "-v", "quiet"]) - return "ffmpeg" + subprocess.call(['ffmpeg', '-v', 'quiet']) + return 'ffmpeg' except OSError: pass # Failed to invoke ffmpeg with current environment, try another possibility. @@ -263,9 +256,8 @@ def get_ffmpeg_path() -> Optional[str]: try: # pylint: disable=import-outside-toplevel from imageio_ffmpeg import get_ffmpeg_exe - # pylint: enable=import-outside-toplevel - subprocess.call([get_ffmpeg_exe(), "-v", "quiet"]) + subprocess.call([get_ffmpeg_exe(), '-v', 'quiet']) return get_ffmpeg_exe() # Gracefully handle case where imageio_ffmpeg is not available. except ModuleNotFoundError: @@ -286,9 +278,9 @@ def get_ffmpeg_version() -> Optional[str]: if ffmpeg_path is None: return None # If get_ffmpeg_path() returns a value, the path it returns should be invocable. - output = subprocess.check_output(args=[ffmpeg_path, "-version"], text=True) + output = subprocess.check_output(args=[ffmpeg_path, '-version'], text=True) output_split = output.split() - if len(output_split) >= 3 and output_split[1] == "version": + if len(output_split) >= 3 and output_split[1] == 'version': return output_split[2] # If parsing the version fails, return the entire first line of output. return output.splitlines()[0] @@ -296,15 +288,15 @@ def get_ffmpeg_version() -> Optional[str]: def get_mkvmerge_version() -> Optional[str]: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" - tool_name = "mkvmerge" + tool_name = 'mkvmerge' try: - output = subprocess.check_output(args=[tool_name, "--version"], text=True) + output = subprocess.check_output(args=[tool_name, '--version'], text=True) except FileNotFoundError: # mkvmerge doesn't exist on the system return None output_split = output.split() if len(output_split) >= 1 and output_split[0] == tool_name: - return " ".join(output_split[1:]) + return ' '.join(output_split[1:]) # If parsing the version fails, return the entire first line of output. return output.splitlines()[0] @@ -315,32 +307,31 @@ def get_system_version_info() -> str: Used for the `scenedetect version -a` command. """ - output_template = "{:<12} {}" - line_separator = "-" * 60 - not_found_str = "Not Installed" + output_template = '{:<12} {}' + line_separator = '-' * 60 + not_found_str = 'Not Installed' out_lines = [] # System (Python, OS) - out_lines += ["System Info", line_separator] + out_lines += ['System Info', line_separator] out_lines += [ - output_template.format(name, version) - for name, version in ( - ("OS", "%s" % platform.platform()), - ("Python", "%d.%d.%d" % sys.version_info[0:3]), + output_template.format(name, version) for name, version in ( + ('OS', '%s' % platform.platform()), + ('Python', '%d.%d.%d' % sys.version_info[0:3]), ) ] # Third-Party Packages - out_lines += ["", "Packages", line_separator] + out_lines += ['', 'Packages', line_separator] third_party_packages = ( - "av", - "click", - "cv2", - "moviepy", - "numpy", - "platformdirs", - "scenedetect", - "tqdm", + 'av', + 'click', + 'cv2', + 'moviepy', + 'numpy', + 'platformdirs', + 'scenedetect', + 'tqdm', ) for module_name in third_party_packages: try: @@ -350,25 +341,21 @@ def get_system_version_info() -> str: out_lines.append(output_template.format(module_name, not_found_str)) # External Tools - out_lines += ["", "Tools", line_separator] + out_lines += ['', 'Tools', line_separator] tool_version_info = ( - ("ffmpeg", get_ffmpeg_version()), - ("mkvmerge", get_mkvmerge_version()), + ('ffmpeg', get_ffmpeg_version()), + ('mkvmerge', get_mkvmerge_version()), ) - for tool_name, tool_version in tool_version_info: + for (tool_name, tool_version) in tool_version_info: out_lines.append( - output_template.format( - tool_name, tool_version if tool_version else not_found_str - ) - ) + output_template.format(tool_name, tool_version if tool_version else not_found_str)) - return "\n".join(out_lines) + return '\n'.join(out_lines) class Template(string.Template): """Template matcher used to replace instances of $TEMPLATES in filenames.""" - - idpattern = "[A-Z0-9_]+" + idpattern = '[A-Z0-9_]+' flags = re.ASCII diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 72bc7a5c..ded5d35d 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -35,7 +35,7 @@ # pylint: disable=unused-argument, no-self-use class SceneDetector: - """Base class to inherit from when implementing a scene detection algorithm. + """ Base class to inherit from when implementing a scene detection algorithm. This API is not yet stable and subject to change. @@ -45,7 +45,6 @@ class SceneDetector: Also see the implemented scene detectors in the scenedetect.detectors module to get an idea of how a particular detector can be created. """ - # TODO(v0.7): Make this a proper abstract base class. stats_manager: ty.Optional[StatsManager] = None @@ -68,10 +67,8 @@ def is_processing_required(self, frame_num: int) -> bool: to be passed to process_frame for the given frame_num). """ metric_keys = self.get_metrics() - return not metric_keys or not ( - self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys) - ) + return not metric_keys or not (self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys)) def stats_manager_required(self) -> bool: """Stats Manager Required: Prototype indicating if detector requires stats. @@ -136,9 +133,8 @@ class SparseSceneDetector(SceneDetector): An example of a SparseSceneDetector is the MotionDetector. """ - def process_frame( - self, frame_num: int, frame_img: numpy.ndarray - ) -> ty.List[ty.Tuple[int, int]]: + def process_frame(self, frame_num: int, + frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: """Process Frame: Computes/stores metrics and detects any scene changes. Prototype method, no actual detection. @@ -162,6 +158,7 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: + class Mode(Enum): MERGE = 0 """Merge consecutive cuts shorter than filter length.""" @@ -170,15 +167,11 @@ class Mode(Enum): def __init__(self, mode: Mode, length: int): self._mode = mode - self._filter_length = ( - length # Number of frames to use for activating the filter. - ) - self._last_above = None # Last frame above threshold. - self._merge_enabled = ( - False # Used to disable merging until at least one cut was found. - ) - self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filte. + self._filter_length = length # Number of frames to use for activating the filter. + self._last_above = None # Last frame above threshold. + self._merge_enabled = False # Used to disable merging until at least one cut was found. + self._merge_triggered = False # True when the merge filter is active. + self._merge_start = None # Frame number where we started the merge filte. def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if not self._filter_length > 0: @@ -186,13 +179,9 @@ def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if self._last_above is None: self._last_above = frame_num if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge( - frame_num=frame_num, above_threshold=above_threshold - ) + return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) if self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress( - frame_num=frame_num, above_threshold=above_threshold - ) + return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length @@ -211,11 +200,7 @@ def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. num_merged_frames = self._last_above - self._merge_start - if ( - min_length_met - and not above_threshold - and num_merged_frames >= self._filter_length - ): + if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 0e4d01aa..bbada707 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -91,26 +91,16 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import cv2 import numpy as np -from scenedetect._thirdparty.simpletable import ( - SimpleTableCell, - SimpleTableImage, - SimpleTableRow, - SimpleTable, - HTMLPage, -) - -from scenedetect.platform import ( - tqdm, - get_and_create_path, - get_cv2_imwrite_params, - Template, -) +from scenedetect._thirdparty.simpletable import (SimpleTableCell, SimpleTableImage, SimpleTableRow, + SimpleTable, HTMLPage) + +from scenedetect.platform import (tqdm, get_and_create_path, get_cv2_imwrite_params, Template) from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream from scenedetect.scene_detector import SceneDetector, SparseSceneDetector from scenedetect.stats_manager import StatsManager, FrameMetricRegistered -logger = logging.getLogger("pyscenedetect") +logger = logging.getLogger('pyscenedetect') # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current @@ -124,13 +114,12 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): MAX_FRAME_SIZE_ERRORS: int = 16 """Maximum number of frame size error messages that can be logged.""" -PROGRESS_BAR_DESCRIPTION = " Detected: %d | Progress" +PROGRESS_BAR_DESCRIPTION = ' Detected: %d | Progress' """Template to use for progress bar.""" class Interpolation(Enum): """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" - NEAREST = cv2.INTER_NEAREST """Nearest neighbor interpolation.""" LINEAR = cv2.INTER_LINEAR @@ -143,9 +132,7 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -def compute_downscale_factor( - frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH -) -> int: +def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> int: """Get the optimal default downscale factor based on a video's resolution (currently only the width in pixels is considered). @@ -194,7 +181,7 @@ def get_scenes_from_cuts( """ # TODO(v0.7): Use the warnings module to turn this into a warning. if base_timecode is not None: - logger.error("`base_timecode` argument is deprecated has no effect.") + logger.error('`base_timecode` argument is deprecated has no effect.') # Scene list, where scenes are tuples of (Start FrameTimecode, End FrameTimecode). scene_list = [] @@ -213,12 +200,10 @@ def get_scenes_from_cuts( return scene_list -def write_scene_list( - output_csv_file: TextIO, - scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], - include_cut_list: bool = True, - cut_list: Optional[Iterable[FrameTimecode]] = None, -) -> None: +def write_scene_list(output_csv_file: TextIO, + scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], + include_cut_list: bool = True, + cut_list: Optional[Iterable[FrameTimecode]] = None) -> None: """Writes the given list of scenes to an output file handle in CSV format. Arguments: @@ -230,56 +215,41 @@ def write_scene_list( in the video that need to be split to generate individual scenes). If not specified, the cut list is generated using the start times of each scene following the first one. """ - csv_writer = csv.writer(output_csv_file, lineterminator="\n") + csv_writer = csv.writer(output_csv_file, lineterminator='\n') # If required, output the cutting list as the first row (i.e. before the header row). if include_cut_list: csv_writer.writerow( - ["Timecode List:"] + cut_list - if cut_list - else [start.get_timecode() for start, _ in scene_list[1:]] - ) - csv_writer.writerow( - [ - "Scene Number", - "Start Frame", - "Start Timecode", - "Start Time (seconds)", - "End Frame", - "End Timecode", - "End Time (seconds)", - "Length (frames)", - "Length (timecode)", - "Length (seconds)", - ] - ) + ["Timecode List:"] + + cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) + csv_writer.writerow([ + "Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)", "End Frame", + "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", + "Length (seconds)" + ]) for i, (start, end) in enumerate(scene_list): duration = end - start - csv_writer.writerow( - [ - "%d" % (i + 1), - "%d" % (start.get_frames() + 1), - start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), - end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), - duration.get_timecode(), - "%.3f" % duration.get_seconds(), - ] - ) - - -def write_scene_list_html( - output_html_filename, - scene_list, - cut_list=None, - css=None, - css_class="mytable", - image_filenames=None, - image_width=None, - image_height=None, -): + csv_writer.writerow([ + '%d' % (i + 1), + '%d' % (start.get_frames() + 1), + start.get_timecode(), + '%.3f' % start.get_seconds(), + '%d' % end.get_frames(), + end.get_timecode(), + '%.3f' % end.get_seconds(), + '%d' % duration.get_frames(), + duration.get_timecode(), + '%.3f' % duration.get_seconds() + ]) + + +def write_scene_list_html(output_html_filename, + scene_list, + cut_list=None, + css=None, + css_class='mytable', + image_filenames=None, + image_width=None, + image_height=None): """Writes the given list of scenes to an output file handle in html format. Arguments: @@ -336,60 +306,40 @@ def write_scene_list_html( # Output Timecode list timecode_table = SimpleTable( - [ - ["Timecode List:"] - + ( - cut_list - if cut_list - else [start.get_timecode() for start, _ in scene_list[1:]] - ) - ], - css_class=css_class, - ) + [["Timecode List:"] + + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]])], + css_class=css_class) # Output list of scenes header_row = [ - "Scene Number", - "Start Frame", - "Start Timecode", - "Start Time (seconds)", - "End Frame", - "End Timecode", - "End Time (seconds)", - "Length (frames)", - "Length (timecode)", - "Length (seconds)", + "Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)", "End Frame", + "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", + "Length (seconds)" ] for i, (start, end) in enumerate(scene_list): duration = end - start - row = SimpleTableRow( - [ - "%d" % (i + 1), - "%d" % (start.get_frames() + 1), - start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), - end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), - duration.get_timecode(), - "%.3f" % duration.get_seconds(), - ] - ) + row = SimpleTableRow([ + '%d' % (i + 1), + '%d' % (start.get_frames() + 1), + start.get_timecode(), + '%.3f' % start.get_seconds(), + '%d' % end.get_frames(), + end.get_timecode(), + '%.3f' % end.get_seconds(), + '%d' % duration.get_frames(), + duration.get_timecode(), + '%.3f' % duration.get_seconds() + ]) if image_filenames: for image in image_filenames[i]: row.add_cell( SimpleTableCell( - SimpleTableImage(image, width=image_width, height=image_height) - ) - ) + SimpleTableImage(image, width=image_width, height=image_height))) if i == 0: - scene_table = SimpleTable( - rows=[row], header_row=header_row, css_class=css_class - ) + scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) else: scene_table.add_row(row=row) @@ -405,22 +355,20 @@ def write_scene_list_html( # TODO(v1.0): Refactor to take a SceneList object; consider moving this and save scene list # to a better spot, or just move them to scene_list.py. # -def save_images( - scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - video: VideoStream, - num_images: int = 3, - frame_margin: int = 1, - image_extension: str = "jpg", - encoder_param: int = 95, - image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: Optional[str] = None, - show_progress: Optional[bool] = False, - scale: Optional[float] = None, - height: Optional[int] = None, - width: Optional[int] = None, - interpolation: Interpolation = Interpolation.CUBIC, - video_manager=None, -) -> Dict[int, List[str]]: +def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + video: VideoStream, + num_images: int = 3, + frame_margin: int = 1, + image_extension: str = 'jpg', + encoder_param: int = 95, + image_name_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', + output_dir: Optional[str] = None, + show_progress: Optional[bool] = False, + scale: Optional[float] = None, + height: Optional[int] = None, + width: Optional[int] = None, + interpolation: Interpolation = Interpolation.CUBIC, + video_manager=None) -> Dict[int, List[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -470,7 +418,7 @@ def save_images( """ # TODO(v0.7): Add DeprecationWarning that `video_manager` will be removed in v0.8. if video_manager is not None: - logger.error("`video_manager` argument is deprecated, use `video` instead.") + logger.error('`video_manager` argument is deprecated, use `video` instead.') video = video_manager if not scene_list: @@ -480,70 +428,56 @@ def save_images( # TODO: Validate that encoder_param is within the proper range. # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. - imwrite_param = ( - [get_cv2_imwrite_params()[image_extension], encoder_param] - if encoder_param is not None - else [] - ) + imwrite_param = [get_cv2_imwrite_params()[image_extension], encoder_param + ] if encoder_param is not None else [] video.reset() # Setup flags and init progress bar if available. completed = True - logger.info("Generating output images (%d per scene)...", num_images) + logger.info('Generating output images (%d per scene)...', num_images) progress_bar = None if show_progress: - progress_bar = tqdm( - total=len(scene_list) * num_images, unit="images", dynamic_ncols=True - ) + progress_bar = tqdm(total=len(scene_list) * num_images, unit='images', dynamic_ncols=True) filename_template = Template(image_name_template) - scene_num_format = "%0" - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" - image_num_format = "%0" - image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" + scene_num_format = '%0' + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' + image_num_format = '%0' + image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + 'd' framerate = scene_list[0][0].framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. timecode_list = [ [ - FrameTimecode(int(f), fps=framerate) - for f in [ - # middle frames - a[len(a) // 2] - if (0 < j < num_images - 1) or num_images == 1 - # first frame - else min(a[0] + frame_margin, a[-1]) - if j == 0 - # last frame + FrameTimecode(int(f), fps=framerate) for f in [ + # middle frames + a[len(a) // 2] if (0 < j < num_images - 1) or num_images == 1 + + # first frame + else min(a[0] + frame_margin, a[-1]) if j == 0 + + # last frame else max(a[-1] - frame_margin, a[0]) - # for each evenly-split array of frames in the scene list + + # for each evenly-split array of frames in the scene list for j, a in enumerate(np.array_split(r, num_images)) ] - ] - for i, r in enumerate( - [ - # pad ranges to number of images - r - if 1 + r[-1] - r[0] >= num_images - else list(r) + [r[-1]] * (num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.get_frames(), - start.get_frames() - + max( - 1, # guard against zero length scenes - end.get_frames() - start.get_frames(), - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ] - ) + ] for i, r in enumerate([ + # pad ranges to number of images + r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) + # create range of frames in scene + for r in ( + range( + start.get_frames(), + start.get_frames() + max( + 1, # guard against zero length scenes + end.get_frames() - start.get_frames())) + # for each scene in scene list + for start, end in scene_list) + ]) ] image_filenames = {i: [] for i in range(len(timecode_list))} @@ -551,34 +485,31 @@ def save_images( if abs(aspect_ratio - 1.0) < 0.01: aspect_ratio = None - logger.debug("Writing images with template %s", filename_template.template) + logger.debug('Writing images with template %s', filename_template.template) for i, scene_timecodes in enumerate(timecode_list): for j, image_timecode in enumerate(scene_timecodes): video.seek(image_timecode) frame_im = video.read() if frame_im is not None: # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = "%s.%s" % ( + file_path = '%s.%s' % ( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), IMAGE_NUMBER=image_num_format % (j + 1), FRAME_NUMBER=image_timecode.get_frames(), TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), - TIMECODE=image_timecode.get_timecode().replace(":", ";"), - ), + TIMECODE=image_timecode.get_timecode().replace(":", ";")), image_extension, ) image_filenames[i].append(file_path) # TODO: Combine this resize with the ones below. if aspect_ratio is not None: frame_im = cv2.resize( - frame_im, - (0, 0), + frame_im, (0, 0), fx=aspect_ratio, fy=1.0, - interpolation=interpolation.value, - ) + interpolation=interpolation.value) frame_height = frame_im.shape[0] frame_width = frame_im.shape[1] @@ -592,20 +523,12 @@ def save_images( height = int(factor * frame_height) assert height > 0 and width > 0 frame_im = cv2.resize( - frame_im, (width, height), interpolation=interpolation.value - ) + frame_im, (width, height), interpolation=interpolation.value) elif scale: frame_im = cv2.resize( - frame_im, - (0, 0), - fx=scale, - fy=scale, - interpolation=interpolation.value, - ) - - cv2.imwrite( - get_and_create_path(file_path, output_dir), frame_im, imwrite_param - ) + frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) + + cv2.imwrite(get_and_create_path(file_path, output_dir), frame_im, imwrite_param) else: completed = False break @@ -616,7 +539,7 @@ def save_images( progress_bar.close() if not completed: - logger.error("Could not generate all output images.") + logger.error('Could not generate all output images.') return image_filenames @@ -702,9 +625,7 @@ def downscale(self, value: int): if value < 1: raise ValueError("Downscale factor must be a positive integer >= 1!") if self.auto_downscale: - logger.warning( - "Downscale factor will be ignored because auto_downscale=True!" - ) + logger.warning("Downscale factor will be ignored because auto_downscale=True!") if value is not None and not isinstance(value, int): logger.warning("Downscale factor will be truncated to integer!") value = int(value) @@ -744,12 +665,10 @@ def add_detector(self, detector: SceneDetector) -> None: else: self._sparse_detector_list.append(detector) - self._frame_buffer_size = max( - detector.event_buffer_length, self._frame_buffer_size - ) + self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) def get_num_detectors(self) -> int: - """Get number of registered scene detectors added via add_detector.""" + """Get number of registered scene detectors added via add_detector. """ return len(self._detector_list) def clear(self) -> None: @@ -768,15 +687,13 @@ def clear(self) -> None: self.clear_detectors() def clear_detectors(self) -> None: - """Remove all scene detectors added to the SceneManager via add_detector().""" + """Remove all scene detectors added to the SceneManager via add_detector(). """ self._detector_list.clear() self._sparse_detector_list.clear() - def get_scene_list( - self, - base_timecode: Optional[FrameTimecode] = None, - start_in_scene: bool = False, - ) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def get_scene_list(self, + base_timecode: Optional[FrameTimecode] = None, + start_in_scene: bool = False) -> List[Tuple[FrameTimecode, FrameTimecode]]: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: @@ -794,13 +711,12 @@ def get_scene_list( """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error("`base_timecode` argument is deprecated and has no effect.") + logger.error('`base_timecode` argument is deprecated and has no effect.') if self._base_timecode is None: return [] cut_list = self._get_cutting_list() scene_list = get_scenes_from_cuts( - cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1 - ) + cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1) # If we didn't actually detect any cuts, make sure the resulting scene_list is empty # unless start_in_scene is True. if not cut_list and not start_in_scene: @@ -819,17 +735,13 @@ def _get_event_list(self) -> List[Tuple[FrameTimecode, FrameTimecode]]: if not self._event_list: return [] assert self._base_timecode is not None - return [ - (self._base_timecode + start, self._base_timecode + end) - for start, end in self._event_list - ] + return [(self._base_timecode + start, self._base_timecode + end) + for start, end in self._event_list] - def _process_frame( - self, - frame_num: int, - frame_im: np.ndarray, - callback: Optional[Callable[[np.ndarray, int], None]] = None, - ) -> bool: + def _process_frame(self, + frame_num: int, + frame_im: np.ndarray, + callback: Optional[Callable[[np.ndarray, int], None]] = None) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" new_cuts = False @@ -839,7 +751,7 @@ def _process_frame( self._frame_buffer.append(frame_im) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] - self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] + self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1):] for detector in self._detector_list: cuts = detector.process_frame(frame_num, frame_im) self._cutting_list += cuts @@ -866,16 +778,14 @@ def stop(self) -> None: """Stop the current :meth:`detect_scenes` call, if any. Thread-safe.""" self._stop.set() - def detect_scenes( - self, - video: VideoStream = None, - duration: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, - frame_skip: int = 0, - show_progress: bool = False, - callback: Optional[Callable[[np.ndarray, int], None]] = None, - frame_source: Optional[VideoStream] = None, - ) -> int: + def detect_scenes(self, + video: VideoStream = None, + duration: Optional[FrameTimecode] = None, + end_time: Optional[FrameTimecode] = None, + frame_skip: int = 0, + show_progress: bool = False, + callback: Optional[Callable[[np.ndarray, int], None]] = None, + frame_source: Optional[VideoStream] = None) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the number of frames processed. Results can be obtained by calling :meth:`get_scene_list` or :meth:`get_cut_list`. @@ -911,18 +821,16 @@ def detect_scenes( video = frame_source # TODO(v0.8): Remove default value for `video` after `frame_source` is removed. if video is None: - raise TypeError( - "detect_scenes() missing 1 required positional argument: 'video'" - ) + raise TypeError("detect_scenes() missing 1 required positional argument: 'video'") if frame_skip > 0 and self.stats_manager is not None: - raise ValueError("frame_skip must be 0 when using a StatsManager.") + raise ValueError('frame_skip must be 0 when using a StatsManager.') if duration is not None and end_time is not None: - raise ValueError("duration and end_time cannot be set at the same time!") + raise ValueError('duration and end_time cannot be set at the same time!') # TODO: These checks should be handled by the FrameTimecode constructor. if duration is not None and isinstance(duration, (int, float)) and duration < 0: - raise ValueError("duration must be greater than or equal to 0!") + raise ValueError('duration must be greater than or equal to 0!') if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: - raise ValueError("end_time must be greater than or equal to 0!") + raise ValueError('end_time must be greater than or equal to 0!') self._base_timecode = video.base_timecode @@ -939,9 +847,9 @@ def detect_scenes( total_frames = 0 if video.duration is not None: if end_time is not None and end_time < video.duration: - total_frames = end_time - start_frame_num + total_frames = (end_time - start_frame_num) else: - total_frames = video.duration.get_frames() - start_frame_num + total_frames = (video.duration.get_frames() - start_frame_num) # Calculate the desired downscale factor and log the effective resolution. if self.auto_downscale: @@ -949,18 +857,15 @@ def detect_scenes( else: downscale_factor = self.downscale if downscale_factor > 1: - logger.info( - "Downscale factor set to %d, effective resolution: %d x %d", - downscale_factor, - video.frame_size[0] // downscale_factor, - video.frame_size[1] // downscale_factor, - ) + logger.info('Downscale factor set to %d, effective resolution: %d x %d', + downscale_factor, video.frame_size[0] // downscale_factor, + video.frame_size[1] // downscale_factor) progress_bar = None if show_progress: progress_bar = tqdm( total=int(total_frames), - unit="frames", + unit='frames', desc=PROGRESS_BAR_DESCRIPTION % 0, dynamic_ncols=True, ) @@ -970,12 +875,11 @@ def detect_scenes( decode_thread = threading.Thread( target=SceneManager._decode_thread, args=(self, video, frame_skip, downscale_factor, end_time, frame_queue), - daemon=True, - ) + daemon=True) decode_thread.start() frame_im = None - logger.info("Detecting scenes...") + logger.info('Detecting scenes...') while not self._stop.is_set(): next_frame, position = frame_queue.get() if next_frame is None and position is None: @@ -986,15 +890,12 @@ def detect_scenes( if progress_bar is not None: if new_cuts: progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), - refresh=False, - ) + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False) progress_bar.update(1 + frame_skip) if progress_bar is not None: progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True - ) + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True) progress_bar.close() # Unblock any puts in the decode thread before joining. This can happen if the main # processing thread stops before the decode thread. @@ -1037,32 +938,25 @@ def _decode_thread( if video.frame_size != decoded_size: logger.warn( f"WARNING: Decoded frame size ({decoded_size}) does not match " - f" video resolution {video.frame_size}, possible corrupt input." - ) + f" video resolution {video.frame_size}, possible corrupt input.") elif self._frame_size != decoded_size: self._frame_size_errors += 1 if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: logger.error( f"ERROR: Frame at {str(video.position)} has incorrect size and " f"cannot be processed: decoded size = {decoded_size}, " - f"expected = {self._frame_size}. Video may be corrupt." - ) + f"expected = {self._frame_size}. Video may be corrupt.") if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: logger.warn( - f"WARNING: Too many errors emitted, skipping future messages." - ) + f"WARNING: Too many errors emitted, skipping future messages.") # Skip processing frames that have an incorrect size. continue if downscale_factor > 1: frame_im = cv2.resize( - frame_im, - ( - round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor), - ), - interpolation=self._interpolation.value, - ) + frame_im, (round(frame_im.shape[1] / downscale_factor), + round(frame_im.shape[0] / downscale_factor)), + interpolation=self._interpolation.value) else: if video.read(decode=False) is False: break @@ -1088,7 +982,7 @@ def _decode_thread( logger.debug("Received KeyboardInterrupt.") self._stop.set() except BaseException: - logger.critical("Fatal error: Exception raised in decode thread.") + logger.critical('Fatal error: Exception raised in decode thread.') self._exception_info = sys.exc_info() self._stop.set() @@ -1107,9 +1001,9 @@ def _decode_thread( # pylint: disable=unused-argument - def get_cut_list( - self, base_timecode: Optional[FrameTimecode] = None, show_warning: bool = True - ) -> List[FrameTimecode]: + def get_cut_list(self, + base_timecode: Optional[FrameTimecode] = None, + show_warning: bool = True) -> List[FrameTimecode]: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing @@ -1132,13 +1026,12 @@ def get_cut_list( """ # TODO(v0.7): Use the warnings module to turn this into a warning. if show_warning: - logger.error( - "`get_cut_list()` is deprecated and will be removed in a future release." - ) + logger.error('`get_cut_list()` is deprecated and will be removed in a future release.') return self._get_cutting_list() def get_event_list( - self, base_timecode: Optional[FrameTimecode] = None + self, + base_timecode: Optional[FrameTimecode] = None ) -> List[Tuple[FrameTimecode, FrameTimecode]]: """[DEPRECATED] DO NOT USE. @@ -1155,9 +1048,7 @@ def get_event_list( List of pairs of FrameTimecode objects denoting the detected scenes. """ # TODO(v0.7): Use the warnings module to turn this into a warning. - logger.error( - "`get_event_list()` is deprecated and will be removed in a future release." - ) + logger.error('`get_event_list()` is deprecated and will be removed in a future release.') return self._get_event_list() # pylint: enable=unused-argument @@ -1166,9 +1057,4 @@ def _is_processing_required(self, frame_num: int) -> bool: """True if frame metrics not in StatsManager, False otherwise.""" if self.stats_manager is None: return True - return all( - [ - detector.is_processing_required(frame_num) - for detector in self._detector_list - ] - ) + return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 9ac7e69e..8bb8b9ec 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -25,14 +25,13 @@ import csv from logging import getLogger import typing as ty - # TODO: Replace below imports with `ty.` prefix. from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union import os.path from scenedetect.frame_timecode import FrameTimecode -logger = getLogger("pyscenedetect") +logger = getLogger('pyscenedetect') ## ## StatsManager CSV File Column Names (Header Row) @@ -51,23 +50,19 @@ class FrameMetricRegistered(Exception): """[DEPRECATED - DO NOT USE] No longer used.""" - pass class FrameMetricNotRegistered(Exception): """[DEPRECATED - DO NOT USE] No longer used.""" - pass class StatsFileCorrupt(Exception): """Raised when frame metrics/stats could not be loaded from a provided CSV file.""" - def __init__( - self, - message: str = "Could not load frame metric data data from passed CSV file.", - ): + def __init__(self, + message: str = "Could not load frame metric data data from passed CSV file."): super().__init__(message) @@ -103,12 +98,8 @@ def __init__(self, base_timecode: FrameTimecode = None): # of each frame metric key and the value it represents (usually float). self._frame_metrics: Dict[FrameTimecode, Dict[str, float]] = dict() self._metric_keys: Set[str] = set() - self._metrics_updated: bool = ( - False # Flag indicating if metrics require saving. - ) - self._base_timecode: Optional[FrameTimecode] = ( - base_timecode # Used for timing calculations. - ) + self._metrics_updated: bool = False # Flag indicating if metrics require saving. + self._base_timecode: Optional[FrameTimecode] = base_timecode # Used for timing calculations. @property def metric_keys(self) -> ty.Iterable[str]: @@ -133,12 +124,10 @@ def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any in the same order as the input list of metric keys. If a metric could not be found, None is returned for that particular metric. """ - return [ - self._get_metric(frame_number, metric_key) for metric_key in metric_keys - ] + return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None: - """Set Metrics: Sets the provided statistics/metrics for a given frame. + """ Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: frame_number: Frame number to retrieve metrics for. @@ -149,20 +138,15 @@ def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) def metrics_exist(self, frame_number: int, metric_keys: Iterable[str]) -> bool: - """Metrics Exist: Checks if the given metrics/stats exist for the given frame. + """ Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: bool: True if the given metric keys exist for the frame, False otherwise. """ - return all( - [ - self._metric_exists(frame_number, metric_key) - for metric_key in metric_keys - ] - ) + return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys]) def is_save_required(self) -> bool: - """Is Save Required: Checks if the stats have been updated since loading. + """ Is Save Required: Checks if the stats have been updated since loading. Returns: bool: True if there are frame metrics/statistics not yet written to disk, @@ -170,13 +154,11 @@ def is_save_required(self) -> bool: """ return self._metrics_updated - def save_to_csv( - self, - csv_file: Union[str, bytes, TextIO], - base_timecode: Optional[FrameTimecode] = None, - force_save=True, - ) -> None: - """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. + def save_to_csv(self, + csv_file: Union[str, bytes, TextIO], + base_timecode: Optional[FrameTimecode] = None, + force_save=True) -> None: + """ Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. Arguments: csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str. @@ -188,7 +170,7 @@ def save_to_csv( """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error("base_timecode is deprecated and has no effect.") + logger.error('base_timecode is deprecated and has no effect.') if not (force_save or self.is_save_required()): logger.info("No metrics to write.") @@ -197,23 +179,21 @@ def save_to_csv( # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. if isinstance(csv_file, (str, bytes)): - with open(csv_file, "w") as file: + with open(csv_file, 'w') as file: self.save_to_csv(csv_file=file, force_save=force_save) return - csv_writer = csv.writer(csv_file, lineterminator="\n") + csv_writer = csv.writer(csv_file, lineterminator='\n') metric_keys = sorted(list(self._metric_keys)) - csv_writer.writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys - ) + csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: frame_timecode = self._base_timecode + frame_key csv_writer.writerow( - [frame_timecode.get_frames() + 1, frame_timecode.get_timecode()] - + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] - ) + [frame_timecode.get_frames() + + 1, frame_timecode.get_timecode()] + + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)]) @staticmethod def valid_header(row: List[str]) -> bool: @@ -251,21 +231,19 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: was specified. """ # TODO: Make this an error, then make load_from_csv() a no-op, and finally, remove it. - logger.warning( - "load_from_csv() is deprecated and will be removed in a future release." - ) + logger.warning("load_from_csv() is deprecated and will be removed in a future release.") # If we get a path instead of an open file handle, check that it exists, and if so, # recursively call ourselves again but with file set instead of path. if isinstance(csv_file, (str, bytes)): if os.path.exists(csv_file): - with open(csv_file, "r") as file: + with open(csv_file, 'r') as file: return self.load_from_csv(csv_file=file) # Path doesn't exist. return None # If we get here, file is a valid file handle in read-only text mode. - csv_reader = csv.reader(csv_file, lineterminator="\n") + csv_reader = csv.reader(csv_file, lineterminator='\n') num_cols = None num_metrics = None num_frames = None @@ -284,31 +262,28 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: num_cols = len(row) num_metrics = num_cols - 2 if not num_metrics > 0: - raise StatsFileCorrupt("No metrics defined in CSV file.") + raise StatsFileCorrupt('No metrics defined in CSV file.') loaded_metrics = list(row[2:]) num_frames = 0 for row in csv_reader: metric_dict = {} if not len(row) == num_cols: - raise StatsFileCorrupt( - "Wrong number of columns detected in stats file row." - ) + raise StatsFileCorrupt('Wrong number of columns detected in stats file row.') frame_number = int(row[0]) # Switch from 1-based to 0-based frame numbers. if frame_number > 0: frame_number -= 1 self.set_metrics(frame_number, metric_dict) for i, metric in enumerate(row[2:]): - if metric and metric != "None": + if metric and metric != 'None': try: self._set_metric(frame_number, loaded_metrics[i], float(metric)) except ValueError: - raise StatsFileCorrupt( - "Corrupted value in stats file: %s" % metric - ) from ValueError + raise StatsFileCorrupt('Corrupted value in stats file: %s' % + metric) from ValueError num_frames += 1 self._metric_keys = self._metric_keys.union(set(loaded_metrics)) - logger.info("Loaded %d metrics for %d frames.", num_metrics, num_frames) + logger.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) self._metrics_updated = False return num_frames @@ -319,16 +294,12 @@ def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]: return self._frame_metrics[frame_number][metric_key] return None - def _set_metric( - self, frame_number: int, metric_key: str, metric_value: Any - ) -> None: + def _set_metric(self, frame_number: int, metric_key: str, metric_value: Any) -> None: self._metrics_updated = True if not frame_number in self._frame_metrics: self._frame_metrics[frame_number] = dict() self._frame_metrics[frame_number][metric_key] = metric_value def _metric_exists(self, frame_number: int, metric_key: str) -> bool: - return ( - frame_number in self._frame_metrics - and metric_key in self._frame_metrics[frame_number] - ) + return (frame_number in self._frame_metrics + and metric_key in self._frame_metrics[frame_number]) diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index 5650d7ad..a927bc95 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -38,14 +38,12 @@ class VideoParameterMismatch(Exception): - """VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some - of the video parameters (frame height, frame width, and framerate/FPS) do not match.""" - - def __init__( - self, - file_list=None, - message="OpenCV VideoCapture object parameters do not match.", - ): + """ VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some + of the video parameters (frame height, frame width, and framerate/FPS) do not match. """ + + def __init__(self, + file_list=None, + message="OpenCV VideoCapture object parameters do not match."): # type: (Iterable[Tuple[int, float, float, str, str]], str) -> None # Pass message string to base Exception class. super(VideoParameterMismatch, self).__init__(message) @@ -56,13 +54,13 @@ def __init__( class VideoDecodingInProgress(RuntimeError): - """VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that - must be called *before* start() has been called.""" + """ VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that + must be called *before* start() has been called. """ class InvalidDownscaleFactor(ValueError): - """InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, - i.e. the supplied downscale factor was not a positive integer greater than zero.""" + """ InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, + i.e. the supplied downscale factor was not a positive integer greater than zero. """ ## @@ -77,12 +75,12 @@ def get_video_name(video_file: str) -> Tuple[str, str]: Tuple of the form [name, video_file]. """ if isinstance(video_file, int): - return ("Device %d" % video_file, video_file) + return ('Device %d' % video_file, video_file) return (os.path.split(video_file)[1], video_file) def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int: - """Get Number of Frames: Returns total number of frames in the cap_list. + """ Get Number of Frames: Returns total number of frames in the cap_list. Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. """ @@ -94,7 +92,7 @@ def open_captures( framerate: Optional[float] = None, validate_parameters: bool = True, ) -> Tuple[List[cv2.VideoCapture], float, Tuple[int, int]]: - """Open Captures - helper function to open all capture objects, set the framerate, + """ Open Captures - helper function to open all capture objects, set the framerate, and ensure that all open captures have been opened and the framerates match on a list of video file paths, or a list containing a single device ID. @@ -130,52 +128,38 @@ def open_captures( raise ValueError("Expected at least 1 video file or device ID.") if isinstance(video_files[0], int): if len(video_files) > 1: - raise ValueError( - "If device ID is specified, no video sources may be appended." - ) + raise ValueError("If device ID is specified, no video sources may be appended.") elif video_files[0] < 0: raise ValueError("Invalid/negative device ID specified.") is_device = True elif not all([isinstance(video_file, (str, bytes)) for video_file in video_files]): print(video_files) - raise ValueError( - "Unexpected element type in video_files list (expected str(s)/int)." - ) + raise ValueError("Unexpected element type in video_files list (expected str(s)/int).") elif framerate is not None and not isinstance(framerate, float): raise TypeError("Expected type float for parameter framerate.") # Check if files exist if passed video file is not an image sequence # (checked with presence of % in filename) or not a URL (://). - if not is_device and any( - [ + if not is_device and any([ not os.path.exists(video_file) for video_file in video_files - if not ("%" in video_file or "://" in video_file) - ] - ): + if not ('%' in video_file or '://' in video_file) + ]): raise IOError("Video file(s) not found.") cap_list = [] try: cap_list = [cv2.VideoCapture(video_file) for video_file in video_files] video_names = [get_video_name(video_file) for video_file in video_files] - closed_caps = [ - video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened() - ] + closed_caps = [video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened()] if closed_caps: raise VideoOpenFailure(str(closed_caps)) cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list] - cap_framerate, check_framerate = validate_capture_framerate( - video_names, cap_framerates, framerate - ) + cap_framerate, check_framerate = validate_capture_framerate(video_names, cap_framerates, + framerate) # Store frame sizes as integers (VideoCapture.get() returns float). - cap_frame_sizes = [ - ( - math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) - for cap in cap_list - ] + cap_frame_sizes = [(math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) for cap in cap_list] cap_frame_size = cap_frame_sizes[0] # If we need to validate the parameters, we check that the FPS and width/height @@ -185,8 +169,7 @@ def open_captures( video_names=video_names, cap_frame_sizes=cap_frame_sizes, check_framerate=check_framerate, - cap_framerates=cap_framerates, - ) + cap_framerates=cap_framerates) except: for cap in cap_list: @@ -214,21 +197,15 @@ def validate_capture_framerate( if framerate is not None: if isinstance(framerate, float): if framerate < MAX_FPS_DELTA: - raise ValueError( - "Invalid framerate (must be a positive non-zero value)." - ) + raise ValueError("Invalid framerate (must be a positive non-zero value).") cap_framerate = framerate check_framerate = False else: - raise TypeError( - "Expected float for framerate, got %s." % type(framerate).__name__ - ) + raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__) else: - unavailable_framerates = [ - (video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if fps < MAX_FPS_DELTA - ] + unavailable_framerates = [(video_names[i][0], video_names[i][1]) + for i, fps in enumerate(cap_framerates) + if fps < MAX_FPS_DELTA] if unavailable_framerates: raise FrameRateUnavailable() return (cap_framerate, check_framerate) @@ -240,7 +217,7 @@ def validate_capture_parameters( check_framerate: bool = False, cap_framerates: Optional[List[float]] = None, ) -> None: - """Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) + """ Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) framerates are equal. Raises VideoParameterMismatch if there is a mismatch. Raises: @@ -249,41 +226,20 @@ def validate_capture_parameters( bad_params = [] max_framerate_delta = MAX_FPS_DELTA # Check heights/widths match. - bad_params += [ - ( - cv2.CAP_PROP_FRAME_WIDTH, - frame_size[0], - cap_frame_sizes[0][0], - video_names[i][0], - video_names[i][1], - ) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0 - ] - bad_params += [ - ( - cv2.CAP_PROP_FRAME_HEIGHT, - frame_size[1], - cap_frame_sizes[0][1], - video_names[i][0], - video_names[i][1], - ) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0 - ] + bad_params += [(cv2.CAP_PROP_FRAME_WIDTH, frame_size[0], cap_frame_sizes[0][0], + video_names[i][0], video_names[i][1]) + for i, frame_size in enumerate(cap_frame_sizes) + if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0] + bad_params += [(cv2.CAP_PROP_FRAME_HEIGHT, frame_size[1], cap_frame_sizes[0][1], + video_names[i][0], video_names[i][1]) + for i, frame_size in enumerate(cap_frame_sizes) + if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0] # Check framerates if required. if check_framerate: - bad_params += [ - ( - cv2.CAP_PROP_FPS, - fps, - cap_framerates[0], - video_names[i][0], - video_names[i][1], - ) - for i, fps in enumerate(cap_framerates) - if math.fabs(fps - cap_framerates[0]) > max_framerate_delta - ] + bad_params += [(cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], + video_names[i][1]) + for i, fps in enumerate(cap_framerates) + if math.fabs(fps - cap_framerates[0]) > max_framerate_delta] if bad_params: raise VideoParameterMismatch(bad_params) @@ -300,14 +256,12 @@ class VideoManager(VideoStream): Provides a cv2.VideoCapture-like interface to a set of one or more video files, or a single device ID. Supports seeking and setting end time/duration.""" - BACKEND_NAME = "video_manager_do_not_use" + BACKEND_NAME = 'video_manager_do_not_use' - def __init__( - self, - video_files: List[str], - framerate: Optional[float] = None, - logger=getLogger("pyscenedetect"), - ): + def __init__(self, + video_files: List[str], + framerate: Optional[float] = None, + logger=getLogger('pyscenedetect')): """[DEPRECATED] DO NOT USE. Arguments: @@ -331,17 +285,14 @@ def __init__( # will be removed in PySceneDetect v0.8. Use VideoStreamCv2 or VideoCaptureAdapter instead.' logger.error("VideoManager is deprecated and will be removed.") if not video_files: - raise ValueError( - "At least one string/integer must be passed in the video_files list." - ) + raise ValueError("At least one string/integer must be passed in the video_files list.") # Need to support video_files as a single str too for compatibility. if isinstance(video_files, str): video_files = [video_files] # These VideoCaptures are only open in this process. self._is_device = isinstance(video_files[0], int) self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=video_files, framerate=framerate - ) + video_files=video_files, framerate=framerate) self._path = video_files[0] if not self._is_device else video_files self._end_of_video = False self._start_time = self.get_base_timecode() @@ -352,18 +303,12 @@ def __init__( self._video_file_paths = video_files self._logger = logger if self._logger is not None: - self._logger.info( - "Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d", - len(self._cap_list), - "s" if len(self._cap_list) > 1 else "", - self.get_framerate(), - *self.get_framesize(), - ) + self._logger.info('Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d', + len(self._cap_list), 's' if len(self._cap_list) > 1 else '', + self.get_framerate(), *self.get_framesize()) self._started = False self._frame_length = self.get_base_timecode() + get_num_frames(self._cap_list) - self._first_cap_len = self.get_base_timecode() + get_num_frames( - [self._cap_list[0]] - ) + self._first_cap_len = self.get_base_timecode() + get_num_frames([self._cap_list[0]]) self._aspect_ratio = _get_aspect_ratio(self._cap_list[0]) def set_downscale_factor(self, downscale_factor=None): @@ -395,10 +340,10 @@ def get_video_name(self) -> str: """ video_paths = self.get_video_paths() if not video_paths: - return "" + return '' video_name = os.path.basename(video_paths[0]) - if video_name.rfind(".") >= 0: - video_name = video_name[: video_name.rfind(".")] + if video_name.rfind('.') >= 0: + video_name = video_name[:video_name.rfind('.')] return video_name def get_framerate(self) -> float: @@ -435,7 +380,7 @@ def get_base_timecode(self) -> FrameTimecode: return FrameTimecode(timecode=0, fps=self._cap_framerate) def get_current_timecode(self) -> FrameTimecode: - """Get Current Timecode - returns a FrameTimecode object at current VideoManager position. + """ Get Current Timecode - returns a FrameTimecode object at current VideoManager position. Returns: Timecode at the current VideoManager position. @@ -451,7 +396,7 @@ def get_framesize(self) -> Tuple[int, int]: return self._cap_framesize def get_framesize_effective(self) -> Tuple[int, int]: - """Get Frame Size - returns the frame size of the video(s) open in the + """ Get Frame Size - returns the frame size of the video(s) open in the VideoManager's capture objects. Returns: @@ -459,13 +404,11 @@ def get_framesize_effective(self) -> Tuple[int, int]: """ return self._cap_framesize - def set_duration( - self, - duration: Optional[FrameTimecode] = None, - start_time: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, - ) -> None: - """Set Duration - sets the duration/length of the video(s) to decode, as well as + def set_duration(self, + duration: Optional[FrameTimecode] = None, + start_time: Optional[FrameTimecode] = None, + end_time: Optional[FrameTimecode] = None) -> None: + """ Set Duration - sets the duration/length of the video(s) to decode, as well as the start/end times. Must be called before :meth:`start()` is called, otherwise a VideoDecodingInProgress exception will be thrown. May be called after :meth:`reset()` as well. @@ -489,23 +432,13 @@ def set_duration( raise VideoDecodingInProgress() # Ensure any passed timecodes have the proper framerate. - if ( - (duration is not None and not duration.equal_framerate(self._cap_framerate)) - or ( - start_time is not None - and not start_time.equal_framerate(self._cap_framerate) - ) - or ( - end_time is not None - and not end_time.equal_framerate(self._cap_framerate) - ) - ): + if ((duration is not None and not duration.equal_framerate(self._cap_framerate)) + or (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) + or (end_time is not None and not end_time.equal_framerate(self._cap_framerate))): raise ValueError("FrameTimecode framerate does not match.") if duration is not None and end_time is not None: - raise TypeError( - "Only one of duration and end_time may be specified, not both." - ) + raise TypeError("Only one of duration and end_time may be specified, not both.") if start_time is not None: self._start_time = start_time @@ -522,15 +455,13 @@ def set_duration( self._frame_length -= self._start_time if self._logger is not None: - self._logger.info( - "Duration set, start: %s, duration: %s, end: %s.", - start_time.get_timecode() if start_time is not None else start_time, - duration.get_timecode() if duration is not None else duration, - end_time.get_timecode() if end_time is not None else end_time, - ) + self._logger.info('Duration set, start: %s, duration: %s, end: %s.', + start_time.get_timecode() if start_time is not None else start_time, + duration.get_timecode() if duration is not None else duration, + end_time.get_timecode() if end_time is not None else end_time) def get_duration(self) -> FrameTimecode: - """Get Duration - gets the duration/length of the video(s) to decode, + """ Get Duration - gets the duration/length of the video(s) to decode, as well as the start/end times. If the end time was not set by :meth:`set_duration()`, the end timecode @@ -546,7 +477,7 @@ def get_duration(self) -> FrameTimecode: return (self._frame_length, self._start_time, end_time) def start(self) -> None: - """Start - starts video decoding and seeks to start time. Raises + """ Start - starts video decoding and seeks to start time. Raises exception VideoDecodingInProgress if the method is called after the decoder process has already been started. @@ -567,9 +498,7 @@ def start(self) -> None: # from `timecode` to `target`. For compatibility, we allow calling seek with the form # seek(0), seek(timecode=0), and seek(target=0). Specifying both arguments is an error. # pylint: disable=arguments-differ - def seek( - self, timecode: FrameTimecode = None, target: FrameTimecode = None - ) -> bool: + def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> bool: """Seek forwards to the passed timecode. Only supports seeking forwards (i.e. timecode must be greater than the @@ -587,9 +516,9 @@ def seek( ValueError: Either none or both `timecode` and `target` were set. """ if timecode is None and target is None: - raise ValueError("`target` must be set.") + raise ValueError('`target` must be set.') if timecode is not None and target is not None: - raise ValueError("Only one of `timecode` or `target` can be set.") + raise ValueError('Only one of `timecode` or `target` can be set.') if target is not None: timecode = target assert timecode is not None @@ -610,10 +539,8 @@ def seek( # TODO: This should throw an exception instead of potentially failing silently # if no logger was provided. if self._logger is not None: - self._logger.error( - "Seeking past the first input video is not currently supported." - ) - self._logger.warning("Seeking to end of first input.") + self._logger.error('Seeking past the first input video is not currently supported.') + self._logger.warning('Seeking to end of first input.') timecode = self._first_cap_len if self._curr_cap is not None and self._end_of_video is not True: self._curr_cap.set(cv2.CAP_PROP_POS_FRAMES, timecode.get_frames() - 1) @@ -627,14 +554,14 @@ def seek( # pylint: enable=arguments-differ def release(self) -> None: - """Release (cv2.VideoCapture method), releases all open capture(s).""" + """ Release (cv2.VideoCapture method), releases all open capture(s). """ for cap in self._cap_list: cap.release() self._cap_list = [] self._started = False def reset(self) -> None: - """Reset - Reopens captures passed to the constructor of the VideoManager. + """ Reset - Reopens captures passed to the constructor of the VideoManager. Can only be called after the :meth:`release()` method has been called. @@ -648,13 +575,11 @@ def reset(self) -> None: self._end_of_video = False self._curr_time = self.get_base_timecode() self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=self._video_file_paths, - framerate=self._curr_time.get_framerate(), - ) + video_files=self._video_file_paths, framerate=self._curr_time.get_framerate()) self._curr_cap, self._curr_cap_idx = None, None def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, int]: - """Get (cv2.VideoCapture method) - obtains capture properties from the current + """ Get (cv2.VideoCapture method) - obtains capture properties from the current VideoCapture object in use. Index represents the same index as the original video_files list passed to the constructor. Getting/setting the position (POS) properties has no effect; seeking is implemented using VideoDecoder methods. @@ -682,7 +607,7 @@ def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, in return self._cap_list[index].get(capture_prop) def grab(self) -> bool: - """Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. + """ Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. Returns: bool: True if a frame was grabbed, False otherwise. @@ -706,7 +631,7 @@ def grab(self) -> bool: return grabbed def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: - """Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. + """ Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. Frame returned corresponds to last call to :meth:`grab()`. @@ -728,10 +653,8 @@ def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: self._last_frame = None return (retrieved, self._last_frame) - def read( - self, decode: bool = True, advance: bool = True - ) -> Union[np.ndarray, bool]: - """Return next frame (or current if advance = False), or False if end of video. + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + """ Return next frame (or current if advance = False), or False if end of video. Arguments: decode: Decode and return the frame. @@ -767,7 +690,7 @@ def _get_next_cap(self) -> bool: return True def _correct_frame_length(self) -> None: - """Checks if the current frame position exceeds that originally calculated, + """ Checks if the current frame position exceeds that originally calculated, and adjusts the internally calculated frame length accordingly. Called after exhausting all input frames from the video source(s). """ @@ -826,10 +749,8 @@ def frame_rate(self) -> float: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return ( - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) + return (math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT))) @property def is_seekable(self) -> bool: diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index ee32e232..a4bce715 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -41,16 +41,10 @@ import time import typing as ty -from scenedetect.platform import ( - tqdm, - invoke_command, - CommandTooLong, - get_ffmpeg_path, - Template, -) +from scenedetect.platform import (tqdm, invoke_command, CommandTooLong, get_ffmpeg_path, Template) from scenedetect.frame_timecode import FrameTimecode -logger = logging.getLogger("pyscenedetect") +logger = logging.getLogger('pyscenedetect') TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" @@ -68,8 +62,7 @@ """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" DEFAULT_FFMPEG_ARGS = ( - "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac" -) + "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac") """Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" ## @@ -78,14 +71,14 @@ def is_mkvmerge_available() -> bool: - """Is mkvmerge Available: Gracefully checks if mkvmerge command is available. + """ Is mkvmerge Available: Gracefully checks if mkvmerge command is available. Returns: True if `mkvmerge` can be invoked, False otherwise. """ ret_val = None try: - ret_val = subprocess.call(["mkvmerge", "--quiet"]) + ret_val = subprocess.call(['mkvmerge', '--quiet']) except OSError: return False if ret_val is not None and ret_val != 2: @@ -94,7 +87,7 @@ def is_mkvmerge_available() -> bool: def is_ffmpeg_available() -> bool: - """Is ffmpeg Available: Gracefully checks if ffmpeg command is available. + """ Is ffmpeg Available: Gracefully checks if ffmpeg command is available. Returns: True if `ffmpeg` can be invoked, False otherwise. @@ -110,7 +103,6 @@ def is_ffmpeg_available() -> bool: @dataclass class VideoMetadata: """Information about the video being split.""" - name: str """Expected name of the video. May differ from `path`.""" path: Path @@ -122,7 +114,6 @@ class VideoMetadata: @dataclass class SceneMetadata: """Information about the scene being extracted.""" - index: int """0-based index of this scene.""" start: FrameTimecode @@ -137,25 +128,20 @@ class SceneMetadata: def default_formatter(template: str) -> PathFormatter: """Formats filenames using a template string which allows the following variables: - `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` """ MIN_DIGITS = 3 format_scene_number: PathFormatter = lambda video, scene: ( - ( - "%0" - + str(max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1)) - + "d" - ) - % (scene.index + 1) - ) + ('%0' + str(max(MIN_DIGITS, + math.floor(math.log(video.total_scenes, 10)) + 1)) + 'd') % + (scene.index + 1)) formatter: PathFormatter = lambda video, scene: Template(template).safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=format_scene_number(video, scene), START_TIME=str(scene.start.get_timecode().replace(":", ";")), END_TIME=str(scene.end.get_timecode().replace(":", ";")), START_FRAME=str(scene.start.get_frames()), - END_FRAME=str(scene.end.get_frames()), - ) + END_FRAME=str(scene.end.get_frames())) return formatter @@ -168,12 +154,12 @@ def split_video_mkvmerge( input_video_path: str, scene_list: ty.Iterable[TimecodePair], output_dir: ty.Optional[Path] = None, - output_file_template: str = "$VIDEO_NAME.mkv", + output_file_template: str = '$VIDEO_NAME.mkv', video_name: ty.Optional[str] = None, show_output: bool = False, suppress_output=None, ) -> int: - """Calls the mkvmerge command on the input video, splitting it at the + """ Calls the mkvmerge command on the input video, splitting it at the passed timecodes, where each scene is written in sequence from 001. Arguments: @@ -193,21 +179,19 @@ def split_video_mkvmerge( """ # Handle backwards compatibility with v0.5 API. if isinstance(input_video_path, list): - logger.error("Using a list of paths is deprecated. Pass a single path instead.") + logger.error('Using a list of paths is deprecated. Pass a single path instead.') if len(input_video_path) > 1: - raise ValueError("Concatenating multiple input videos is not supported.") + raise ValueError('Concatenating multiple input videos is not supported.') input_video_path = input_video_path[0] if suppress_output is not None: - logger.error("suppress_output is deprecated, use show_output instead.") + logger.error('suppress_output is deprecated, use show_output instead.') show_output = not suppress_output if not scene_list: return 0 - logger.info( - "Splitting input video using mkvmerge, output path template:\n %s", - output_file_template, - ) + logger.info('Splitting input video using mkvmerge, output path template:\n %s', + output_file_template) if video_name is None: video_name = Path(input_video_path).stem @@ -223,40 +207,31 @@ def split_video_mkvmerge( output_path.parent.mkdir(parents=True, exist_ok=True) try: - call_list = ["mkvmerge"] + call_list = ['mkvmerge'] if not show_output: - call_list.append("--quiet") + call_list.append('--quiet') call_list += [ - "-o", - str(output_path), - "--split", - "parts:%s" - % ",".join( - [ - "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list - ] - ), - input_video_path, + '-o', + str(output_path), '--split', + 'parts:%s' % ','.join([ + '%s-%s' % (start_time.get_timecode(), end_time.get_timecode()) + for start_time, end_time in scene_list + ]), input_video_path ] total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() processing_start_time = time.time() # TODO: Capture stdout/stderr and show that if the command fails. ret_val = invoke_command(call_list) if show_output: - logger.info( - "Average processing speed %.2f frames/sec.", - float(total_frames) / (time.time() - processing_start_time), - ) + logger.info('Average processing speed %.2f frames/sec.', + float(total_frames) / (time.time() - processing_start_time)) except CommandTooLong: logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logger.error( - "mkvmerge could not be found on the system." - " Please install mkvmerge to enable video output support." - ) + logger.error('mkvmerge could not be found on the system.' + ' Please install mkvmerge to enable video output support.') if ret_val != 0: - logger.error("Error splitting video (mkvmerge returned %d).", ret_val) + logger.error('Error splitting video (mkvmerge returned %d).', ret_val) return ret_val @@ -264,7 +239,7 @@ def split_video_ffmpeg( input_video_path: str, scene_list: ty.Iterable[TimecodePair], output_dir: ty.Optional[Path] = None, - output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", + output_file_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4', video_name: ty.Optional[str] = None, arg_override: str = DEFAULT_FFMPEG_ARGS, show_progress: bool = False, @@ -273,7 +248,7 @@ def split_video_ffmpeg( hide_progress=None, formatter: ty.Optional[PathFormatter] = None, ) -> int: - """Calls the ffmpeg command on the input video, generating a new video for + """ Calls the ffmpeg command on the input video, generating a new video for each scene based on the start/end timecodes. Arguments: @@ -299,24 +274,22 @@ def split_video_ffmpeg( """ # Handle backwards compatibility with v0.5 API. if isinstance(input_video_path, list): - logger.error("Using a list of paths is deprecated. Pass a single path instead.") + logger.error('Using a list of paths is deprecated. Pass a single path instead.') if len(input_video_path) > 1: - raise ValueError("Concatenating multiple input videos is not supported.") + raise ValueError('Concatenating multiple input videos is not supported.') input_video_path = input_video_path[0] if suppress_output is not None: - logger.error("suppress_output is deprecated, use show_output instead.") + logger.error('suppress_output is deprecated, use show_output instead.') show_output = not suppress_output if hide_progress is not None: - logger.error("hide_progress is deprecated, use show_progress instead.") + logger.error('hide_progress is deprecated, use show_progress instead.') show_progress = not hide_progress if not scene_list: return 0 - logger.info( - "Splitting input video using ffmpeg, output path template:\n %s", - output_file_template, - ) + logger.info('Splitting input video using ffmpeg, output path template:\n %s', + output_file_template) if video_name is None: video_name = Path(input_video_path).stem @@ -324,26 +297,23 @@ def split_video_ffmpeg( arg_override = arg_override.replace('\\"', '"') ret_val = 0 - arg_override = arg_override.split(" ") - scene_num_format = "%0" - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + arg_override = arg_override.split(' ') + scene_num_format = '%0' + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' if formatter is None: formatter = default_formatter(output_file_template) video_metadata = VideoMetadata( - name=video_name, path=input_video_path, total_scenes=len(scene_list) - ) + name=video_name, path=input_video_path, total_scenes=len(scene_list)) try: progress_bar = None total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() if show_progress: - progress_bar = tqdm( - total=total_frames, unit="frame", miniters=1, dynamic_ncols=True - ) + progress_bar = tqdm(total=total_frames, unit='frame', miniters=1, dynamic_ncols=True) processing_start_time = time.time() for i, (start_time, end_time) in enumerate(scene_list): - duration = end_time - start_time + duration = (end_time - start_time) scene_metadata = SceneMetadata(index=i, start=start_time, end=end_time) output_path = Path(formatter(scene=scene_metadata, video=video_metadata)) if output_dir: @@ -351,35 +321,29 @@ def split_video_ffmpeg( output_path.parent.mkdir(parents=True, exist_ok=True) # Gracefully handle case where FFMPEG_PATH might be unset. - call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else "ffmpeg"] + call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else 'ffmpeg'] if not show_output: - call_list += ["-v", "quiet"] + call_list += ['-v', 'quiet'] elif i > 0: # Only show ffmpeg output for the first call, which will display any # errors if it fails, and then break the loop. We only show error messages # for the remaining calls. - call_list += ["-v", "error"] + call_list += ['-v', 'error'] call_list += [ - "-nostdin", - "-y", - "-ss", - str(start_time.get_seconds()), - "-i", - input_video_path, - "-t", - str(duration.get_seconds()), + '-nostdin', '-y', '-ss', + str(start_time.get_seconds()), '-i', input_video_path, '-t', + str(duration.get_seconds()) ] call_list += arg_override - call_list += ["-sn"] + call_list += ['-sn'] call_list += [str(output_path)] ret_val = invoke_command(call_list) if show_output and i == 0 and len(scene_list) > 1: logger.info( - "Output from ffmpeg for Scene 1 shown above, splitting remaining scenes..." - ) + 'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...') if ret_val != 0: # TODO: Capture stdout/stderr and display it on any failed calls. - logger.error("Error splitting video (ffmpeg returned %d).", ret_val) + logger.error('Error splitting video (ffmpeg returned %d).', ret_val) break if progress_bar: progress_bar.update(duration.get_frames()) @@ -387,16 +351,12 @@ def split_video_ffmpeg( if progress_bar: progress_bar.close() if show_output: - logger.info( - "Average processing speed %.2f frames/sec.", - float(total_frames) / (time.time() - processing_start_time), - ) + logger.info('Average processing speed %.2f frames/sec.', + float(total_frames) / (time.time() - processing_start_time)) except CommandTooLong: logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logger.error( - "ffmpeg could not be found on the system." - " Please install ffmpeg to enable video output support." - ) + logger.error('ffmpeg could not be found on the system.' + ' Please install ffmpeg to enable video output support.') return ret_val diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index f157b972..bfdcbbf0 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -70,10 +70,8 @@ class FrameRateUnavailable(VideoOpenFailure): rate is unavailable or cannot be calculated. Subclass of VideoOpenFailure.""" def __init__(self): - super().__init__( - "Unable to obtain video framerate! Specify `framerate` manually, or" - " re-encode/re-mux the video and try again." - ) + super().__init__('Unable to obtain video framerate! Specify `framerate` manually, or' + ' re-encode/re-mux the video and try again.') ## @@ -82,7 +80,7 @@ def __init__(self): class VideoStream(ABC): - """Interface which all video backends must implement.""" + """ Interface which all video backends must implement. """ # # Default Implementations @@ -179,9 +177,7 @@ def frame_number(self) -> int: # @abstractmethod - def read( - self, decode: bool = True, advance: bool = True - ) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -196,7 +192,7 @@ def read( @abstractmethod def reset(self) -> None: - """Close and re-open the VideoStream (equivalent to seeking back to beginning).""" + """ Close and re-open the VideoStream (equivalent to seeking back to beginning). """ raise NotImplementedError @abstractmethod diff --git a/setup.py b/setup.py index 498f577c..2d8b2415 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ # # Copyright (C) 2014-2024 Brandon Castellano . # -"""PySceneDetect setup.py - DEPRECATED. +""" PySceneDetect setup.py - DEPRECATED. Build using `python -m build` and installing the resulting .whl using `pip`. """ diff --git a/tests/__init__.py b/tests/__init__.py index 6ff20946..5a618310 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect Unit Test Suite +""" PySceneDetect Unit Test Suite To run all available tests run `pytest -v` from the parent directory (i.e. the root project folder of PySceneDetect containing the scenedetect/ diff --git a/tests/conftest.py b/tests/conftest.py index 3e3e47a7..f7e8a25a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect Test Configuration +""" PySceneDetect Test Configuration This file includes all pytest configuration for running PySceneDetect's tests. @@ -39,22 +39,19 @@ def check_exists(path: AnyStr) -> AnyStr: - """Returns the absolute path to a (relative) path of a file that + """ Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ if not os.path.exists(path): - raise FileNotFoundError( - """ + raise FileNotFoundError(""" Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" - % path - ) +""" % path) return path @@ -87,16 +84,13 @@ def pytest_assertrepr_compare(op, left, right): def no_logs_gte_error(caplog): """Ensure no log messages with error severity or higher were reported during test execution.""" # TODO: Remove exclusion for VideoManager module when removed from codebase. - EXCLUDED_MODULES = {"video_manager"} + EXCLUDED_MODULES = {'video_manager'} yield errors = [ - record - for record in caplog.get_records("call") + record for record in caplog.get_records('call') if record.levelno >= logging.ERROR and not record.module in EXCLUDED_MODULES ] - assert ( - not errors - ), "Test failed due to presence of one or more logs with ERROR severity." + assert not errors, "Test failed due to presence of one or more logs with ERROR severity." @pytest.fixture diff --git a/tests/test_api.py b/tests/test_api.py index 50fad8c5..1ddb5596 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -24,79 +24,59 @@ def test_api_detect(test_video_file: str): """Demonstrate usage of the `detect()` function to process a complete video.""" from scenedetect import detect, ContentDetector - scene_list = detect(test_video_file, ContentDetector()) for i, scene in enumerate(scene_list): - print( - "Scene %d: %s - %s" - % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) - ) + print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_detect_start_end_time(test_video_file: str): """Demonstrate usage of the `detect()` function to process a subset of a video.""" from scenedetect import detect, ContentDetector - # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). # See test_api_timecode_types() for examples of each format. - scene_list = detect( - test_video_file, ContentDetector(), start_time=10.5, end_time=15.9 - ) + scene_list = detect(test_video_file, ContentDetector(), start_time=10.5, end_time=15.9) for i, scene in enumerate(scene_list): - print( - "Scene %d: %s - %s" - % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) - ) + print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_detect_stats(test_video_file: str): """Demonstrate usage of the `detect()` function to generate a statsfile.""" from scenedetect import detect, ContentDetector - detect(test_video_file, ContentDetector(), stats_file_path="frame_metrics.csv") def test_api_scene_manager(test_video_file: str): """Demonstrate how to use a SceneManager to implement a function similar to `detect()`.""" from scenedetect import SceneManager, ContentDetector, open_video - video = open_video(test_video_file) scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print( - "Scene %d: %s - %s" - % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) - ) + print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_scene_manager_start_end_time(test_video_file: str): """Demonstrate how to use a SceneManager to process a subset of the input video.""" from scenedetect import SceneManager, ContentDetector, open_video - video = open_video(test_video_file) scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). # See test_api_timecode_types() for examples of each format. - start_time = 200 # Start at frame (int) 200 + start_time = 200 # Start at frame (int) 200 end_time = 15.0 # End at 15 seconds (float) video.seek(start_time) scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print( - "Scene %d: %s - %s" - % (i + 1, scene[0].get_timecode(), scene[1].get_timecode()) - ) + print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_timecode_types(): """Demonstrate all different types of timecodes that can be used.""" from scenedetect import FrameTimecode - base_timecode = FrameTimecode(timecode=0, fps=10.0) # Frames (int) timecode = base_timecode + 1 @@ -105,23 +85,22 @@ def test_api_timecode_types(): timecode = base_timecode + 1.0 assert timecode.get_frames() == 10 # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') - timecode = base_timecode + "00:00:01.500" + timecode = base_timecode + '00:00:01.500' assert timecode.get_frames() == 15 # Seconds (str, 'SSSs' or 'SSSS.SSSs') - timecode = base_timecode + "1.5s" + timecode = base_timecode + '1.5s' assert timecode.get_frames() == 15 def test_api_stats_manager(test_video_file: str): """Demonstrate using a StatsManager to save per-frame statistics to disk.""" from scenedetect import SceneManager, StatsManager, ContentDetector, open_video - video = open_video(test_video_file) scene_manager = SceneManager(stats_manager=StatsManager()) scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. - filename = "%s.stats.csv" % test_video_file + filename = '%s.stats.csv' % test_video_file scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -160,6 +139,4 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): total_frames = 1000 scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) - scene_manager.detect_scenes( - video=video, duration=total_frames, callback=on_new_scene - ) + scene_manager.detect_scenes(video=video, duration=total_frames, callback=on_new_scene) diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index 0b383463..eeae7620 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect scenedetect.backend.opencv Tests +""" PySceneDetect scenedetect.backend.opencv Tests This file includes unit tests for the scenedetect.backend.opencv module that implements the VideoStreamCv2 ('opencv') backend. These tests validate behaviour specific to this backend. @@ -47,14 +47,10 @@ def test_capture_adapter(test_movie_clip: str): scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) - assert scene_manager.detect_scenes( - video=adapter, duration=adapter.base_timecode + 10.0 - ) + assert scene_manager.detect_scenes(video=adapter, duration=adapter.base_timecode + 10.0) scenes = scene_manager.get_scene_list() assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST) - assert [ - start.get_frames() for (start, _) in scenes - ] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST + assert [start.get_frames() for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST def test_capture_adapter_callback(test_video_file: str): diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index 5f8243e8..bfcc4bfb 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect scenedetect.backend.pyav Tests +""" PySceneDetect scenedetect.backend.pyav Tests This file includes unit tests for the scenedetect.backend.pyav module that implements the VideoStreamAv ('pyav') backend. These tests validate behaviour specific to this backend. @@ -24,7 +24,7 @@ def test_video_stream_pyav_bytesio(test_video_file: str): """Test that VideoStreamAv works with a BytesIO input in addition to a path.""" # Mode must be binary! - video_file = open(test_video_file, mode="rb") + video_file = open(test_video_file, mode='rb') stream = VideoStreamAv(path_or_io=video_file, threading_mode=None) assert stream.is_seekable stream.seek(50) diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py index fdca641f..2c7b5064 100644 --- a/tests/test_backwards_compat.py +++ b/tests/test_backwards_compat.py @@ -35,59 +35,49 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) # Suppress errors generated by using deprecated classes/arguments below. init_logger(log_level=logging.CRITICAL) video_manager = VideoManager([test_video_file]) - stats_file_path = test_video_file + ".csv" + stats_file_path = test_video_file + '.csv' stats_manager = StatsManager() scene_manager = SceneManager(stats_manager) scene_manager.add_detector(ContentDetector()) base_timecode = video_manager.get_base_timecode() scene_list = [] try: - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 10.0 # 00:00:10.000 + start_time = base_timecode + 20 # 00:00:00.667 + end_time = base_timecode + 10.0 # 00:00:10.000 if os.path.exists(stats_file_path): - with open(stats_file_path, "r") as stats_file: + with open(stats_file_path, 'r') as stats_file: stats_manager.load_from_csv(stats_file) # ContentDetector requires at least 1 frame before it can calculate any metrics. - assert stats_manager.metrics_exist( - start_time.get_frames() + 1, [ContentDetector.FRAME_SCORE_KEY] - ) + assert stats_manager.metrics_exist(start_time.get_frames() + 1, + [ContentDetector.FRAME_SCORE_KEY]) # Correct end frame # for presentation duration. - assert stats_manager.metrics_exist( - end_time.get_frames() - 1, [ContentDetector.FRAME_SCORE_KEY] - ) + assert stats_manager.metrics_exist(end_time.get_frames() - 1, + [ContentDetector.FRAME_SCORE_KEY]) video_manager.set_duration(start_time=start_time, end_time=end_time) video_manager.set_downscale_factor() video_manager.start() - assert ( - video_manager.get_current_timecode().get_frames() == start_time.get_frames() - ) + assert video_manager.get_current_timecode().get_frames() == start_time.get_frames() scene_manager.detect_scenes(frame_source=video_manager) scene_list = scene_manager.get_scene_list() # Correct end frame # for presentation duration. - assert ( - video_manager.get_current_timecode().get_frames() - == end_time.get_frames() + 1 - ) + assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1 - print("List of scenes obtained:") + print('List of scenes obtained:') for i, scene in enumerate(scene_list): - print( - " Scene %2d: Start %s / Frame %d, End %s / Frame %d" - % ( - i + 1, - scene[0].get_timecode(), - scene[0].get_frames(), - scene[1].get_timecode(), - scene[1].get_frames(), - ) - ) + print(' Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( + i + 1, + scene[0].get_timecode(), + scene[0].get_frames(), + scene[1].get_timecode(), + scene[1].get_frames(), + )) if stats_manager.is_save_required(): - with open(stats_file_path, "w") as stats_file: + with open(stats_file_path, 'w') as stats_file: stats_manager.save_to_csv(stats_file, base_timecode=base_timecode) finally: video_manager.release() @@ -97,7 +87,7 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) def test_backwards_compatibility_with_stats(test_video_file: str): """Runs equivalent code to `tests/api_test.py` from v0.5 twice to also exercise loading a statsfile from disk.""" - stats_file_path = test_video_file + ".csv" + stats_file_path = test_video_file + '.csv' if os.path.exists(stats_file_path): os.remove(stats_file_path) scenes = validate_backwards_compatibility(test_video_file, stats_file_path) diff --git a/tests/test_cli.py b/tests/test_cli.py index 43710c4f..fffa0a56 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,28 +42,24 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. -SCENEDETECT_CMD = "python -m scenedetect" +SCENEDETECT_CMD = 'python -m scenedetect' ALL_DETECTORS = [ - "detect-content", - "detect-threshold", - "detect-adaptive", - "detect-hist", - "detect-hash", + 'detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist', 'detect-hash' ] -ALL_BACKENDS = ["opencv", "pyav"] +ALL_BACKENDS = ['opencv', 'pyav'] -DEFAULT_VIDEO_PATH = "tests/resources/goldeneye.mp4" +DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' DEFAULT_VIDEO_NAME = Path(DEFAULT_VIDEO_PATH).stem -DEFAULT_BACKEND = "opencv" -DEFAULT_STATSFILE = "statsfile.csv" -DEFAULT_TIME = "-s 2s -d 4s" # Seek forward a bit but limit the amount we process. -DEFAULT_DETECTOR = "detect-content" -DEFAULT_CONFIG_FILE = "scenedetect.cfg" # Ensure we default to a "blank" config file. -DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. +DEFAULT_BACKEND = 'opencv' +DEFAULT_STATSFILE = 'statsfile.csv' +DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. +DEFAULT_DETECTOR = 'detect-content' +DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. +DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. def invoke_scenedetect( - args: str = "", + args: str = '', output_dir: ty.Optional[str] = None, config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, **kwargs, @@ -95,11 +91,11 @@ def invoke_scenedetect( value_dict.update(**kwargs) command = SCENEDETECT_CMD if output_dir: - command += " -o %s" % output_dir + command += ' -o %s' % output_dir if config_file: - command += " -c %s" % config_file - command += " " + args.format(**value_dict) - return subprocess.call(command.strip().split(" ")) + command += ' -c %s' % config_file + command += ' ' + args.format(**value_dict) + return subprocess.call(command.strip().split(' ')) def test_cli_no_args(): @@ -109,10 +105,10 @@ def test_cli_no_args(): def test_cli_default_detector(): """Test `scenedetect` command invoked without a detector.""" - assert invoke_scenedetect("-i {VIDEO} time {TIME}", config_file=None) == 0 + assert invoke_scenedetect('-i {VIDEO} time {TIME}', config_file=None) == 0 -@pytest.mark.parametrize("info_command", ["help", "about", "version"]) +@pytest.mark.parametrize('info_command', ['help', 'about', 'version']) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about).""" assert invoke_scenedetect(info_command) == 0 @@ -120,10 +116,10 @@ def test_cli_info_command(info_command): def test_cli_time_validate_options(): """Validate behavior of setting parameters via the `time` command.""" - base_command = "-i {VIDEO} time {TIME} {DETECTOR}" + base_command = '-i {VIDEO} time {TIME} {DETECTOR}' # Ensure cannot set end and duration together. - assert invoke_scenedetect(base_command, TIME="-s 2.0 -d 6.0 -e 8.0") != 0 - assert invoke_scenedetect(base_command, TIME="-s 2.0 -e 8.0 -d 6.0 ") != 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 6.0 -e 8.0') != 0 + assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 8.0 -d 6.0 ') != 0 def test_cli_time_end(): @@ -146,19 +142,10 @@ def test_cli_time_end(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", - DEFAULT_VIDEO_PATH, - "-m", - "0", - "detect-content", - "list-scenes", - "-n", - ] - + test_case.split(), - text=True, - ) + SCENEDETECT_CMD.split(' ') + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True) assert EXPECTED in output, test_case @@ -182,19 +169,10 @@ def test_cli_time_start(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", - DEFAULT_VIDEO_PATH, - "-m", - "0", - "detect-content", - "list-scenes", - "-n", - ] - + test_case.split(), - text=True, - ) + SCENEDETECT_CMD.split(' ') + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True) assert EXPECTED in output, test_case @@ -235,19 +213,10 @@ def test_cli_time_scene_boundary(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", - DEFAULT_VIDEO_PATH, - "-m", - "0", - "detect-content", - "list-scenes", - "-n", - ] - + test_case.split(), - text=True, - ) + SCENEDETECT_CMD.split(' ') + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True) assert EXPECTED in output, test_case @@ -255,21 +224,10 @@ def test_cli_time_end_of_video(): """Validate frame number/timecode alignment at the end of the video. The end timecode includes presentation time and therefore should represent the full length of the video.""" output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", - DEFAULT_VIDEO_PATH, - "detect-content", - "list-scenes", - "-n", - "time", - "-s", - "1872", - ], - text=True, - ) - assert ( - """ + SCENEDETECT_CMD.split(' ') + + ['-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], + text=True) + assert """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -277,44 +235,31 @@ def test_cli_time_end_of_video(): | 2 | 1917 | 00:01:19.913 | 1966 | 00:01:21.999 | | 3 | 1967 | 00:01:21.999 | 1980 | 00:01:22.582 | ----------------------------------------------------------------------- -""" - in output - ) +""" in output assert "00:01:19.913,00:01:21.999" in output -@pytest.mark.parametrize("detector_command", ALL_DETECTORS) +@pytest.mark.parametrize('detector_command', ALL_DETECTORS) def test_cli_detector(detector_command: str): """Test each detection algorithm.""" # Ensure all detectors work without a statsfile. - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR}", DETECTOR=detector_command - ) - == 0 - ) + assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR}', DETECTOR=detector_command) == 0 -@pytest.mark.parametrize("detector_command", ALL_DETECTORS) +@pytest.mark.parametrize('detector_command', ALL_DETECTORS) def test_cli_detector_with_stats(tmp_path, detector_command: str): """Test each detection algorithm with a statsfile.""" # Run with a statsfile twice to ensure the file is populated with those metrics and reloaded. - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", - output_dir=tmp_path, - DETECTOR=detector_command, - ) - == 0 - ) - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", - output_dir=tmp_path, - DETECTOR=detector_command, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}', + output_dir=tmp_path, + DETECTOR=detector_command, + ) == 0 + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}', + output_dir=tmp_path, + DETECTOR=detector_command, + ) == 0 # TODO: Check for existence of statsfile by trying to load it with the library, # and ensuring that we got some frames. @@ -322,126 +267,78 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" # Regular invocation - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} time {TIME} {DETECTOR} list-scenes', + output_dir=tmp_path, + ) == 0 # Add statsfile - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes', + output_dir=tmp_path, + ) == 0 # Suppress output file - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n', + output_dir=tmp_path, + ) == 0 # TODO: Check for output files from regular invocation. # TODO: Delete scene list and ensure is not recreated using -n. -@pytest.mark.skipif( - condition=not is_ffmpeg_available(), reason="ffmpeg is not available" -) +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") def test_cli_split_video_ffmpeg(tmp_path: Path): """Test `split-video` command using ffmpeg.""" # Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER. - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video', output_dir=tmp_path) == 0 entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) - assert len(entries) == DEFAULT_NUM_SCENES, entries + assert (len(entries) == DEFAULT_NUM_SCENES), entries [entry.unlink() for entry in entries] - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c', output_dir=tmp_path) == 0 entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) - assert len(entries) == DEFAULT_NUM_SCENES + assert (len(entries) == DEFAULT_NUM_SCENES) [entry.unlink() for entry in entries] - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER', + output_dir=tmp_path) == 0 entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*")) - assert len(entries) == DEFAULT_NUM_SCENES, entries + assert (len(entries) == DEFAULT_NUM_SCENES), entries [entry.unlink() for entry in entries] # -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero) assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a "-c:v libx264"', - output_dir=tmp_path, - ) + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a \"-c:v libx264\"", + output_dir=tmp_path) -@pytest.mark.skipif( - condition=not is_mkvmerge_available(), reason="mkvmerge is not available" -) +@pytest.mark.skipif(condition=not is_mkvmerge_available(), reason="mkvmerge is not available") def test_cli_split_video_mkvmerge(tmp_path: Path): """Test `split-video` command using mkvmerge.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m", - output_dir=tmp_path, - ) - == 0 - ) - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c", - output_dir=tmp_path, - ) - == 0 - ) - assert ( - invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m', output_dir=tmp_path) == 0 + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c', output_dir=tmp_path) == 0 + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', + output_dir=tmp_path) == 0 # -a/--args and -m/--mkvmerge are mutually exclusive assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -a "-c:v libx264"', - output_dir=tmp_path, - ) + output_dir=tmp_path) # TODO: Check for existence of split video files. def test_cli_save_images(tmp_path: Path): """Test `save-images` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images", - output_dir=tmp_path, - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images', output_dir=tmp_path) == 0 # Open one of the created images and make sure it has the correct resolution. # TODO: Also need to test that the right number of images was generated, and compare with # expected frames from the actual video. - images = glob.glob(os.path.join(tmp_path, "*.jpg")) + images = glob.glob(os.path.join(tmp_path, '*.jpg')) assert images image = cv2.imread(images[0]) assert image.shape == (544, 1280, 3) @@ -450,15 +347,11 @@ def test_cli_save_images(tmp_path: Path): # TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. def test_cli_save_images_rotation(rotated_video_file, tmp_path): """Test that `save-images` command rotates images correctly with the default backend.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} {DETECTOR} time {TIME} save-images", - VIDEO=rotated_video_file, - output_dir=tmp_path, - ) - == 0 - ) - images = glob.glob(os.path.join(tmp_path, "*.jpg")) + assert invoke_scenedetect( + '-i {VIDEO} {DETECTOR} time {TIME} save-images', + VIDEO=rotated_video_file, + output_dir=tmp_path) == 0 + images = glob.glob(os.path.join(tmp_path, '*.jpg')) assert images image = cv2.imread(images[0]) # Note same resolution as in test_cli_save_images but rotated 90 degrees. @@ -467,69 +360,42 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path): def test_cli_export_html(tmp_path: Path): """Test `export-html` command.""" - base_command = "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}" - assert ( - invoke_scenedetect( - base_command, COMMAND="save-images export-html", output_dir=tmp_path - ) - == 0 - ) - assert ( - invoke_scenedetect( - base_command, COMMAND="export-html --no-images", output_dir=tmp_path - ) - == 0 - ) + base_command = '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}' + assert invoke_scenedetect( + base_command, COMMAND='save-images export-html', output_dir=tmp_path) == 0 + assert invoke_scenedetect( + base_command, COMMAND='export-html --no-images', output_dir=tmp_path) == 0 # TODO: Check for existence of HTML & image files. -@pytest.mark.parametrize("backend_type", ALL_BACKENDS) +@pytest.mark.parametrize('backend_type', ALL_BACKENDS) def test_cli_backend(backend_type: str): """Test setting the `-b`/`--backend` argument.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}", BACKEND=backend_type - ) - == 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}', BACKEND=backend_type) == 0 def test_cli_backend_unsupported(): """Ensure setting an invalid backend returns an error.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} -b {BACKEND} {DETECTOR}", BACKEND="unknown_backend_type" - ) - != 0 - ) + assert invoke_scenedetect( + '-i {VIDEO} -b {BACKEND} {DETECTOR}', BACKEND='unknown_backend_type') != 0 def test_cli_load_scenes(): """Ensure we can load scenes both with and without the cut row.""" - assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes") == 0 - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv" - ) - == 0 - ) + assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes') == 0 + assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 # Specifying a detector with load-scenes should be disallowed. assert invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv" - ) + '-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv') # Specifying load-scenes several times should be disallowed. assert invoke_scenedetect( - "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv" + '-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv' ) # If `-s`/`--skip-cuts` is specified, the resulting scene list should still be compatible with # the `load-scenes` command. - assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s") == 0 - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv" - ) - == 0 - ) + assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s') == 0 + assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 def test_cli_load_scenes_with_time_frames(): @@ -540,27 +406,24 @@ def test_cli_load_scenes_with_time_frames(): 2,91 3,211 """ - with open("test_scene_list.csv", "w") as f: + with open('test_scene_list.csv', 'w') as f: f.write(scenes_csv) output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", + SCENEDETECT_CMD.split(' ') + [ + '-i', DEFAULT_VIDEO_PATH, - "load-scenes", - "-i", - "test_scene_list.csv", - "time", - "-s", - "2s", - "-e", - "10s", - "list-scenes", + 'load-scenes', + '-i', + 'test_scene_list.csv', + 'time', + '-s', + '2s', + '-e', + '10s', + 'list-scenes', ], - text=True, - ) - assert ( - """ + text=True) + assert """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -568,9 +431,7 @@ def test_cli_load_scenes_with_time_frames(): | 2 | 91 | 00:00:03.754 | 210 | 00:00:08.759 | | 3 | 211 | 00:00:08.759 | 240 | 00:00:10.010 | ----------------------------------------------------------------------- -""" - in output - ) +""" in output assert "00:00:03.754,00:00:08.759" in output @@ -582,47 +443,21 @@ def test_cli_load_scenes_round_trip(): 2,91 3,211 """ - with open("test_scene_list.csv", "w") as f: + with open('test_scene_list.csv', 'w') as f: f.write(scenes_csv) ground_truth = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", - DEFAULT_VIDEO_PATH, - "detect-content", - "list-scenes", - "-f", - "testout.csv", - "time", - "-s", - "200", - "-e", - "400", + SCENEDETECT_CMD.split(' ') + [ + '-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-f', 'testout.csv', 'time', + '-s', '200', '-e', '400' ], - text=True, - ) + text=True) loaded_first_pass = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ - "-i", - DEFAULT_VIDEO_PATH, - "load-scenes", - "-i", - "testout.csv", - "time", - "-s", - "200", - "-e", - "400", - "list-scenes", - "-f", - "testout2.csv", + SCENEDETECT_CMD.split(' ') + [ + '-i', DEFAULT_VIDEO_PATH, 'load-scenes', '-i', 'testout.csv', 'time', '-s', '200', '-e', + '400', 'list-scenes', '-f', 'testout2.csv' ], - text=True, - ) - SPLIT_POINT = ( - " | Scene # | Start Frame | Start Time | End Frame | End Time |" - ) + text=True) + SPLIT_POINT = ' | Scene # | Start Frame | Start Time | End Frame | End Time |' assert ground_truth.split(SPLIT_POINT)[1] == loaded_first_pass.split(SPLIT_POINT)[1] - with open("testout.csv") as first, open("testout2.csv") as second: + with open('testout.csv') as first, open('testout2.csv') as second: assert first.readlines() == second.readlines() diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 7d1d5323..38152b01 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect Scene Detection Tests +""" PySceneDetect Scene Detection Tests These tests ensure that the detection algorithms deliver consistent results by using known ground truths of scene cut locations in the @@ -34,10 +34,7 @@ HistogramDetector, ) -ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( - *FAST_CUT_DETECTORS, - ThresholdDetector, -) +ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) # TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores # regardless of resolution. This will ensure that threshold values will hold true for different @@ -47,23 +44,20 @@ # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: - """Returns the absolute path to a (relative) path of a file that + """ Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError( - """ + raise FileNotFoundError(""" Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" - % relative_path - ) +""" % relative_path) return abs_path @@ -88,8 +82,7 @@ def detect(self): video_path=self.path, detector=self.detector, start_time=self.start_time, - end_time=self.end_time, - ) + end_time=self.end_time) def get_fast_cut_test_cases(): @@ -103,11 +96,8 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=15), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], - ), - id="%s/default" % detector_type.__name__, - ) - for detector_type in FAST_CUT_DETECTORS + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), + id="%s/default" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS ] # goldeneye.mp4 with min_scene_len = 30 test_cases += [ @@ -117,11 +107,8 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=30), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365], - ), - id="%s/m=30" % detector_type.__name__, - ) - for detector_type in FAST_CUT_DETECTORS + scene_boundaries=[1199, 1260, 1334, 1365]), + id="%s/m=30" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS ] return test_cases @@ -137,20 +124,16 @@ def get_fade_in_out_test_cases(): detector=ThresholdDetector(), start_time=0, end_time=500, - scene_boundaries=[0, 15, 198, 376], - ), - id="threshold_testvideo_default", - ), + scene_boundaries=[0, 15, 198, 376]), + id="threshold_testvideo_default"), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), detector=ThresholdDetector(), start_time=0, end_time=250, - scene_boundaries=[0, 84, 167], - ), - id="threshold_fades_default", - ), + scene_boundaries=[0, 84, 167]), + id="threshold_fades_default"), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), @@ -161,10 +144,8 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 84, 167, 245], - ), - id="threshold_fades_floor", - ), + scene_boundaries=[0, 84, 167, 245]), + id="threshold_fades_floor"), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), @@ -175,10 +156,8 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 42, 125, 209], - ), - id="threshold_fades_ceil", - ), + scene_boundaries=[0, 42, 125, 209]), + id="threshold_fades_ceil"), ] @@ -202,7 +181,7 @@ def test_detect_fades(test_case: TestCase): def test_detectors_with_stats(test_video_file): - """Test all detectors functionality with a StatsManager.""" + """ Test all detectors functionality with a StatsManager. """ # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). for detector in ALL_DETECTORS: video = VideoStreamCv2(test_video_file) @@ -210,7 +189,7 @@ def test_detectors_with_stats(test_video_file): scene_manager = SceneManager(stats_manager=stats) scene_manager.add_detector(detector()) scene_manager.auto_downscale = True - end_time = FrameTimecode("00:00:08", video.frame_rate) + end_time = FrameTimecode('00:00:08', video.frame_rate) scene_manager.detect_scenes(video=video, end_time=end_time) initial_scene_len = len(scene_manager.get_scene_list()) assert initial_scene_len > 0, "Test case must have at least one scene." diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index f6728cab..aa5c5386 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect scenedetect.timecode Tests +""" PySceneDetect scenedetect.timecode Tests This file includes unit tests for the scenedetect.timecode module (specifically, the FrameTimecode object, used for representing frame-accurate timestamps and time values). @@ -32,7 +32,7 @@ def test_framerate(): - """Test FrameTimecode constructor argument "fps".""" + ''' Test FrameTimecode constructor argument "fps". ''' # Not passing fps results in TypeError. with pytest.raises(TypeError): FrameTimecode() @@ -65,7 +65,7 @@ def test_framerate(): def test_timecode_numeric(): - """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" + ''' Test FrameTimecode constructor argument "timecode" with numeric arguments. ''' with pytest.raises(ValueError): FrameTimecode(timecode=-1, fps=1) with pytest.raises(ValueError): @@ -81,131 +81,113 @@ def test_timecode_numeric(): def test_timecode_string(): - """Test FrameTimecode constructor argument "timecode" with string arguments.""" + ''' Test FrameTimecode constructor argument "timecode" with string arguments. ''' # Invalid strings: with pytest.raises(ValueError): - FrameTimecode(timecode="-1", fps=1) + FrameTimecode(timecode='-1', fps=1) with pytest.raises(ValueError): - FrameTimecode(timecode="-1.0", fps=1.0) + FrameTimecode(timecode='-1.0', fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode="-0.1", fps=1.0) + FrameTimecode(timecode='-0.1', fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode="1.9x", fps=1) + FrameTimecode(timecode='1.9x', fps=1) with pytest.raises(ValueError): - FrameTimecode(timecode="1x", fps=1.0) + FrameTimecode(timecode='1x', fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode="1.9.9", fps=1.0) + FrameTimecode(timecode='1.9.9', fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode="1.0-", fps=1.0) + FrameTimecode(timecode='1.0-', fps=1.0) # Frame number integer [int->str] ('%d', integer number as string) - assert FrameTimecode(timecode="0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1", fps=1).frame_num == 1 - assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='0', fps=1).frame_num == 0 + assert FrameTimecode(timecode='1', fps=1).frame_num == 1 + assert FrameTimecode(timecode='10', fps=1.0).frame_num == 10 # Seconds format [float->str] ('%f', number as string) - assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1 - assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 + assert FrameTimecode(timecode='0.0', fps=1).frame_num == 0 + assert FrameTimecode(timecode='1.0', fps=1).frame_num == 1 + assert FrameTimecode(timecode='10.0', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.0000000000', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.100', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='1.100', fps=10.0).frame_num == 11 # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) - assert FrameTimecode(timecode="0s", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1s", fps=1).frame_num == 1 - assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10 - assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 + assert FrameTimecode(timecode='0s', fps=1).frame_num == 0 + assert FrameTimecode(timecode='1s', fps=1).frame_num == 1 + assert FrameTimecode(timecode='10s', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.0s', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.0000000000s', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='10.100s', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode='1.100s', fps=10.0).frame_num == 11 # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) - assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1 - assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 + assert FrameTimecode(timecode='00:00:01', fps=1).frame_num == 1 + assert FrameTimecode(timecode='00:00:01.9999', fps=1).frame_num == 2 + assert FrameTimecode(timecode='00:00:02.0000', fps=1).frame_num == 2 + assert FrameTimecode(timecode='00:00:02.0001', fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 - assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 - assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 - assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1 + assert FrameTimecode(timecode='00:00:01', fps=10).frame_num == 10 + assert FrameTimecode(timecode='00:00:00.5', fps=10).frame_num == 5 + assert FrameTimecode(timecode='00:00:00.100', fps=10).frame_num == 1 + assert FrameTimecode(timecode='00:00:00.001', fps=1000).frame_num == 1 - assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60 + assert FrameTimecode(timecode='00:00:59.999', fps=1).frame_num == 60 + assert FrameTimecode(timecode='00:01:00.000', fps=1).frame_num == 60 + assert FrameTimecode(timecode='00:01:00.001', fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 + assert FrameTimecode(timecode='00:59:59.999', fps=1).frame_num == 3600 + assert FrameTimecode(timecode='01:00:00.000', fps=1).frame_num == 3600 + assert FrameTimecode(timecode='01:00:00.001', fps=1).frame_num == 3600 def test_get_frames(): - """Test FrameTimecode get_frames() method.""" + ''' Test FrameTimecode get_frames() method. ''' assert FrameTimecode(timecode=1, fps=1.0).get_frames(), 1 assert FrameTimecode(timecode=1000, fps=60.0).get_frames(), 1000 assert FrameTimecode(timecode=1000000000, fps=29.97).get_frames(), 1000000000 assert FrameTimecode(timecode=1.0, fps=1.0).get_frames(), int(1.0 / 1.0) assert FrameTimecode(timecode=1000.0, fps=60.0).get_frames(), int(1000.0 * 60.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int( - 1000000000.0 * 29.97 - ) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int(1000000000.0 * 29.97) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_frames(), 2 - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_frames(), 5 - assert FrameTimecode(timecode="00:00:01", fps=10).get_frames(), 10 - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_frames(), 60 + assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_frames(), 2 + assert FrameTimecode(timecode='00:00:00.5', fps=10).get_frames(), 5 + assert FrameTimecode(timecode='00:00:01', fps=10).get_frames(), 10 + assert FrameTimecode(timecode='00:01:00.000', fps=1).get_frames(), 60 def test_get_seconds(): - """Test FrameTimecode get_seconds() method.""" + ''' Test FrameTimecode get_seconds() method. ''' assert FrameTimecode(timecode=1, fps=1.0).get_seconds(), pytest.approx(1.0 / 1.0) - assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx( - 1000 / 60.0 - ) - assert FrameTimecode(timecode=1000000000, fps=29.97).get_seconds(), pytest.approx( - 1000000000 / 29.97 - ) + assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx(1000 / 60.0) + assert FrameTimecode( + timecode=1000000000, fps=29.97).get_seconds(), pytest.approx(1000000000 / 29.97) assert FrameTimecode(timecode=1.0, fps=1.0).get_seconds(), pytest.approx(1.0) assert FrameTimecode(timecode=1000.0, fps=60.0).get_seconds(), pytest.approx(1000.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx( - 1000000000.0 - ) - - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_seconds(), pytest.approx( - 2.0 - ) - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_seconds(), pytest.approx( - 0.5 - ) - assert FrameTimecode(timecode="00:00:01", fps=10).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_seconds(), pytest.approx( - 60.0 - ) + assert FrameTimecode( + timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx(1000000000.0) + + assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_seconds(), pytest.approx(2.0) + assert FrameTimecode(timecode='00:00:00.5', fps=10).get_seconds(), pytest.approx(0.5) + assert FrameTimecode(timecode='00:00:01', fps=10).get_seconds(), pytest.approx(1.0) + assert FrameTimecode(timecode='00:01:00.000', fps=1).get_seconds(), pytest.approx(60.0) def test_get_timecode(): - """Test FrameTimecode get_timecode() method.""" - assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == "00:00:01.000" - assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117" - assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234" - - assert ( - FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" - ) - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" - assert ( - FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.500" - ) - assert ( - FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" - ) + ''' Test FrameTimecode get_timecode() method. ''' + assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == '00:00:01.000' + assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == '00:01:00.117' + assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == '01:00:00.234' + + assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_timecode() == '00:00:02.000' + assert FrameTimecode(timecode='00:00:00.5', fps=10).get_timecode() == '00:00:00.500' + assert FrameTimecode(timecode='00:00:01.501', fps=10).get_timecode() == '00:00:01.500' + assert FrameTimecode(timecode='00:01:00.000', fps=1).get_timecode() == '00:01:00.000' def test_equality(): - """Test FrameTimecode equality (==, __eq__) operator.""" + ''' Test FrameTimecode equality (==, __eq__) operator. ''' x = FrameTimecode(timecode=1.0, fps=10.0) assert x == x assert x == FrameTimecode(timecode=1.0, fps=10.0) @@ -221,19 +203,19 @@ def test_equality(): assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) - assert x == "00:00:01" - assert x == "00:00:01.0" - assert x == "00:00:01.00" - assert x == "00:00:01.000" - assert x == "00:00:01.0000" - assert x == "00:00:01.00000" + assert x == '00:00:01' + assert x == '00:00:01.0' + assert x == '00:00:01.00' + assert x == '00:00:01.000' + assert x == '00:00:01.0000' + assert x == '00:00:01.00000' assert x == 10 assert x == 1.0 with pytest.raises(ValueError): - x == "0x" + x == '0x' with pytest.raises(ValueError): - x == "x00:00:00.000" + x == 'x00:00:00.000' with pytest.raises(TypeError): x == [0] with pytest.raises(TypeError): @@ -243,31 +225,31 @@ def test_equality(): with pytest.raises(TypeError): x == {0: 0} - assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.501" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.502" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.508" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.509" - assert FrameTimecode(timecode="00:00:01.519", fps=10) == "00:00:01.510" + assert FrameTimecode(timecode='00:00:00.5', fps=10) == '00:00:00.500' + assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.500' + assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.501' + assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.502' + assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.508' + assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.509' + assert FrameTimecode(timecode='00:00:01.519', fps=10) == '00:00:01.510' def test_addition(): - """Test FrameTimecode addition (+/+=, __add__/__iadd__) operator.""" + ''' Test FrameTimecode addition (+/+=, __add__/__iadd__) operator. ''' x = FrameTimecode(timecode=1.0, fps=10.0) assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) assert x + 1 == FrameTimecode(1.1, x) assert x + 10 == 20 assert x + 10 == 2.0 - assert x + 10 == "00:00:02.000" + assert x + 10 == '00:00:02.000' with pytest.raises(TypeError): - FrameTimecode("00:00:02.000", fps=20.0) == x + 10 + FrameTimecode('00:00:02.000', fps=20.0) == x + 10 def test_subtraction(): - """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" + ''' Test FrameTimecode subtraction (-/-=, __sub__) operator. ''' x = FrameTimecode(timecode=1.0, fps=10.0) assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) assert x - 2 == FrameTimecode(0.8, x) @@ -282,14 +264,12 @@ def test_subtraction(): assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) with pytest.raises(TypeError): - FrameTimecode("00:00:02.000", fps=20.0) == x - 10 + FrameTimecode('00:00:02.000', fps=20.0) == x - 10 -@pytest.mark.parametrize( - "frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)] -) +@pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) def test_identity(frame_num, fps): - """Test FrameTimecode values, when used in init return the same values""" + ''' Test FrameTimecode values, when used in init return the same values ''' frame_time_code = FrameTimecode(frame_num, fps=fps) assert FrameTimecode(frame_time_code) == frame_time_code assert FrameTimecode(frame_time_code.get_frames(), fps=fps) == frame_time_code @@ -302,52 +282,16 @@ def test_precision(): fps = 1000.0 - assert ( - FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) - == "00:00:00.11" - ) - assert ( - FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) - == "00:00:00.11" - ) - assert ( - FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) - == "00:00:00.1" - ) - assert ( - FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) - == "00:00:00.1" - ) - assert ( - FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) - == "00:00:00" - ) - assert ( - FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) - == "00:00:00" - ) - - assert ( - FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) - == "00:00:00.99" - ) - assert ( - FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) - == "00:00:00.99" - ) - assert ( - FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) - == "00:00:01.0" - ) - assert ( - FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) - == "00:00:00.9" - ) - assert ( - FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) - == "00:00:01" - ) - assert ( - FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) - == "00:00:00" - ) + assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.11" + assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.11" + assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) == "00:00:00.1" + assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.1" + assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) == "00:00:00" + assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + + assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.99" + assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.99" + assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) == "00:00:01.0" + assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" + assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" + assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" diff --git a/tests/test_platform.py b/tests/test_platform.py index 767aeecb..4f90ff1e 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect scenedetect.platform Tests +""" PySceneDetect scenedetect.platform Tests This file includes unit tests for the scenedetect.platform module, containing all platform/library/OS-specific compatibility fixes. @@ -23,18 +23,18 @@ def test_invoke_command(): - """Ensures the function exists and is callable without throwing - an exception.""" - if platform.system() == "Windows": - invoke_command(["cmd"]) + """ Ensures the function exists and is callable without throwing + an exception. """ + if platform.system() == 'Windows': + invoke_command(['cmd']) else: - invoke_command(["echo"]) + invoke_command(['echo']) def test_long_command(): - """[Windows Only] Ensures that a command string too large to be handled + """ [Windows Only] Ensures that a command string too large to be handled is translated to the correct exception for error handling. """ - if platform.system() == "Windows": + if platform.system() == 'Windows': with pytest.raises(CommandTooLong): - invoke_command("x" * 2**15) + invoke_command('x' * 2**15) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index a6f0234f..c974398d 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -38,8 +38,8 @@ def test_scene_list(test_video_file): sm.add_detector(ContentDetector()) video_fps = video.frame_rate - start_time = FrameTimecode("00:00:05", video_fps) - end_time = FrameTimecode("00:00:15", video_fps) + start_time = FrameTimecode('00:00:05', video_fps) + end_time = FrameTimecode('00:00:15', video_fps) assert end_time.get_frames() > start_time.get_frames() @@ -93,27 +93,22 @@ def test_save_images(test_video_file): sm = SceneManager() sm.add_detector(ContentDetector()) - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = ( - "scenedetect.tempfile." - "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." - "$TIMESTAMP_MS.$TIMECODE" - ) + image_name_glob = 'scenedetect.tempfile.*.jpg' + image_name_template = ('scenedetect.tempfile.' + '$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.' + '$TIMESTAMP_MS.$TIMECODE') try: video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)] - ] + scene_list = [(FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)]] image_filenames = save_images( scene_list=scene_list, video=video, num_images=3, - image_extension="jpg", - image_name_template=image_name_template, - ) + image_extension='jpg', + image_name_template=image_name_template) # Ensure images got created, and the proper number got created. total_images = 0 @@ -133,26 +128,21 @@ def test_save_images(test_video_file): def test_save_images_zero_width_scene(test_video_file): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" video = VideoStreamCv2(test_video_file) - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" + image_name_glob = 'scenedetect.tempfile.*.jpg' + image_name_template = 'scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER' try: video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)] - ] + scene_list = [(FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)]] NUM_IMAGES = 10 image_filenames = save_images( scene_list=scene_list, video=video, num_images=10, - image_extension="jpg", - image_name_template=image_name_template, - ) + image_extension='jpg', + image_name_template=image_name_template) assert len(image_filenames) == 3 - assert all( - len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames - ) + assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) total_images = 0 for scene_number in image_filenames: for path in image_filenames[scene_number]: @@ -205,14 +195,13 @@ def test_detect_scenes_callback(test_video_file): fake_callback = FakeCallback() video_fps = video.frame_rate - start_time = FrameTimecode("00:00:05", video_fps) - end_time = FrameTimecode("00:00:15", video_fps) + start_time = FrameTimecode('00:00:05', video_fps) + end_time = FrameTimecode('00:00:15', video_fps) video.seek(start_time) sm.auto_downscale = True _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() - ) + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda()) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -223,9 +212,7 @@ def test_detect_scenes_callback(test_video_file): fake_callback = FakeCallback() video.seek(start_time) - _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_func() - ) + _ = sm.detect_scenes(video=video, end_time=end_time, callback=fake_callback.get_callback_func()) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -244,14 +231,13 @@ def test_detect_scenes_callback_adaptive(test_video_file): fake_callback = FakeCallback() video_fps = video.frame_rate - start_time = FrameTimecode("00:00:05", video_fps) - end_time = FrameTimecode("00:00:15", video_fps) + start_time = FrameTimecode('00:00:05', video_fps) + end_time = FrameTimecode('00:00:15', video_fps) video.seek(start_time) sm.auto_downscale = True _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() - ) + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda()) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -262,9 +248,7 @@ def test_detect_scenes_callback_adaptive(test_video_file): fake_callback = FakeCallback() video.seek(start_time) - _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_func() - ) + _ = sm.detect_scenes(video=video, end_time=end_time, callback=fake_callback.get_callback_func()) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 91bcadb3..9c2f0af6 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect scenedetect.stats_manager Tests +""" PySceneDetect scenedetect.stats_manager Tests This file includes unit tests for the scenedetect.stats_manager module (specifically, the StatsManager object, used to coordinate caching of frame metrics to/from a CSV @@ -27,7 +27,7 @@ These files will be deleted, if possible, after the tests are completed running. """ -# pylint: disable=protected-access +#pylint: disable=protected-access import csv import os @@ -47,25 +47,24 @@ from scenedetect.stats_manager import COLUMN_NAME_TIMECODE # TODO(v1.0): use https://docs.pytest.org/en/6.2.x/tmpdir.html -TEST_STATS_FILES = ["TEST_STATS_FILE"] * 4 +TEST_STATS_FILES = ['TEST_STATS_FILE'] * 4 TEST_STATS_FILES = [ - "%s_%012d.csv" % (stats_file, random.randint(0, 10**12)) - for stats_file in TEST_STATS_FILES + '%s_%012d.csv' % (stats_file, random.randint(0, 10**12)) for stats_file in TEST_STATS_FILES ] def teardown_module(): - """Removes any created stats files, if any.""" + """ Removes any created stats files, if any. """ for stats_file in TEST_STATS_FILES: if os.path.exists(stats_file): os.remove(stats_file) def test_metrics(): - """Test StatsManager metric registration/setting/getting with a set of pre-defined + """ Test StatsManager metric registration/setting/getting with a set of pre-defined key-value pairs (metric_dict). """ - metric_dict = {"some_metric": 1.2345, "another_metric": 6.7890} + metric_dict = {'some_metric': 1.2345, 'another_metric': 6.7890} metric_keys = list(metric_dict.keys()) stats = StatsManager() @@ -86,13 +85,12 @@ def test_metrics(): assert stats.metrics_exist(frame_key, metric_keys) assert stats.metrics_exist(frame_key, metric_keys[1:]) - assert stats.get_metrics(frame_key, metric_keys) == [ - metric_dict[metric_key] for metric_key in metric_keys - ] + assert stats.get_metrics( + frame_key, metric_keys) == [metric_dict[metric_key] for metric_key in metric_keys] def test_detector_metrics(test_video_file): - """Test passing StatsManager to a SceneManager and using it for storing the frame metrics + """ Test passing StatsManager to a SceneManager and using it for storing the frame metrics from a ContentDetector. """ video = VideoStreamCv2(test_video_file) @@ -100,7 +98,7 @@ def test_detector_metrics(test_video_file): scene_manager = SceneManager(stats_manager) scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode("00:00:05", video_fps) + duration = FrameTimecode('00:00:05', video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video=video, duration=duration) # Check that metrics were written to the StatsManager. @@ -108,8 +106,8 @@ def test_detector_metrics(test_video_file): def test_load_empty_stats(): - """Test loading an empty stats file, ensuring it results in no errors.""" - open(TEST_STATS_FILES[0], "w").close() + """ Test loading an empty stats file, ensuring it results in no errors. """ + open(TEST_STATS_FILES[0], 'w').close() stats_manager = StatsManager() stats_manager.load_from_csv(TEST_STATS_FILES[0]) @@ -121,41 +119,36 @@ def test_save_no_detect_scenes(): def test_load_hardcoded_file(): - """Test loading a stats file with some hard-coded data generated by this test case.""" + """ Test loading a stats file with some hard-coded data generated by this test case. """ stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], "w") as stats_file: - stats_writer = csv.writer(stats_file, lineterminator="\n") + with open(TEST_STATS_FILES[0], 'w') as stats_file: - some_metric_key = "some_metric" + stats_writer = csv.writer(stats_file, lineterminator='\n') + + some_metric_key = 'some_metric' some_metric_value = 1.2 some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) some_frame_timecode = base_timecode + some_frame_key # Write out a valid file. + stats_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) stats_writer.writerow( - [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key] - ) - stats_writer.writerow( - [ - some_frame_key + 1, - some_frame_timecode.get_timecode(), - str(some_metric_value), - ] - ) + [some_frame_key + 1, + some_frame_timecode.get_timecode(), + str(some_metric_value)]) stats_manager.load_from_csv(TEST_STATS_FILES[0]) # Check that we decoded the correct values. assert stats_manager.metrics_exist(some_frame_key, [some_metric_key]) - assert stats_manager.get_metrics(some_frame_key, [some_metric_key])[ - 0 - ] == pytest.approx(some_metric_value) + assert stats_manager.get_metrics(some_frame_key, + [some_metric_key])[0] == pytest.approx(some_metric_value) def test_save_load_from_video(test_video_file): - """Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and + """ Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and loading the file back to ensure the loaded frame metrics agree with those that were saved. """ video = VideoStreamCv2(test_video_file) @@ -165,7 +158,7 @@ def test_save_load_from_video(test_video_file): scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode("00:00:05", video_fps) + duration = FrameTimecode('00:00:05', video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video, duration=duration) @@ -188,14 +181,14 @@ def test_save_load_from_video(test_video_file): def test_load_corrupt_stats(): - """Test loading a corrupted stats file created by outputting data in the wrong format.""" + """ Test loading a corrupted stats file created by outputting data in the wrong format. """ stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], "wt") as stats_file: - stats_writer = csv.writer(stats_file, lineterminator="\n") + with open(TEST_STATS_FILES[0], 'wt') as stats_file: + stats_writer = csv.writer(stats_file, lineterminator='\n') - some_metric_key = "some_metric" + some_metric_key = 'some_metric' some_metric_value = str(1.2) some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) @@ -205,12 +198,9 @@ def test_load_corrupt_stats(): # File #0: Wrong Header Names [StatsFileCorrupt] # Swapped timecode & frame number. + stats_writer.writerow([COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key]) stats_writer.writerow( - [COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key] - ) - stats_writer.writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value] - ) + [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) stats_file.close() diff --git a/tests/test_video_splitter.py b/tests/test_video_splitter.py index f13008f2..2cd77cbb 100644 --- a/tests/test_video_splitter.py +++ b/tests/test_video_splitter.py @@ -18,17 +18,11 @@ import pytest from scenedetect import open_video -from scenedetect.video_splitter import ( - split_video_ffmpeg, - is_ffmpeg_available, - SceneMetadata, - VideoMetadata, -) +from scenedetect.video_splitter import (split_video_ffmpeg, is_ffmpeg_available, SceneMetadata, + VideoMetadata) -@pytest.mark.skipif( - condition=not is_ffmpeg_available(), reason="ffmpeg is not available" -) +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): video = open_video(test_movie_clip) # Extract three hard-coded scenes for testing, each 60 frames. @@ -41,12 +35,10 @@ def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) - assert len(entries) == len(scenes) + assert (len(entries) == len(scenes)) -@pytest.mark.skipif( - condition=not is_ffmpeg_available(), reason="ffmpeg is not available" -) +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): video = open_video(test_movie_clip) # Extract three hard-coded scenes for testing, each 60 frames. @@ -60,13 +52,10 @@ def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): def name_formatter(video: VideoMetadata, scene: SceneMetadata): return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" - assert ( - split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) - == 0 - ) + assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) == 0 video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) - assert len(entries) == len(scenes) + assert (len(entries) == len(scenes)) # TODO: Add tests for `split_video_mkvmerge`. diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index a139a0ab..7e952881 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -10,7 +10,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""PySceneDetect scenedetect.video_stream Tests +""" PySceneDetect scenedetect.video_stream Tests This file includes unit tests for the scenedetect.video_stream module, as well as the video backends implemented in scenedetect.backends. These tests enforce a consistent interface across @@ -48,7 +48,7 @@ def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: if roi: - assert False # TODO + assert False # TODO assert frame_a.shape == frame_b.shape num_pixels = frame_a.shape[0] * frame_a.shape[1] return numpy.sum(numpy.abs(frame_b - frame_a)) / num_pixels @@ -56,30 +56,26 @@ def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: - """Returns the absolute path to a (relative) path of a file that + """ Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError( - """ + raise FileNotFoundError(""" Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" - % relative_path - ) +""" % relative_path) return abs_path @dataclass class VideoParameters: """Properties for each input a VideoStream is tested against.""" - path: str height: int width: int @@ -124,17 +120,12 @@ def get_test_video_params() -> List[VideoParameters]: pytest.mark.parametrize( "vs_type", list( - filter( - lambda x: x is not None, - [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - VideoManager, - ], - ) - ), - ), + filter(lambda x: x is not None, [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + VideoManager, + ]))), pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), ] @@ -147,16 +138,13 @@ def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameter """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) - assert stream.frame_rate == pytest.approx( - test_video.frame_rate, FRAMERATE_TOLERANCE - ) + assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) assert stream.duration.get_frames() == test_video.total_frames file_name = os.path.basename(test_video.path) - last_dot_pos = file_name.rfind(".") + last_dot_pos = file_name.rfind('.') assert stream.name == file_name[:last_dot_pos] - assert stream.aspect_ratio == pytest.approx( - test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE - ) + assert stream.aspect_ratio == pytest.approx(test_video.aspect_ratio, + PIXEL_ASPECT_RATIO_TOLERANCE) def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" @@ -166,9 +154,7 @@ def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_advance( - self, vs_type: Type[VideoStream], test_video: VideoParameters - ): + def test_read_no_advance(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `advance` set to False.""" stream = vs_type(test_video.path) frame = stream.read().copy() @@ -177,9 +163,7 @@ def test_read_no_advance( assert stream.frame_number == 1 assert calculate_frame_delta(frame, frame_copy) == pytest.approx(0.0) - def test_read_no_decode( - self, vs_type: Type[VideoStream], test_video: VideoParameters - ): + def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True @@ -187,9 +171,7 @@ def test_read_no_decode( stream.read(decode=False, advance=False) assert stream.frame_number == 1 - def test_time_invariants( - self, vs_type: Type[VideoStream], test_video: VideoParameters - ): + def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -209,8 +191,7 @@ def test_time_invariants( assert stream.frame_number == i assert stream.position == stream.base_timecode + (i - 1) assert stream.position_ms == pytest.approx( - 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS - ) + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS) def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" @@ -233,14 +214,12 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.frame_number == 200 assert stream.position == stream.base_timecode + 199 assert stream.position_ms == pytest.approx( - 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS - ) + 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS) stream.read() assert stream.frame_number == 201 assert stream.position == stream.base_timecode + 200 assert stream.position_ms == pytest.approx( - 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS - ) + 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS) # Seek to a time in seconds (float). stream.seek(2.0) @@ -249,14 +228,11 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): # starts counting from zero. This should eventually be changed. assert stream.position == (stream.base_timecode + 2.0) - 1 assert stream.position_ms == pytest.approx( - 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate - ) + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate) stream.read() assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) assert stream.position == stream.base_timecode + 2.0 - assert stream.position_ms == pytest.approx( - 2000.0, abs=1000.0 / stream.frame_rate - ) + assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) # Seek to a FrameTimecode. stream.seek(stream.base_timecode + 2.0) @@ -265,14 +241,11 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): # starts counting from zero. This should eventually be changed. assert stream.position == (stream.base_timecode + 2.0) - 1 assert stream.position_ms == pytest.approx( - 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate - ) + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate) stream.read() assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) assert stream.position == stream.base_timecode + 2.0 - assert stream.position_ms == pytest.approx( - 2000.0, abs=1000.0 / stream.frame_rate - ) + assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" @@ -292,8 +265,7 @@ def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameter assert stream.frame_number == i assert stream.position == stream.base_timecode + (i - 1) assert stream.position_ms == pytest.approx( - 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS - ) + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS) stream.seek(0) assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -318,21 +290,14 @@ def test_read_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters) pass # TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs. if vs_type == VideoStreamAv: - assert stream.frame_number in ( - test_video.total_frames, - test_video.total_frames - 1, - ) + assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1) else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof( - self, vs_type: Type[VideoStream], test_video: VideoParameters - ): + def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate calling `seek()` to offset past end of video.""" if vs_type == VideoManager: - pytest.skip( - reason="VideoManager does not have compliant end-of-video seek behaviour." - ) + pytest.skip(reason='VideoManager does not have compliant end-of-video seek behaviour.') stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit # frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF, @@ -347,16 +312,11 @@ def test_seek_past_eof( assert stream.read(advance=False) is not False # TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs. if vs_type == VideoStreamAv: - assert stream.frame_number in ( - test_video.total_frames, - test_video.total_frames - 1, - ) + assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1) else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid( - self, vs_type: Type[VideoStream], test_video: VideoParameters - ): + def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -375,13 +335,13 @@ def test_seek_invalid( def test_invalid_path(vs_type: Type[VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): - _ = vs_type("this_path_should_not_exist.mp4") + _ = vs_type('this_path_should_not_exist.mp4') def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoManager: - pytest.skip(reason="VideoManager does not support handling corrupt videos.") + pytest.skip(reason='VideoManager does not support handling corrupt videos.') stream = vs_type(corrupt_video_file) From 0f2eddf5e8b100549331258159b16133a49d8bd1 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sat, 7 Sep 2024 21:22:41 -0400 Subject: [PATCH 123/407] Migrate to main branch development (#419) * [cli] Fix SyntaxWarning due to incorrect escaping #400 * [cli] Fix exception when detect-hash is set as default detector * [cli] Fix new detectors not working with default-detector * [cli] Fix outstanding CodeQL lint warnings. * [cli] Unify type hints and clean up imports * add detect-hash and detect-hist as options for default-detector (#403) * Bump jinja2 from 3.1.3 to 3.1.4 in /website (#397) Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.3...3.1.4) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [dist] Fix Github license detection. * [dist] Use Github license template. Fixes #365. * [docs] Add CITATION.cff #399 * [dist] Prepare for v0.6.4 release. * [build] Auto-generate .version_info and verify installer version. * [build] Add missing pre-release script invocation for Windows build on Github. * [build] Fix incorrect path to pre_release script. * [build] Omit unnecessary files in distributed docs. * [dist] Update Windows installer for v0.6.4. Bump OpenCV to 4.10. * [build] Use specific OpenCV version for Windows build. * [dist] Release v0.6.4. * [docs] Update changelog and image URI. * add detect-hash and detect-hist as options for default-detector --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Breakthrough * [dist] Prepare changelog for next release. * [project] Switch from yapf to ruff for formatting * [project] Use ruff for linting project Now passes `ruff check` with some fixes suppressed. * [project] Enable more lint rules. * [docs] Change single quotes to double quotes. * Transition from yapf to ruff (#418) * [project] Enable more lint rules. * Bump actions/download-artifact from 3 to 4.1.7 in /.github/workflows in the github_actions group across 1 directory (#417) Bump actions/download-artifact Bumps the github_actions group with 1 update in the /.github/workflows directory: [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/download-artifact` from 3 to 4.1.7 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v3...v4.1.7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [build] Fix incorrect version conversion for Pyinstaller build * [build] Update workflow actions. * [build] Update workflow actions. * Revert "[build] Update workflow actions." Mistaken merge commit. This reverts commit c23eee83b17d0b51e4e55dad852abcf0441d4b91. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: moritzbrantner <31051084+moritzbrantner@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .style.yapf | 6 - dist/pre_release.py | 29 +- docs/api.rst | 10 +- docs/conf.py | 113 +- docs/generate_cli_docs.py | 152 +-- pyproject.toml | 51 +- scenedetect/__init__.py | 44 +- scenedetect/__main__.py | 25 +- scenedetect/_cli/__init__.py | 1028 ++++++++++--------- scenedetect/_cli/config.py | 187 ++-- scenedetect/_cli/context.py | 677 ++++++------ scenedetect/_cli/controller.py | 196 ++-- scenedetect/_thirdparty/__init__.py | 1 - scenedetect/_thirdparty/simpletable.py | 46 +- scenedetect/backends/__init__.py | 17 +- scenedetect/backends/moviepy.py | 28 +- scenedetect/backends/opencv.py | 99 +- scenedetect/backends/pyav.py | 59 +- scenedetect/detectors/__init__.py | 3 +- scenedetect/detectors/adaptive_detector.py | 27 +- scenedetect/detectors/content_detector.py | 32 +- scenedetect/detectors/hash_detector.py | 14 +- scenedetect/detectors/histogram_detector.py | 22 +- scenedetect/detectors/threshold_detector.py | 71 +- scenedetect/frame_timecode.py | 133 +-- scenedetect/platform.py | 112 +- scenedetect/scene_detector.py | 30 +- scenedetect/scene_manager.py | 420 ++++---- scenedetect/stats_manager.py | 75 +- scenedetect/video_manager.py | 218 ++-- scenedetect/video_splitter.py | 147 +-- scenedetect/video_stream.py | 16 +- setup.py | 3 +- tests/__init__.py | 3 +- tests/conftest.py | 22 +- tests/test_api.py | 44 +- tests/test_backend_opencv.py | 5 +- tests/test_backend_pyav.py | 15 +- tests/test_backwards_compat.py | 44 +- tests/test_cli.py | 367 ++++--- tests/test_detectors.py | 69 +- tests/test_frame_timecode.py | 194 ++-- tests/test_platform.py | 20 +- tests/test_scene_manager.py | 63 +- tests/test_stats_manager.py | 79 +- tests/test_video_splitter.py | 16 +- tests/test_video_stream.py | 72 +- website/pages/changelog.md | 10 + website/pages/contributing.md | 4 +- 49 files changed, 2835 insertions(+), 2283 deletions(-) delete mode 100644 .style.yapf diff --git a/.style.yapf b/.style.yapf deleted file mode 100644 index 9f089c5b..00000000 --- a/.style.yapf +++ /dev/null @@ -1,6 +0,0 @@ -[style] -based_on_style = yapf -spaces_before_comment = 15, 20 -indent_width = 4 -split_before_logical_operator = true -column_limit = 100 diff --git a/dist/pre_release.py b/dist/pre_release.py index c40751e1..11d00154 100644 --- a/dist/pre_release.py +++ b/dist/pre_release.py @@ -4,9 +4,13 @@ sys.path.append(os.path.abspath(".")) import scenedetect + + VERSION = scenedetect.__version__ -if len(sys.argv) <= 2 or not ("--ignore-installer" in sys.argv): +run_version_check = ("--ignore-installer" not in sys.argv) + +if run_version_check: installer_aip = '' with open("dist/installer/PySceneDetect.aip", "r") as f: installer_aip = f.read() @@ -16,12 +20,19 @@ with open("dist/.version_info", "wb") as f: v = VERSION.split(".") assert 2 <= len(v) <= 3, f"Unrecognized version format: {VERSION}" - - if len(v) == 3: - (maj, min, pat) = int(v[0]), int(v[1]), int(v[2]) - else: - (maj, min, pat) = int(v[0]), int(v[1]), 0 - + if len(v) < 3: + v.append("0") + (maj, min, pat, bld) = v[0], v[1], v[2], 0 + # If either major or minor have suffixes, assume it's a dev/beta build and set + # the final component to 999. + if not min.isdigit(): + assert "-" in min + min = min[:min.find("-")] + bld = 999 + if not pat.isdigit(): + assert "-" in pat + pat = pat[:pat.find("-")] + bld = 999 f.write(f"""# UTF-8 # # For more details about fixed file info 'ffi' see: @@ -30,8 +41,8 @@ ffi=FixedFileInfo( # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # Set not needed items to zero 0. -filevers=(0, {maj}, {min}, {pat}), -prodvers=(0, {maj}, {min}, {pat}), +filevers=({maj}, {min}, {pat}, {bld}), +prodvers=({maj}, {min}, {pat}, {bld}), # Contains a bitmask that specifies the valid bits 'flags'r mask=0x3f, # Contains a bitmask that specifies the Boolean attributes of the file. diff --git a/docs/api.rst b/docs/api.rst index 7271b42c..ab34f97a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -61,7 +61,7 @@ To get started, the :func:`scenedetect.detect` function takes a path to a video .. code:: python from scenedetect import detect, ContentDetector - scene_list = detect('my_video.mp4', ContentDetector()) + scene_list = detect("my_video.mp4", ContentDetector()) ``scene_list`` is now a list of :class:`FrameTimecode ` pairs representing the start/end of each scene (try calling ``print(scene_list)``). Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. @@ -70,7 +70,7 @@ Next, let's print the scene list in a more readable format by iterating over it: .. code:: python for i, scene in enumerate(scene_list): - print('Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( + print("Scene %2d: Start %s / Frame %d, End %s / Frame %d" % ( i+1, scene[0].get_timecode(), scene[0].get_frames(), scene[1].get_timecode(), scene[1].get_frames(),)) @@ -80,8 +80,8 @@ Now that we know where each scene is, we can also :ref:`split the input video ` with ``show_stdout=True`` or specify a log file (verbosity can also be specified) to attach some common handlers, or use ``logging.getLogger('pyscenedetect')`` and attach log handlers manually. +PySceneDetect outputs messages to a logger named ``pyscenedetect`` which does not have any default handlers. You can use :func:`scenedetect.init_logger ` with ``show_stdout=True`` or specify a log file (verbosity can also be specified) to attach some common handlers, or use ``logging.getLogger("pyscenedetect")`` and attach log handlers manually. ======================================================================= diff --git a/docs/conf.py b/docs/conf.py index 0cb4f243..60ad6188 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # @@ -15,15 +14,15 @@ import os import sys -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) from scenedetect import __version__ as scenedetect_version # -- Project information ----------------------------------------------------- -project = 'PySceneDetect' -copyright = '2014-2024, Brandon Castellano' -author = 'Brandon Castellano' +project = "PySceneDetect" +copyright = "2014-2024, Brandon Castellano" +author = "Brandon Castellano" # The short X.Y version version = scenedetect_version @@ -36,49 +35,49 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.autodoc', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", ] autoclass_content = "both" autodoc_member_order = "groupwise" -autodoc_typehints = 'description' -autodoc_typehints_format = 'short' +autodoc_typehints = "description" +autodoc_typehints_format = "short" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The root toctree document. -root_doc = 'index' +root_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = 'en' +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path . -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] -html_css_files = ['pyscenedetect.css'] +html_static_path = ["_static"] +html_css_files = ["pyscenedetect.css"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -93,40 +92,37 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'PySceneDetectdoc' +htmlhelp_basename = "PySceneDetectdoc" # -- Options for LaTeX output ------------------------------------------------ latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (root_doc, 'PySceneDetect.tex', 'PySceneDetect Documentation', 'Brandon Castellano', 'manual'), + (root_doc, "PySceneDetect.tex", "PySceneDetect Documentation", "Brandon Castellano", "manual"), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(root_doc, 'pyscenedetect', 'PySceneDetect Documentation', [author], 1)] +man_pages = [(root_doc, "pyscenedetect", "PySceneDetect Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -134,31 +130,38 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (root_doc, 'PySceneDetect', 'PySceneDetect Documentation', author, 'PySceneDetect', - 'Python API and `scenedetect` command reference.', 'Miscellaneous'), + ( + root_doc, + "PySceneDetect", + "PySceneDetect Documentation", + author, + "PySceneDetect", + "Python API and `scenedetect` command reference.", + "Miscellaneous", + ), ] # -- Theme ------------------------------------------------- # TODO: Consider switching to sphinx_material. -html_theme = 'alabaster' +html_theme = "alabaster" html_theme_options = { - 'sidebar_width': '235px', - 'description': 'Version: [%s]' % (release), - 'show_relbar_bottom': True, - 'show_relbar_top': False, - 'github_user': 'Breakthrough', - 'github_repo': 'PySceneDetect', - 'github_type': 'star', - 'tip_bg': '#f0f6fa', - 'tip_border': '#c2dcf2', - 'hint_bg': '#f0faf0', - 'hint_border': '#d3ebdc', - 'warn_bg': '#f5ebd0', - 'warn_border': '#f2caa2', - 'attention_bg': '#f5dcdc', - 'attention_border': '#ffaaaa', - 'logo': 'pyscenedetect_logo.png', - 'logo_name': False, + "sidebar_width": "235px", + "description": "Version: [%s]" % (release), + "show_relbar_bottom": True, + "show_relbar_top": False, + "github_user": "Breakthrough", + "github_repo": "PySceneDetect", + "github_type": "star", + "tip_bg": "#f0f6fa", + "tip_border": "#c2dcf2", + "hint_bg": "#f0faf0", + "hint_border": "#d3ebdc", + "warn_bg": "#f5ebd0", + "warn_border": "#f2caa2", + "attention_bg": "#f5dcdc", + "attention_border": "#ffaaaa", + "logo": "pyscenedetect_logo.png", + "logo_name": False, } diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index cd5c6f6f..f2c85c5d 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Generate formatted CLI documentation for PySceneDetect. # # Inspired by sphinx-click: https://github.com/click-contrib/sphinx-click @@ -10,40 +9,39 @@ Run from main repo folder as working directory.""" +import inspect import os +import re import sys -import inspect import typing as ty -import re from dataclasses import dataclass # Add parent folder to path so we can resolve `scenedetect` imports. currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parentdir = os.path.dirname(currentdir) sys.path.insert(0, parentdir) -from scenedetect._cli import scenedetect - # Third-party imports import click +from scenedetect._cli import scenedetect + StrGenerator = ty.Generator[str, None, None] -INDENT = ' ' * 4 +INDENT = " " * 4 -PAGE_SEP = '*' * 72 -TITLE_SEP = '=' * 72 -HEADING_SEP = '-' * 72 +PAGE_SEP = "*" * 72 +TITLE_SEP = "=" * 72 +HEADING_SEP = "-" * 72 OPTION_HELP_OVERRIDES = { - 'scenedetect': { - 'config': - 'Path to config file. See :ref:`config file reference ` for details.' + "scenedetect": { + "config": "Path to config file. See :ref:`config file reference ` for details." }, } -TITLE_LEVELS = ['*', '=', '-'] +TITLE_LEVELS = ["*", "=", "-"] -INFO_COMMANDS = ['help', 'about', 'version'] +INFO_COMMANDS = ["help", "about", "version"] INFO_COMMAND_OVERRIDE = """ .. _command-help: @@ -73,26 +71,28 @@ def patch_help(s: str, commands: ty.List[str]) -> str: # Patch some TODOs still not handled correctly below. pos = 0 while True: - pos = s.find('global option :option:', pos) + pos = s.find("global option :option:", pos) if pos < 0: break - pos = s.find('<-', pos) + pos = s.find("<-", pos) assert pos > 0 - s = s[:pos + 1] + 'scenedetect ' + s[pos + 1:] + s = s[: pos + 1] + "scenedetect " + s[pos + 1 :] + + for command in [command for command in commands if command not in INFO_COMMANDS]: - for command in [command for command in commands if not command in INFO_COMMANDS]: def add_link(_match: re.Match) -> str: - return ':ref:`%s `' % (command, command) - s = re.sub('``%s``(?!\\n)' % command, add_link, s) + return ":ref:`%s `" % (command, command) + + s = re.sub("``%s``(?!\\n)" % command, add_link, s) return s def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator: - yield '\n' + yield "\n" if level == 0: - yield TITLE_LEVELS[level] * len + '\n' - yield s + '\n' - yield TITLE_LEVELS[level] * len + '\n\n' + yield TITLE_LEVELS[level] * len + "\n" + yield s + "\n" + yield TITLE_LEVELS[level] * len + "\n\n" @dataclass @@ -103,11 +103,11 @@ class ReplaceWithReference: def transform_backquotes(s: str) -> str: - return s.replace('``', '`').replace('`', '``') + return s.replace("``", "`").replace("`", "``") def add_backquotes(match: re.Match) -> str: - return '``%s``' % match.string[match.start():match.end()] + return "``%s``" % match.string[match.start() : match.end()] def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: @@ -115,13 +115,13 @@ def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: references to any found options.""" def _add_backquotes(s: re.Match) -> str: - to_add: str = s.string[s.start():s.end()] - flag = re.search('-+[\w-]+[^\.\=\s\/]*', to_add) - if flag is not None and flag.string[flag.start():flag.end()] in refs: + to_add: str = s.string[s.start() : s.end()] + flag = re.search("-+[\w-]+[^\.\=\s\/]*", to_add) + if flag is not None and flag.string[flag.start() : flag.end()] in refs: # add cross reference - cross_ref = flag.string[flag.start():flag.end()] - option = s.string[s.start():s.end()] - return ':option:`%s <%s>`' % (option, cross_ref) + cross_ref = flag.string[flag.start() : flag.end()] + option = s.string[s.start() : s.end()] + return ":option:`%s <%s>`" % (option, cross_ref) else: return add_backquotes(s) @@ -129,13 +129,13 @@ def _add_backquotes(s: re.Match) -> str: def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: - default = re.search('\[default: .*\]', s) + default = re.search("\[default: .*\]", s) if default is not None: span = default.span() assert span[1] == len(s) - s, default = s[:span[0]].strip(), s[span[0]:span[1]][len('[default: '):-1] + s, default = s[: span[0]].strip(), s[span[0] : span[1]][len("[default: ") : -1] # Double-quote any default values that contain spaces. - if ' ' in default and not '"' in default and not ',' in default: + if " " in default and '"' not in default and "," not in default: default = '"%s"' % default return (s, default) @@ -145,57 +145,63 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. # -c/--command - s = re.sub('-\w/--\w[\w-]*', transform, s) + s = re.sub("-\w/--\w[\w-]*", transform, s) # --arg=value, --arg=1.2.3, --arg=1,2,3 s = re.sub('-+[\w-]+=[^"\s\)]+(? StrGenerator: if isinstance(opt, click.Argument): - yield '\n.. option:: %s\n' % opt.name + yield "\n.. option:: %s\n" % opt.name return - yield '\n.. option:: %s\n' % ', '.join(arg if opt.metavar is None else '%s %s' % - (arg, opt.metavar) - for arg in sorted(opt.opts, reverse=True)) + yield "\n.. option:: %s\n" % ", ".join( + arg if opt.metavar is None else "%s %s" % (arg, opt.metavar) + for arg in sorted(opt.opts, reverse=True) + ) - help = OPTION_HELP_OVERRIDES[command.name][ - opt.name] if command.name in OPTION_HELP_OVERRIDES and opt.name in OPTION_HELP_OVERRIDES[ - command.name] else opt.help.strip() + help = ( + OPTION_HELP_OVERRIDES[command.name][opt.name] + if command.name in OPTION_HELP_OVERRIDES and opt.name in OPTION_HELP_OVERRIDES[command.name] + else opt.help.strip() + ) # TODO: Make metavars link to the option as well. help, default = extract_default_value(help) help = transform_add_option_refs(help, flags) - yield '\n %s\n' % help + yield "\n %s\n" % help if default is not None: - yield '\n Default: ``%s``\n' % default + yield "\n Default: ``%s``\n" % default -def generate_command_help(ctx: click.Context, - command: click.Command, - parent_name: ty.Optional[str] = None) -> StrGenerator: +def generate_command_help( + ctx: click.Context, command: click.Command, parent_name: ty.Optional[str] = None +) -> StrGenerator: # TODO: Add references to long options. Requires splitting out examples. # TODO: Add references to subcommands. Need to add actual refs, since programs can't be ref'd. # TODO: Handle dollar signs in examples by having both escaped and unescaped versions - yield '\n.. _command-%s:\n' % command.name - yield '\n.. program:: %s\n\n' % ( - command.name if parent_name is None else '%s %s' % (parent_name, command.name)) + yield "\n.. _command-%s:\n" % command.name + yield "\n.. program:: %s\n\n" % ( + command.name if parent_name is None else "%s %s" % (parent_name, command.name) + ) if parent_name: - yield from generate_title('``%s``' % command.name, 1) + yield from generate_title("``%s``" % command.name, 1) replacements = [ - opt for opts in [param.opts for param in command.params if hasattr(param, 'opts')] + opt + for opts in [param.opts for param in command.params if hasattr(param, "opts")] for opt in opts ] help = command.help - help = help.replace('Examples:\n', - ''.join(generate_title('Examples', 0 if not parent_name else 2))) - help = help.replace('\b\n', '') - help = help.format(scenedetect='scenedetect', scenedetect_with_video='scenedetect -i video.mp4') + help = help.replace( + "Examples:\n", "".join(generate_title("Examples", 0 if not parent_name else 2)) + ) + help = help.replace("\b\n", "") + help = help.format(scenedetect="scenedetect", scenedetect_with_video="scenedetect -i video.mp4") help = transform_backquotes(help) help = transform_add_option_refs(help, replacements) @@ -203,20 +209,19 @@ def generate_command_help(ctx: click.Context, if line.startswith(INDENT): indent = line.count(INDENT) line = line.strip() - yield '%s``%s``\n' % (indent * INDENT, line) if line else '\n' + yield "%s``%s``\n" % (indent * INDENT, line) if line else "\n" else: - yield '%s\n' % line + yield "%s\n" % line if command.params: - yield '\n' - yield from generate_title('Options', 0 if not parent_name else 2) + yield "\n" + yield from generate_title("Options", 0 if not parent_name else 2) for param in command.params: yield from format_option(command, param, replacements) - yield '\n' + yield "\n" def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGenerator: - processed = set() for info_command in INFO_COMMANDS: @@ -224,16 +229,17 @@ def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGener processed.add(info_command) yield INFO_COMMAND_OVERRIDE - yield from generate_title('Detectors', 0) - detectors = [command for command in commands if command.startswith('detect-')] + yield from generate_title("Detectors", 0) + detectors = [command for command in commands if command.startswith("detect-")] for detector in detectors: yield from generate_command_help(ctx, ctx.command.get_command(ctx, detector), ctx.info_name) processed.add(detector) - yield from generate_title('Commands', 0) + yield from generate_title("Commands", 0) output_commands = [ - command for command in commands - if (not command.startswith('detect-') and not command in INFO_COMMANDS) + command + for command in commands + if (not command.startswith("detect-") and command not in INFO_COMMANDS) ] for command in output_commands: yield from generate_command_help(ctx, ctx.command.get_command(ctx, command), ctx.info_name) @@ -246,22 +252,22 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) - #ctx.to_info_dict lacks metavar so we have to use the context directly. + # ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ - generate_title('``scenedetect`` 🎬 Command', level=0), + generate_title("``scenedetect`` 🎬 Command", level=0), generate_command_help(ctx, ctx.command), generate_subcommands(ctx, commands), ] lines = [] for action in actions: lines.extend(action) - return ''.join(lines), commands + return "".join(lines), commands def main(): help, commands = create_help() help = patch_help(help, commands) - with open('docs/cli.rst', 'wb') as f: + with open("docs/cli.rst", "wb") as f: f.write(help.encode()) diff --git a/pyproject.toml b/pyproject.toml index ff343399..8186012b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,53 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://www.scenedetect.com/docs/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# -# TODO: Switch to poetry to try and fix dependents graph. Example: -# https://github.com/Textualize/rich/blob/master/pyproject.toml [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" + +[tool.ruff] +exclude = [ + "docs" +] +line-length = 100 +indent-width = 4 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # flake8-bugbear + "B", + # pycodestyle + "E", + # Pyflakes + "F", + # isort + "I", + # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): + # pyupgrade + #"UP", + # flake8-simplify + #"SIM", +] +ignore = [ + # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. + "F401", + # TODO: Line too long + "E501", + # TODO: Do not assign a `lambda` expression, use a `def` + "E731", +] +fixable = ["ALL"] +unfixable = [] diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 160bee61..544be977 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -27,36 +26,45 @@ except ModuleNotFoundError as ex: raise ModuleNotFoundError( "OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python", - name='cv2', + name="cv2", ) from ex # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. -from scenedetect.platform import init_logger +from scenedetect.platform import init_logger # noqa: I001 from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.scene_detector import SceneDetector -from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HistogramDetector, HashDetector -from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, - VideoStreamMoviePy, VideoCaptureAdapter) +from scenedetect.detectors import ( + ContentDetector, + AdaptiveDetector, + ThresholdDetector, + HistogramDetector, + HashDetector, +) +from scenedetect.backends import ( + AVAILABLE_BACKENDS, + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + VideoCaptureAdapter, +) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt from scenedetect.scene_manager import SceneManager, save_images - -# [DEPRECATED] DO NOT USE. -from scenedetect.video_manager import VideoManager +from scenedetect.video_manager import VideoManager # [DEPRECATED] DO NOT USE. # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.4' +__version__ = "0.6.5-dev1" init_logger() -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") def open_video( path: str, framerate: Optional[float] = None, - backend: str = 'opencv', + backend: str = "opencv", **kwargs, ) -> VideoStream: """Open a video at the given path. If `backend` is specified but not available on the current @@ -83,22 +91,22 @@ def open_video( if backend in AVAILABLE_BACKENDS: backend_type = AVAILABLE_BACKENDS[backend] try: - logger.debug('Opening video with %s...', backend_type.BACKEND_NAME) + logger.debug("Opening video with %s...", backend_type.BACKEND_NAME) return backend_type(path, framerate, **kwargs) except VideoOpenFailure as ex: - logger.warning('Failed to open video with %s: %s', backend_type.BACKEND_NAME, str(ex)) + logger.warning("Failed to open video with %s: %s", backend_type.BACKEND_NAME, str(ex)) if backend == VideoStreamCv2.BACKEND_NAME: raise last_error = ex else: - logger.warning('Backend %s not available.', backend) + logger.warning("Backend %s not available.", backend) # Fallback to OpenCV if `backend` is unavailable, or specified backend failed to open `path`. backend_type = VideoStreamCv2 - logger.warning('Trying another backend: %s', backend_type.BACKEND_NAME) + logger.warning("Trying another backend: %s", backend_type.BACKEND_NAME) try: return backend_type(path, framerate) except VideoOpenFailure as ex: - logger.debug('Failed to open video: %s', str(ex)) + logger.debug("Failed to open video: %s", str(ex)) if last_error is None: last_error = ex # Propagate any exceptions raised from specified backend, instead of errors from the fallback. @@ -158,6 +166,6 @@ def detect( show_progress=show_progress, end_time=end_time, ) - if not scene_manager.stats_manager is None: + if scene_manager.stats_manager is not None: scene_manager.stats_manager.save_to_csv(csv_file=stats_file_path) return scene_manager.get_scene_list(start_in_scene=start_in_scene) diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 7a8cfb9a..7c9ec1b9 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -12,14 +11,13 @@ # """Entry point for PySceneDetect's command-line interface.""" -from logging import getLogger import sys +from logging import getLogger from scenedetect._cli import scenedetect from scenedetect._cli.context import CliContext from scenedetect._cli.controller import run_scenedetect - -from scenedetect.platform import logging_redirect_tqdm, FakeTqdmLoggingRedirect +from scenedetect.platform import FakeTqdmLoggingRedirect, logging_redirect_tqdm def main(): @@ -27,35 +25,36 @@ def main(): cli_ctx = CliContext() try: # Process command line arguments and subcommands to initialize the context. - scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. + scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. except SystemExit as exit: - help_command = any(arg in sys.argv for arg in ['-h', '--help']) + help_command = any(arg in sys.argv for arg in ["-h", "--help"]) if help_command or exit.code != 0: raise # If we get here, processing the command line and loading the context worked. Let's run # the controller if we didn't process any help requests. - logger = getLogger('pyscenedetect') + logger = getLogger("pyscenedetect") # Ensure log messages don't conflict with any progress bars. If we're in quiet mode, where # no progress bars get created, we instead create a fake context manager. This is done here # to avoid needing a separate context manager at each point a progress bar is created. - log_redirect = FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm( - loggers=[logger]) + log_redirect = ( + FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm(loggers=[logger]) + ) with log_redirect: try: run_scenedetect(cli_ctx) except KeyboardInterrupt: - logger.info('Stopped.') + logger.info("Stopped.") if __debug__: raise except BaseException as ex: if __debug__: raise else: - logger.critical('Unhandled exception:', exc_info=ex) - raise SystemExit(1) + logger.critical("Unhandled exception:", exc_info=ex) + raise SystemExit(1) from None -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1890b9b5..18047181 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -18,29 +17,32 @@ """ # Some parts of this file need word wrap to be displayed. -# pylint: disable=line-too-long import inspect import logging -from typing import AnyStr, Optional, Tuple +import typing as ty import click import scenedetect -from scenedetect.detectors import (AdaptiveDetector, ContentDetector, HashDetector, - HistogramDetector, ThresholdDetector) +from scenedetect._cli.config import CHOICE_MAP, CONFIG_FILE_PATH, CONFIG_MAP +from scenedetect._cli.context import USER_CONFIG, CliContext from scenedetect.backends import AVAILABLE_BACKENDS +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) from scenedetect.platform import get_system_version_info -from scenedetect._cli.config import CHOICE_MAP, CONFIG_FILE_PATH, CONFIG_MAP -from scenedetect._cli.context import CliContext, USER_CONFIG - _PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") -_LINE_SEPARATOR = '-' * 72 +_LINE_SEPARATOR = "-" * 72 # About & copyright message string shown for the 'about' CLI command (scenedetect about). _ABOUT_STRING = """ @@ -83,16 +85,16 @@ class _Command(click.Command): def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help into the formatter if it exists.""" if ctx.parent: - formatter.write(click.style('`%s` Command' % ctx.command.name, fg='cyan')) + formatter.write(click.style("`%s` Command" % ctx.command.name, fg="cyan")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg='cyan')) + formatter.write(click.style(_LINE_SEPARATOR, fg="cyan")) formatter.write_paragraph() else: - formatter.write(click.style(_LINE_SEPARATOR, fg='yellow')) + formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() - formatter.write(click.style('PySceneDetect Help', fg='yellow')) + formatter.write(click.style("PySceneDetect Help", fg="yellow")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg='yellow')) + formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() self.format_usage(ctx, formatter) @@ -103,9 +105,10 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help text to the formatter if it exists.""" if self.help: - base_command = (ctx.parent.info_name if ctx.parent is not None else ctx.info_name) + base_command = ctx.parent.info_name if ctx.parent is not None else ctx.info_name formatted_help = self.help.format( - scenedetect=base_command, scenedetect_with_video='%s -i video.mp4' % base_command) + scenedetect=base_command, scenedetect_with_video="%s -i video.mp4" % base_command + ) text = inspect.cleandoc(formatted_help).partition("\f")[0] formatter.write_paragraph() formatter.write_text(text) @@ -120,6 +123,7 @@ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> N class _CommandGroup(_Command, click.Group): """Custom formatting for command groups.""" + pass @@ -127,179 +131,180 @@ def _print_command_help(ctx: click.Context, command: click.Command): """Print help/usage for a given command. Modifies `ctx` in-place.""" ctx.info_name = command.name ctx.command = command - click.echo('') + click.echo("") click.echo(command.get_help(ctx)) @click.group( cls=_CommandGroup, chain=True, - context_settings=dict(help_option_names=['-h', '--help']), + context_settings=dict(help_option_names=["-h", "--help"]), invoke_without_command=True, - epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""" + epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""", ) # *NOTE*: Although input is required, we cannot mark it as `required=True`, otherwise we will reject # commands of the form `scenedetect detect-content --help`. @click.option( - '--input', - '-i', + "--input", + "-i", multiple=False, required=False, - metavar='VIDEO', + metavar="VIDEO", type=click.STRING, - help='[REQUIRED] Input video file. Image sequences and URLs are supported.', + help="[REQUIRED] Input video file. Image sequences and URLs are supported.", ) @click.option( - '--output', - '-o', + "--output", + "-o", multiple=False, required=False, - metavar='DIR', + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help='Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s' + help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s" % (USER_CONFIG.get_help_string("global", "output", show_default=False)), ) @click.option( - '--config', - '-c', - metavar='FILE', + "--config", + "-c", + metavar="FILE", type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help='Path to config file. If unset, tries to load config from %s' % (CONFIG_FILE_PATH), + help="Path to config file. If unset, tries to load config from %s" % (CONFIG_FILE_PATH), ) @click.option( - '--stats', - '-s', - metavar='CSV', + "--stats", + "-s", + metavar="CSV", type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.', + help="Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.", ) @click.option( - '--framerate', - '-f', - metavar='FPS', + "--framerate", + "-f", + metavar="FPS", type=click.FLOAT, default=None, - help='Override framerate with value as frames/sec.', + help="Override framerate with value as frames/sec.", ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s' + help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s" % USER_CONFIG.get_help_string("global", "min-scene-len"), ) @click.option( - '--drop-short-scenes', + "--drop-short-scenes", is_flag=True, flag_value=True, - help='Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s' % - (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), + help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" + % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), ) @click.option( - '--merge-last-scene', + "--merge-last-scene", is_flag=True, flag_value=True, - help='Merge last scene with previous if shorter than -m/--min-scene-len.%s' % - (USER_CONFIG.get_help_string('global', 'merge-last-scene')), + help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" + % (USER_CONFIG.get_help_string("global", "merge-last-scene")), ) @click.option( - '--backend', - '-b', - metavar='BACKEND', + "--backend", + "-b", + metavar="BACKEND", type=click.Choice(CHOICE_MAP["global"]["backend"]), default=None, - help='Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s' - % (', '.join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), + help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" + % (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), ) @click.option( - '--downscale', - '-d', - metavar='N', + "--downscale", + "-d", + metavar="N", type=click.INT, default=None, - help='Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s' + help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s" % (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), ) @click.option( - '--frame-skip', - '-fs', - metavar='N', + "--frame-skip", + "-fs", + metavar="N", type=click.INT, default=None, - help='Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s' + help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s" % USER_CONFIG.get_help_string("global", "frame-skip"), ) @click.option( - '--verbosity', - '-v', - metavar='LEVEL', - type=click.Choice(CHOICE_MAP['global']['verbosity'], False), + "--verbosity", + "-v", + metavar="LEVEL", + type=click.Choice(CHOICE_MAP["global"]["verbosity"], False), default=None, - help='Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s' % - (', '.join(CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string( - "global", "verbosity")), + help="Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s" + % ( + ", ".join(CHOICE_MAP["global"]["verbosity"]), + USER_CONFIG.get_help_string("global", "verbosity"), + ), ) @click.option( - '--logfile', - '-l', - metavar='FILE', + "--logfile", + "-l", + metavar="FILE", type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Save debug log to FILE. Appends to existing file if present.', + help="Save debug log to FILE. Appends to existing file if present.", ) @click.option( - '--quiet', - '-q', + "--quiet", + "-q", is_flag=True, flag_value=True, - help='Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.', + help="Suppress output to terminal/stdout. Equivalent to setting --verbosity=none.", ) @click.pass_context -# pylint: disable=redefined-builtin def scenedetect( ctx: click.Context, - input: Optional[AnyStr], - output: Optional[AnyStr], - stats: Optional[AnyStr], - config: Optional[AnyStr], - framerate: Optional[float], - min_scene_len: Optional[str], + input: ty.Optional[ty.AnyStr], + output: ty.Optional[ty.AnyStr], + stats: ty.Optional[ty.AnyStr], + config: ty.Optional[ty.AnyStr], + framerate: ty.Optional[float], + min_scene_len: ty.Optional[str], drop_short_scenes: bool, merge_last_scene: bool, - backend: Optional[str], - downscale: Optional[int], - frame_skip: Optional[int], - verbosity: Optional[str], - logfile: Optional[AnyStr], + backend: ty.Optional[str], + downscale: ty.Optional[int], + frame_skip: ty.Optional[int], + verbosity: ty.Optional[str], + logfile: ty.Optional[ty.AnyStr], quiet: bool, ): """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: - {scenedetect_with_video} [detector] [commands] + {scenedetect_with_video} [detector] [commands] -For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. + For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. -Examples: + Examples: -Split video wherever a new scene is detected: + Split video wherever a new scene is detected: - {scenedetect_with_video} split-video + {scenedetect_with_video} split-video -Save scene list in CSV format with images at the start, middle, and end of each scene: + Save scene list in CSV format with images at the start, middle, and end of each scene: - {scenedetect_with_video} list-scenes save-images + {scenedetect_with_video} list-scenes save-images -Skip the first 10 seconds of the input video: + Skip the first 10 seconds of the input video: - {scenedetect_with_video} time --start 10s detect-content + {scenedetect_with_video} time --start 10s detect-content -Show summary of all options and commands: + Show summary of all options and commands: - {scenedetect} --help + {scenedetect} --help -Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. -""" + Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_options( input_path=input, @@ -320,12 +325,9 @@ def scenedetect( ) -# pylint: enable=redefined-builtin - - -@click.command('help', cls=_Command) +@click.command("help", cls=_Command) @click.argument( - 'command_name', + "command_name", required=False, type=click.STRING, ) @@ -337,13 +339,13 @@ def help_command(ctx: click.Context, command_name: str): parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) if command_name is not None: - if not command_name in all_commands: + if command_name not in all_commands: error_strs = [ - 'unknown command. List of valid commands:', - ' %s' % ', '.join(sorted(all_commands)) + "unknown command. List of valid commands:", + " %s" % ", ".join(sorted(all_commands)), ] - raise click.BadParameter('\n'.join(error_strs), param_hint='command') - click.echo('') + raise click.BadParameter("\n".join(error_strs), param_hint="command") + click.echo("") _print_command_help(ctx, parent_command.get_command(ctx, command_name)) else: click.echo(ctx.parent.get_help()) @@ -352,73 +354,73 @@ def help_command(ctx: click.Context, command_name: str): ctx.exit() -@click.command('about', cls=_Command, add_help_option=False) +@click.command("about", cls=_Command, add_help_option=False) @click.pass_context def about_command(ctx: click.Context): """Print license/copyright info.""" assert isinstance(ctx.obj, CliContext) - click.echo('') - click.echo(click.style(_LINE_SEPARATOR, fg='cyan')) - click.echo(click.style(' About PySceneDetect %s' % _PROGRAM_VERSION, fg='yellow')) - click.echo(click.style(_LINE_SEPARATOR, fg='cyan')) + click.echo("") + click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) + click.echo(click.style(" About PySceneDetect %s" % _PROGRAM_VERSION, fg="yellow")) + click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) click.echo(_ABOUT_STRING) ctx.exit() -@click.command('version', cls=_Command, add_help_option=False) +@click.command("version", cls=_Command, add_help_option=False) @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" assert isinstance(ctx.obj, CliContext) - click.echo('') + click.echo("") click.echo(get_system_version_info()) ctx.exit() -@click.command('time', cls=_Command) +@click.command("time", cls=_Command) @click.option( - '--start', - '-s', - metavar='TIMECODE', + "--start", + "-s", + metavar="TIMECODE", type=click.STRING, default=None, - help='Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).', + help="Time in video to start detection. TIMECODE can be specified as seconds (--start=100.0), frames (--start=100), or timecode (--start=00:01:40.000).", ) @click.option( - '--duration', - '-d', - metavar='TIMECODE', + "--duration", + "-d", + metavar="TIMECODE", type=click.STRING, default=None, - help='Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.', + help="Maximum time in video to process. TIMECODE format is the same as other arguments. Mutually exclusive with -e/--end.", ) @click.option( - '--end', - '-e', - metavar='TIMECODE', + "--end", + "-e", + metavar="TIMECODE", type=click.STRING, default=None, - help='Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration', + help="Time in video to end detecting scenes. TIMECODE format is the same as other arguments. Mutually exclusive with -d/--duration", ) @click.pass_context def time_command( ctx: click.Context, - start: Optional[str], - duration: Optional[str], - end: Optional[str], + start: ty.Optional[str], + duration: ty.Optional[str], + end: ty.Optional[str], ): """Set start/end/duration of input video. -Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: + Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - {scenedetect_with_video} time --end 00:01:00 + {scenedetect_with_video} time --end 00:01:00 - {scenedetect_with_video} time --duration 60.0 + {scenedetect_with_video} time --duration 60.0 -Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: + Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: - {scenedetect_with_video} time --start 0 --end 1000 -""" + {scenedetect_with_video} time --start 0 --end 1000 + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_time( start=start, @@ -432,10 +434,12 @@ def time_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange(CONFIG_MAP["detect-content"]["threshold"].min_val, - CONFIG_MAP["detect-content"]["threshold"].max_val), + type=click.FloatRange( + CONFIG_MAP["detect-content"]["threshold"].min_val, + CONFIG_MAP["detect-content"]["threshold"].max_val, + ), default=None, - help="The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to \"content_val\" in stats file.%s" + help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.%s' % (USER_CONFIG.get_help_string("detect-content", "threshold")), ) @click.option( @@ -452,7 +456,7 @@ def time_command( "-l", is_flag=True, flag_value=True, - help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w=\"0 0 1 0\".%s" + help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w="0 0 1 0".%s' % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( @@ -470,9 +474,12 @@ def time_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" % - ("" if USER_CONFIG.is_default("detect-content", "min-scene-len") else - USER_CONFIG.get_help_string("detect-content", "min-scene-len")), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" + % ( + "" + if USER_CONFIG.is_default("detect-content", "min-scene-len") + else USER_CONFIG.get_help_string("detect-content", "min-scene-len") + ), ) @click.option( "--filter-mode", @@ -480,42 +487,44 @@ def time_command( metavar="MODE", type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), default=None, - help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" % - (", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), - USER_CONFIG.get_help_string("detect-content", "filter-mode")), + help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" + % ( + ", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), + USER_CONFIG.get_help_string("detect-content", "filter-mode"), + ), ) @click.pass_context def detect_content_command( ctx: click.Context, - threshold: Optional[float], - weights: Optional[Tuple[float, float, float, float]], + threshold: ty.Optional[float], + weights: ty.Optional[ty.Tuple[float, float, float, float]], luma_only: bool, - kernel_size: Optional[int], - min_scene_len: Optional[str], - filter_mode: Optional[str], + kernel_size: ty.Optional[int], + min_scene_len: ty.Optional[str], + filter_mode: ty.Optional[str], ): """Find fast cuts using differences in HSL (filtered). -For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. + For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. -Scores are calculated from several components which are also recorded in the statsfile: + Scores are calculated from several components which are also recorded in the statsfile: - - *delta_hue*: Difference between pixel hue values of adjacent frames. + - *delta_hue*: Difference between pixel hue values of adjacent frames. - - *delta_sat*: Difference between pixel saturation values of adjacent frames. + - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. -Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. + Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. -Examples: + Examples: - {scenedetect_with_video} detect-content + {scenedetect_with_video} detect-content - {scenedetect_with_video} detect-content --threshold 27.5 -""" + {scenedetect_with_video} detect-content --threshold 27.5 + """ assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_content_params( threshold=threshold, @@ -523,106 +532,110 @@ def detect_content_command( min_scene_len=min_scene_len, weights=weights, kernel_size=kernel_size, - filter_mode=filter_mode) - logger.debug('Adding detector: ContentDetector(%s)', detector_args) + filter_mode=filter_mode, + ) + logger.debug("Adding detector: ContentDetector(%s)", detector_args) ctx.obj.add_detector(ContentDetector(**detector_args)) -@click.command('detect-adaptive', cls=_Command) +@click.command("detect-adaptive", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', + "--threshold", + "-t", + metavar="VAL", type=click.FLOAT, default=None, help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.%s' - % (USER_CONFIG.get_help_string('detect-adaptive', 'threshold')), + % (USER_CONFIG.get_help_string("detect-adaptive", "threshold")), ) @click.option( - '--min-content-val', - '-c', - metavar='VAL', + "--min-content-val", + "-c", + metavar="VAL", type=click.FLOAT, default=None, - help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-content-val')), + help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' + % (USER_CONFIG.get_help_string("detect-adaptive", "min-content-val")), ) @click.option( - '--min-delta-hsv', - '-d', - metavar='VAL', + "--min-delta-hsv", + "-d", + metavar="VAL", type=click.FLOAT, default=None, - help='[DEPRECATED] Use -c/--min-content-val instead.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-delta-hsv')), + help="[DEPRECATED] Use -c/--min-content-val instead.%s" + % (USER_CONFIG.get_help_string("detect-adaptive", "min-delta-hsv")), hidden=True, ) @click.option( - '--frame-window', - '-f', - metavar='VAL', + "--frame-window", + "-f", + metavar="VAL", type=click.INT, default=None, - help='Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s' - % (USER_CONFIG.get_help_string('detect-adaptive', 'frame-window')), + help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s" + % (USER_CONFIG.get_help_string("detect-adaptive", "frame-window")), ) @click.option( - '--weights', - '-w', + "--weights", + "-w", type=(float, float, float, float), default=None, help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".%s' % (USER_CONFIG.get_help_string("detect-content", "weights")), ) @click.option( - '--luma-only', - '-l', + "--luma-only", + "-l", is_flag=True, flag_value=True, help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".%s' % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( - '--kernel-size', - '-k', - metavar='N', + "--kernel-size", + "-k", + metavar="N", type=click.INT, default=None, - help='Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s' + help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s" % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' - % ('' if USER_CONFIG.is_default('detect-adaptive', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-adaptive', 'min-scene-len')), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + % ( + "" + if USER_CONFIG.is_default("detect-adaptive", "min-scene-len") + else USER_CONFIG.get_help_string("detect-adaptive", "min-scene-len") + ), ) @click.pass_context def detect_adaptive_command( ctx: click.Context, - threshold: Optional[float], - min_content_val: Optional[float], - min_delta_hsv: Optional[float], - frame_window: Optional[int], - weights: Optional[Tuple[float, float, float, float]], + threshold: ty.Optional[float], + min_content_val: ty.Optional[float], + min_delta_hsv: ty.Optional[float], + frame_window: ty.Optional[int], + weights: ty.Optional[ty.Tuple[float, float, float, float]], luma_only: bool, - kernel_size: Optional[int], - min_scene_len: Optional[str], + kernel_size: ty.Optional[int], + min_scene_len: ty.Optional[str], ): """Find fast cuts using diffs in HSL colorspace (rolling average). -Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. + Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. -Examples: + Examples: - {scenedetect_with_video} detect-adaptive + {scenedetect_with_video} detect-adaptive - {scenedetect_with_video} detect-adaptive --threshold 3.2 -""" + {scenedetect_with_video} detect-adaptive --threshold 3.2 + """ assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_adaptive_params( threshold=threshold, @@ -634,67 +647,74 @@ def detect_adaptive_command( weights=weights, kernel_size=kernel_size, ) - logger.debug('Adding detector: AdaptiveDetector(%s)', detector_args) + logger.debug("Adding detector: AdaptiveDetector(%s)", detector_args) ctx.obj.add_detector(AdaptiveDetector(**detector_args)) -@click.command('detect-threshold', cls=_Command) +@click.command("detect-threshold", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['threshold'].min_val, - CONFIG_MAP['detect-threshold']['threshold'].max_val), + "--threshold", + "-t", + metavar="VAL", + type=click.FloatRange( + CONFIG_MAP["detect-threshold"]["threshold"].min_val, + CONFIG_MAP["detect-threshold"]["threshold"].max_val, + ), default=None, help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.%s' - % (USER_CONFIG.get_help_string('detect-threshold', 'threshold')), + % (USER_CONFIG.get_help_string("detect-threshold", "threshold")), ) @click.option( - '--fade-bias', - '-f', - metavar='PERCENT', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['fade-bias'].min_val, - CONFIG_MAP['detect-threshold']['fade-bias'].max_val), + "--fade-bias", + "-f", + metavar="PERCENT", + type=click.FloatRange( + CONFIG_MAP["detect-threshold"]["fade-bias"].min_val, + CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, + ), default=None, - help='Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s' - % (USER_CONFIG.get_help_string('detect-threshold', 'fade-bias')), + help="Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s" + % (USER_CONFIG.get_help_string("detect-threshold", "fade-bias")), ) @click.option( - '--add-last-scene', - '-l', + "--add-last-scene", + "-l", is_flag=True, flag_value=True, - help='If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s' - % (USER_CONFIG.get_help_string('detect-threshold', 'add-last-scene')), + help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s" + % (USER_CONFIG.get_help_string("detect-threshold", "add-last-scene")), ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' - % ('' if USER_CONFIG.is_default('detect-threshold', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-threshold', 'min-scene-len')), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + % ( + "" + if USER_CONFIG.is_default("detect-threshold", "min-scene-len") + else USER_CONFIG.get_help_string("detect-threshold", "min-scene-len") + ), ) @click.pass_context def detect_threshold_command( ctx: click.Context, - threshold: Optional[float], - fade_bias: Optional[float], + threshold: ty.Optional[float], + fade_bias: ty.Optional[float], add_last_scene: bool, - min_scene_len: Optional[str], + min_scene_len: ty.Optional[str], ): """Find fade in/out using averaging. -Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. + Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. -Examples: + Examples: - {scenedetect_with_video} detect-threshold + {scenedetect_with_video} detect-threshold - {scenedetect_with_video} detect-threshold --threshold 15 -""" + {scenedetect_with_video} detect-threshold --threshold 15 + """ assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_threshold_params( threshold=threshold, @@ -702,7 +722,7 @@ def detect_threshold_command( add_last_scene=add_last_scene, min_scene_len=min_scene_len, ) - logger.debug('Adding detector: ThresholdDetector(%s)', detector_args) + logger.debug("Adding detector: ThresholdDetector(%s)", detector_args) ctx.obj.add_detector(ThresholdDetector(**detector_args)) @@ -711,21 +731,26 @@ def detect_threshold_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange(CONFIG_MAP["detect-hist"]["threshold"].min_val, - CONFIG_MAP["detect-hist"]["threshold"].max_val), + type=click.FloatRange( + CONFIG_MAP["detect-hist"]["threshold"].min_val, + CONFIG_MAP["detect-hist"]["threshold"].max_val, + ), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " - "values are more sensitive to changes.%s" % - (USER_CONFIG.get_help_string("detect-hist", "threshold"))) + "values are more sensitive to changes.%s" + % (USER_CONFIG.get_help_string("detect-hist", "threshold")), +) @click.option( "--bins", "-b", metavar="NUM", - type=click.IntRange(CONFIG_MAP["detect-hist"]["bins"].min_val, - CONFIG_MAP["detect-hist"]["bins"].max_val), + type=click.IntRange( + CONFIG_MAP["detect-hist"]["bins"].min_val, CONFIG_MAP["detect-hist"]["bins"].max_val + ), default=None, - help="The number of bins to use for the histogram calculation.%s" % - (USER_CONFIG.get_help_string("detect-hist", "bins"))) + help="The number of bins to use for the histogram calculation.%s" + % (USER_CONFIG.get_help_string("detect-hist", "bins")), +) @click.option( "--min-scene-len", "-m", @@ -734,29 +759,38 @@ def detect_threshold_command( default=None, help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," - " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % - ("" if USER_CONFIG.is_default("detect-hist", "min-scene-len") else USER_CONFIG.get_help_string( - "detect-hist", "min-scene-len"))) + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" + % ( + "" + if USER_CONFIG.is_default("detect-hist", "min-scene-len") + else USER_CONFIG.get_help_string("detect-hist", "min-scene-len") + ), +) @click.pass_context -def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Optional[int], - min_scene_len: Optional[str]): +def detect_hist_command( + ctx: click.Context, + threshold: ty.Optional[float], + bins: ty.Optional[int], + min_scene_len: ty.Optional[str], +): """Find fast cuts by differencing YUV histograms. -Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. + Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. -Saved as the `hist_diff` metric in a statsfile. + Saved as the `hist_diff` metric in a statsfile. -Examples: + Examples: - {scenedetect_with_video} detect-hist + {scenedetect_with_video} detect-hist - {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 + {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hist_params( - threshold=threshold, bins=bins, min_scene_len=min_scene_len) + threshold=threshold, bins=bins, min_scene_len=min_scene_len + ) logger.debug("Adding detector: HistogramDetector(%s)", detector_args) ctx.obj.add_detector(HistogramDetector(**detector_args)) @@ -766,31 +800,41 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op "--threshold", "-t", metavar="VAL", - type=click.FloatRange(CONFIG_MAP["detect-hash"]["threshold"].min_val, - CONFIG_MAP["detect-hash"]["threshold"].max_val), + type=click.FloatRange( + CONFIG_MAP["detect-hash"]["threshold"].min_val, + CONFIG_MAP["detect-hash"]["threshold"].max_val, + ), default=None, - help=("Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " - "more sensitive to changes.%s" % - (USER_CONFIG.get_help_string("detect-hash", "threshold")))) + help=( + "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " + "more sensitive to changes.%s" % (USER_CONFIG.get_help_string("detect-hash", "threshold")) + ), +) @click.option( "--size", "-s", metavar="SIZE", - type=click.IntRange(CONFIG_MAP["detect-hash"]["size"].min_val, - CONFIG_MAP["detect-hash"]["size"].max_val), + type=click.IntRange( + CONFIG_MAP["detect-hash"]["size"].min_val, CONFIG_MAP["detect-hash"]["size"].max_val + ), default=None, - help="Size of square of low frequency data to include from the discrete cosine transform.%s" % - (USER_CONFIG.get_help_string("detect-hash", "size"))) + help="Size of square of low frequency data to include from the discrete cosine transform.%s" + % (USER_CONFIG.get_help_string("detect-hash", "size")), +) @click.option( "--lowpass", "-l", metavar="FRAC", - type=click.IntRange(CONFIG_MAP["detect-hash"]["lowpass"].min_val, - CONFIG_MAP["detect-hash"]["lowpass"].max_val), + type=click.IntRange( + CONFIG_MAP["detect-hash"]["lowpass"].min_val, CONFIG_MAP["detect-hash"]["lowpass"].max_val + ), default=None, - help=("How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " - "the frequency data, 4 means only keep 1/4, etc...%s" % - (USER_CONFIG.get_help_string("detect-hash", "lowpass")))) + help=( + "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " + "the frequency data, 4 means only keep 1/4, etc...%s" + % (USER_CONFIG.get_help_string("detect-hash", "lowpass")) + ), +) @click.option( "--min-scene-len", "-m", @@ -799,105 +843,119 @@ def detect_hist_command(ctx: click.Context, threshold: Optional[float], bins: Op default=None, help="Minimum length of any scene. Overrides global min-scene-len (-m) setting." " TIMECODE can be specified as exact number of frames, a time in seconds followed by s," - " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" % - ("" if USER_CONFIG.is_default("detect-hash", "min-scene-len") else USER_CONFIG.get_help_string( - "detect-hash", "min-scene-len"))) + " or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s" + % ( + "" + if USER_CONFIG.is_default("detect-hash", "min-scene-len") + else USER_CONFIG.get_help_string("detect-hash", "min-scene-len") + ), +) @click.pass_context -def detect_hash_command(ctx: click.Context, threshold: Optional[float], size: Optional[int], - lowpass: Optional[int], min_scene_len: Optional[str]): +def detect_hash_command( + ctx: click.Context, + threshold: ty.Optional[float], + size: ty.Optional[int], + lowpass: ty.Optional[int], + min_scene_len: ty.Optional[str], +): """Find fast cuts using perceptual hashing. -The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. + The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. -Saved as the `hash_dist` metric in a statsfile. + Saved as the `hash_dist` metric in a statsfile. -Examples: + Examples: - {scenedetect_with_video} detect-hash + {scenedetect_with_video} detect-hash - {scenedetect_with_video} detect-hash --size 32 --lowpass 3 + {scenedetect_with_video} detect-hash --size 32 --lowpass 3 """ assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.obj, CliContext) detector_args = ctx.obj.get_detect_hash_params( - threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len) + threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len + ) logger.debug("Adding detector: HashDetector(%s)", detector_args) ctx.obj.add_detector(HashDetector(**detector_args)) -@click.command('load-scenes', cls=_Command) +@click.command("load-scenes", cls=_Command) @click.option( - '--input', - '-i', + "--input", + "-i", multiple=False, - metavar='FILE', + metavar="FILE", required=True, type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=True), - help='Scene list to read cut information from.') + help="Scene list to read cut information from.", +) @click.option( - '--start-col-name', - '-c', - metavar='STRING', + "--start-col-name", + "-c", + metavar="STRING", type=click.STRING, default=None, - help='Name of column used to mark scene cuts.%s' % - (USER_CONFIG.get_help_string('load-scenes', 'start-col-name'))) + help="Name of column used to mark scene cuts.%s" + % (USER_CONFIG.get_help_string("load-scenes", "start-col-name")), +) @click.pass_context -def load_scenes_command(ctx: click.Context, input: Optional[str], start_col_name: Optional[str]): +def load_scenes_command( + ctx: click.Context, input: ty.Optional[str], start_col_name: ty.Optional[str] +): """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). -Examples: + Examples: - {scenedetect_with_video} load-scenes -i scenes.csv + {scenedetect_with_video} load-scenes -i scenes.csv - {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" -""" + {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" + """ assert isinstance(ctx.obj, CliContext) - logger.debug('Loading scenes from %s (start_col_name = %s)', input, start_col_name) + logger.debug("Loading scenes from %s (start_col_name = %s)", input, start_col_name) ctx.obj.handle_load_scenes(input=input, start_col_name=start_col_name) -@click.command('export-html', cls=_Command) +@click.command("export-html", cls=_Command) @click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.html', + "--filename", + "-f", + metavar="NAME", + default="$VIDEO_NAME-Scenes.html", type=click.STRING, - help='Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s' - % (USER_CONFIG.get_help_string('export-html', 'filename')), + help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s" + % (USER_CONFIG.get_help_string("export-html", "filename")), ) @click.option( - '--no-images', + "--no-images", is_flag=True, flag_value=True, - help='Export the scene list including or excluding the saved images.%s' % - (USER_CONFIG.get_help_string('export-html', 'no-images')), + help="Export the scene list including or excluding the saved images.%s" + % (USER_CONFIG.get_help_string("export-html", "no-images")), ) @click.option( - '--image-width', - '-w', - metavar='pixels', + "--image-width", + "-w", + metavar="pixels", type=click.INT, - help='Width in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-width', show_default=False)), + help="Width in pixels of the images in the resulting HTML table.%s" + % (USER_CONFIG.get_help_string("export-html", "image-width", show_default=False)), ) @click.option( - '--image-height', - '-h', - metavar='pixels', + "--image-height", + "-h", + metavar="pixels", type=click.INT, - help='Height in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-height', show_default=False)), + help="Height in pixels of the images in the resulting HTML table.%s" + % (USER_CONFIG.get_help_string("export-html", "image-height", show_default=False)), ) @click.pass_context def export_html_command( ctx: click.Context, - filename: Optional[AnyStr], + filename: ty.Optional[ty.AnyStr], no_images: bool, - image_width: Optional[int], - image_height: Optional[int], + image_width: ty.Optional[int], + image_height: ty.Optional[int], ): """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" assert isinstance(ctx.obj, CliContext) @@ -909,52 +967,52 @@ def export_html_command( ) -@click.command('list-scenes', cls=_Command) +@click.command("list-scenes", cls=_Command) @click.option( - '--output', - '-o', - metavar='DIR', + "--output", + "-o", + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'output', show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output if set.%s" + % (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)), ) @click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.csv', + "--filename", + "-f", + metavar="NAME", + default="$VIDEO_NAME-Scenes.csv", type=click.STRING, - help='Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\$VIDEO_NAME-Scenes.csv).%s' - % (USER_CONFIG.get_help_string('list-scenes', 'filename')), + help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\\$VIDEO_NAME-Scenes.csv).%s" + % (USER_CONFIG.get_help_string("list-scenes", "filename")), ) @click.option( - '--no-output-file', - '-n', + "--no-output-file", + "-n", is_flag=True, flag_value=True, - help='Only print scene list.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'no-output-file')), + help="Only print scene list.%s" + % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), ) @click.option( - '--quiet', - '-q', + "--quiet", + "-q", is_flag=True, flag_value=True, - help='Suppress printing scene list.%s' % (USER_CONFIG.get_help_string('list-scenes', 'quiet')), + help="Suppress printing scene list.%s" % (USER_CONFIG.get_help_string("list-scenes", "quiet")), ) @click.option( - '--skip-cuts', - '-s', + "--skip-cuts", + "-s", is_flag=True, flag_value=True, - help='Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'skip-cuts')), + help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s" + % (USER_CONFIG.get_help_string("list-scenes", "skip-cuts")), ) @click.pass_context def list_scenes_command( ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], + output: ty.Optional[ty.AnyStr], + filename: ty.Optional[ty.AnyStr], no_output_file: bool, quiet: bool, skip_cuts: bool, @@ -970,108 +1028,112 @@ def list_scenes_command( ) -@click.command('split-video', cls=_Command) +@click.command("split-video", cls=_Command) @click.option( - '--output', - '-o', - metavar='DIR', + "--output", + "-o", + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('split-video', 'output', show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output if set.%s" + % (USER_CONFIG.get_help_string("split-video", "output", show_default=False)), ) @click.option( - '--filename', - '-f', - metavar='NAME', + "--filename", + "-f", + metavar="NAME", default=None, type=click.STRING, - help='File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s' - % (USER_CONFIG.get_help_string('split-video', 'filename')), + help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" + % (USER_CONFIG.get_help_string("split-video", "filename")), ) @click.option( - '--quiet', - '-q', + "--quiet", + "-q", is_flag=True, flag_value=True, - help='Hide output from external video splitting tool.%s' % - (USER_CONFIG.get_help_string('split-video', 'quiet')), + help="Hide output from external video splitting tool.%s" + % (USER_CONFIG.get_help_string("split-video", "quiet")), ) @click.option( - '--copy', - '-c', + "--copy", + "-c", is_flag=True, flag_value=True, - help="Copy instead of re-encode. Faster but less precise.%s" % - (USER_CONFIG.get_help_string('split-video', 'copy')), + help="Copy instead of re-encode. Faster but less precise.%s" + % (USER_CONFIG.get_help_string("split-video", "copy")), ) @click.option( - '--high-quality', - '-hq', + "--high-quality", + "-hq", is_flag=True, flag_value=True, - help='Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s' - % (USER_CONFIG.get_help_string('split-video', 'high-quality')), + help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s" + % (USER_CONFIG.get_help_string("split-video", "high-quality")), ) @click.option( - '--rate-factor', - '-crf', - metavar='RATE', + "--rate-factor", + "-crf", + metavar="RATE", default=None, - type=click.IntRange(CONFIG_MAP['split-video']['rate-factor'].min_val, - CONFIG_MAP['split-video']['rate-factor'].max_val), - help='Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s' - % (USER_CONFIG.get_help_string('split-video', 'rate-factor')), + type=click.IntRange( + CONFIG_MAP["split-video"]["rate-factor"].min_val, + CONFIG_MAP["split-video"]["rate-factor"].max_val, + ), + help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s" + % (USER_CONFIG.get_help_string("split-video", "rate-factor")), ) @click.option( - '--preset', - '-p', - metavar='LEVEL', + "--preset", + "-p", + metavar="LEVEL", default=None, - type=click.Choice(CHOICE_MAP['split-video']['preset']), - help='Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s' - % (', '.join( - CHOICE_MAP['split-video']['preset']), USER_CONFIG.get_help_string('split-video', 'preset')), + type=click.Choice(CHOICE_MAP["split-video"]["preset"]), + help="Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s" + % ( + ", ".join(CHOICE_MAP["split-video"]["preset"]), + USER_CONFIG.get_help_string("split-video", "preset"), + ), ) @click.option( - '--args', - '-a', - metavar='ARGS', + "--args", + "-a", + metavar="ARGS", type=click.STRING, default=None, help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.%s' - % (USER_CONFIG.get_help_string('split-video', 'args')), + % (USER_CONFIG.get_help_string("split-video", "args")), ) @click.option( - '--mkvmerge', - '-m', + "--mkvmerge", + "-m", is_flag=True, flag_value=True, - help='Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s' - % (USER_CONFIG.get_help_string('split-video', 'mkvmerge')), + help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s" + % (USER_CONFIG.get_help_string("split-video", "mkvmerge")), ) @click.pass_context def split_video_command( ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], + output: ty.Optional[ty.AnyStr], + filename: ty.Optional[ty.AnyStr], quiet: bool, copy: bool, high_quality: bool, - rate_factor: Optional[int], - preset: Optional[str], - args: Optional[str], + rate_factor: ty.Optional[int], + preset: ty.Optional[str], + args: ty.Optional[str], mkvmerge: bool, ): """Split input video using ffmpeg or mkvmerge. -Examples: + Examples: - {scenedetect_with_video} split-video + {scenedetect_with_video} split-video - {scenedetect_with_video} split-video --copy + {scenedetect_with_video} split-video --copy - {scenedetect_with_video} split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER -""" + {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_split_video( output=output, @@ -1086,137 +1148,137 @@ def split_video_command( ) -@click.command('save-images', cls=_Command) +@click.command("save-images", cls=_Command) @click.option( - '--output', - '-o', - metavar='DIR', + "--output", + "-o", + metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory for images. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('save-images', 'output', show_default=False)), + help="Output directory for images. Overrides global option -o/--output if set.%s" + % (USER_CONFIG.get_help_string("save-images", "output", show_default=False)), ) @click.option( - '--filename', - '-f', - metavar='NAME', + "--filename", + "-f", + metavar="NAME", default=None, type=click.STRING, - help='Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s' - % (USER_CONFIG.get_help_string('save-images', 'filename')), + help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" + % (USER_CONFIG.get_help_string("save-images", "filename")), ) @click.option( - '--num-images', - '-n', - metavar='N', + "--num-images", + "-n", + metavar="N", default=None, type=click.INT, - help='Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s' - % (USER_CONFIG.get_help_string('save-images', 'num-images')), + help="Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s" + % (USER_CONFIG.get_help_string("save-images", "num-images")), ) @click.option( - '--jpeg', - '-j', + "--jpeg", + "-j", is_flag=True, flag_value=True, - help='Set output format to JPEG (default).%s' % - (USER_CONFIG.get_help_string('save-images', 'format', show_default=False)), + help="Set output format to JPEG (default).%s" + % (USER_CONFIG.get_help_string("save-images", "format", show_default=False)), ) @click.option( - '--webp', - '-w', + "--webp", + "-w", is_flag=True, flag_value=True, - help='Set output format to WebP', + help="Set output format to WebP", ) @click.option( - '--quality', - '-q', - metavar='Q', + "--quality", + "-q", + metavar="Q", default=None, type=click.IntRange(0, 100), - help='JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s' - % (USER_CONFIG.get_help_string('save-images', 'quality', show_default=False)), + help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s" + % (USER_CONFIG.get_help_string("save-images", "quality", show_default=False)), ) @click.option( - '--png', - '-p', + "--png", + "-p", is_flag=True, flag_value=True, - help='Set output format to PNG.', + help="Set output format to PNG.", ) @click.option( - '--compression', - '-c', - metavar='C', + "--compression", + "-c", + metavar="C", default=None, type=click.IntRange(0, 9), - help='PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s' - % (USER_CONFIG.get_help_string('save-images', 'compression')), + help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s" + % (USER_CONFIG.get_help_string("save-images", "compression")), ) @click.option( - '-m', - '--frame-margin', - metavar='N', + "-m", + "--frame-margin", + metavar="N", default=None, type=click.INT, - help='Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s' - % (USER_CONFIG.get_help_string('save-images', 'num-images')), + help="Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s" + % (USER_CONFIG.get_help_string("save-images", "num-images")), ) @click.option( - '--scale', - '-s', - metavar='S', + "--scale", + "-s", + metavar="S", default=None, type=click.FLOAT, - help='Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s' % - (USER_CONFIG.get_help_string('save-images', 'scale', show_default=False)), + help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s" + % (USER_CONFIG.get_help_string("save-images", "scale", show_default=False)), ) @click.option( - '--height', - '-H', - metavar='H', + "--height", + "-H", + metavar="H", default=None, type=click.INT, - help='Height (pixels) of images.%s' % - (USER_CONFIG.get_help_string('save-images', 'height', show_default=False)), + help="Height (pixels) of images.%s" + % (USER_CONFIG.get_help_string("save-images", "height", show_default=False)), ) @click.option( - '--width', - '-W', - metavar='W', + "--width", + "-W", + metavar="W", default=None, type=click.INT, - help='Width (pixels) of images.%s' % - (USER_CONFIG.get_help_string('save-images', 'width', show_default=False)), + help="Width (pixels) of images.%s" + % (USER_CONFIG.get_help_string("save-images", "width", show_default=False)), ) @click.pass_context def save_images_command( ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - num_images: Optional[int], + output: ty.Optional[ty.AnyStr], + filename: ty.Optional[ty.AnyStr], + num_images: ty.Optional[int], jpeg: bool, webp: bool, - quality: Optional[int], + quality: ty.Optional[int], png: bool, - compression: Optional[int], - frame_margin: Optional[int], - scale: Optional[float], - height: Optional[int], - width: Optional[int], + compression: ty.Optional[int], + frame_margin: ty.Optional[int], + scale: ty.Optional[float], + height: ty.Optional[int], + width: ty.Optional[int], ): """Create images for each detected scene. -Images can be resized + Images can be resized -Examples: + Examples: - {scenedetect_with_video} save-images + {scenedetect_with_video} save-images - {scenedetect_with_video} save-images --width 1024 + {scenedetect_with_video} save-images --width 1024 - {scenedetect_with_video} save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER -""" + {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER + """ assert isinstance(ctx.obj, CliContext) ctx.obj.handle_save_images( num_images=num_images, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 3407b2a5..929587ad 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -15,12 +14,12 @@ possible and re-used by the CLI so that there is one source of truth. """ -from abc import ABC, abstractmethod -from enum import Enum import logging import os import os.path +from abc import ABC, abstractmethod from configparser import ConfigParser, ParsingError +from enum import Enum from typing import Any, AnyStr, Dict, List, Optional, Tuple, Union from platformdirs import user_config_dir @@ -31,7 +30,7 @@ from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS -VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO'] +VALID_PYAV_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] class OptionParseFailure(Exception): @@ -53,7 +52,7 @@ def value(self) -> Any: @staticmethod @abstractmethod - def from_config(config_value: str, default: 'ValidatedValue') -> 'ValidatedValue': + def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue": """Validate and get the user-specified configuration option. Raises: @@ -83,12 +82,13 @@ def __str__(self) -> str: return str(self.value) @staticmethod - def from_config(config_value: str, default: 'TimecodeValue') -> 'TimecodeValue': + def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": try: return TimecodeValue(config_value) except ValueError as ex: raise OptionParseFailure( - 'Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS.') from ex + "Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS." + ) from ex class RangeValue(ValidatedValue): @@ -128,7 +128,7 @@ def __str__(self) -> str: return str(self.value) @staticmethod - def from_config(config_value: str, default: 'RangeValue') -> 'RangeValue': + def from_config(config_value: str, default: "RangeValue") -> "RangeValue": try: return RangeValue( value=int(config_value) if isinstance(default.value, int) else float(config_value), @@ -136,14 +136,15 @@ def from_config(config_value: str, default: 'RangeValue') -> 'RangeValue': max_val=default.max_val, ) except ValueError as ex: - raise OptionParseFailure('Value must be between %s and %s.' % - (default.min_val, default.max_val)) from ex + raise OptionParseFailure( + "Value must be between %s and %s." % (default.min_val, default.max_val) + ) from ex class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" - _IGNORE_CHARS = [',', '/', '(', ')'] + _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" def __init__(self, value: Union[str, ContentDetector.Components]): @@ -151,7 +152,8 @@ def __init__(self, value: Union[str, ContentDetector.Components]): self._value = value else: translation_table = str.maketrans( - {char: ' ' for char in ScoreWeightsValue._IGNORE_CHARS}) + {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} + ) values = value.translate(translation_table).split() if not len(values) == 4: raise ValueError("Score weights must be specified as four numbers!") @@ -165,16 +167,17 @@ def __repr__(self) -> str: return str(self.value) def __str__(self) -> str: - return '%.3f, %.3f, %.3f, %.3f' % self.value + return "%.3f, %.3f, %.3f, %.3f" % self.value @staticmethod - def from_config(config_value: str, default: 'ScoreWeightsValue') -> 'ScoreWeightsValue': + def from_config(config_value: str, default: "ScoreWeightsValue") -> "ScoreWeightsValue": try: return ScoreWeightsValue(config_value) except ValueError as ex: raise OptionParseFailure( - 'Score weights must be specified as four numbers in the form (H,S,L,E),' - ' e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored.') from ex + "Score weights must be specified as four numbers in the form (H,S,L,E)," + " e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored." + ) from ex class KernelSizeValue(ValidatedValue): @@ -201,21 +204,22 @@ def __repr__(self) -> str: def __str__(self) -> str: if self.value is None: - return 'auto' + return "auto" return str(self.value) @staticmethod - def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeValue': + def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue": try: return KernelSizeValue(int(config_value)) except ValueError as ex: raise OptionParseFailure( - 'Value must be an odd integer greater than 1, or set to -1 for auto kernel size.' + "Value must be an odd integer greater than 1, or set to -1 for auto kernel size." ) from ex class TimecodeFormat(Enum): """Format to display timecodes.""" + FRAMES = 0 """Print timecodes as exact frame number.""" TIMECODE = 1 @@ -229,16 +233,16 @@ def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return '%.3f' % timecode.get_seconds() - assert False + return "%.3f" % timecode.get_seconds() + raise RuntimeError("Unhandled format specifier.") ConfigValue = Union[bool, int, float, str] ConfigDict = Dict[str, Dict[str, ConfigValue]] -_CONFIG_FILE_NAME: AnyStr = 'scenedetect.cfg' +_CONFIG_FILE_NAME: AnyStr = "scenedetect.cfg" _CONFIG_FILE_DIR: AnyStr = user_config_dir("PySceneDetect", False) -_PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format +_PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format CONFIG_FILE_PATH: AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) DEFAULT_JPG_QUALITY = 95 @@ -349,29 +353,42 @@ def format(self, timecode: FrameTimecode) -> str: certain string options are stored in `CHOICE_MAP`.""" CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { - 'backend-pyav': { - 'threading_mode': [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + "backend-pyav": { + "threading_mode": [mode.lower() for mode in VALID_PYAV_THREAD_MODES], }, - 'detect-content': { - 'filter-mode': [mode.name.lower() for mode in FlashFilter.Mode], + "detect-content": { + "filter-mode": [mode.name.lower() for mode in FlashFilter.Mode], }, - 'global': { - 'backend': ['opencv', 'pyav', 'moviepy'], - 'default-detector': ['detect-adaptive', 'detect-content', 'detect-threshold'], - 'downscale-method': [value.name.lower() for value in Interpolation], - 'verbosity': ['debug', 'info', 'warning', 'error', 'none'], + "global": { + "backend": ["opencv", "pyav", "moviepy"], + "default-detector": [ + "detect-adaptive", + "detect-content", + "detect-threshold", + "detect-hash", + "detect-hist", + ], + "downscale-method": [value.name.lower() for value in Interpolation], + "verbosity": ["debug", "info", "warning", "error", "none"], }, - 'list-scenes': { - 'cut-format': [value.name.lower() for value in TimecodeFormat], + "list-scenes": { + "cut-format": [value.name.lower() for value in TimecodeFormat], }, - 'save-images': { - 'format': ['jpeg', 'png', 'webp'], - 'scale-method': [value.name.lower() for value in Interpolation], + "save-images": { + "format": ["jpeg", "png", "webp"], + "scale-method": [value.name.lower() for value in Interpolation], }, - 'split-video': { - 'preset': [ - 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', - 'veryslow' + "split-video": { + "preset": [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", ], }, } @@ -390,12 +407,12 @@ def _validate_structure(config: ConfigParser) -> List[str]: """ errors: List[str] = [] for section in config.sections(): - if not section in CONFIG_MAP.keys(): - errors.append('Unsupported config section: [%s]' % (section)) + if section not in CONFIG_MAP.keys(): + errors.append("Unsupported config section: [%s]" % (section)) continue - for (option_name, _) in config.items(section): - if not option_name in CONFIG_MAP[section].keys(): - errors.append('Unsupported config option in [%s]: %s' % (section, option_name)) + for option_name, _ in config.items(section): + if option_name not in CONFIG_MAP[section].keys(): + errors.append("Unsupported config option in [%s]: %s" % (section, option_name)) return errors @@ -414,20 +431,22 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: try: value_type = None if isinstance(CONFIG_MAP[command][option], bool): - value_type = 'yes/no value' + value_type = "yes/no value" out_map[command][option] = config.getboolean(command, option) continue elif isinstance(CONFIG_MAP[command][option], int): - value_type = 'integer' + value_type = "integer" out_map[command][option] = config.getint(command, option) continue elif isinstance(CONFIG_MAP[command][option], float): - value_type = 'number' + value_type = "number" out_map[command][option] = config.getfloat(command, option) continue except ValueError as _: - errors.append('Invalid [%s] value for %s: %s is not a valid %s.' % - (command, option, config.get(command, option), value_type)) + errors.append( + "Invalid [%s] value for %s: %s is not a valid %s." + % (command, option, config.get(command, option), value_type) + ) continue # Handle custom validation types. @@ -437,21 +456,30 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: if issubclass(option_type, ValidatedValue): try: out_map[command][option] = option_type.from_config( - config_value=config_value, default=default) + config_value=config_value, default=default + ) except OptionParseFailure as ex: - errors.append('Invalid [%s] value for %s:\n %s\n%s' % - (command, option, config_value, ex.error)) + errors.append( + "Invalid [%s] value for %s:\n %s\n%s" + % (command, option, config_value, ex.error) + ) continue # If we didn't process the value as a given type, handle it as a string. We also # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: - config_value = config.get(command, option).replace('\n', ' ').strip() + config_value = config.get(command, option).replace("\n", " ").strip() if command in CHOICE_MAP and option in CHOICE_MAP[command]: if config_value.lower() not in CHOICE_MAP[command][option]: - errors.append('Invalid [%s] value for %s: %s. Must be one of: %s.' % - (command, option, config.get(command, option), ', '.join( - choice for choice in CHOICE_MAP[command][option]))) + errors.append( + "Invalid [%s] value for %s: %s. Must be one of: %s." + % ( + command, + option, + config.get(command, option), + ", ".join(choice for choice in CHOICE_MAP[command][option]), + ) + ) continue out_map[command][option] = config_value continue @@ -469,9 +497,8 @@ def __init__(self, init_log: Tuple[int, str], reason: Optional[Exception] = None class ConfigRegistry: - def __init__(self, path: Optional[str] = None, throw_exception: bool = True): - self._config: ConfigDict = {} # Options set in the loaded config file. + self._config: ConfigDict = {} # Options set in the loaded config file. self._init_log: List[Tuple[int, str]] = [] self._initialized = False @@ -487,7 +514,7 @@ def __init__(self, path: Optional[str] = None, throw_exception: bool = True): self._init_log = ex.init_log if ex.reason is not None: self._init_log += [ - (logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' ')), + (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")), ] self._initialized = False @@ -527,13 +554,13 @@ def _load_from_disk(self, path=None): # Try to load and parse the config file at `path`. config = ConfigParser() try: - with open(path, 'r') as config_file: + with open(path) as config_file: config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) except ParsingError as ex: - raise ConfigLoadFailure(self._init_log, reason=ex) + raise ConfigLoadFailure(self._init_log, reason=ex) from None except OSError as ex: - raise ConfigLoadFailure(self._init_log, reason=ex) + raise ConfigLoadFailure(self._init_log, reason=ex) from None # At this point the config file syntax is correct, but we need to still validate # the parsed options (i.e. that the options have valid values). errors = _validate_structure(config) @@ -548,11 +575,13 @@ def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" return not (command in self._config and option in self._config[command]) - def get_value(self, - command: str, - option: str, - override: Optional[ConfigValue] = None, - ignore_default: bool = False) -> ConfigValue: + def get_value( + self, + command: str, + option: str, + override: Optional[ConfigValue] = None, + ignore_default: bool = False, + ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: @@ -567,10 +596,9 @@ def get_value(self, return value.value return value - def get_help_string(self, - command: str, - option: str, - show_default: Optional[bool] = None) -> str: + def get_help_string( + self, command: str, option: str, show_default: Optional[bool] = None + ) -> str: """Get a string to specify for the help text indicating the current command option value, if set, or the default. @@ -584,11 +612,12 @@ def get_help_string(self, is_flag = isinstance(CONFIG_MAP[command][option], bool) if command in self._config and option in self._config[command]: if is_flag: - value_str = 'on' if self._config[command][option] else 'off' + value_str = "on" if self._config[command][option] else "off" else: value_str = str(self._config[command][option]) - return ' [setting: %s]' % (value_str) - if show_default is False or (show_default is None and is_flag - and CONFIG_MAP[command][option] is False): - return '' - return ' [default: %s]' % (str(CONFIG_MAP[command][option])) + return " [setting: %s]" % (value_str) + if show_default is False or ( + show_default is None and is_flag and CONFIG_MAP[command][option] is False + ): + return "" + return " [default: %s]" % (str(CONFIG_MAP[command][option])) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index ee583727..de0e95a0 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -15,41 +14,49 @@ import logging import os import typing as ty -from typing import Any, AnyStr, Dict, Optional, Tuple, Type import click -import scenedetect - -from scenedetect import open_video, AVAILABLE_BACKENDS - -from scenedetect.scene_detector import SceneDetector, FlashFilter -from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger -from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA -from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable -from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector +import scenedetect # Required to access __version__ +from scenedetect import AVAILABLE_BACKENDS, open_video +from scenedetect._cli.config import ( + CHOICE_MAP, + DEFAULT_JPG_QUALITY, + DEFAULT_WEBP_QUALITY, + ConfigLoadFailure, + ConfigRegistry, + TimecodeFormat, +) +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) +from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode +from scenedetect.platform import get_cv2_imwrite_params, init_logger +from scenedetect.scene_detector import FlashFilter, SceneDetector +from scenedetect.scene_manager import Interpolation, SceneManager from scenedetect.stats_manager import StatsManager -from scenedetect.scene_manager import SceneManager, Interpolation - -from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP, - DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY) +from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available +from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") USER_CONFIG = ConfigRegistry(throw_exception=False) -def parse_timecode(value: ty.Optional[str], - frame_rate: float, - correct_pts: bool = False) -> FrameTimecode: +def parse_timecode( + value: ty.Optional[str], frame_rate: float, correct_pts: bool = False +) -> FrameTimecode: """Parses a user input string into a FrameTimecode assuming the given framerate. If value is None, None will be returned instead of processing the value. Raises: click.BadParameter - """ + """ if value is None: return None try: @@ -60,16 +67,17 @@ def parse_timecode(value: ty.Optional[str], return FrameTimecode(timecode=value, fps=frame_rate) except ValueError as ex: raise click.BadParameter( - 'timecode must be in seconds (100.0), frames (100), or HH:MM:SS') from ex + "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" + ) from ex def contains_sequence_or_url(video_path: str) -> bool: """Checks if the video path is a URL or image sequence.""" - return '%' in video_path or '://' in video_path + return "%" in video_path or "://" in video_path def check_split_video_requirements(use_mkvmerge: bool) -> None: - """ Validates that the proper tool is available on the system to perform the + """Validates that the proper tool is available on the system to perform the `split-video` command. Arguments: @@ -81,19 +89,19 @@ def check_split_video_requirements(use_mkvmerge: bool) -> None: if (use_mkvmerge and not is_mkvmerge_available()) or not is_ffmpeg_available(): error_strs = [ "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format( - EXTERN_TOOL='mkvmerge' if use_mkvmerge else 'ffmpeg', - EXTRA_ARGS=' when mkvmerge (-m) is set' if use_mkvmerge else '') + EXTERN_TOOL="mkvmerge" if use_mkvmerge else "ffmpeg", + EXTRA_ARGS=" when mkvmerge (-m) is set" if use_mkvmerge else "", + ) ] - error_strs += ['Ensure the program is available on your system and try again.'] + error_strs += ["Ensure the program is available on your system and try again."] if not use_mkvmerge and is_mkvmerge_available(): - error_strs += ['You can specify mkvmerge (-m) to use mkvmerge for splitting.'] + error_strs += ["You can specify mkvmerge (-m) to use mkvmerge for splitting."] elif use_mkvmerge and is_ffmpeg_available(): - error_strs += ['You can specify copy (-c) to use ffmpeg stream copying.'] - error_str = '\n'.join(error_strs) - raise click.BadParameter(error_str, param_hint='split-video') + error_strs += ["You can specify copy (-c) to use ffmpeg stream copying."] + error_str = "\n".join(error_strs) + raise click.BadParameter(error_str, param_hint="split-video") -# pylint: disable=too-many-instance-attributes,too-many-arguments,too-many-locals class CliContext: """Context of the command-line interface and config file parameters passed between sub-commands. @@ -113,65 +121,66 @@ def __init__(self): self.added_detector: bool = False # Global `scenedetect` Options - self.output_dir: str = None # -o/--output - self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet - self.stats_file_path: str = None # -s/--stats - self.drop_short_scenes: bool = None # --drop-short-scenes - self.merge_last_scene: bool = None # --merge-last-scene - self.min_scene_len: FrameTimecode = None # -m/--min-scene-len - self.frame_skip: int = None # -fs/--frame-skip - self.default_detector: Tuple[Type[SceneDetector], - Dict[str, Any]] = None # [global] default-detector + self.output_dir: str = None # -o/--output + self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet + self.stats_file_path: str = None # -s/--stats + self.drop_short_scenes: bool = None # --drop-short-scenes + self.merge_last_scene: bool = None # --merge-last-scene + self.min_scene_len: FrameTimecode = None # -m/--min-scene-len + self.frame_skip: int = None # -fs/--frame-skip + self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = ( + None # [global] default-detector + ) # `time` Command Options self.time: bool = False - self.start_time: FrameTimecode = None # time -s/--start - self.end_time: FrameTimecode = None # time -e/--end - self.duration: FrameTimecode = None # time -d/--duration + self.start_time: FrameTimecode = None # time -s/--start + self.end_time: FrameTimecode = None # time -e/--end + self.duration: FrameTimecode = None # time -d/--duration # `save-images` Command Options self.save_images: bool = False - self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_dir: str = None # save-images -o/--output - self.image_param: int = None # save-images -q/--quality if -j/-w, - # otherwise -c/--compression if -p - self.image_name_format: str = None # save-images -f/--name-format - self.num_images: int = None # save-images -n/--num-images - self.frame_margin: int = 1 # save-images -m/--frame-margin - self.scale: float = None # save-images -s/--scale - self.height: int = None # save-images -h/--height - self.width: int = None # save-images -w/--width - self.scale_method: Interpolation = None # [save-images] scale-method + self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png + self.image_dir: str = None # save-images -o/--output + self.image_param: int = None # save-images -q/--quality if -j/-w, + # otherwise -c/--compression if -p + self.image_name_format: str = None # save-images -f/--name-format + self.num_images: int = None # save-images -n/--num-images + self.frame_margin: int = 1 # save-images -m/--frame-margin + self.scale: float = None # save-images -s/--scale + self.height: int = None # save-images -h/--height + self.width: int = None # save-images -w/--width + self.scale_method: Interpolation = None # [save-images] scale-method # `split-video` Command Options self.split_video: bool = False - self.split_mkvmerge: bool = None # split-video -m/--mkvmerge - self.split_args: str = None # split-video -a/--args, -c/--copy - self.split_dir: str = None # split-video -o/--output - self.split_name_format: str = None # split-video -f/--filename - self.split_quiet: bool = None # split-video -q/--quiet + self.split_mkvmerge: bool = None # split-video -m/--mkvmerge + self.split_args: str = None # split-video -a/--args, -c/--copy + self.split_dir: str = None # split-video -o/--output + self.split_name_format: str = None # split-video -f/--filename + self.split_quiet: bool = None # split-video -q/--quiet # `list-scenes` Command Options self.list_scenes: bool = False - self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_dir: str = None # list-scenes -o/--output - self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output-file - self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = True # [list-scenes] display-cuts - self.display_scenes: bool = True # [list-scenes] display-scenes - self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format + self.list_scenes_quiet: bool = None # list-scenes -q/--quiet + self.scene_list_dir: str = None # list-scenes -o/--output + self.scene_list_name_format: str = None # list-scenes -f/--filename + self.scene_list_output: bool = None # list-scenes -n/--no-output-file + self.skip_cuts: bool = None # list-scenes -s/--skip-cuts + self.display_cuts: bool = True # [list-scenes] display-cuts + self.display_scenes: bool = True # [list-scenes] display-scenes + self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format # `export-html` Command Options self.export_html: bool = False - self.html_name_format: str = None # export-html -f/--filename - self.html_include_images: bool = None # export-html --no-images - self.image_width: int = None # export-html -w/--image-width - self.image_height: int = None # export-html -h/--image-height + self.html_name_format: str = None # export-html -f/--filename + self.html_include_images: bool = None # export-html --no-images + self.image_width: int = None # export-html -w/--image-width + self.image_height: int = None # export-html -h/--image-height # `load-scenes` Command Options - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name # # Command Handlers @@ -179,21 +188,21 @@ def __init__(self): def handle_options( self, - input_path: AnyStr, - output: Optional[AnyStr], + input_path: ty.AnyStr, + output: ty.Optional[ty.AnyStr], framerate: float, - stats_file: Optional[AnyStr], - downscale: Optional[int], + stats_file: ty.Optional[ty.AnyStr], + downscale: ty.Optional[int], frame_skip: int, min_scene_len: str, drop_short_scenes: bool, merge_last_scene: bool, - backend: Optional[str], + backend: ty.Optional[str], quiet: bool, - logfile: Optional[AnyStr], - config: Optional[AnyStr], - stats: Optional[AnyStr], - verbosity: Optional[str], + logfile: ty.Optional[ty.AnyStr], + config: ty.Optional[ty.AnyStr], + stats: ty.Optional[ty.AnyStr], + verbosity: ty.Optional[str], ): """Parse all global options/arguments passed to the main scenedetect command, before other sub-commands (e.g. this function processes the [options] when calling @@ -218,9 +227,9 @@ def handle_options( self.config = ConfigRegistry(config) init_log += self.config.get_init_log() # Re-initialize logger with the correct verbosity. - if verbosity is None and not self.config.is_default('global', 'verbosity'): - verbosity_str = self.config.get_value('global', 'verbosity') - assert verbosity_str in CHOICE_MAP['global']['verbosity'] + if verbosity is None and not self.config.is_default("global", "verbosity"): + verbosity_str = self.config.get_value("global", "verbosity") + assert verbosity_str in CHOICE_MAP["global"]["verbosity"] self.quiet_mode = False self._initialize_logging(verbosity=verbosity_str, logfile=logfile) @@ -228,11 +237,11 @@ def handle_options( init_failure = True init_log += ex.init_log if ex.reason is not None: - init_log += [(logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' '))] + init_log += [(logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " "))] finally: # Make sure we print the version number even on any kind of init failure. - logger.info('PySceneDetect %s', scenedetect.__version__) - for (log_level, log_str) in init_log: + logger.info("PySceneDetect %s", scenedetect.__version__) + for log_level, log_str in init_log: logger.log(log_level, log_str) if init_failure: logger.critical("Error processing configuration file.") @@ -241,16 +250,17 @@ def handle_options( if self.config.config_dict: logger.debug("Current configuration:\n%s", str(self.config.config_dict)) - logger.debug('Parsing program options.') + logger.debug("Parsing program options.") if stats is not None and frame_skip: error_strs = [ - 'Unable to detect scenes with stats file if frame skip is not 0.', - ' Either remove the -fs/--frame-skip option, or the -s/--stats file.\n' + "Unable to detect scenes with stats file if frame skip is not 0.", + " Either remove the -fs/--frame-skip option, or the -s/--stats file.\n", ] - logger.error('\n'.join(error_strs)) + logger.error("\n".join(error_strs)) raise click.BadParameter( - 'Combining the -s/--stats and -fs/--frame-skip options is not supported.', - param_hint='frame skip + stats file') + "Combining the -s/--stats and -fs/--frame-skip options is not supported.", + param_hint="frame skip + stats file", + ) # Handle the case where -i/--input was not specified (e.g. for the `help` command). if input_path is None: @@ -260,19 +270,25 @@ def handle_options( self._open_video_stream( input_path=input_path, framerate=framerate, - backend=self.config.get_value("global", "backend", backend, ignore_default=True)) + backend=self.config.get_value("global", "backend", backend, ignore_default=True), + ) self.output_dir = output if output else self.config.get_value("global", "output") if self.output_dir: - logger.info('Output directory set:\n %s', self.output_dir) + logger.info("Output directory set:\n %s", self.output_dir) self.min_scene_len = parse_timecode( - min_scene_len if min_scene_len is not None else self.config.get_value( - "global", "min-scene-len"), self.video_stream.frame_rate) + min_scene_len + if min_scene_len is not None + else self.config.get_value("global", "min-scene-len"), + self.video_stream.frame_rate, + ) self.drop_short_scenes = drop_short_scenes or self.config.get_value( - "global", "drop-short-scenes") + "global", "drop-short-scenes" + ) self.merge_last_scene = merge_last_scene or self.config.get_value( - "global", "merge-last-scene") + "global", "merge-last-scene" + ) self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) # Create StatsManager if --stats is specified. @@ -282,20 +298,20 @@ def handle_options( # Initialize default detector with values in the config file. default_detector = self.config.get_value("global", "default-detector") - if default_detector == 'detect-adaptive': + if default_detector == "detect-adaptive": self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params()) - elif default_detector == 'detect-content': + elif default_detector == "detect-content": self.default_detector = (ContentDetector, self.get_detect_content_params()) - elif default_detector == 'detect-hash': + elif default_detector == "detect-hash": self.default_detector = (HashDetector, self.get_detect_hash_params()) - elif default_detector == 'detect-hist': + elif default_detector == "detect-hist": self.default_detector = (HistogramDetector, self.get_detect_hist_params()) - elif default_detector == 'detect-threshold': + elif default_detector == "detect-threshold": self.default_detector = (ThresholdDetector, self.get_detect_threshold_params()) else: - raise click.BadParameter("Unknown detector type!", param_hint='default-detector') + raise click.BadParameter("Unknown detector type!", param_hint="default-detector") - logger.debug('Initializing SceneManager.') + logger.debug("Initializing SceneManager.") scene_manager = SceneManager(self.stats_manager) if downscale is None and self.config.is_default("global", "downscale"): @@ -307,20 +323,21 @@ def handle_options( scene_manager.downscale = downscale except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='downscale factor') - scene_manager.interpolation = Interpolation[self.config.get_value( - 'global', 'downscale-method').upper()] + raise click.BadParameter(str(ex), param_hint="downscale factor") from None + scene_manager.interpolation = Interpolation[ + self.config.get_value("global", "downscale-method").upper() + ] self.scene_manager = scene_manager def get_detect_content_params( self, - threshold: Optional[float] = None, + threshold: ty.Optional[float] = None, luma_only: bool = None, - min_scene_len: Optional[str] = None, - weights: Optional[Tuple[float, float, float, float]] = None, - kernel_size: Optional[int] = None, - filter_mode: Optional[str] = None, - ) -> Dict[str, Any]: + min_scene_len: ty.Optional[str] = None, + weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, + kernel_size: ty.Optional[int] = None, + filter_mode: ty.Optional[str] = None, + ) -> ty.Dict[str, ty.Any]: """Handle detect-content command options and return args to construct one with.""" self._ensure_input_open() @@ -328,10 +345,10 @@ def get_detect_content_params( min_scene_len = 0 else: if min_scene_len is None: - if self.config.is_default('detect-content', 'min-scene-len'): + if self.config.is_default("detect-content", "min-scene-len"): min_scene_len = self.min_scene_len.frame_num else: - min_scene_len = self.config.get_value('detect-content', 'min-scene-len') + min_scene_len = self.config.get_value("detect-content", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num if weights is not None: @@ -339,50 +356,48 @@ def get_detect_content_params( weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='weights') + raise click.BadParameter(str(ex), param_hint="weights") from None return { - 'weights': - self.config.get_value('detect-content', 'weights', weights), - 'kernel_size': - self.config.get_value('detect-content', 'kernel-size', kernel_size), - 'luma_only': - luma_only or self.config.get_value('detect-content', 'luma-only'), - 'min_scene_len': - min_scene_len, - 'threshold': - self.config.get_value('detect-content', 'threshold', threshold), - 'filter_mode': - FlashFilter.Mode[self.config.get_value("detect-content", "filter-mode", - filter_mode).upper()], + "weights": self.config.get_value("detect-content", "weights", weights), + "kernel_size": self.config.get_value("detect-content", "kernel-size", kernel_size), + "luma_only": luma_only or self.config.get_value("detect-content", "luma-only"), + "min_scene_len": min_scene_len, + "threshold": self.config.get_value("detect-content", "threshold", threshold), + "filter_mode": FlashFilter.Mode[ + self.config.get_value("detect-content", "filter-mode", filter_mode).upper() + ], } def get_detect_adaptive_params( self, - threshold: Optional[float] = None, - min_content_val: Optional[float] = None, - frame_window: Optional[int] = None, + threshold: ty.Optional[float] = None, + min_content_val: ty.Optional[float] = None, + frame_window: ty.Optional[int] = None, luma_only: bool = None, - min_scene_len: Optional[str] = None, - weights: Optional[Tuple[float, float, float, float]] = None, - kernel_size: Optional[int] = None, - min_delta_hsv: Optional[float] = None, - ) -> Dict[str, Any]: + min_scene_len: ty.Optional[str] = None, + weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, + kernel_size: ty.Optional[int] = None, + min_delta_hsv: ty.Optional[float] = None, + ) -> ty.Dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" self._ensure_input_open() # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: - logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.') + logger.error("-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.") if min_content_val is None: min_content_val = min_delta_hsv # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated' - ', use `min-delta-hsv` instead.') + logger.error( + "[detect-adaptive] config file option `min-delta-hsv` is deprecated" + ", use `min-delta-hsv` instead." + ) if self.config.is_default("detect-adaptive", "min-content-val"): self.config.config_dict["detect-adaptive"]["min-content-val"] = ( - self.config.config_dict["detect-adaptive"]["min-deleta-hsv"]) + self.config.config_dict["detect-adaptive"]["min-deleta-hsv"] + ) if self.drop_short_scenes: min_scene_len = 0 @@ -399,31 +414,26 @@ def get_detect_adaptive_params( weights = ContentDetector.Components(*weights) except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint='weights') + raise click.BadParameter(str(ex), param_hint="weights") from None return { - 'adaptive_threshold': - self.config.get_value("detect-adaptive", "threshold", threshold), - 'weights': - self.config.get_value("detect-adaptive", "weights", weights), - 'kernel_size': - self.config.get_value("detect-adaptive", "kernel-size", kernel_size), - 'luma_only': - luma_only or self.config.get_value("detect-adaptive", "luma-only"), - 'min_content_val': - self.config.get_value("detect-adaptive", "min-content-val", min_content_val), - 'min_scene_len': - min_scene_len, - 'window_width': - self.config.get_value("detect-adaptive", "frame-window", frame_window), + "adaptive_threshold": self.config.get_value("detect-adaptive", "threshold", threshold), + "weights": self.config.get_value("detect-adaptive", "weights", weights), + "kernel_size": self.config.get_value("detect-adaptive", "kernel-size", kernel_size), + "luma_only": luma_only or self.config.get_value("detect-adaptive", "luma-only"), + "min_content_val": self.config.get_value( + "detect-adaptive", "min-content-val", min_content_val + ), + "min_scene_len": min_scene_len, + "window_width": self.config.get_value("detect-adaptive", "frame-window", frame_window), } def get_detect_threshold_params( self, - threshold: Optional[float] = None, - fade_bias: Optional[float] = None, + threshold: ty.Optional[float] = None, + fade_bias: ty.Optional[float] = None, add_last_scene: bool = None, - min_scene_len: Optional[str] = None, - ) -> Dict[str, Any]: + min_scene_len: ty.Optional[str] = None, + ) -> ty.Dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" self._ensure_input_open() @@ -438,17 +448,14 @@ def get_detect_threshold_params( min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { - 'add_final_scene': - add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"), - 'fade_bias': - self.config.get_value("detect-threshold", "fade-bias", fade_bias), - 'min_scene_len': - min_scene_len, - 'threshold': - self.config.get_value("detect-threshold", "threshold", threshold), + "add_final_scene": add_last_scene + or self.config.get_value("detect-threshold", "add-last-scene"), + "fade_bias": self.config.get_value("detect-threshold", "fade-bias", fade_bias), + "min_scene_len": min_scene_len, + "threshold": self.config.get_value("detect-threshold", "threshold", threshold), } - def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): + def handle_load_scenes(self, input: ty.AnyStr, start_col_name: ty.Optional[str]): """Handle `load-scenes` command options.""" self._ensure_input_open() if self.added_detector: @@ -458,13 +465,19 @@ def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]): input = os.path.abspath(input) if not os.path.exists(input): raise click.BadParameter( - f'Could not load scenes, file does not exist: {input}', param_hint='-i/--input') + f"Could not load scenes, file does not exist: {input}", param_hint="-i/--input" + ) self.load_scenes_input = input - self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name", - start_col_name) + self.load_scenes_column_name = self.config.get_value( + "load-scenes", "start-col-name", start_col_name + ) - def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int], - min_scene_len: Optional[str]) -> Dict[str, Any]: + def get_detect_hist_params( + self, + threshold: ty.Optional[float] = None, + bins: ty.Optional[int] = None, + min_scene_len: ty.Optional[str] = None, + ) -> ty.Dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -477,14 +490,18 @@ def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int] min_scene_len = self.config.get_value("detect-hist", "min-scene-len") min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num return { - 'bins': self.config.get_value("detect-hist", "bins", bins), - 'min_scene_len': min_scene_len, - 'threshold': self.config.get_value("detect-hist", "threshold", threshold), + "bins": self.config.get_value("detect-hist", "bins", bins), + "min_scene_len": min_scene_len, + "threshold": self.config.get_value("detect-hist", "threshold", threshold), } - def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int], - lowpass: Optional[int], - min_scene_len: Optional[str]) -> Dict[str, Any]: + def get_detect_hash_params( + self, + threshold: ty.Optional[float] = None, + size: ty.Optional[int] = None, + lowpass: ty.Optional[int] = None, + min_scene_len: ty.Optional[str] = None, + ) -> ty.Dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" self._ensure_input_open() if self.drop_short_scenes: @@ -505,35 +522,36 @@ def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int] def handle_export_html( self, - filename: Optional[AnyStr], + filename: ty.Optional[ty.AnyStr], no_images: bool, - image_width: Optional[int], - image_height: Optional[int], + image_width: ty.Optional[int], + image_height: ty.Optional[int], ): """Handle `export-html` command options.""" self._ensure_input_open() if self.export_html: - self._on_duplicate_command('export_html') + self._on_duplicate_command("export_html") - no_images = no_images or self.config.get_value('export-html', 'no-images') + no_images = no_images or self.config.get_value("export-html", "no-images") self.html_include_images = not no_images - self.html_name_format = self.config.get_value('export-html', 'filename', filename) - self.image_width = self.config.get_value('export-html', 'image-width', image_width) - self.image_height = self.config.get_value('export-html', 'image-height', image_height) + self.html_name_format = self.config.get_value("export-html", "filename", filename) + self.image_width = self.config.get_value("export-html", "image-width", image_width) + self.image_height = self.config.get_value("export-html", "image-height", image_height) if not self.save_images and not no_images: raise click.BadArgumentUsage( - 'The export-html command requires that the save-images command\n' - 'is specified before it, unless --no-images is specified.') - logger.info('HTML file name format:\n %s', filename) + "The export-html command requires that the save-images command\n" + "is specified before it, unless --no-images is specified." + ) + logger.info("HTML file name format:\n %s", filename) self.export_html = True def handle_list_scenes( self, - output: Optional[AnyStr], - filename: Optional[AnyStr], + output: ty.Optional[ty.AnyStr], + filename: ty.Optional[ty.AnyStr], no_output_file: bool, quiet: bool, skip_cuts: bool, @@ -551,7 +569,8 @@ def handle_list_scenes( no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") self.scene_list_dir = self.config.get_value( - "list-scenes", "output", output, ignore_default=True) + "list-scenes", "output", output, ignore_default=True + ) self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) if self.scene_list_name_format is not None and not no_output_file: logger.info("Scene list filename format:\n %s", self.scene_list_name_format) @@ -563,75 +582,78 @@ def handle_list_scenes( def handle_split_video( self, - output: Optional[AnyStr], - filename: Optional[AnyStr], + output: ty.Optional[ty.AnyStr], + filename: ty.Optional[ty.AnyStr], quiet: bool, copy: bool, high_quality: bool, - rate_factor: Optional[int], - preset: Optional[str], - args: Optional[str], + rate_factor: ty.Optional[int], + preset: ty.Optional[str], + args: ty.Optional[str], mkvmerge: bool, ): """Handle `split-video` command options.""" self._ensure_input_open() if self.split_video: - self._on_duplicate_command('split-video') + self._on_duplicate_command("split-video") check_split_video_requirements(use_mkvmerge=mkvmerge) if contains_sequence_or_url(self.video_stream.path): - error_str = 'The split-video command is incompatible with image sequences/URLs.' - raise click.BadParameter(error_str, param_hint='split-video') + error_str = "The split-video command is incompatible with image sequences/URLs." + raise click.BadParameter(error_str, param_hint="split-video") ## ## Common Arguments/Options ## self.split_video = True - self.split_quiet = quiet or self.config.get_value('split-video', 'quiet') - self.split_dir = self.config.get_value('split-video', 'output', output, ignore_default=True) + self.split_quiet = quiet or self.config.get_value("split-video", "quiet") + self.split_dir = self.config.get_value("split-video", "output", output, ignore_default=True) if self.split_dir is not None: - logger.info('Video output path set: \n%s', self.split_dir) - self.split_name_format = self.config.get_value('split-video', 'filename', filename) + logger.info("Video output path set: \n%s", self.split_dir) + self.split_name_format = self.config.get_value("split-video", "filename", filename) # We only load the config values for these flags/options if none of the other # encoder flags/options were set via the CLI to avoid any conflicting options # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). if not (mkvmerge or copy or high_quality or args or rate_factor or preset): - mkvmerge = self.config.get_value('split-video', 'mkvmerge') - copy = self.config.get_value('split-video', 'copy') - high_quality = self.config.get_value('split-video', 'high-quality') - rate_factor = self.config.get_value('split-video', 'rate-factor') - preset = self.config.get_value('split-video', 'preset') - args = self.config.get_value('split-video', 'args') + mkvmerge = self.config.get_value("split-video", "mkvmerge") + copy = self.config.get_value("split-video", "copy") + high_quality = self.config.get_value("split-video", "high-quality") + rate_factor = self.config.get_value("split-video", "rate-factor") + preset = self.config.get_value("split-video", "preset") + args = self.config.get_value("split-video", "args") # Disallow certain combinations of flags/options. if mkvmerge or copy: - command = 'mkvmerge (-m)' if mkvmerge else 'copy (-c)' + command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" if high_quality: raise click.BadParameter( - 'high-quality (-hq) cannot be used with %s' % (command), - param_hint='split-video') + "high-quality (-hq) cannot be used with %s" % (command), + param_hint="split-video", + ) if args: raise click.BadParameter( - 'args (-a) cannot be used with %s' % (command), param_hint='split-video') + "args (-a) cannot be used with %s" % (command), param_hint="split-video" + ) if rate_factor: raise click.BadParameter( - 'rate-factor (crf) cannot be used with %s' % (command), - param_hint='split-video') + "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" + ) if preset: raise click.BadParameter( - 'preset (-p) cannot be used with %s' % (command), param_hint='split-video') + "preset (-p) cannot be used with %s" % (command), param_hint="split-video" + ) ## ## mkvmerge-Specific Arguments/Options ## if mkvmerge: if copy: - logger.warning('copy mode (-c) ignored due to mkvmerge mode (-m).') + logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") self.split_mkvmerge = True - logger.info('Using mkvmerge for video splitting.') + logger.info("Using mkvmerge for video splitting.") return ## @@ -644,96 +666,102 @@ def handle_split_video( rate_factor = 22 if not high_quality else 17 if preset is None: preset = "veryfast" if not high_quality else "slow" - args = ("-map 0:v:0 -map 0:a? -map 0:s? " - f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac") + args = ( + "-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" + ) - logger.info('ffmpeg arguments: %s', args) + logger.info("ffmpeg arguments: %s", args) self.split_args = args if filename: - logger.info('Output file name format: %s', filename) + logger.info("Output file name format: %s", filename) def handle_save_images( self, - num_images: Optional[int], - output: Optional[AnyStr], - filename: Optional[AnyStr], + num_images: ty.Optional[int], + output: ty.Optional[ty.AnyStr], + filename: ty.Optional[ty.AnyStr], jpeg: bool, webp: bool, - quality: Optional[int], + quality: ty.Optional[int], png: bool, - compression: Optional[int], - frame_margin: Optional[int], - scale: Optional[float], - height: Optional[int], - width: Optional[int], + compression: ty.Optional[int], + frame_margin: ty.Optional[int], + scale: ty.Optional[float], + height: ty.Optional[int], + width: ty.Optional[int], ): """Handle `save-images` command options.""" self._ensure_input_open() if self.save_images: - self._on_duplicate_command('save-images') + self._on_duplicate_command("save-images") - if '://' in self.video_stream.path: - error_str = '\nThe save-images command is incompatible with URLs.' + if "://" in self.video_stream.path: + error_str = "\nThe save-images command is incompatible with URLs." logger.error(error_str) - raise click.BadParameter(error_str, param_hint='save-images') + raise click.BadParameter(error_str, param_hint="save-images") num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) if num_flags > 1: - logger.error('Multiple image type flags set for save-images command.') + logger.error("Multiple image type flags set for save-images command.") raise click.BadParameter( - 'Only one image type (JPG/PNG/WEBP) can be specified.', param_hint='save-images') + "Only one image type (JPG/PNG/WEBP) can be specified.", param_hint="save-images" + ) # Only use config params for image format if one wasn't specified. elif num_flags == 0: - image_format = self.config.get_value('save-images', 'format').lower() - jpeg = image_format == 'jpeg' - webp = image_format == 'webp' - png = image_format == 'png' + image_format = self.config.get_value("save-images", "format").lower() + jpeg = image_format == "jpeg" + webp = image_format == "webp" + png = image_format == "png" # Only use config params for scale/height/width if none of them are specified explicitly. if scale is None and height is None and width is None: - self.scale = self.config.get_value('save-images', 'scale') - self.height = self.config.get_value('save-images', 'height') - self.width = self.config.get_value('save-images', 'width') + self.scale = self.config.get_value("save-images", "scale") + self.height = self.config.get_value("save-images", "height") + self.width = self.config.get_value("save-images", "width") else: self.scale = scale self.height = height self.width = width - self.scale_method = Interpolation[self.config.get_value('save-images', - 'scale-method').upper()] + self.scale_method = Interpolation[ + self.config.get_value("save-images", "scale-method").upper() + ] default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY quality = ( - default_quality if self.config.is_default('save-images', 'quality') else - self.config.get_value('save-images', 'quality')) + default_quality + if self.config.is_default("save-images", "quality") + else self.config.get_value("save-images", "quality") + ) - compression = self.config.get_value('save-images', 'compression', compression) + compression = self.config.get_value("save-images", "compression", compression) self.image_param = compression if png else quality - self.image_extension = 'jpg' if jpeg else 'png' if png else 'webp' + self.image_extension = "jpg" if jpeg else "png" if png else "webp" valid_params = get_cv2_imwrite_params() - if not self.image_extension in valid_params or valid_params[self.image_extension] is None: + if self.image_extension not in valid_params or valid_params[self.image_extension] is None: error_strs = [ - 'Image encoder type `%s` not supported.' % self.image_extension.upper(), - 'The specified encoder type could not be found in the current OpenCV module.', - 'To enable this output format, please update the installed version of OpenCV.', - 'If you build OpenCV, ensure the the proper dependencies are enabled. ' + "Image encoder type `%s` not supported." % self.image_extension.upper(), + "The specified encoder type could not be found in the current OpenCV module.", + "To enable this output format, please update the installed version of OpenCV.", + "If you build OpenCV, ensure the the proper dependencies are enabled. ", ] - logger.debug('\n'.join(error_strs)) - raise click.BadParameter('\n'.join(error_strs), param_hint='save-images') + logger.debug("\n".join(error_strs)) + raise click.BadParameter("\n".join(error_strs), param_hint="save-images") - self.image_dir = self.config.get_value('save-images', 'output', output, ignore_default=True) + self.image_dir = self.config.get_value("save-images", "output", output, ignore_default=True) - self.image_name_format = self.config.get_value('save-images', 'filename', filename) - self.num_images = self.config.get_value('save-images', 'num-images', num_images) - self.frame_margin = self.config.get_value('save-images', 'frame-margin', frame_margin) + self.image_name_format = self.config.get_value("save-images", "filename", filename) + self.num_images = self.config.get_value("save-images", "num-images", num_images) + self.frame_margin = self.config.get_value("save-images", "frame-margin", frame_margin) - image_type = ('jpeg' if jpeg else self.image_extension).upper() - image_param_type = 'Compression' if png else 'Quality' - image_param_type = ' [%s: %d]' % (image_param_type, self.image_param) - logger.info('Image output format set: %s%s', image_type, image_param_type) + image_type = ("jpeg" if jpeg else self.image_extension).upper() + image_param_type = "Compression" if png else "Quality" + image_param_type = " [%s: %d]" % (image_param_type, self.image_param) + logger.info("Image output format set: %s%s", image_type, image_param_type) if self.image_dir is not None: - logger.info('Image output directory set:\n %s', os.path.abspath(self.image_dir)) + logger.info("Image output directory set:\n %s", os.path.abspath(self.image_dir)) self.save_images = True @@ -741,13 +769,15 @@ def handle_time(self, start, duration, end): """Handle `time` command options.""" self._ensure_input_open() if self.time: - self._on_duplicate_command('time') + self._on_duplicate_command("time") if duration is not None and end is not None: raise click.BadParameter( - 'Only one of --duration/-d or --end/-e can be specified, not both.', - param_hint='time') - logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration, - end) + "Only one of --duration/-d or --end/-e can be specified, not both.", + param_hint="time", + ) + logger.debug( + "Setting video time:\n start: %s, duration: %s, end: %s", start, duration, end + ) # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to # match the default start number used by `ffmpeg` when saving frames as images. As such, # we must correct start time if set as frames. See the test_cli_time* tests for for details. @@ -764,9 +794,9 @@ def handle_time(self, start, duration, end): def _initialize_logging( self, - quiet: Optional[bool] = None, - verbosity: Optional[str] = None, - logfile: Optional[AnyStr] = None, + quiet: ty.Optional[bool] = None, + verbosity: ty.Optional[str] = None, + logfile: ty.Optional[ty.AnyStr] = None, ): """Setup logging based on CLI args and user configuration settings.""" if quiet is not None: @@ -774,29 +804,29 @@ def _initialize_logging( curr_verbosity = logging.INFO # Convert verbosity into it's log level enum, and override quiet mode if set. if verbosity is not None: - assert verbosity in CHOICE_MAP['global']['verbosity'] - if verbosity.lower() == 'none': + assert verbosity in CHOICE_MAP["global"]["verbosity"] + if verbosity.lower() == "none": self.quiet_mode = True - verbosity = 'info' + verbosity = "info" else: # Override quiet mode if verbosity is set. self.quiet_mode = False curr_verbosity = getattr(logging, verbosity.upper()) else: - verbosity_str = USER_CONFIG.get_value('global', 'verbosity') - assert verbosity_str in CHOICE_MAP['global']['verbosity'] - if verbosity_str.lower() == 'none': + verbosity_str = USER_CONFIG.get_value("global", "verbosity") + assert verbosity_str in CHOICE_MAP["global"]["verbosity"] + if verbosity_str.lower() == "none": self.quiet_mode = True else: curr_verbosity = getattr(logging, verbosity_str.upper()) # Override quiet mode if verbosity is set. - if not USER_CONFIG.is_default('global', 'verbosity'): + if not USER_CONFIG.is_default("global", "verbosity"): self.quiet_mode = False # Initialize logger with the set CLI args / user configuration. init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) def add_detector(self, detector): - """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """ + """Add Detector: Adds a detection algorithm to the CliContext's SceneManager.""" if self.load_scenes_input: raise click.ClickException("The load-scenes command cannot be used with detectors.") self._ensure_input_open() @@ -812,40 +842,44 @@ def _ensure_input_open(self) -> None: click.BadParameter: self.video_stream was not initialized. """ if self.video_stream is None: - raise click.ClickException('No input video (-i/--input) was specified.') + raise click.ClickException("No input video (-i/--input) was specified.") - def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float], - backend: Optional[str]): - if '%' in input_path and backend != 'opencv': + def _open_video_stream( + self, input_path: ty.AnyStr, framerate: ty.Optional[float], backend: ty.Optional[str] + ): + if "%" in input_path and backend != "opencv": raise click.BadParameter( - 'The OpenCV backend (`--backend opencv`) must be used to process image sequences.', - param_hint='-i/--input') + "The OpenCV backend (`--backend opencv`) must be used to process image sequences.", + param_hint="-i/--input", + ) if framerate is not None and framerate < MAX_FPS_DELTA: - raise click.BadParameter('Invalid framerate specified!', param_hint='-f/--framerate') + raise click.BadParameter("Invalid framerate specified!", param_hint="-f/--framerate") try: if backend is None: - backend = self.config.get_value('global', 'backend') + backend = self.config.get_value("global", "backend") else: - if not backend in AVAILABLE_BACKENDS: + if backend not in AVAILABLE_BACKENDS: raise click.BadParameter( - 'Specified backend %s is not available on this system!' % backend, - param_hint='-b/--backend') + "Specified backend %s is not available on this system!" % backend, + param_hint="-b/--backend", + ) # Open the video with the specified backend, loading any required config settings. - if backend == 'pyav': + if backend == "pyav": self.video_stream = open_video( path=input_path, framerate=framerate, backend=backend, - threading_mode=self.config.get_value('backend-pyav', 'threading-mode'), - suppress_output=self.config.get_value('backend-pyav', 'suppress-output'), + threading_mode=self.config.get_value("backend-pyav", "threading-mode"), + suppress_output=self.config.get_value("backend-pyav", "suppress-output"), ) - elif backend == 'opencv': + elif backend == "opencv": self.video_stream = open_video( path=input_path, framerate=framerate, backend=backend, - max_decode_attempts=self.config.get_value('backend-opencv', - 'max-decode-attempts'), + max_decode_attempts=self.config.get_value( + "backend-opencv", "max-decode-attempts" + ), ) # Handle backends without any config options. else: @@ -854,19 +888,23 @@ def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float], framerate=framerate, backend=backend, ) - logger.debug('Video opened using backend %s', type(self.video_stream).__name__) + logger.debug("Video opened using backend %s", type(self.video_stream).__name__) except FrameRateUnavailable as ex: raise click.BadParameter( - 'Failed to obtain framerate for input video. Manually specify framerate with the' - ' -f/--framerate option, or try re-encoding the file.', - param_hint='-i/--input') from ex + "Failed to obtain framerate for input video. Manually specify framerate with the" + " -f/--framerate option, or try re-encoding the file.", + param_hint="-i/--input", + ) from ex except VideoOpenFailure as ex: raise click.BadParameter( - 'Failed to open input video%s: %s' % - (' using %s backend' % backend if backend else '', str(ex)), - param_hint='-i/--input') from ex + "Failed to open input video%s: %s" + % (" using %s backend" % backend if backend else "", str(ex)), + param_hint="-i/--input", + ) from ex except OSError as ex: - raise click.BadParameter('Input error:\n\n\t%s\n' % str(ex), param_hint='-i/--input') + raise click.BadParameter( + "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" + ) from None def _on_duplicate_command(self, command: str) -> None: """Called when a command is duplicated to stop parsing and raise an error. @@ -878,10 +916,11 @@ def _on_duplicate_command(self, command: str) -> None: click.BadParameter """ error_strs = [] - error_strs.append('Error: Command %s specified multiple times.' % command) - error_strs.append('The %s command may appear only one time.') + error_strs.append("Error: Command %s specified multiple times." % command) + error_strs.append("The %s command may appear only one time.") - logger.error('\n'.join(error_strs)) + logger.error("\n".join(error_strs)) raise click.BadParameter( - '\n Command %s may only be specified once.' % command, - param_hint='%s command' % command) + "\n Command %s may only be specified once." % command, + param_hint="%s command" % command, + ) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index d7180542..eae039d4 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -15,21 +14,27 @@ import csv import logging import os -from string import Template import time import typing as ty -from typing import Dict, List, Tuple, Optional from string import Template +from scenedetect._cli.context import CliContext, check_split_video_requirements from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path -from scenedetect.scene_manager import get_scenes_from_cuts, save_images, write_scene_list, write_scene_list_html -from scenedetect.video_splitter import split_video_mkvmerge, split_video_ffmpeg +from scenedetect.scene_manager import ( + get_scenes_from_cuts, + save_images, + write_scene_list, + write_scene_list_html, +) +from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.video_stream import SeekError -from scenedetect._cli.context import CliContext, check_split_video_requirements +logger = logging.getLogger("pyscenedetect") + +SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] -logger = logging.getLogger('pyscenedetect') +CutList = ty.List[FrameTimecode] def run_scenedetect(context: CliContext): @@ -61,11 +66,13 @@ def run_scenedetect(context: CliContext): _save_stats(context) if scene_list: logger.info( - 'Detected %d scenes, average shot length %.1f seconds.', len(scene_list), + "Detected %d scenes, average shot length %.1f seconds.", + len(scene_list), sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) - / float(len(scene_list))) + / float(len(scene_list)), + ) else: - logger.info('No scenes detected.') + logger.info("No scenes detected.") # Handle list-scenes command. _list_scenes(context, scene_list, cut_list) @@ -80,48 +87,56 @@ def run_scenedetect(context: CliContext): _split_video(context, scene_list) -def _detect(context: CliContext): +def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: # Use default detector if one was not specified. if context.scene_manager.get_num_detectors() == 0: detector_type, detector_args = context.default_detector - logger.debug('Using default detector: %s(%s)' % (detector_type.__name__, detector_args)) + logger.debug("Using default detector: %s(%s)" % (detector_type.__name__, detector_args)) context.scene_manager.add_detector(detector_type(**detector_args)) perf_start_time = time.time() if context.start_time is not None: - logger.debug('Seeking to start time...') + logger.debug("Seeking to start time...") try: context.video_stream.seek(target=context.start_time) except SeekError as ex: - logger.critical('Failed to seek to %s / frame %d: %s', - context.start_time.get_timecode(), context.start_time.get_frames(), - str(ex)) - return + logger.critical( + "Failed to seek to %s / frame %d: %s", + context.start_time.get_timecode(), + context.start_time.get_frames(), + str(ex), + ) + return None num_frames = context.scene_manager.detect_scenes( video=context.video_stream, duration=context.duration, end_time=context.end_time, frame_skip=context.frame_skip, - show_progress=not context.quiet_mode) + show_progress=not context.quiet_mode, + ) # Handle case where video failure is most likely due to multiple audio tracks (#179). # TODO(#380): Ensure this does not erroneusly fire. - if num_frames <= 0 and context.video_stream.BACKEND_NAME == 'opencv': + if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv": logger.critical( - 'Failed to read any frames from video file. This could be caused by the video' - ' having multiple audio tracks. If so, try installing the PyAV backend:\n' - ' pip install av\n' - 'Or remove the audio tracks by running either:\n' - ' ffmpeg -i input.mp4 -c copy -an output.mp4\n' - ' mkvmerge -o output.mkv input.mp4\n' - 'For details, see https://scenedetect.com/faq/') - return + "Failed to read any frames from video file. This could be caused by the video" + " having multiple audio tracks. If so, try installing the PyAV backend:\n" + " pip install av\n" + "Or remove the audio tracks by running either:\n" + " ffmpeg -i input.mp4 -c copy -an output.mp4\n" + " mkvmerge -o output.mkv input.mp4\n" + "For details, see https://scenedetect.com/faq/" + ) + return None perf_duration = time.time() - perf_start_time - logger.info('Processed %d frames in %.1f seconds (average %.2f FPS).', num_frames, - perf_duration, - float(num_frames) / perf_duration) + logger.info( + "Processed %d frames in %.1f seconds (average %.2f FPS).", + num_frames, + perf_duration, + float(num_frames) / perf_duration, + ) # Get list of detected cuts/scenes from the SceneManager to generate the required output # files, based on the given commands (list-scenes, split-video, save-images, etc...). @@ -137,34 +152,36 @@ def _save_stats(context: CliContext) -> None: return if context.stats_manager.is_save_required(): path = get_and_create_path(context.stats_file_path, context.output_dir) - logger.info('Saving frame metrics to stats file: %s', path) + logger.info("Saving frame metrics to stats file: %s", path) with open(path, mode="w") as file: context.stats_manager.save_to_csv(csv_file=file) else: - logger.debug('No frame metrics updated, skipping update of the stats file.') + logger.debug("No frame metrics updated, skipping update of the stats file.") -def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - cut_list: List[FrameTimecode]) -> None: +def _list_scenes(context: CliContext, scene_list: SceneList, cut_list: CutList) -> None: """Handles the `list-scenes` command.""" if not context.list_scenes: return # Write scene list CSV to if required. if context.scene_list_output: - scene_list_filename = Template( - context.scene_list_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) - if not scene_list_filename.lower().endswith('.csv'): - scene_list_filename += '.csv' + scene_list_filename = Template(context.scene_list_name_format).safe_substitute( + VIDEO_NAME=context.video_stream.name + ) + if not scene_list_filename.lower().endswith(".csv"): + scene_list_filename += ".csv" scene_list_path = get_and_create_path( scene_list_filename, - context.scene_list_dir if context.scene_list_dir is not None else context.output_dir) - logger.info('Writing scene list to CSV file:\n %s', scene_list_path) - with open(scene_list_path, 'wt') as scene_list_file: + context.scene_list_dir if context.scene_list_dir is not None else context.output_dir, + ) + logger.info("Writing scene list to CSV file:\n %s", scene_list_path) + with open(scene_list_path, "w") as scene_list_file: write_scene_list( output_csv_file=scene_list_file, scene_list=scene_list, include_cut_list=not context.skip_cuts, - cut_list=cut_list) + cut_list=cut_list, + ) # Suppress output if requested. if context.list_scenes_quiet: return @@ -176,26 +193,37 @@ def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- %s ------------------------------------------------------------------------""", '\n'.join([ - " | %5d | %11d | %s | %11d | %s |" % - (i + 1, start_time.get_frames() + 1, start_time.get_timecode(), - end_time.get_frames(), end_time.get_timecode()) - for i, (start_time, end_time) in enumerate(scene_list) - ])) +-----------------------------------------------------------------------""", + "\n".join( + [ + " | %5d | %11d | %s | %11d | %s |" + % ( + i + 1, + start_time.get_frames() + 1, + start_time.get_timecode(), + end_time.get_frames(), + end_time.get_timecode(), + ) + for i, (start_time, end_time) in enumerate(scene_list) + ] + ), + ) # Print cut list. if cut_list and context.display_cuts: - logger.info("Comma-separated timecode list:\n %s", - ",".join([context.cut_format.format(cut) for cut in cut_list])) + logger.info( + "Comma-separated timecode list:\n %s", + ",".join([context.cut_format.format(cut) for cut in cut_list]), + ) def _save_images( - context: CliContext, - scene_list: List[Tuple[FrameTimecode, FrameTimecode]]) -> Optional[Dict[int, List[str]]]: + context: CliContext, scene_list: SceneList +) -> ty.Optional[ty.Dict[int, ty.List[str]]]: """Handles the `save-images` command.""" if not context.save_images: return None # Command can override global output directory setting. - output_dir = (context.output_dir if context.image_dir is None else context.image_dir) + output_dir = context.output_dir if context.image_dir is None else context.image_dir return save_images( scene_list=scene_list, video=context.video_stream, @@ -209,23 +237,28 @@ def _save_images( scale=context.scale, height=context.height, width=context.width, - interpolation=context.scale_method) + interpolation=context.scale_method, + ) -def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - cut_list: List[FrameTimecode], image_filenames: Optional[Dict[int, - List[str]]]) -> None: +def _export_html( + context: CliContext, + scene_list: SceneList, + cut_list: CutList, + image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]], +) -> None: """Handles the `export-html` command.""" if not context.export_html: return # Command can override global output directory setting. - output_dir = (context.output_dir if context.image_dir is None else context.image_dir) - html_filename = Template( - context.html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) - if not html_filename.lower().endswith('.html'): - html_filename += '.html' + output_dir = context.output_dir if context.image_dir is None else context.image_dir + html_filename = Template(context.html_name_format).safe_substitute( + VIDEO_NAME=context.video_stream.name + ) + if not html_filename.lower().endswith(".html"): + html_filename += ".html" html_path = get_and_create_path(html_filename, output_dir) - logger.info('Exporting to html file:\n %s:', html_path) + logger.info("Exporting to html file:\n %s:", html_path) if not context.html_include_images: image_filenames = None write_scene_list_html( @@ -234,24 +267,24 @@ def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, Fram cut_list, image_filenames=image_filenames, image_width=context.image_width, - image_height=context.image_height) + image_height=context.image_height, + ) -def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, - FrameTimecode]]) -> None: +def _split_video(context: CliContext, scene_list: SceneList) -> None: """Handles the `split-video` command.""" if not context.split_video: return output_path_template = context.split_name_format # Add proper extension to filename template if required. - dot_pos = output_path_template.rfind('.') + dot_pos = output_path_template.rfind(".") extension_length = 0 if dot_pos < 0 else len(output_path_template) - (dot_pos + 1) # If using mkvmerge, force extension to .mkv. - if context.split_mkvmerge and not output_path_template.endswith('.mkv'): - output_path_template += '.mkv' + if context.split_mkvmerge and not output_path_template.endswith(".mkv"): + output_path_template += ".mkv" # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. elif not 2 <= extension_length <= 4: - output_path_template += '.mp4' + output_path_template += ".mp4" # Ensure the appropriate tool is available before handling split-video. check_split_video_requirements(context.split_mkvmerge) # Command can override global output directory setting. @@ -275,29 +308,28 @@ def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode, show_output=not (context.quiet_mode or context.split_quiet), ) if scene_list: - logger.info('Video splitting completed, scenes written to disk.') + logger.info("Video splitting completed, scenes written to disk.") -def _load_scenes( - context: CliContext -) -> ty.Tuple[ty.Iterable[ty.Tuple[FrameTimecode, FrameTimecode]], ty.Iterable[FrameTimecode]]: +def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) - with open(context.load_scenes_input, 'r') as input_file: + with open(context.load_scenes_input) as input_file: file_reader = csv.reader(input_file) csv_headers = next(file_reader) - if not context.load_scenes_column_name in csv_headers: + if context.load_scenes_column_name not in csv_headers: csv_headers = next(file_reader) # Check to make sure column headers are present if context.load_scenes_column_name not in csv_headers: - raise ValueError('specified column header for scene start is not present') + raise ValueError("specified column header for scene start is not present") col_idx = csv_headers.index(context.load_scenes_column_name) cut_list = sorted( FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 - for row in file_reader) + for row in file_reader + ) # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` # but this part of the API is being reworked and hasn't been used by any detectors yet. @@ -319,13 +351,11 @@ def _load_scenes( cut_list = [cut for cut in cut_list if cut < end_time] return get_scenes_from_cuts( - cut_list=cut_list, start_pos=start_time, end_pos=end_time), cut_list - + cut_list=cut_list, start_pos=start_time, end_pos=end_time + ), cut_list -def _postprocess_scene_list( - context: CliContext, scene_list: ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] -) -> ty.List[ty.Tuple[FrameTimecode, FrameTimecode]]: +def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, # it will be merged with the previous one. if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: diff --git a/scenedetect/_thirdparty/__init__.py b/scenedetect/_thirdparty/__init__.py index 83987ca8..5442893f 100644 --- a/scenedetect/_thirdparty/__init__.py +++ b/scenedetect/_thirdparty/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py index e940a432..df01519d 100644 --- a/scenedetect/_thirdparty/simpletable.py +++ b/scenedetect/_thirdparty/simpletable.py @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- # The MIT License (MIT) # @@ -56,13 +55,15 @@ def quote(string): try: from urllib.parse import quote + return quote(string) except ModuleNotFoundError: from urllib import pathname2url + return pathname2url(string) -class SimpleTableCell(object): +class SimpleTableCell: """A table class to create table cells. Example: @@ -82,12 +83,12 @@ def __init__(self, text, header=False): def __str__(self): """Return the HTML code for the table cell.""" if self.header: - return '%s' % (self.text) + return "%s" % (self.text) else: - return '%s' % (self.text) + return "%s" % (self.text) -class SimpleTableImage(object): +class SimpleTableImage: """A table class to create table cells with an image. Example: @@ -121,12 +122,12 @@ def __str__(self): output += ' height="%s"' % (self.height) if self.width: output += ' width="%s"' % (self.width) - output += '>' + output += ">" return output -class SimpleTableRow(object): +class SimpleTableRow: """A table class to create table rows, populated by table cells. Example: @@ -161,14 +162,14 @@ def __str__(self): """Return the HTML code for the table row and its cells as a string.""" row = [] - row.append('') + row.append("") for cell in self.cells: row.append(str(cell)) - row.append('') + row.append("") - return '\n'.join(row) + return "\n".join(row) def __iter__(self): """Iterate through row cells""" @@ -185,7 +186,7 @@ def add_cells(self, cells): self.cells.append(cell) -class SimpleTable(object): +class SimpleTable: """A table class to create HTML tables, populated by HTML table rows. Example: @@ -232,9 +233,9 @@ def __str__(self): table = [] if self.css_class: - table.append('' % self.css_class) + table.append("
    " % self.css_class) else: - table.append('
    ') + table.append("
    ") if self.header_row: table.append(str(self.header_row)) @@ -242,9 +243,9 @@ def __str__(self): for row in self.rows: table.append(str(row)) - table.append('
    ') + table.append("") - return '\n'.join(table) + return "\n".join(table) def __iter__(self): """Iterate through table rows""" @@ -261,7 +262,7 @@ def add_rows(self, rows): self.rows.append(row) -class HTMLPage(object): +class HTMLPage: """A class to create HTML pages containing CSS and tables.""" def __init__(self, tables=None, css=None, encoding="utf-8"): @@ -285,14 +286,15 @@ def __str__(self): page.append('' % self.css) # Set encoding - page.append('' % self.encoding) + page.append( + '' % self.encoding + ) for table in self.tables: page.append(str(table)) - page.append('
    ') + page.append("
    ") - return '\n'.join(page) + return "\n".join(page) def __iter__(self): """Iterate through tables""" @@ -301,7 +303,7 @@ def __iter__(self): def save(self, filename): """Save HTML page to a file using the proper encoding""" - with codecs.open(filename, 'w', self.encoding) as outfile: + with codecs.open(filename, "w", self.encoding) as outfile: for line in str(self): outfile.write(line) @@ -324,4 +326,4 @@ def fit_data_to_columns(data, num_cols): if len(data) % num_cols != 0: num_iterations += 1 - return [data[num_cols * i:num_cols * i + num_cols] for i in range(num_iterations)] + return [data[num_cols * i : num_cols * i + num_cols] for i in range(num_iterations)] diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 6296bd31..a8bd763a 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -87,7 +86,7 @@ from typing import Dict, Type # OpenCV must be available at minimum. -from scenedetect.backends.opencv import VideoStreamCv2, VideoCaptureAdapter +from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 try: from scenedetect.backends.pyav import VideoStreamAv @@ -102,11 +101,15 @@ # TODO: Lazy-loading backends would improve startup performance. However, this requires removing # some of the re-exported types above from the public API. AVAILABLE_BACKENDS: Dict[str, Type] = { - backend.BACKEND_NAME: backend for backend in filter(None, [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - ]) + backend.BACKEND_NAME: backend + for backend in filter( + None, + [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + ], + ) } """All available backends that :func:`scenedetect.open_video` can consider for the `backend` parameter. These backends must support construction with the following signature: diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index e0c4a92b..e85f37c4 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -18,18 +17,18 @@ """ from logging import getLogger -from typing import AnyStr, Tuple, Union, Optional +from typing import AnyStr, Optional, Tuple, Union import cv2 -from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader import numpy as np +from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader +from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure -from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") class VideoStreamMoviePy(VideoStream): @@ -53,7 +52,8 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: # TODO: Add framerate override. if framerate is not None: raise NotImplementedError( - "VideoStreamMoviePy does not support the `framerate` argument yet.") + "VideoStreamMoviePy does not support the `framerate` argument yet." + ) self._path = path # TODO: Need to map errors based on the strings, since several failure @@ -77,7 +77,7 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: # VideoStream Methods/Properties # - BACKEND_NAME = 'moviepy' + BACKEND_NAME = "moviepy" """Unique name used to identify this backend.""" @property @@ -103,13 +103,13 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return tuple(self._reader.infos['video_size']) + return tuple(self._reader.infos["video_size"]) @property def duration(self) -> Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - assert isinstance(self._reader.infos['duration'], float) - return self.base_timecode + self._reader.infos['duration'] + assert isinstance(self._reader.infos["duration"], float) + return self.base_timecode + self._reader.infos["duration"] @property def aspect_ratio(self) -> float: @@ -178,7 +178,7 @@ def seek(self, target: Union[FrameTimecode, float, int]): target = FrameTimecode(target, self.frame_rate) try: self._reader.get_frame(target.get_seconds()) - except IOError as ex: + except OSError as ex: # Leave the object in a valid state. self.reset() # TODO(#380): Other backends do not currently throw an exception if attempting to seek @@ -192,7 +192,7 @@ def seek(self, target: Union[FrameTimecode, float, int]): self._frame_number = target.frame_num def reset(self): - """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._reader.initialize() self._last_frame = self._reader.read_frame() self._frame_number = 0 @@ -213,7 +213,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b if self._last_frame_rgb is None: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) return self._last_frame_rgb - if not hasattr(self._reader, 'lastread'): + if not hasattr(self._reader, "lastread"): return False self._last_frame = self._reader.lastread self._reader.read_frame() diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 4ab9a897..862a19e2 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -18,33 +17,33 @@ which do not support seeking. """ -from logging import getLogger import math -from typing import AnyStr, Tuple, Union, Optional import os.path +from logging import getLogger +from typing import AnyStr, Optional, Tuple, Union import cv2 import numpy as np -from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA +from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure, FrameRateUnavailable +from scenedetect.video_stream import FrameRateUnavailable, SeekError, VideoOpenFailure, VideoStream -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") -IMAGE_SEQUENCE_IDENTIFIER = '%' +IMAGE_SEQUENCE_IDENTIFIER = "%" NON_VIDEO_FILE_INPUT_IDENTIFIERS = ( - IMAGE_SEQUENCE_IDENTIFIER, # image sequence - '://', # URL/network stream - ' ! ', # gstreamer pipe + IMAGE_SEQUENCE_IDENTIFIER, # image sequence + "://", # URL/network stream + " ! ", # gstreamer pipe ) def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float: """Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels).""" # Versions of OpenCV < 3.4.1 do not support this, so we fall back to 1.0. - if not 'CAP_PROP_SAR_NUM' in dir(cv2): + if "CAP_PROP_SAR_NUM" not in dir(cv2): return 1.0 num: float = cap.get(cv2.CAP_PROP_SAR_NUM) den: float = cap.get(cv2.CAP_PROP_SAR_DEN) @@ -86,21 +85,22 @@ def __init__( super().__init__() # TODO(v0.7): Replace with DeprecationWarning that `path_or_device` will be removed in v0.8. if path_or_device is not None: - logger.error('path_or_device is deprecated, use path or VideoCaptureAdapter instead.') + logger.error("path_or_device is deprecated, use path or VideoCaptureAdapter instead.") path = path_or_device if path is None: - raise ValueError('Path must be specified!') + raise ValueError("Path must be specified!") if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError('Specified framerate (%f) is invalid!' % framerate) + raise ValueError("Specified framerate (%f) is invalid!" % framerate) if max_decode_attempts < 0: - raise ValueError('Maximum decode attempts must be >= 0!') + raise ValueError("Maximum decode attempts must be >= 0!") self._path_or_device = path self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: - self._cap: Optional[ - cv2.VideoCapture] = None # Reference to underlying cv2.VideoCapture object. + self._cap: Optional[cv2.VideoCapture] = ( + None # Reference to underlying cv2.VideoCapture object. + ) self._frame_rate: Optional[float] = None # VideoCapture state @@ -130,7 +130,7 @@ def capture(self) -> cv2.VideoCapture: # VideoStream Methods/Properties # - BACKEND_NAME = 'opencv' + BACKEND_NAME = "opencv" """Unique name used to identify this backend.""" @property @@ -157,7 +157,7 @@ def name(self) -> str: if IMAGE_SEQUENCE_IDENTIFIER in file_name: # file_name is an image sequence, trim everything including/after the %. # TODO: This excludes any suffix after the sequence identifier. - file_name = file_name[:file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] + file_name = file_name[: file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)] return file_name @property @@ -170,8 +170,10 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) + return ( + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) @property def duration(self) -> Optional[FrameTimecode]: @@ -258,7 +260,7 @@ def seek(self, target: Union[FrameTimecode, float, int]): self._has_grabbed = self._cap.grab() def reset(self): - """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._cap.release() self._open_capture(self._frame_rate) @@ -289,9 +291,9 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b # Report previous failure in debug mode. if has_grabbed: self._decode_failures += 1 - logger.debug('Frame failed to decode.') + logger.debug("Frame failed to decode.") if not self._warning_displayed and self._decode_failures > 1: - logger.warning('Failed to decode some frames, results may be inaccurate.') + logger.warning("Failed to decode some frames, results may be inaccurate.") # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: return False @@ -309,30 +311,33 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b def _open_capture(self, framerate: Optional[float] = None): """Opens capture referenced by this object and resets internal state.""" if self._is_device and self._path_or_device < 0: - raise ValueError('Invalid/negative device ID specified.') + raise ValueError("Invalid/negative device ID specified.") input_is_video_file = not self._is_device and not any( - identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS) + identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS + ) # We don't have a way of querying why opening a video fails (errors are logged at least), # so provide a better error message if we try to open a file that doesn't exist. - if input_is_video_file: - if not os.path.exists(self._path_or_device): - raise OSError('Video file not found.') + if input_is_video_file and not os.path.exists(self._path_or_device): + raise OSError("Video file not found.") cap = cv2.VideoCapture(self._path_or_device) if not cap.isOpened(): raise VideoOpenFailure( - 'Ensure file is valid video and system dependencies are up to date.\n') + "Ensure file is valid video and system dependencies are up to date.\n" + ) # Display an error if the video codec type seems unsupported (#86) as this indicates # potential video corruption, or may explain missing frames. We only perform this check # for video files on-disk (skipped for devices, image sequences, streams, etc...). - codec_unsupported: bool = (int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0) + codec_unsupported: bool = int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0 if codec_unsupported and input_is_video_file: - logger.error('Video codec detection failed. If output is incorrect:\n' - ' - Re-encode the input video with ffmpeg\n' - ' - Update OpenCV (pip install --upgrade opencv-python)\n' - ' - Use the PyAV backend (--backend pyav)\n' - 'For details, see https://github.com/Breakthrough/PySceneDetect/issues/86') + logger.error( + "Video codec detection failed. If output is incorrect:\n" + " - Re-encode the input video with ffmpeg\n" + " - Update OpenCV (pip install --upgrade opencv-python)\n" + " - Use the PyAV backend (--backend pyav)\n" + "For details, see https://github.com/Breakthrough/PySceneDetect/issues/86" + ) # Ensure the framerate is correct to avoid potential divide by zero errors. This can be # addressed in the PyAV backend if required since it supports integer timebases. @@ -380,11 +385,11 @@ def __init__( super().__init__() if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError('Specified framerate (%f) is invalid!' % framerate) + raise ValueError("Specified framerate (%f) is invalid!" % framerate) if max_read_attempts < 0: - raise ValueError('Maximum decode attempts must be >= 0!') + raise ValueError("Maximum decode attempts must be >= 0!") if not cap.isOpened(): - raise ValueError('Specified VideoCapture must already be opened!') + raise ValueError("Specified VideoCapture must already be opened!") if framerate is None: framerate = cap.get(cv2.CAP_PROP_FPS) if framerate < MAX_FPS_DELTA: @@ -417,7 +422,7 @@ def capture(self) -> cv2.VideoCapture: # VideoStream Methods/Properties # - BACKEND_NAME = 'opencv_adapter' + BACKEND_NAME = "opencv_adapter" """Unique name used to identify this backend.""" @property @@ -429,12 +434,12 @@ def frame_rate(self) -> float: @property def path(self) -> str: """Always 'CAP_ADAPTER'.""" - return 'CAP_ADAPTER' + return "CAP_ADAPTER" @property def name(self) -> str: """Always 'CAP_ADAPTER'.""" - return 'CAP_ADAPTER' + return "CAP_ADAPTER" @property def is_seekable(self) -> bool: @@ -444,8 +449,10 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> Tuple[int, int]: """Reported size of each video frame in pixels as a tuple of (width, height).""" - return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) + return ( + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) @property def duration(self) -> Optional[FrameTimecode]: @@ -526,9 +533,9 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b # Report previous failure in debug mode. if has_grabbed: self._decode_failures += 1 - logger.debug('Frame failed to decode.') + logger.debug("Frame failed to decode.") if not self._warning_displayed and self._decode_failures > 1: - logger.warning('Failed to decode some frames, results may be inaccurate.') + logger.warning("Failed to decode some frames, results may be inaccurate.") # We didn't manage to grab a frame even after retrying, so just return. if not has_grabbed: return False diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 07647818..cba203c7 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -15,15 +14,14 @@ from logging import getLogger from typing import AnyStr, BinaryIO, Optional, Tuple, Union -# pylint: disable=c-extension-no-member import av import numpy as np -from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA +from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable +from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") VALID_THREAD_MODES = [ av.codec.context.ThreadType.NONE, @@ -82,26 +80,26 @@ def __init__( # Ensure specified framerate is valid if set. if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError('Specified framerate (%f) is invalid!' % framerate) + raise ValueError("Specified framerate (%f) is invalid!" % framerate) - self._name = '' if name is None else name - self._path = '' + self._name = "" if name is None else name + self._path = "" self._frame = None self._reopened = True if threading_mode: threading_mode = threading_mode.upper() - if not threading_mode in VALID_THREAD_MODES: - raise ValueError('Invalid threading mode! Must be one of: %s' % VALID_THREAD_MODES) + if threading_mode not in VALID_THREAD_MODES: + raise ValueError("Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES) if not suppress_output: - logger.debug('Restoring default ffmpeg log callbacks.') + logger.debug("Restoring default ffmpeg log callbacks.") av.logging.restore_default_callback() try: if isinstance(path_or_io, (str, bytes)): self._path = path_or_io - self._io = open(path_or_io, 'rb') + self._io = open(path_or_io, "rb") if not self._name: self._name = get_file_name(self.path, include_extension=False) else: @@ -111,7 +109,7 @@ def __init__( if threading_mode is not None: self._video_stream.thread_type = threading_mode self._reopened = False - logger.debug('Threading mode set: %s', threading_mode) + logger.debug("Threading mode set: %s", threading_mode) except OSError: raise except Exception as ex: @@ -119,8 +117,11 @@ def __init__( if framerate is None: # Calculate framerate from video container. `guessed_rate` below appears in PyAV 9. - frame_rate = self._video_stream.guessed_rate if hasattr( - self._video_stream, 'guessed_rate') else self._codec_context.framerate + frame_rate = ( + self._video_stream.guessed_rate + if hasattr(self._video_stream, "guessed_rate") + else self._codec_context.framerate + ) if frame_rate is None or frame_rate == 0: raise FrameRateUnavailable() # TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations. @@ -144,7 +145,7 @@ def __del__(self): # VideoStream Methods/Properties # - BACKEND_NAME = 'pyav' + BACKEND_NAME = "pyav" """Unique name used to identify this backend.""" @property @@ -207,8 +208,10 @@ def frame_number(self) -> int: @property def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" - if not hasattr(self._codec_context, - "display_aspect_ratio") or self._codec_context.display_aspect_ratio is None: + if ( + not hasattr(self._codec_context, "display_aspect_ratio") + or self._codec_context.display_aspect_ratio is None + ): return 1.0 ar_denom = self._codec_context.display_aspect_ratio.denominator if ar_denom <= 0: @@ -238,12 +241,13 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: """ if target < 0: raise ValueError("Target cannot be negative!") - beginning = (target == 0) - target = (self.base_timecode + target) + beginning = target == 0 + target = self.base_timecode + target if target >= 1: target = target - 1 target_pts = self._video_stream.start_time + int( - (self.base_timecode + target).get_seconds() / self._video_stream.time_base) + (self.base_timecode + target).get_seconds() / self._video_stream.time_base + ) self._frame = None self._container.seek(target_pts, stream=self._video_stream) if not beginning: @@ -253,7 +257,7 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: break def reset(self): - """ Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """ + """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._container.close() self._frame = None try: @@ -286,7 +290,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b return False has_advanced = True if decode: - return self._frame.to_ndarray(format='bgr24') + return self._frame.to_ndarray(format="bgr24") return has_advanced # @@ -320,14 +324,15 @@ def _get_duration(self) -> int: # Lastly, if that calculation fails, try to calculate it based on the stream duration. if duration_sec is None or duration_sec < MAX_FPS_DELTA: if self._video_stream.duration is None: - logger.warning('Video duration unavailable.') + logger.warning("Video duration unavailable.") return 0 # Streams use stream `time_base` as the time base. time_base = self._video_stream.time_base if time_base.denominator == 0: logger.warning( - 'Unable to calculate video duration: time_base (%s) has zero denominator!', - str(time_base)) + "Unable to calculate video duration: time_base (%s) has zero denominator!", + str(time_base), + ) return 0 duration_sec = float(self._video_stream.duration / time_base) return round(duration_sec * self.frame_rate) @@ -341,7 +346,7 @@ def _handle_eof(self): return False self._reopened = True # Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode. - if not self.is_seekable or not self._video_stream.thread_type in ('AUTO', 'FRAME'): + if not self.is_seekable or self._video_stream.thread_type not in ("AUTO", "FRAME"): return False last_frame = self.frame_number orig_pos = self._io.tell() diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index c7a0833c..a87a5689 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -36,7 +35,7 @@ processing videos, however they can also be used to process frames directly. """ -from scenedetect.detectors.content_detector import ContentDetector +from scenedetect.detectors.content_detector import ContentDetector # noqa: I001 from scenedetect.detectors.threshold_detector import ThresholdDetector from scenedetect.detectors.adaptive_detector import AdaptiveDetector from scenedetect.detectors.hash_detector import HashDetector diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 064255f5..0cbb4895 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -24,7 +23,7 @@ from scenedetect.detectors import ContentDetector -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") class AdaptiveDetector(ContentDetector): @@ -71,12 +70,12 @@ def __init__( # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will # be removed in v0.8. if video_manager is not None: - logger.error('video_manager is deprecated, use video instead.') + logger.error("video_manager is deprecated, use video instead.") if min_delta_hsv is not None: - logger.error('min_delta_hsv is deprecated, use min_content_val instead.') + logger.error("min_delta_hsv is deprecated, use min_content_val instead.") min_content_val = min_delta_hsv if window_width < 1: - raise ValueError('window_width must be at least 1.') + raise ValueError("window_width must be at least 1.") super().__init__( threshold=255.0, @@ -93,7 +92,8 @@ def __init__( self.window_width = window_width self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( - window_width=window_width, luma_only='' if not luma_only else '_lum') + window_width=window_width, luma_only="" if not luma_only else "_lum" + ) self._first_frame_num = None # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. @@ -141,9 +141,9 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List return [] self._buffer = self._buffer[-required_frames:] (target_frame, target_score) = self._buffer[self.window_width] - average_window_score = ( - sum(score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width) / - (2.0 * self.window_width)) + average_window_score = sum( + score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width + ) / (2.0 * self.window_width) average_is_zero = abs(average_window_score) < 0.00001 @@ -159,7 +159,8 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut threshold_met: bool = ( - adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val) + adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val + ) min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: self._last_cut = target_frame @@ -169,8 +170,10 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List def get_content_val(self, frame_num: int) -> Optional[float]: """Returns the average content change for a frame.""" # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. - logger.error("get_content_val is deprecated and will be removed. Lookup the value" - " using a StatsManager with ContentDetector.FRAME_SCORE_KEY.") + logger.error( + "get_content_val is deprecated and will be removed. Lookup the value" + " using a StatsManager with ContentDetector.FRAME_SCORE_KEY." + ) if self.stats_manager is not None: return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] return 0.0 diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 954a91d7..bfa99ac4 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -15,14 +14,15 @@ This detector is available from the command-line as the `detect-content` command. """ -from dataclasses import dataclass + import math +from dataclasses import dataclass from typing import List, NamedTuple, Optional -import numpy import cv2 +import numpy -from scenedetect.scene_detector import SceneDetector, FlashFilter +from scenedetect.scene_detector import FlashFilter, SceneDetector def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: @@ -32,7 +32,7 @@ def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: assert len(left.shape) == 2 and len(right.shape) == 2 assert left.shape == right.shape num_pixels: float = float(left.shape[0] * left.shape[1]) - return (numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels) + return numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels def _estimated_kernel_size(frame_width: int, frame_height: int) -> int: @@ -56,6 +56,7 @@ class ContentDetector(SceneDetector): # a wider variety of test cases. class Components(NamedTuple): """Components that make up a frame's score, and their default values.""" + delta_hue: float = 1.0 """Difference between pixel hue values of adjacent frames.""" delta_sat: float = 1.0 @@ -80,7 +81,7 @@ class Components(NamedTuple): ) """Component weights to use if `luma_only` is set.""" - FRAME_SCORE_KEY = 'content_val' + FRAME_SCORE_KEY = "content_val" """Key in statsfile representing the final frame score after weighed by specified components.""" METRIC_KEYS = [FRAME_SCORE_KEY, *Components._fields] @@ -89,6 +90,7 @@ class Components(NamedTuple): @dataclass class _FrameData: """Data calculated for a given frame.""" + hue: numpy.ndarray """Frame hue map [2D 8-bit].""" sat: numpy.ndarray @@ -102,7 +104,7 @@ def __init__( self, threshold: float = 27.0, min_scene_len: int = 15, - weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, + weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: Optional[int] = None, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, @@ -133,7 +135,7 @@ def __init__( if kernel_size is not None: print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: - raise ValueError('kernel_size must be odd integer >= 3') + raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: Optional[float] = None self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) @@ -155,8 +157,7 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl hue, sat, lum = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) # Performance: Only calculate edges if we have to. - calculate_edges: bool = ((self._weights.delta_edges > 0.0) - or self.stats_manager is not None) + calculate_edges: bool = (self._weights.delta_edges > 0.0) or self.stats_manager is not None edges = self._detect_edges(lum) if calculate_edges else None if self._last_frame is None: @@ -168,13 +169,14 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl delta_hue=_mean_pixel_distance(hue, self._last_frame.hue), delta_sat=_mean_pixel_distance(sat, self._last_frame.sat), delta_lum=_mean_pixel_distance(lum, self._last_frame.lum), - delta_edges=(0.0 if edges is None else _mean_pixel_distance( - edges, self._last_frame.edges)), + delta_edges=( + 0.0 if edges is None else _mean_pixel_distance(edges, self._last_frame.edges) + ), ) - frame_score: float = ( - sum(component * weight for (component, weight) in zip(score_components, self._weights)) - / sum(abs(weight) for weight in self._weights)) + frame_score: float = sum( + component * weight for (component, weight) in zip(score_components, self._weights) + ) / sum(abs(weight) for weight in self._weights) # Record components and frame score if needed for analysis. if self.stats_manager is not None: diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 1ec508a7..36f7e1b5 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # --------------------------------------------------------------- @@ -35,8 +34,8 @@ """ # Third-Party Library Imports -import numpy import cv2 +import numpy # PySceneDetect Library Imports from scenedetect.scene_detector import SceneDetector @@ -112,14 +111,16 @@ def process_frame(self, frame_num, frame_img): if self._last_frame is not None: # We obtain the change in hash value between subsequent frames. curr_hash = self.hash_frame( - frame_img=frame_img, hash_size=self._size, factor=self._factor) + frame_img=frame_img, hash_size=self._size, factor=self._factor + ) last_hash = self._last_hash if last_hash.size == 0: # Calculate hash of last frame last_hash = self.hash_frame( - frame_img=self._last_frame, hash_size=self._size, factor=self._factor) + frame_img=self._last_frame, hash_size=self._size, factor=self._factor + ) # Hamming distance is calculated to compare to last frame hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) @@ -134,8 +135,9 @@ def process_frame(self, frame_num, frame_img): # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - if hash_dist_norm >= self._threshold and ((frame_num - self._last_scene_cut) - >= self._min_scene_len): + if hash_dist_norm >= self._threshold and ( + (frame_num - self._last_scene_cut) >= self._min_scene_len + ): cut_list.append(frame_num) self._last_scene_cut = frame_num diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index ad469489..9e37df09 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # --------------------------------------------------------------- @@ -29,7 +28,7 @@ class HistogramDetector(SceneDetector): """Compares the difference in the Y channel of YUV histograms for adjacent frames. When the difference exceeds a given threshold, a cut is detected.""" - METRIC_KEYS = ['hist_diff'] + METRIC_KEYS = ["hist_diff"] def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): """ @@ -71,10 +70,10 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: np_data_type = frame_img.dtype if np_data_type != numpy.uint8: - raise ValueError('Image must be 8-bit rgb for HistogramDetector') + raise ValueError("Image must be 8-bit rgb for HistogramDetector") if frame_img.shape[2] != 3: - raise ValueError('Image must have three color channels for HistogramDetector') + raise ValueError("Image must have three color channels for HistogramDetector") # Initialize last scene cut point at the beginning of the frames of interest. if not self._last_scene_cut: @@ -84,7 +83,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # We can only start detecting once we have a frame to compare with. if self._last_hist is not None: - #TODO: We can have EMA of histograms to make it more robust + # TODO: We can have EMA of histograms to make it more robust # ema_hist = alpha * hist + (1 - alpha) * ema_hist # Compute histogram difference between frames @@ -97,8 +96,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # Values close to 1 indicate very similar frames, while lower values suggest changes. # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation # less than 0.8 between histograms will be considered significant enough to denote a scene change. - if hist_diff <= self._threshold and ((frame_num - self._last_scene_cut) - >= self._min_scene_len): + if hist_diff <= self._threshold and ( + (frame_num - self._last_scene_cut) >= self._min_scene_len + ): cut_list.append(frame_num) self._last_scene_cut = frame_num @@ -111,9 +111,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: return cut_list @staticmethod - def calculate_histogram(frame_img: numpy.ndarray, - bins: int = 256, - normalize: bool = True) -> numpy.ndarray: + def calculate_histogram( + frame_img: numpy.ndarray, bins: int = 256, normalize: bool = True + ) -> numpy.ndarray: """ Calculates and optionally normalizes the histogram of the luma (Y) channel of an image converted from BGR to YUV color space. @@ -142,7 +142,7 @@ def calculate_histogram(frame_img: numpy.ndarray, Examples: --------- - >>> img = cv2.imread('path_to_image.jpg') + >>> img = cv2.imread("path_to_image.jpg") >>> hist = calculate_histogram(img, bins=256, normalize=True) >>> print(hist.shape) (256,) diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 784bd1f9..f14d1882 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -16,15 +15,15 @@ This detector is available from the command-line as the `detect-threshold` command. """ +import typing as ty from enum import Enum from logging import getLogger -from typing import List, Optional import numpy from scenedetect.scene_detector import SceneDetector -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") ## ## ThresholdDetector Helper Functions @@ -62,12 +61,13 @@ class ThresholdDetector(SceneDetector): class Method(Enum): """Method for ThresholdDetector to use when comparing frame brightness to the threshold.""" + FLOOR = 0 """Fade out happens when frame brightness falls below threshold.""" CEILING = 1 """Fade out happens when frame brightness rises above threshold.""" - THRESHOLD_VALUE_KEY = 'average_rgb' + THRESHOLD_VALUE_KEY = "average_rgb" def __init__( self, @@ -95,7 +95,7 @@ def __init__( """ # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. if block_size is not None: - logger.error('block_size is deprecated.') + logger.error("block_size is deprecated.") super().__init__() self.threshold = int(threshold) @@ -109,15 +109,15 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - 'frame': 0, # frame number where the last detected fade is - 'type': None # type of fade, can be either 'in' or 'out' + "frame": 0, # frame number where the last detected fade is + "type": None, # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -126,7 +126,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ @@ -145,8 +145,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this # frame metric instead (to assist with manually selecting a threshold) - if (self.stats_manager is not None) and (self.stats_manager.metrics_exist( - frame_num, self._metric_keys)): + if (self.stats_manager is not None) and ( + self.stats_manager.metrics_exist(frame_num, self._metric_keys) + ): frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] else: frame_avg = _compute_frame_average(frame_img) @@ -154,33 +155,36 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) if self.processed_frame: - if self.last_fade['type'] == 'in' and (( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))): + if self.last_fade["type"] == "in" and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) + or (self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold) + ): # Just faded out of a scene, wait for next fade in. - self.last_fade['type'] = 'out' - self.last_fade['frame'] = frame_num + self.last_fade["type"] = "out" + self.last_fade["frame"] = frame_num - elif self.last_fade['type'] == 'out' and ( - (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or - (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)): + elif self.last_fade["type"] == "out" and ( + (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) + or (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold) + ): # Only add the scene if min_scene_len frames have passed. if (frame_num - self.last_scene_cut) >= self.min_scene_len: # Just faded into a new scene, compute timecode for the scene # split based on the fade bias. - f_out = self.last_fade['frame'] + f_out = self.last_fade["frame"] f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2) + (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2 + ) cut_list.append(f_split) self.last_scene_cut = frame_num - self.last_fade['type'] = 'in' - self.last_fade['frame'] = frame_num + self.last_fade["type"] = "in" + self.last_fade["frame"] = frame_num else: - self.last_fade['frame'] = 0 + self.last_fade["frame"] = 0 if frame_avg < self.threshold: - self.last_fade['type'] = 'out' + self.last_fade["type"] = "out" else: - self.last_fade['type'] = 'in' + self.last_fade["type"] = "in" self.processed_frame = True return cut_list @@ -197,8 +201,13 @@ def post_process(self, frame_num: int): # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cut_times = [] - if self.last_fade['type'] == 'out' and self.add_final_scene and ( - (self.last_scene_cut is None and frame_num >= self.min_scene_len) or - (frame_num - self.last_scene_cut) >= self.min_scene_len): - cut_times.append(self.last_fade['frame']) + if ( + self.last_fade["type"] == "out" + and self.add_final_scene + and ( + (self.last_scene_cut is None and frame_num >= self.min_scene_len) + or (frame_num - self.last_scene_cut) >= self.min_scene_len + ) + ): + cut_times.append(self.last_fade["frame"]) return cut_times diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 5c009f52..ffb836b4 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -88,9 +87,11 @@ class FrameTimecode: 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) """ - def __init__(self, - timecode: Union[int, float, str, 'FrameTimecode'] = None, - fps: Union[int, float, str, 'FrameTimecode'] = None): + 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 @@ -112,20 +113,21 @@ def __init__(self, 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.') + 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.') + 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.') + 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. @@ -197,7 +199,7 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: # Compute hours and minutes based off of seconds, and update seconds. secs = self.get_seconds() hrs = int(secs / _SECONDS_PER_HOUR) - secs -= (hrs * _SECONDS_PER_HOUR) + secs -= hrs * _SECONDS_PER_HOUR mins = int(secs / _SECONDS_PER_MINUTE) secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) if use_rounding: @@ -211,15 +213,15 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: mins = 0 hrs += 1 # We have to extend the precision by 1 here, since `format` will round up. - msec = format(secs, '.%df' % (precision + 1)) if precision else '' + msec = format(secs, ".%df" % (precision + 1)) if precision else "" # Need to include decimal place in `msec_str`. - msec_str = msec[-(2 + precision):-1] + msec_str = msec[-(2 + precision) : -1] secs_str = f"{int(secs):02d}{msec_str}" # Return hours, minutes, and seconds as a formatted timecode string. - return '%02d:%02d:%s' % (hrs, mins, secs_str) + return "%02d:%02d:%s" % (hrs, mins, secs_str) # TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter. - def previous_frame(self) -> 'FrameTimecode': + 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) @@ -236,7 +238,7 @@ def _seconds_to_frames(self, seconds: float) -> int: 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. + """Parse a timecode number, storing it as the exact number of frames. Can be passed as frame number (int), seconds (float) Raises: @@ -246,20 +248,20 @@ def _parse_timecode_number(self, timecode: Union[int, float]) -> int: # Exact number of frames N if isinstance(timecode, int): if timecode < 0: - raise ValueError('Timecode frame number must be positive and greater than zero.') + 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.') + 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!') + raise TypeError("Timecode/frame number must be specified!") else: - raise TypeError('Timecode format/type unrecognized.') + raise TypeError("Timecode format/type unrecognized.") def _parse_timecode_string(self, input: str) -> int: """Parses a string based on the three possible forms (in timecode format, @@ -273,83 +275,84 @@ def _parse_timecode_string(self, input: str) -> int: Raises: ValueError: Value could not be parsed correctly. """ - assert not self.framerate is None + assert self.framerate is not None input = input.strip() # Exact number of frames N if input.isdigit(): timecode = int(input) if timecode < 0: - raise ValueError('Timecode frame number must be positive.') + raise ValueError("Timecode frame number must be positive.") return timecode # Timecode in string format 'HH:MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") hrs, mins = int(values[0]), int(values[1]) - secs = float(values[2]) if '.' in values[2] else int(values[2]) + secs = float(values[2]) if "." in values[2] else int(values[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).') + raise ValueError("Invalid timecode range (values outside allowed range).") secs += (hrs * 60 * 60) + (mins * 60) return self._seconds_to_frames(secs) # Try to parse the number as seconds in the format 1234.5 or 1234s - if input.endswith('s'): + if input.endswith("s"): input = input[:-1] - if not input.replace('.', '').isdigit(): - raise ValueError('All characters in timecode seconds string must be digits.') + if not input.replace(".", "").isdigit(): + raise ValueError("All characters in timecode seconds string must be digits.") as_float = float(input) if as_float < 0.0: - raise ValueError('Timecode seconds value must be positive.') + raise ValueError("Timecode seconds value must be positive.") return self._seconds_to_frames(as_float) - def __iadd__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode': + 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.') + 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. + 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': + 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': + 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.') + 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)) + 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': + 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': + def __eq__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): return self.frame_num == other elif isinstance(other, float): @@ -361,17 +364,19 @@ def __eq__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimeco return self.frame_num == other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "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)) + raise TypeError( + "Unsupported type for performing == with FrameTimecode: %s" % type(other) + ) - def __ne__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __ne__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: return not self == other - def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num < other elif isinstance(other, float): @@ -383,12 +388,14 @@ def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num < other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing < with FrameTimecode: %s' % - type(other)) + raise TypeError( + "Unsupported type for performing < with FrameTimecode: %s" % type(other) + ) - def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num <= other elif isinstance(other, float): @@ -400,12 +407,14 @@ def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num <= other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing <= with FrameTimecode: %s' % - type(other)) + raise TypeError( + "Unsupported type for performing <= with FrameTimecode: %s" % type(other) + ) - def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num > other elif isinstance(other, float): @@ -417,12 +426,14 @@ def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num > other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing > with FrameTimecode: %s' % - type(other)) + raise TypeError( + "Unsupported type for performing > with FrameTimecode: %s" % type(other) + ) - def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: + def __ge__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num >= other elif isinstance(other, float): @@ -434,10 +445,12 @@ def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool: return self.frame_num >= other.frame_num else: raise TypeError( - 'FrameTimecode objects must have the same framerate to be compared.') + "FrameTimecode objects must have the same framerate to be compared." + ) else: - raise TypeError('Unsupported type for performing >= with FrameTimecode: %s' % - type(other)) + 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. @@ -452,7 +465,7 @@ 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) + return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self.frame_num, self.framerate) def __hash__(self) -> int: return self.frame_num diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 38c86bf3..65aa7f80 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -37,7 +36,6 @@ class FakeTqdmObject: """Provides a no-op tqdm-like object.""" - # pylint: disable=unused-argument def __init__(self, **kawrgs): """No-op.""" @@ -50,13 +48,10 @@ def close(self): def set_description(self, desc=None, refresh=True): """No-op.""" - # pylint: enable=unused-argument - class FakeTqdmLoggingRedirect: """Provides a no-op tqdm context manager for redirecting log messages.""" - # pylint: disable=redefined-builtin,unused-argument def __init__(self, **kawrgs): """No-op.""" @@ -66,20 +61,14 @@ def __enter__(self): def __exit__(self, type, value, traceback): """No-op.""" - # pylint: enable=redefined-builtin,unused-argument - # Try to import tqdm and the logging redirect, otherwise provide fake implementations.. try: - # pylint: disable=unused-import from tqdm import tqdm from tqdm.contrib.logging import logging_redirect_tqdm - # pylint: enable=unused-import except ModuleNotFoundError: - # pylint: disable=invalid-name tqdm = FakeTqdmObject logging_redirect_tqdm = FakeTqdmLoggingRedirect - # pylint: enable=invalid-name ## ## OpenCV imwrite Supported Image Types & Quality/Compression Parameters @@ -88,7 +77,7 @@ def __exit__(self, type, value, traceback): # TODO: Move this into scene_manager. def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: - """ Get OpenCV imwrite Params: Returns a dict of supported image formats and + """Get OpenCV imwrite Params: Returns a dict of supported image formats and their associated quality/compression parameter index, or None if that format is not supported. @@ -100,7 +89,7 @@ def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: """ def _get_cv2_param(param_name: str) -> Union[int, None]: - if param_name.startswith('CV_'): + if param_name.startswith("CV_"): param_name = param_name[3:] try: return getattr(cv2, param_name) @@ -108,9 +97,9 @@ def _get_cv2_param(param_name: str) -> Union[int, None]: return None return { - 'jpg': _get_cv2_param('IMWRITE_JPEG_QUALITY'), - 'png': _get_cv2_param('IMWRITE_PNG_COMPRESSION'), - 'webp': _get_cv2_param('IMWRITE_WEBP_QUALITY') + "jpg": _get_cv2_param("IMWRITE_JPEG_QUALITY"), + "png": _get_cv2_param("IMWRITE_PNG_COMPRESSION"), + "webp": _get_cv2_param("IMWRITE_WEBP_QUALITY"), } @@ -128,14 +117,14 @@ def get_file_name(file_path: AnyStr, include_extension=True) -> AnyStr: file_name = os.path.basename(file_path) if not include_extension: file_name = str(file_name) - last_dot_pos = file_name.rfind('.') + last_dot_pos = file_name.rfind(".") if last_dot_pos >= 0: file_name = file_name[:last_dot_pos] return file_name def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = None) -> AnyStr: - """ Get & Create Path: Gets and returns the full/absolute path to file_path + """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories along the way. @@ -167,9 +156,9 @@ def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = ## -def init_logger(log_level: int = logging.INFO, - show_stdout: bool = False, - log_file: Optional[str] = None): +def init_logger( + log_level: int = logging.INFO, show_stdout: bool = False, log_file: Optional[str] = None +): """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'. By default the logger has no handlers to suppress output. All existing log handlers are replaced every time this function is invoked. @@ -181,10 +170,10 @@ def init_logger(log_level: int = logging.INFO, log_file: If set, add handler to dump debug log messages to given file path. """ # Format of log messages depends on verbosity. - INFO_TEMPLATE = '[PySceneDetect] %(message)s' - DEBUG_TEMPLATE = '%(levelname)s: %(module)s.%(funcName)s(): %(message)s' + INFO_TEMPLATE = "[PySceneDetect] %(message)s" + DEBUG_TEMPLATE = "%(levelname)s: %(module)s.%(funcName)s(): %(message)s" # Get the named logger and remove any existing handlers. - logger_instance = logging.getLogger('pyscenedetect') + logger_instance = logging.getLogger("pyscenedetect") logger_instance.handlers = [] logger_instance.setLevel(log_level) # Add stdout handler if required. @@ -192,7 +181,8 @@ def init_logger(log_level: int = logging.INFO, handler = logging.StreamHandler(stream=sys.stdout) handler.setLevel(log_level) handler.setFormatter( - logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE)) + logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE) + ) logger_instance.addHandler(handler) # Add debug log handler if required. if log_file: @@ -230,12 +220,12 @@ def invoke_command(args: List[str]) -> int: try: return subprocess.call(args) except OSError as err: - if os.name != 'nt': + if os.name != "nt": raise exception_string = str(err) # Error 206: The filename or extension is too long # Error 87: The parameter is incorrect - to_match = ('206', '87') + to_match = ("206", "87") if any([x in exception_string for x in to_match]): raise CommandTooLong() from err raise @@ -247,17 +237,16 @@ def get_ffmpeg_path() -> Optional[str]: """ # Try invoking ffmpeg with the current environment. try: - subprocess.call(['ffmpeg', '-v', 'quiet']) - return 'ffmpeg' + subprocess.call(["ffmpeg", "-v", "quiet"]) + return "ffmpeg" except OSError: pass # Failed to invoke ffmpeg with current environment, try another possibility. # Try invoking ffmpeg using the one from `imageio_ffmpeg` if available. try: - # pylint: disable=import-outside-toplevel from imageio_ffmpeg import get_ffmpeg_exe - # pylint: enable=import-outside-toplevel - subprocess.call([get_ffmpeg_exe(), '-v', 'quiet']) + + subprocess.call([get_ffmpeg_exe(), "-v", "quiet"]) return get_ffmpeg_exe() # Gracefully handle case where imageio_ffmpeg is not available. except ModuleNotFoundError: @@ -278,9 +267,9 @@ def get_ffmpeg_version() -> Optional[str]: if ffmpeg_path is None: return None # If get_ffmpeg_path() returns a value, the path it returns should be invocable. - output = subprocess.check_output(args=[ffmpeg_path, '-version'], text=True) + output = subprocess.check_output(args=[ffmpeg_path, "-version"], text=True) output_split = output.split() - if len(output_split) >= 3 and output_split[1] == 'version': + if len(output_split) >= 3 and output_split[1] == "version": return output_split[2] # If parsing the version fails, return the entire first line of output. return output.splitlines()[0] @@ -288,15 +277,15 @@ def get_ffmpeg_version() -> Optional[str]: def get_mkvmerge_version() -> Optional[str]: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" - tool_name = 'mkvmerge' + tool_name = "mkvmerge" try: - output = subprocess.check_output(args=[tool_name, '--version'], text=True) + output = subprocess.check_output(args=[tool_name, "--version"], text=True) except FileNotFoundError: # mkvmerge doesn't exist on the system return None output_split = output.split() if len(output_split) >= 1 and output_split[0] == tool_name: - return ' '.join(output_split[1:]) + return " ".join(output_split[1:]) # If parsing the version fails, return the entire first line of output. return output.splitlines()[0] @@ -307,31 +296,32 @@ def get_system_version_info() -> str: Used for the `scenedetect version -a` command. """ - output_template = '{:<12} {}' - line_separator = '-' * 60 - not_found_str = 'Not Installed' + output_template = "{:<12} {}" + line_separator = "-" * 60 + not_found_str = "Not Installed" out_lines = [] # System (Python, OS) - out_lines += ['System Info', line_separator] + out_lines += ["System Info", line_separator] out_lines += [ - output_template.format(name, version) for name, version in ( - ('OS', '%s' % platform.platform()), - ('Python', '%d.%d.%d' % sys.version_info[0:3]), + output_template.format(name, version) + for name, version in ( + ("OS", "%s" % platform.platform()), + ("Python", "%d.%d.%d" % sys.version_info[0:3]), ) ] # Third-Party Packages - out_lines += ['', 'Packages', line_separator] + out_lines += ["", "Packages", line_separator] third_party_packages = ( - 'av', - 'click', - 'cv2', - 'moviepy', - 'numpy', - 'platformdirs', - 'scenedetect', - 'tqdm', + "av", + "click", + "cv2", + "moviepy", + "numpy", + "platformdirs", + "scenedetect", + "tqdm", ) for module_name in third_party_packages: try: @@ -341,21 +331,23 @@ def get_system_version_info() -> str: out_lines.append(output_template.format(module_name, not_found_str)) # External Tools - out_lines += ['', 'Tools', line_separator] + out_lines += ["", "Tools", line_separator] tool_version_info = ( - ('ffmpeg', get_ffmpeg_version()), - ('mkvmerge', get_mkvmerge_version()), + ("ffmpeg", get_ffmpeg_version()), + ("mkvmerge", get_mkvmerge_version()), ) - for (tool_name, tool_version) in tool_version_info: + for tool_name, tool_version in tool_version_info: out_lines.append( - output_template.format(tool_name, tool_version if tool_version else not_found_str)) + output_template.format(tool_name, tool_version if tool_version else not_found_str) + ) - return '\n'.join(out_lines) + return "\n".join(out_lines) class Template(string.Template): """Template matcher used to replace instances of $TEMPLATES in filenames.""" - idpattern = '[A-Z0-9_]+' + + idpattern = "[A-Z0-9_]+" flags = re.ASCII diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index ded5d35d..6ce50993 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -25,17 +24,16 @@ event (in, out, cut, etc...). """ -from enum import Enum import typing as ty +from enum import Enum import numpy from scenedetect.stats_manager import StatsManager -# pylint: disable=unused-argument, no-self-use class SceneDetector: - """ Base class to inherit from when implementing a scene detection algorithm. + """Base class to inherit from when implementing a scene detection algorithm. This API is not yet stable and subject to change. @@ -45,6 +43,7 @@ class SceneDetector: Also see the implemented scene detectors in the scenedetect.detectors module to get an idea of how a particular detector can be created. """ + # TODO(v0.7): Make this a proper abstract base class. stats_manager: ty.Optional[StatsManager] = None @@ -67,8 +66,10 @@ def is_processing_required(self, frame_num: int) -> bool: to be passed to process_frame for the given frame_num). """ metric_keys = self.get_metrics() - return not metric_keys or not (self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys)) + return not metric_keys or not ( + self.stats_manager is not None + and self.stats_manager.metrics_exist(frame_num, metric_keys) + ) def stats_manager_required(self) -> bool: """Stats Manager Required: Prototype indicating if detector requires stats. @@ -133,8 +134,9 @@ class SparseSceneDetector(SceneDetector): An example of a SparseSceneDetector is the MotionDetector. """ - def process_frame(self, frame_num: int, - frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: + def process_frame( + self, frame_num: int, frame_img: numpy.ndarray + ) -> ty.List[ty.Tuple[int, int]]: """Process Frame: Computes/stores metrics and detects any scene changes. Prototype method, no actual detection. @@ -158,7 +160,6 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: - class Mode(Enum): MERGE = 0 """Merge consecutive cuts shorter than filter length.""" @@ -168,10 +169,10 @@ class Mode(Enum): def __init__(self, mode: Mode, length: int): self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. - self._last_above = None # Last frame above threshold. - self._merge_enabled = False # Used to disable merging until at least one cut was found. - self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filte. + self._last_above = None # Last frame above threshold. + self._merge_enabled = False # Used to disable merging until at least one cut was found. + self._merge_triggered = False # True when the merge filter is active. + self._merge_start = None # Frame number where we started the merge filte. def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if not self._filter_length > 0: @@ -180,8 +181,9 @@ def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: self._last_above = frame_num if self._mode == FlashFilter.Mode.MERGE: return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) - if self._mode == FlashFilter.Mode.SUPPRESS: + elif self._mode == FlashFilter.Mode.SUPPRESS: return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) + raise RuntimeError("Unhandled FlashFilter mode.") def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: min_length_met: bool = (frame_num - self._last_above) >= self._filter_length diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index bbada707..dc3bba04 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -81,26 +80,31 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): """ import csv -from enum import Enum -from typing import Iterable, List, Tuple, Optional, Dict, Callable, Union, TextIO -import threading -import queue import logging import math +import queue import sys +import threading +from enum import Enum +from typing import Callable, Dict, Iterable, List, Optional, TextIO, Tuple, Union import cv2 import numpy as np -from scenedetect._thirdparty.simpletable import (SimpleTableCell, SimpleTableImage, SimpleTableRow, - SimpleTable, HTMLPage) -from scenedetect.platform import (tqdm, get_and_create_path, get_cv2_imwrite_params, Template) +from scenedetect._thirdparty.simpletable import ( + HTMLPage, + SimpleTable, + SimpleTableCell, + SimpleTableImage, + SimpleTableRow, +) from scenedetect.frame_timecode import FrameTimecode -from scenedetect.video_stream import VideoStream +from scenedetect.platform import Template, get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.scene_detector import SceneDetector, SparseSceneDetector -from scenedetect.stats_manager import StatsManager, FrameMetricRegistered +from scenedetect.stats_manager import StatsManager +from scenedetect.video_stream import VideoStream -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current @@ -114,12 +118,13 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): MAX_FRAME_SIZE_ERRORS: int = 16 """Maximum number of frame size error messages that can be logged.""" -PROGRESS_BAR_DESCRIPTION = ' Detected: %d | Progress' +PROGRESS_BAR_DESCRIPTION = " Detected: %d | Progress" """Template to use for progress bar.""" class Interpolation(Enum): """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" + NEAREST = cv2.INTER_NEAREST """Nearest neighbor interpolation.""" LINEAR = cv2.INTER_LINEAR @@ -181,7 +186,7 @@ def get_scenes_from_cuts( """ # TODO(v0.7): Use the warnings module to turn this into a warning. if base_timecode is not None: - logger.error('`base_timecode` argument is deprecated has no effect.') + logger.error("`base_timecode` argument is deprecated has no effect.") # Scene list, where scenes are tuples of (Start FrameTimecode, End FrameTimecode). scene_list = [] @@ -200,10 +205,12 @@ def get_scenes_from_cuts( return scene_list -def write_scene_list(output_csv_file: TextIO, - scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], - include_cut_list: bool = True, - cut_list: Optional[Iterable[FrameTimecode]] = None) -> None: +def write_scene_list( + output_csv_file: TextIO, + scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], + include_cut_list: bool = True, + cut_list: Optional[Iterable[FrameTimecode]] = None, +) -> None: """Writes the given list of scenes to an output file handle in CSV format. Arguments: @@ -215,41 +222,56 @@ def write_scene_list(output_csv_file: TextIO, in the video that need to be split to generate individual scenes). If not specified, the cut list is generated using the start times of each scene following the first one. """ - csv_writer = csv.writer(output_csv_file, lineterminator='\n') + csv_writer = csv.writer(output_csv_file, lineterminator="\n") # If required, output the cutting list as the first row (i.e. before the header row). if include_cut_list: csv_writer.writerow( - ["Timecode List:"] + - cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) - csv_writer.writerow([ - "Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)", "End Frame", - "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", - "Length (seconds)" - ]) + ["Timecode List:"] + cut_list + if cut_list + else [start.get_timecode() for start, _ in scene_list[1:]] + ) + csv_writer.writerow( + [ + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", + ] + ) for i, (start, end) in enumerate(scene_list): duration = end - start - csv_writer.writerow([ - '%d' % (i + 1), - '%d' % (start.get_frames() + 1), - start.get_timecode(), - '%.3f' % start.get_seconds(), - '%d' % end.get_frames(), - end.get_timecode(), - '%.3f' % end.get_seconds(), - '%d' % duration.get_frames(), - duration.get_timecode(), - '%.3f' % duration.get_seconds() - ]) - - -def write_scene_list_html(output_html_filename, - scene_list, - cut_list=None, - css=None, - css_class='mytable', - image_filenames=None, - image_width=None, - image_height=None): + csv_writer.writerow( + [ + "%d" % (i + 1), + "%d" % (start.get_frames() + 1), + start.get_timecode(), + "%.3f" % start.get_seconds(), + "%d" % end.get_frames(), + end.get_timecode(), + "%.3f" % end.get_seconds(), + "%d" % duration.get_frames(), + duration.get_timecode(), + "%.3f" % duration.get_seconds(), + ] + ) + + +def write_scene_list_html( + output_html_filename, + scene_list, + cut_list=None, + css=None, + css_class="mytable", + image_filenames=None, + image_width=None, + image_height=None, +): """Writes the given list of scenes to an output file handle in html format. Arguments: @@ -306,37 +328,49 @@ def write_scene_list_html(output_html_filename, # Output Timecode list timecode_table = SimpleTable( - [["Timecode List:"] + - (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]])], - css_class=css_class) + [ + ["Timecode List:"] + + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) + ], + css_class=css_class, + ) # Output list of scenes header_row = [ - "Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)", "End Frame", - "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", - "Length (seconds)" + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", ] for i, (start, end) in enumerate(scene_list): duration = end - start - row = SimpleTableRow([ - '%d' % (i + 1), - '%d' % (start.get_frames() + 1), - start.get_timecode(), - '%.3f' % start.get_seconds(), - '%d' % end.get_frames(), - end.get_timecode(), - '%.3f' % end.get_seconds(), - '%d' % duration.get_frames(), - duration.get_timecode(), - '%.3f' % duration.get_seconds() - ]) + row = SimpleTableRow( + [ + "%d" % (i + 1), + "%d" % (start.get_frames() + 1), + start.get_timecode(), + "%.3f" % start.get_seconds(), + "%d" % end.get_frames(), + end.get_timecode(), + "%.3f" % end.get_seconds(), + "%d" % duration.get_frames(), + duration.get_timecode(), + "%.3f" % duration.get_seconds(), + ] + ) if image_filenames: for image in image_filenames[i]: row.add_cell( - SimpleTableCell( - SimpleTableImage(image, width=image_width, height=image_height))) + SimpleTableCell(SimpleTableImage(image, width=image_width, height=image_height)) + ) if i == 0: scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) @@ -355,20 +389,22 @@ def write_scene_list_html(output_html_filename, # TODO(v1.0): Refactor to take a SceneList object; consider moving this and save scene list # to a better spot, or just move them to scene_list.py. # -def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], - video: VideoStream, - num_images: int = 3, - frame_margin: int = 1, - image_extension: str = 'jpg', - encoder_param: int = 95, - image_name_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', - output_dir: Optional[str] = None, - show_progress: Optional[bool] = False, - scale: Optional[float] = None, - height: Optional[int] = None, - width: Optional[int] = None, - interpolation: Interpolation = Interpolation.CUBIC, - video_manager=None) -> Dict[int, List[str]]: +def save_images( + scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + video: VideoStream, + num_images: int = 3, + frame_margin: int = 1, + image_extension: str = "jpg", + encoder_param: int = 95, + image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + output_dir: Optional[str] = None, + show_progress: Optional[bool] = False, + scale: Optional[float] = None, + height: Optional[int] = None, + width: Optional[int] = None, + interpolation: Interpolation = Interpolation.CUBIC, + video_manager=None, +) -> Dict[int, List[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -418,7 +454,7 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], """ # TODO(v0.7): Add DeprecationWarning that `video_manager` will be removed in v0.8. if video_manager is not None: - logger.error('`video_manager` argument is deprecated, use `video` instead.') + logger.error("`video_manager` argument is deprecated, use `video` instead.") video = video_manager if not scene_list: @@ -428,56 +464,66 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], # TODO: Validate that encoder_param is within the proper range. # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. - imwrite_param = [get_cv2_imwrite_params()[image_extension], encoder_param - ] if encoder_param is not None else [] + imwrite_param = ( + [get_cv2_imwrite_params()[image_extension], encoder_param] + if encoder_param is not None + else [] + ) video.reset() # Setup flags and init progress bar if available. completed = True - logger.info('Generating output images (%d per scene)...', num_images) + logger.info("Generating output images (%d per scene)...", num_images) progress_bar = None if show_progress: - progress_bar = tqdm(total=len(scene_list) * num_images, unit='images', dynamic_ncols=True) + progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) filename_template = Template(image_name_template) - scene_num_format = '%0' - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' - image_num_format = '%0' - image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + 'd' + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" framerate = scene_list[0][0].framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. timecode_list = [ [ - FrameTimecode(int(f), fps=framerate) for f in [ - # middle frames - a[len(a) // 2] if (0 < j < num_images - 1) or num_images == 1 - - # first frame - else min(a[0] + frame_margin, a[-1]) if j == 0 - - # last frame + FrameTimecode(int(f), fps=framerate) + for f in [ + # middle frames + a[len(a) // 2] + if (0 < j < num_images - 1) or num_images == 1 + # first frame + else min(a[0] + frame_margin, a[-1]) + if j == 0 + # last frame else max(a[-1] - frame_margin, a[0]) - - # for each evenly-split array of frames in the scene list + # for each evenly-split array of frames in the scene list for j, a in enumerate(np.array_split(r, num_images)) ] - ] for i, r in enumerate([ - # pad ranges to number of images - r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.get_frames(), - start.get_frames() + max( - 1, # guard against zero length scenes - end.get_frames() - start.get_frames())) - # for each scene in scene list - for start, end in scene_list) - ]) + ] + for i, r in enumerate( + [ + # pad ranges to number of images + r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) + # create range of frames in scene + for r in ( + range( + start.get_frames(), + start.get_frames() + + max( + 1, # guard against zero length scenes + end.get_frames() - start.get_frames(), + ), + ) + # for each scene in scene list + for start, end in scene_list + ) + ] + ) ] image_filenames = {i: [] for i in range(len(timecode_list))} @@ -485,31 +531,30 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], if abs(aspect_ratio - 1.0) < 0.01: aspect_ratio = None - logger.debug('Writing images with template %s', filename_template.template) + logger.debug("Writing images with template %s", filename_template.template) for i, scene_timecodes in enumerate(timecode_list): for j, image_timecode in enumerate(scene_timecodes): video.seek(image_timecode) frame_im = video.read() if frame_im is not None: # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = '%s.%s' % ( + file_path = "%s.%s" % ( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), IMAGE_NUMBER=image_num_format % (j + 1), FRAME_NUMBER=image_timecode.get_frames(), TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), - TIMECODE=image_timecode.get_timecode().replace(":", ";")), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), image_extension, ) image_filenames[i].append(file_path) # TODO: Combine this resize with the ones below. if aspect_ratio is not None: frame_im = cv2.resize( - frame_im, (0, 0), - fx=aspect_ratio, - fy=1.0, - interpolation=interpolation.value) + frame_im, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value + ) frame_height = frame_im.shape[0] frame_width = frame_im.shape[1] @@ -523,10 +568,12 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], height = int(factor * frame_height) assert height > 0 and width > 0 frame_im = cv2.resize( - frame_im, (width, height), interpolation=interpolation.value) + frame_im, (width, height), interpolation=interpolation.value + ) elif scale: frame_im = cv2.resize( - frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) + frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value + ) cv2.imwrite(get_and_create_path(file_path, output_dir), frame_im, imwrite_param) else: @@ -539,7 +586,7 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], progress_bar.close() if not completed: - logger.error('Could not generate all output images.') + logger.error("Could not generate all output images.") return image_filenames @@ -668,7 +715,7 @@ def add_detector(self, detector: SceneDetector) -> None: self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) def get_num_detectors(self) -> int: - """Get number of registered scene detectors added via add_detector. """ + """Get number of registered scene detectors added via add_detector.""" return len(self._detector_list) def clear(self) -> None: @@ -687,13 +734,13 @@ def clear(self) -> None: self.clear_detectors() def clear_detectors(self) -> None: - """Remove all scene detectors added to the SceneManager via add_detector(). """ + """Remove all scene detectors added to the SceneManager via add_detector().""" self._detector_list.clear() self._sparse_detector_list.clear() - def get_scene_list(self, - base_timecode: Optional[FrameTimecode] = None, - start_in_scene: bool = False) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def get_scene_list( + self, base_timecode: Optional[FrameTimecode] = None, start_in_scene: bool = False + ) -> List[Tuple[FrameTimecode, FrameTimecode]]: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: @@ -711,12 +758,13 @@ def get_scene_list(self, """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error('`base_timecode` argument is deprecated and has no effect.') + logger.error("`base_timecode` argument is deprecated and has no effect.") if self._base_timecode is None: return [] cut_list = self._get_cutting_list() scene_list = get_scenes_from_cuts( - cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1) + cut_list=cut_list, start_pos=self._start_pos, end_pos=self._last_pos + 1 + ) # If we didn't actually detect any cuts, make sure the resulting scene_list is empty # unless start_in_scene is True. if not cut_list and not start_in_scene: @@ -735,13 +783,17 @@ def _get_event_list(self) -> List[Tuple[FrameTimecode, FrameTimecode]]: if not self._event_list: return [] assert self._base_timecode is not None - return [(self._base_timecode + start, self._base_timecode + end) - for start, end in self._event_list] + return [ + (self._base_timecode + start, self._base_timecode + end) + for start, end in self._event_list + ] - def _process_frame(self, - frame_num: int, - frame_im: np.ndarray, - callback: Optional[Callable[[np.ndarray, int], None]] = None) -> bool: + def _process_frame( + self, + frame_num: int, + frame_im: np.ndarray, + callback: Optional[Callable[[np.ndarray, int], None]] = None, + ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" new_cuts = False @@ -751,7 +803,7 @@ def _process_frame(self, self._frame_buffer.append(frame_im) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] - self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1):] + self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] for detector in self._detector_list: cuts = detector.process_frame(frame_num, frame_im) self._cutting_list += cuts @@ -778,14 +830,16 @@ def stop(self) -> None: """Stop the current :meth:`detect_scenes` call, if any. Thread-safe.""" self._stop.set() - def detect_scenes(self, - video: VideoStream = None, - duration: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, - frame_skip: int = 0, - show_progress: bool = False, - callback: Optional[Callable[[np.ndarray, int], None]] = None, - frame_source: Optional[VideoStream] = None) -> int: + def detect_scenes( + self, + video: VideoStream = None, + duration: Optional[FrameTimecode] = None, + end_time: Optional[FrameTimecode] = None, + frame_skip: int = 0, + show_progress: bool = False, + callback: Optional[Callable[[np.ndarray, int], None]] = None, + frame_source: Optional[VideoStream] = None, + ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the number of frames processed. Results can be obtained by calling :meth:`get_scene_list` or :meth:`get_cut_list`. @@ -823,14 +877,14 @@ def detect_scenes(self, if video is None: raise TypeError("detect_scenes() missing 1 required positional argument: 'video'") if frame_skip > 0 and self.stats_manager is not None: - raise ValueError('frame_skip must be 0 when using a StatsManager.') + raise ValueError("frame_skip must be 0 when using a StatsManager.") if duration is not None and end_time is not None: - raise ValueError('duration and end_time cannot be set at the same time!') + raise ValueError("duration and end_time cannot be set at the same time!") # TODO: These checks should be handled by the FrameTimecode constructor. if duration is not None and isinstance(duration, (int, float)) and duration < 0: - raise ValueError('duration must be greater than or equal to 0!') + raise ValueError("duration must be greater than or equal to 0!") if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: - raise ValueError('end_time must be greater than or equal to 0!') + raise ValueError("end_time must be greater than or equal to 0!") self._base_timecode = video.base_timecode @@ -847,9 +901,9 @@ def detect_scenes(self, total_frames = 0 if video.duration is not None: if end_time is not None and end_time < video.duration: - total_frames = (end_time - start_frame_num) + total_frames = end_time - start_frame_num else: - total_frames = (video.duration.get_frames() - start_frame_num) + total_frames = video.duration.get_frames() - start_frame_num # Calculate the desired downscale factor and log the effective resolution. if self.auto_downscale: @@ -857,15 +911,18 @@ def detect_scenes(self, else: downscale_factor = self.downscale if downscale_factor > 1: - logger.info('Downscale factor set to %d, effective resolution: %d x %d', - downscale_factor, video.frame_size[0] // downscale_factor, - video.frame_size[1] // downscale_factor) + logger.info( + "Downscale factor set to %d, effective resolution: %d x %d", + downscale_factor, + video.frame_size[0] // downscale_factor, + video.frame_size[1] // downscale_factor, + ) progress_bar = None if show_progress: progress_bar = tqdm( total=int(total_frames), - unit='frames', + unit="frames", desc=PROGRESS_BAR_DESCRIPTION % 0, dynamic_ncols=True, ) @@ -875,27 +932,30 @@ def detect_scenes(self, decode_thread = threading.Thread( target=SceneManager._decode_thread, args=(self, video, frame_skip, downscale_factor, end_time, frame_queue), - daemon=True) + daemon=True, + ) decode_thread.start() frame_im = None - logger.info('Detecting scenes...') + logger.info("Detecting scenes...") while not self._stop.is_set(): next_frame, position = frame_queue.get() if next_frame is None and position is None: break - if not next_frame is None: + if next_frame is not None: frame_im = next_frame new_cuts = self._process_frame(position.frame_num, frame_im, callback) if progress_bar is not None: if new_cuts: progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False) + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=False + ) progress_bar.update(1 + frame_skip) if progress_bar is not None: progress_bar.set_description( - PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True) + PROGRESS_BAR_DESCRIPTION % len(self._cutting_list), refresh=True + ) progress_bar.close() # Unblock any puts in the decode thread before joining. This can happen if the main # processing thread stops before the decode thread. @@ -938,25 +998,32 @@ def _decode_thread( if video.frame_size != decoded_size: logger.warn( f"WARNING: Decoded frame size ({decoded_size}) does not match " - f" video resolution {video.frame_size}, possible corrupt input.") + f" video resolution {video.frame_size}, possible corrupt input." + ) elif self._frame_size != decoded_size: self._frame_size_errors += 1 if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: logger.error( f"ERROR: Frame at {str(video.position)} has incorrect size and " f"cannot be processed: decoded size = {decoded_size}, " - f"expected = {self._frame_size}. Video may be corrupt.") + f"expected = {self._frame_size}. Video may be corrupt." + ) if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: logger.warn( - f"WARNING: Too many errors emitted, skipping future messages.") + "WARNING: Too many errors emitted, skipping future messages." + ) # Skip processing frames that have an incorrect size. continue if downscale_factor > 1: frame_im = cv2.resize( - frame_im, (round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor)), - interpolation=self._interpolation.value) + frame_im, + ( + round(frame_im.shape[1] / downscale_factor), + round(frame_im.shape[0] / downscale_factor), + ), + interpolation=self._interpolation.value, + ) else: if video.read(decode=False) is False: break @@ -982,7 +1049,7 @@ def _decode_thread( logger.debug("Received KeyboardInterrupt.") self._stop.set() except BaseException: - logger.critical('Fatal error: Exception raised in decode thread.') + logger.critical("Fatal error: Exception raised in decode thread.") self._exception_info = sys.exc_info() self._stop.set() @@ -993,17 +1060,13 @@ def _decode_thread( # Make sure main thread stops processing loop. out_queue.put((None, None)) - # pylint: enable=bare-except - # # Deprecated Methods # - # pylint: disable=unused-argument - - def get_cut_list(self, - base_timecode: Optional[FrameTimecode] = None, - show_warning: bool = True) -> List[FrameTimecode]: + def get_cut_list( + self, base_timecode: Optional[FrameTimecode] = None, show_warning: bool = True + ) -> List[FrameTimecode]: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing @@ -1026,12 +1089,11 @@ def get_cut_list(self, """ # TODO(v0.7): Use the warnings module to turn this into a warning. if show_warning: - logger.error('`get_cut_list()` is deprecated and will be removed in a future release.') + logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") return self._get_cutting_list() def get_event_list( - self, - base_timecode: Optional[FrameTimecode] = None + self, base_timecode: Optional[FrameTimecode] = None ) -> List[Tuple[FrameTimecode, FrameTimecode]]: """[DEPRECATED] DO NOT USE. @@ -1048,11 +1110,9 @@ def get_event_list( List of pairs of FrameTimecode objects denoting the detected scenes. """ # TODO(v0.7): Use the warnings module to turn this into a warning. - logger.error('`get_event_list()` is deprecated and will be removed in a future release.') + logger.error("`get_event_list()` is deprecated and will be removed in a future release.") return self._get_event_list() - # pylint: enable=unused-argument - def _is_processing_required(self, frame_num: int) -> bool: """True if frame metrics not in StatsManager, False otherwise.""" if self.stats_manager is None: diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 8bb8b9ec..b028e244 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -23,15 +22,16 @@ """ import csv -from logging import getLogger +import os.path import typing as ty +from logging import getLogger + # TODO: Replace below imports with `ty.` prefix. from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union -import os.path from scenedetect.frame_timecode import FrameTimecode -logger = getLogger('pyscenedetect') +logger = getLogger("pyscenedetect") ## ## StatsManager CSV File Column Names (Header Row) @@ -50,19 +50,22 @@ class FrameMetricRegistered(Exception): """[DEPRECATED - DO NOT USE] No longer used.""" + pass class FrameMetricNotRegistered(Exception): """[DEPRECATED - DO NOT USE] No longer used.""" + pass class StatsFileCorrupt(Exception): """Raised when frame metrics/stats could not be loaded from a provided CSV file.""" - def __init__(self, - message: str = "Could not load frame metric data data from passed CSV file."): + def __init__( + self, message: str = "Could not load frame metric data data from passed CSV file." + ): super().__init__(message) @@ -98,8 +101,10 @@ def __init__(self, base_timecode: FrameTimecode = None): # of each frame metric key and the value it represents (usually float). self._frame_metrics: Dict[FrameTimecode, Dict[str, float]] = dict() self._metric_keys: Set[str] = set() - self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: Optional[FrameTimecode] = base_timecode # Used for timing calculations. + self._metrics_updated: bool = False # Flag indicating if metrics require saving. + self._base_timecode: Optional[FrameTimecode] = ( + base_timecode # Used for timing calculations. + ) @property def metric_keys(self) -> ty.Iterable[str]: @@ -127,7 +132,7 @@ def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None: - """ Set Metrics: Sets the provided statistics/metrics for a given frame. + """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: frame_number: Frame number to retrieve metrics for. @@ -138,7 +143,7 @@ def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) def metrics_exist(self, frame_number: int, metric_keys: Iterable[str]) -> bool: - """ Metrics Exist: Checks if the given metrics/stats exist for the given frame. + """Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: bool: True if the given metric keys exist for the frame, False otherwise. @@ -146,7 +151,7 @@ def metrics_exist(self, frame_number: int, metric_keys: Iterable[str]) -> bool: return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys]) def is_save_required(self) -> bool: - """ Is Save Required: Checks if the stats have been updated since loading. + """Is Save Required: Checks if the stats have been updated since loading. Returns: bool: True if there are frame metrics/statistics not yet written to disk, @@ -154,11 +159,13 @@ def is_save_required(self) -> bool: """ return self._metrics_updated - def save_to_csv(self, - csv_file: Union[str, bytes, TextIO], - base_timecode: Optional[FrameTimecode] = None, - force_save=True) -> None: - """ Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. + def save_to_csv( + self, + csv_file: Union[str, bytes, TextIO], + base_timecode: Optional[FrameTimecode] = None, + force_save=True, + ) -> None: + """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. Arguments: csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str. @@ -170,7 +177,7 @@ def save_to_csv(self, """ # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. if base_timecode is not None: - logger.error('base_timecode is deprecated and has no effect.') + logger.error("base_timecode is deprecated and has no effect.") if not (force_save or self.is_save_required()): logger.info("No metrics to write.") @@ -179,11 +186,11 @@ def save_to_csv(self, # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. if isinstance(csv_file, (str, bytes)): - with open(csv_file, 'w') as file: + with open(csv_file, "w") as file: self.save_to_csv(csv_file=file, force_save=force_save) return - csv_writer = csv.writer(csv_file, lineterminator='\n') + csv_writer = csv.writer(csv_file, lineterminator="\n") metric_keys = sorted(list(self._metric_keys)) csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) frame_keys = sorted(self._frame_metrics.keys()) @@ -191,9 +198,9 @@ def save_to_csv(self, for frame_key in frame_keys: frame_timecode = self._base_timecode + frame_key csv_writer.writerow( - [frame_timecode.get_frames() + - 1, frame_timecode.get_timecode()] + - [str(metric) for metric in self.get_metrics(frame_key, metric_keys)]) + [frame_timecode.get_frames() + 1, frame_timecode.get_timecode()] + + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] + ) @staticmethod def valid_header(row: List[str]) -> bool: @@ -237,13 +244,13 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: # recursively call ourselves again but with file set instead of path. if isinstance(csv_file, (str, bytes)): if os.path.exists(csv_file): - with open(csv_file, 'r') as file: + with open(csv_file) as file: return self.load_from_csv(csv_file=file) # Path doesn't exist. return None # If we get here, file is a valid file handle in read-only text mode. - csv_reader = csv.reader(csv_file, lineterminator='\n') + csv_reader = csv.reader(csv_file, lineterminator="\n") num_cols = None num_metrics = None num_frames = None @@ -262,28 +269,29 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: num_cols = len(row) num_metrics = num_cols - 2 if not num_metrics > 0: - raise StatsFileCorrupt('No metrics defined in CSV file.') + raise StatsFileCorrupt("No metrics defined in CSV file.") loaded_metrics = list(row[2:]) num_frames = 0 for row in csv_reader: metric_dict = {} if not len(row) == num_cols: - raise StatsFileCorrupt('Wrong number of columns detected in stats file row.') + raise StatsFileCorrupt("Wrong number of columns detected in stats file row.") frame_number = int(row[0]) # Switch from 1-based to 0-based frame numbers. if frame_number > 0: frame_number -= 1 self.set_metrics(frame_number, metric_dict) for i, metric in enumerate(row[2:]): - if metric and metric != 'None': + if metric and metric != "None": try: self._set_metric(frame_number, loaded_metrics[i], float(metric)) except ValueError: - raise StatsFileCorrupt('Corrupted value in stats file: %s' % - metric) from ValueError + raise StatsFileCorrupt( + "Corrupted value in stats file: %s" % metric + ) from ValueError num_frames += 1 self._metric_keys = self._metric_keys.union(set(loaded_metrics)) - logger.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) + logger.info("Loaded %d metrics for %d frames.", num_metrics, num_frames) self._metrics_updated = False return num_frames @@ -296,10 +304,11 @@ def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]: def _set_metric(self, frame_number: int, metric_key: str, metric_value: Any) -> None: self._metrics_updated = True - if not frame_number in self._frame_metrics: + if frame_number not in self._frame_metrics: self._frame_metrics[frame_number] = dict() self._frame_metrics[frame_number][metric_key] = metric_value def _metric_exists(self, frame_number: int, metric_key: str) -> bool: - return (frame_number in self._frame_metrics - and metric_key in self._frame_metrics[frame_number]) + return ( + frame_number in self._frame_metrics and metric_key in self._frame_metrics[frame_number] + ) diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index a927bc95..ab09c8a5 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -19,18 +18,18 @@ in a future release. """ -import os import math +import os from logging import getLogger - from typing import Iterable, List, Optional, Tuple, Union -import numpy as np + import cv2 +import numpy as np -from scenedetect.platform import get_file_name -from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA -from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable from scenedetect.backends.opencv import _get_aspect_ratio +from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode +from scenedetect.platform import get_file_name +from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream ## ## VideoManager Exceptions @@ -38,12 +37,12 @@ class VideoParameterMismatch(Exception): - """ VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some - of the video parameters (frame height, frame width, and framerate/FPS) do not match. """ + """VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some + of the video parameters (frame height, frame width, and framerate/FPS) do not match.""" - def __init__(self, - file_list=None, - message="OpenCV VideoCapture object parameters do not match."): + def __init__( + self, file_list=None, message="OpenCV VideoCapture object parameters do not match." + ): # type: (Iterable[Tuple[int, float, float, str, str]], str) -> None # Pass message string to base Exception class. super(VideoParameterMismatch, self).__init__(message) @@ -54,13 +53,13 @@ def __init__(self, class VideoDecodingInProgress(RuntimeError): - """ VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that - must be called *before* start() has been called. """ + """VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that + must be called *before* start() has been called.""" class InvalidDownscaleFactor(ValueError): - """ InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, - i.e. the supplied downscale factor was not a positive integer greater than zero. """ + """InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, + i.e. the supplied downscale factor was not a positive integer greater than zero.""" ## @@ -75,12 +74,12 @@ def get_video_name(video_file: str) -> Tuple[str, str]: Tuple of the form [name, video_file]. """ if isinstance(video_file, int): - return ('Device %d' % video_file, video_file) + return ("Device %d" % video_file, video_file) return (os.path.split(video_file)[1], video_file) def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int: - """ Get Number of Frames: Returns total number of frames in the cap_list. + """Get Number of Frames: Returns total number of frames in the cap_list. Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. """ @@ -92,7 +91,7 @@ def open_captures( framerate: Optional[float] = None, validate_parameters: bool = True, ) -> Tuple[List[cv2.VideoCapture], float, Tuple[int, int]]: - """ Open Captures - helper function to open all capture objects, set the framerate, + """Open Captures - helper function to open all capture objects, set the framerate, and ensure that all open captures have been opened and the framerates match on a list of video file paths, or a list containing a single device ID. @@ -139,12 +138,14 @@ def open_captures( raise TypeError("Expected type float for parameter framerate.") # Check if files exist if passed video file is not an image sequence # (checked with presence of % in filename) or not a URL (://). - if not is_device and any([ + if not is_device and any( + [ not os.path.exists(video_file) for video_file in video_files - if not ('%' in video_file or '://' in video_file) - ]): - raise IOError("Video file(s) not found.") + if not ("%" in video_file or "://" in video_file) + ] + ): + raise OSError("Video file(s) not found.") cap_list = [] try: @@ -155,11 +156,17 @@ def open_captures( raise VideoOpenFailure(str(closed_caps)) cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list] - cap_framerate, check_framerate = validate_capture_framerate(video_names, cap_framerates, - framerate) + cap_framerate, check_framerate = validate_capture_framerate( + video_names, cap_framerates, framerate + ) # Store frame sizes as integers (VideoCapture.get() returns float). - cap_frame_sizes = [(math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) for cap in cap_list] + cap_frame_sizes = [ + ( + math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) + for cap in cap_list + ] cap_frame_size = cap_frame_sizes[0] # If we need to validate the parameters, we check that the FPS and width/height @@ -169,7 +176,8 @@ def open_captures( video_names=video_names, cap_frame_sizes=cap_frame_sizes, check_framerate=check_framerate, - cap_framerates=cap_framerates) + cap_framerates=cap_framerates, + ) except: for cap in cap_list: @@ -203,9 +211,11 @@ def validate_capture_framerate( else: raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__) else: - unavailable_framerates = [(video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if fps < MAX_FPS_DELTA] + unavailable_framerates = [ + (video_names[i][0], video_names[i][1]) + for i, fps in enumerate(cap_framerates) + if fps < MAX_FPS_DELTA + ] if unavailable_framerates: raise FrameRateUnavailable() return (cap_framerate, check_framerate) @@ -217,7 +227,7 @@ def validate_capture_parameters( check_framerate: bool = False, cap_framerates: Optional[List[float]] = None, ) -> None: - """ Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) + """Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) framerates are equal. Raises VideoParameterMismatch if there is a mismatch. Raises: @@ -226,20 +236,35 @@ def validate_capture_parameters( bad_params = [] max_framerate_delta = MAX_FPS_DELTA # Check heights/widths match. - bad_params += [(cv2.CAP_PROP_FRAME_WIDTH, frame_size[0], cap_frame_sizes[0][0], - video_names[i][0], video_names[i][1]) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0] - bad_params += [(cv2.CAP_PROP_FRAME_HEIGHT, frame_size[1], cap_frame_sizes[0][1], - video_names[i][0], video_names[i][1]) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0] + bad_params += [ + ( + cv2.CAP_PROP_FRAME_WIDTH, + frame_size[0], + cap_frame_sizes[0][0], + video_names[i][0], + video_names[i][1], + ) + for i, frame_size in enumerate(cap_frame_sizes) + if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0 + ] + bad_params += [ + ( + cv2.CAP_PROP_FRAME_HEIGHT, + frame_size[1], + cap_frame_sizes[0][1], + video_names[i][0], + video_names[i][1], + ) + for i, frame_size in enumerate(cap_frame_sizes) + if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0 + ] # Check framerates if required. if check_framerate: - bad_params += [(cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], - video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if math.fabs(fps - cap_framerates[0]) > max_framerate_delta] + bad_params += [ + (cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], video_names[i][1]) + for i, fps in enumerate(cap_framerates) + if math.fabs(fps - cap_framerates[0]) > max_framerate_delta + ] if bad_params: raise VideoParameterMismatch(bad_params) @@ -256,12 +281,14 @@ class VideoManager(VideoStream): Provides a cv2.VideoCapture-like interface to a set of one or more video files, or a single device ID. Supports seeking and setting end time/duration.""" - BACKEND_NAME = 'video_manager_do_not_use' + BACKEND_NAME = "video_manager_do_not_use" - def __init__(self, - video_files: List[str], - framerate: Optional[float] = None, - logger=getLogger('pyscenedetect')): + def __init__( + self, + video_files: List[str], + framerate: Optional[float] = None, + logger=None, + ): """[DEPRECATED] DO NOT USE. Arguments: @@ -283,6 +310,8 @@ def __init__(self, """ # TODO(v0.7): Add DeprecationWarning that this class will be removed in v0.8: 'VideoManager # will be removed in PySceneDetect v0.8. Use VideoStreamCv2 or VideoCaptureAdapter instead.' + if logger is None: + logger = getLogger("pyscenedetect") logger.error("VideoManager is deprecated and will be removed.") if not video_files: raise ValueError("At least one string/integer must be passed in the video_files list.") @@ -292,7 +321,8 @@ def __init__(self, # These VideoCaptures are only open in this process. self._is_device = isinstance(video_files[0], int) self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=video_files, framerate=framerate) + video_files=video_files, framerate=framerate + ) self._path = video_files[0] if not self._is_device else video_files self._end_of_video = False self._start_time = self.get_base_timecode() @@ -303,9 +333,13 @@ def __init__(self, self._video_file_paths = video_files self._logger = logger if self._logger is not None: - self._logger.info('Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d', - len(self._cap_list), 's' if len(self._cap_list) > 1 else '', - self.get_framerate(), *self.get_framesize()) + self._logger.info( + "Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d", + len(self._cap_list), + "s" if len(self._cap_list) > 1 else "", + self.get_framerate(), + *self.get_framesize(), + ) self._started = False self._frame_length = self.get_base_timecode() + get_num_frames(self._cap_list) self._first_cap_len = self.get_base_timecode() + get_num_frames([self._cap_list[0]]) @@ -340,10 +374,10 @@ def get_video_name(self) -> str: """ video_paths = self.get_video_paths() if not video_paths: - return '' + return "" video_name = os.path.basename(video_paths[0]) - if video_name.rfind('.') >= 0: - video_name = video_name[:video_name.rfind('.')] + if video_name.rfind(".") >= 0: + video_name = video_name[: video_name.rfind(".")] return video_name def get_framerate(self) -> float: @@ -380,7 +414,7 @@ def get_base_timecode(self) -> FrameTimecode: return FrameTimecode(timecode=0, fps=self._cap_framerate) def get_current_timecode(self) -> FrameTimecode: - """ Get Current Timecode - returns a FrameTimecode object at current VideoManager position. + """Get Current Timecode - returns a FrameTimecode object at current VideoManager position. Returns: Timecode at the current VideoManager position. @@ -396,7 +430,7 @@ def get_framesize(self) -> Tuple[int, int]: return self._cap_framesize def get_framesize_effective(self) -> Tuple[int, int]: - """ Get Frame Size - returns the frame size of the video(s) open in the + """Get Frame Size - returns the frame size of the video(s) open in the VideoManager's capture objects. Returns: @@ -404,11 +438,13 @@ def get_framesize_effective(self) -> Tuple[int, int]: """ return self._cap_framesize - def set_duration(self, - duration: Optional[FrameTimecode] = None, - start_time: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None) -> None: - """ Set Duration - sets the duration/length of the video(s) to decode, as well as + def set_duration( + self, + duration: Optional[FrameTimecode] = None, + start_time: Optional[FrameTimecode] = None, + end_time: Optional[FrameTimecode] = None, + ) -> None: + """Set Duration - sets the duration/length of the video(s) to decode, as well as the start/end times. Must be called before :meth:`start()` is called, otherwise a VideoDecodingInProgress exception will be thrown. May be called after :meth:`reset()` as well. @@ -432,9 +468,11 @@ def set_duration(self, raise VideoDecodingInProgress() # Ensure any passed timecodes have the proper framerate. - if ((duration is not None and not duration.equal_framerate(self._cap_framerate)) - or (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) - or (end_time is not None and not end_time.equal_framerate(self._cap_framerate))): + if ( + (duration is not None and not duration.equal_framerate(self._cap_framerate)) + or (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) + or (end_time is not None and not end_time.equal_framerate(self._cap_framerate)) + ): raise ValueError("FrameTimecode framerate does not match.") if duration is not None and end_time is not None: @@ -455,13 +493,15 @@ def set_duration(self, self._frame_length -= self._start_time if self._logger is not None: - self._logger.info('Duration set, start: %s, duration: %s, end: %s.', - start_time.get_timecode() if start_time is not None else start_time, - duration.get_timecode() if duration is not None else duration, - end_time.get_timecode() if end_time is not None else end_time) + self._logger.info( + "Duration set, start: %s, duration: %s, end: %s.", + start_time.get_timecode() if start_time is not None else start_time, + duration.get_timecode() if duration is not None else duration, + end_time.get_timecode() if end_time is not None else end_time, + ) def get_duration(self) -> FrameTimecode: - """ Get Duration - gets the duration/length of the video(s) to decode, + """Get Duration - gets the duration/length of the video(s) to decode, as well as the start/end times. If the end time was not set by :meth:`set_duration()`, the end timecode @@ -477,7 +517,7 @@ def get_duration(self) -> FrameTimecode: return (self._frame_length, self._start_time, end_time) def start(self) -> None: - """ Start - starts video decoding and seeks to start time. Raises + """Start - starts video decoding and seeks to start time. Raises exception VideoDecodingInProgress if the method is called after the decoder process has already been started. @@ -497,7 +537,6 @@ def start(self) -> None: # This overrides the seek method from the VideoStream interface, but the name was changed # from `timecode` to `target`. For compatibility, we allow calling seek with the form # seek(0), seek(timecode=0), and seek(target=0). Specifying both arguments is an error. - # pylint: disable=arguments-differ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> bool: """Seek forwards to the passed timecode. @@ -516,9 +555,9 @@ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> ValueError: Either none or both `timecode` and `target` were set. """ if timecode is None and target is None: - raise ValueError('`target` must be set.') + raise ValueError("`target` must be set.") if timecode is not None and target is not None: - raise ValueError('Only one of `timecode` or `target` can be set.') + raise ValueError("Only one of `timecode` or `target` can be set.") if target is not None: timecode = target assert timecode is not None @@ -539,8 +578,8 @@ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> # TODO: This should throw an exception instead of potentially failing silently # if no logger was provided. if self._logger is not None: - self._logger.error('Seeking past the first input video is not currently supported.') - self._logger.warning('Seeking to end of first input.') + self._logger.error("Seeking past the first input video is not currently supported.") + self._logger.warning("Seeking to end of first input.") timecode = self._first_cap_len if self._curr_cap is not None and self._end_of_video is not True: self._curr_cap.set(cv2.CAP_PROP_POS_FRAMES, timecode.get_frames() - 1) @@ -551,17 +590,15 @@ def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> return False return True - # pylint: enable=arguments-differ - def release(self) -> None: - """ Release (cv2.VideoCapture method), releases all open capture(s). """ + """Release (cv2.VideoCapture method), releases all open capture(s).""" for cap in self._cap_list: cap.release() self._cap_list = [] self._started = False def reset(self) -> None: - """ Reset - Reopens captures passed to the constructor of the VideoManager. + """Reset - Reopens captures passed to the constructor of the VideoManager. Can only be called after the :meth:`release()` method has been called. @@ -575,11 +612,12 @@ def reset(self) -> None: self._end_of_video = False self._curr_time = self.get_base_timecode() self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=self._video_file_paths, framerate=self._curr_time.get_framerate()) + video_files=self._video_file_paths, framerate=self._curr_time.get_framerate() + ) self._curr_cap, self._curr_cap_idx = None, None def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, int]: - """ Get (cv2.VideoCapture method) - obtains capture properties from the current + """Get (cv2.VideoCapture method) - obtains capture properties from the current VideoCapture object in use. Index represents the same index as the original video_files list passed to the constructor. Getting/setting the position (POS) properties has no effect; seeking is implemented using VideoDecoder methods. @@ -607,7 +645,7 @@ def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, in return self._cap_list[index].get(capture_prop) def grab(self) -> bool: - """ Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. + """Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. Returns: bool: True if a frame was grabbed, False otherwise. @@ -631,7 +669,7 @@ def grab(self) -> bool: return grabbed def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: - """ Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. + """Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. Frame returned corresponds to last call to :meth:`grab()`. @@ -654,7 +692,7 @@ def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: return (retrieved, self._last_frame) def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: - """ Return next frame (or current if advance = False), or False if end of video. + """Return next frame (or current if advance = False), or False if end of video. Arguments: decode: Decode and return the frame. @@ -690,7 +728,7 @@ def _get_next_cap(self) -> bool: return True def _correct_frame_length(self) -> None: - """ Checks if the current frame position exceeds that originally calculated, + """Checks if the current frame position exceeds that originally calculated, and adjusts the internally calculated frame length accordingly. Called after exhausting all input frames from the video source(s). """ @@ -749,8 +787,10 @@ def frame_rate(self) -> float: @property def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - return (math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT))) + return ( + math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), + math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) @property def is_seekable(self) -> bool: diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index a4bce715..8b41834d 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -33,18 +32,18 @@ available on the computer, depending on the specified command-line options. """ -from dataclasses import dataclass import logging import math -from pathlib import Path import subprocess import time import typing as ty +from dataclasses import dataclass +from pathlib import Path -from scenedetect.platform import (tqdm, invoke_command, CommandTooLong, get_ffmpeg_path, Template) from scenedetect.frame_timecode import FrameTimecode +from scenedetect.platform import CommandTooLong, Template, get_ffmpeg_path, invoke_command, tqdm -logger = logging.getLogger('pyscenedetect') +logger = logging.getLogger("pyscenedetect") TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" @@ -62,7 +61,8 @@ """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" DEFAULT_FFMPEG_ARGS = ( - "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac") + "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac" +) """Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" ## @@ -71,14 +71,14 @@ def is_mkvmerge_available() -> bool: - """ Is mkvmerge Available: Gracefully checks if mkvmerge command is available. + """Is mkvmerge Available: Gracefully checks if mkvmerge command is available. Returns: True if `mkvmerge` can be invoked, False otherwise. """ ret_val = None try: - ret_val = subprocess.call(['mkvmerge', '--quiet']) + ret_val = subprocess.call(["mkvmerge", "--quiet"]) except OSError: return False if ret_val is not None and ret_val != 2: @@ -87,7 +87,7 @@ def is_mkvmerge_available() -> bool: def is_ffmpeg_available() -> bool: - """ Is ffmpeg Available: Gracefully checks if ffmpeg command is available. + """Is ffmpeg Available: Gracefully checks if ffmpeg command is available. Returns: True if `ffmpeg` can be invoked, False otherwise. @@ -103,6 +103,7 @@ def is_ffmpeg_available() -> bool: @dataclass class VideoMetadata: """Information about the video being split.""" + name: str """Expected name of the video. May differ from `path`.""" path: Path @@ -114,6 +115,7 @@ class VideoMetadata: @dataclass class SceneMetadata: """Information about the scene being extracted.""" + index: int """0-based index of this scene.""" start: FrameTimecode @@ -128,20 +130,21 @@ class SceneMetadata: def default_formatter(template: str) -> PathFormatter: """Formats filenames using a template string which allows the following variables: - `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` """ MIN_DIGITS = 3 format_scene_number: PathFormatter = lambda video, scene: ( - ('%0' + str(max(MIN_DIGITS, - math.floor(math.log(video.total_scenes, 10)) + 1)) + 'd') % - (scene.index + 1)) + ("%0" + str(max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1)) + "d") + % (scene.index + 1) + ) formatter: PathFormatter = lambda video, scene: Template(template).safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=format_scene_number(video, scene), START_TIME=str(scene.start.get_timecode().replace(":", ";")), END_TIME=str(scene.end.get_timecode().replace(":", ";")), START_FRAME=str(scene.start.get_frames()), - END_FRAME=str(scene.end.get_frames())) + END_FRAME=str(scene.end.get_frames()), + ) return formatter @@ -154,12 +157,12 @@ def split_video_mkvmerge( input_video_path: str, scene_list: ty.Iterable[TimecodePair], output_dir: ty.Optional[Path] = None, - output_file_template: str = '$VIDEO_NAME.mkv', + output_file_template: str = "$VIDEO_NAME.mkv", video_name: ty.Optional[str] = None, show_output: bool = False, suppress_output=None, ) -> int: - """ Calls the mkvmerge command on the input video, splitting it at the + """Calls the mkvmerge command on the input video, splitting it at the passed timecodes, where each scene is written in sequence from 001. Arguments: @@ -179,19 +182,20 @@ def split_video_mkvmerge( """ # Handle backwards compatibility with v0.5 API. if isinstance(input_video_path, list): - logger.error('Using a list of paths is deprecated. Pass a single path instead.') + logger.error("Using a list of paths is deprecated. Pass a single path instead.") if len(input_video_path) > 1: - raise ValueError('Concatenating multiple input videos is not supported.') + raise ValueError("Concatenating multiple input videos is not supported.") input_video_path = input_video_path[0] if suppress_output is not None: - logger.error('suppress_output is deprecated, use show_output instead.') + logger.error("suppress_output is deprecated, use show_output instead.") show_output = not suppress_output if not scene_list: return 0 - logger.info('Splitting input video using mkvmerge, output path template:\n %s', - output_file_template) + logger.info( + "Splitting input video using mkvmerge, output path template:\n %s", output_file_template + ) if video_name is None: video_name = Path(input_video_path).stem @@ -207,31 +211,40 @@ def split_video_mkvmerge( output_path.parent.mkdir(parents=True, exist_ok=True) try: - call_list = ['mkvmerge'] + call_list = ["mkvmerge"] if not show_output: - call_list.append('--quiet') + call_list.append("--quiet") call_list += [ - '-o', - str(output_path), '--split', - 'parts:%s' % ','.join([ - '%s-%s' % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list - ]), input_video_path + "-o", + str(output_path), + "--split", + "parts:%s" + % ",".join( + [ + "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) + for start_time, end_time in scene_list + ] + ), + input_video_path, ] total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() processing_start_time = time.time() # TODO: Capture stdout/stderr and show that if the command fails. ret_val = invoke_command(call_list) if show_output: - logger.info('Average processing speed %.2f frames/sec.', - float(total_frames) / (time.time() - processing_start_time)) + logger.info( + "Average processing speed %.2f frames/sec.", + float(total_frames) / (time.time() - processing_start_time), + ) except CommandTooLong: logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logger.error('mkvmerge could not be found on the system.' - ' Please install mkvmerge to enable video output support.') + logger.error( + "mkvmerge could not be found on the system." + " Please install mkvmerge to enable video output support." + ) if ret_val != 0: - logger.error('Error splitting video (mkvmerge returned %d).', ret_val) + logger.error("Error splitting video (mkvmerge returned %d).", ret_val) return ret_val @@ -239,7 +252,7 @@ def split_video_ffmpeg( input_video_path: str, scene_list: ty.Iterable[TimecodePair], output_dir: ty.Optional[Path] = None, - output_file_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4', + output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", video_name: ty.Optional[str] = None, arg_override: str = DEFAULT_FFMPEG_ARGS, show_progress: bool = False, @@ -248,7 +261,7 @@ def split_video_ffmpeg( hide_progress=None, formatter: ty.Optional[PathFormatter] = None, ) -> int: - """ Calls the ffmpeg command on the input video, generating a new video for + """Calls the ffmpeg command on the input video, generating a new video for each scene based on the start/end timecodes. Arguments: @@ -274,22 +287,23 @@ def split_video_ffmpeg( """ # Handle backwards compatibility with v0.5 API. if isinstance(input_video_path, list): - logger.error('Using a list of paths is deprecated. Pass a single path instead.') + logger.error("Using a list of paths is deprecated. Pass a single path instead.") if len(input_video_path) > 1: - raise ValueError('Concatenating multiple input videos is not supported.') + raise ValueError("Concatenating multiple input videos is not supported.") input_video_path = input_video_path[0] if suppress_output is not None: - logger.error('suppress_output is deprecated, use show_output instead.') + logger.error("suppress_output is deprecated, use show_output instead.") show_output = not suppress_output if hide_progress is not None: - logger.error('hide_progress is deprecated, use show_progress instead.') + logger.error("hide_progress is deprecated, use show_progress instead.") show_progress = not hide_progress if not scene_list: return 0 - logger.info('Splitting input video using ffmpeg, output path template:\n %s', - output_file_template) + logger.info( + "Splitting input video using ffmpeg, output path template:\n %s", output_file_template + ) if video_name is None: video_name = Path(input_video_path).stem @@ -297,23 +311,24 @@ def split_video_ffmpeg( arg_override = arg_override.replace('\\"', '"') ret_val = 0 - arg_override = arg_override.split(' ') - scene_num_format = '%0' - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd' + arg_override = arg_override.split(" ") + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" if formatter is None: formatter = default_formatter(output_file_template) video_metadata = VideoMetadata( - name=video_name, path=input_video_path, total_scenes=len(scene_list)) + name=video_name, path=input_video_path, total_scenes=len(scene_list) + ) try: progress_bar = None total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() if show_progress: - progress_bar = tqdm(total=total_frames, unit='frame', miniters=1, dynamic_ncols=True) + progress_bar = tqdm(total=total_frames, unit="frame", miniters=1, dynamic_ncols=True) processing_start_time = time.time() for i, (start_time, end_time) in enumerate(scene_list): - duration = (end_time - start_time) + duration = end_time - start_time scene_metadata = SceneMetadata(index=i, start=start_time, end=end_time) output_path = Path(formatter(scene=scene_metadata, video=video_metadata)) if output_dir: @@ -321,29 +336,35 @@ def split_video_ffmpeg( output_path.parent.mkdir(parents=True, exist_ok=True) # Gracefully handle case where FFMPEG_PATH might be unset. - call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else 'ffmpeg'] + call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else "ffmpeg"] if not show_output: - call_list += ['-v', 'quiet'] + call_list += ["-v", "quiet"] elif i > 0: # Only show ffmpeg output for the first call, which will display any # errors if it fails, and then break the loop. We only show error messages # for the remaining calls. - call_list += ['-v', 'error'] + call_list += ["-v", "error"] call_list += [ - '-nostdin', '-y', '-ss', - str(start_time.get_seconds()), '-i', input_video_path, '-t', - str(duration.get_seconds()) + "-nostdin", + "-y", + "-ss", + str(start_time.get_seconds()), + "-i", + input_video_path, + "-t", + str(duration.get_seconds()), ] call_list += arg_override - call_list += ['-sn'] + call_list += ["-sn"] call_list += [str(output_path)] ret_val = invoke_command(call_list) if show_output and i == 0 and len(scene_list) > 1: logger.info( - 'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...') + "Output from ffmpeg for Scene 1 shown above, splitting remaining scenes..." + ) if ret_val != 0: # TODO: Capture stdout/stderr and display it on any failed calls. - logger.error('Error splitting video (ffmpeg returned %d).', ret_val) + logger.error("Error splitting video (ffmpeg returned %d).", ret_val) break if progress_bar: progress_bar.update(duration.get_frames()) @@ -351,12 +372,16 @@ def split_video_ffmpeg( if progress_bar: progress_bar.close() if show_output: - logger.info('Average processing speed %.2f frames/sec.', - float(total_frames) / (time.time() - processing_start_time)) + logger.info( + "Average processing speed %.2f frames/sec.", + float(total_frames) / (time.time() - processing_start_time), + ) except CommandTooLong: logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logger.error('ffmpeg could not be found on the system.' - ' Please install ffmpeg to enable video output support.') + logger.error( + "ffmpeg could not be found on the system." + " Please install ffmpeg to enable video output support." + ) return ret_val diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index bfdcbbf0..8d188daf 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -33,7 +32,7 @@ """ from abc import ABC, abstractmethod -from typing import Tuple, Optional, Union +from typing import Optional, Tuple, Union import numpy as np @@ -54,7 +53,6 @@ class SeekError(Exception): class VideoOpenFailure(Exception): """Raised by a backend if opening a video fails.""" - # pylint: disable=useless-super-delegation def __init__(self, message: str = "Unknown backend error."): """ Arguments: @@ -62,16 +60,16 @@ def __init__(self, message: str = "Unknown backend error."): """ super().__init__(message) - # pylint: enable=useless-super-delegation - class FrameRateUnavailable(VideoOpenFailure): """Exception instance to provide consistent error messaging across backends when the video frame rate is unavailable or cannot be calculated. Subclass of VideoOpenFailure.""" def __init__(self): - super().__init__('Unable to obtain video framerate! Specify `framerate` manually, or' - ' re-encode/re-mux the video and try again.') + super().__init__( + "Unable to obtain video framerate! Specify `framerate` manually, or" + " re-encode/re-mux the video and try again." + ) ## @@ -80,7 +78,7 @@ def __init__(self): class VideoStream(ABC): - """ Interface which all video backends must implement. """ + """Interface which all video backends must implement.""" # # Default Implementations @@ -192,7 +190,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b @abstractmethod def reset(self) -> None: - """ Close and re-open the VideoStream (equivalent to seeking back to beginning). """ + """Close and re-open the VideoStream (equivalent to seeking back to beginning).""" raise NotImplementedError @abstractmethod diff --git a/setup.py b/setup.py index 2d8b2415..ec281380 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # --------------------------------------------------------------- @@ -9,7 +8,7 @@ # # Copyright (C) 2014-2024 Brandon Castellano . # -""" PySceneDetect setup.py - DEPRECATED. +"""PySceneDetect setup.py - DEPRECATED. Build using `python -m build` and installing the resulting .whl using `pip`. """ diff --git a/tests/__init__.py b/tests/__init__.py index 5a618310..981ec4b7 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect Unit Test Suite +"""PySceneDetect Unit Test Suite To run all available tests run `pytest -v` from the parent directory (i.e. the root project folder of PySceneDetect containing the scenedetect/ diff --git a/tests/conftest.py b/tests/conftest.py index f7e8a25a..6034456c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect Test Configuration +"""PySceneDetect Test Configuration This file includes all pytest configuration for running PySceneDetect's tests. @@ -27,9 +26,9 @@ # TODO: Properly cleanup temporary files. -from typing import AnyStr import logging import os +from typing import AnyStr import pytest @@ -39,19 +38,22 @@ def check_exists(path: AnyStr) -> AnyStr: - """ Returns the absolute path to a (relative) path of a file that + """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ if not os.path.exists(path): - raise FileNotFoundError(""" + raise FileNotFoundError( + """ Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" % path) +""" + % path + ) return path @@ -73,6 +75,7 @@ def pytest_assertrepr_compare(op, left, right): "", *right.splitlines(), ] + return [] # @@ -84,11 +87,12 @@ def pytest_assertrepr_compare(op, left, right): def no_logs_gte_error(caplog): """Ensure no log messages with error severity or higher were reported during test execution.""" # TODO: Remove exclusion for VideoManager module when removed from codebase. - EXCLUDED_MODULES = {'video_manager'} + EXCLUDED_MODULES = {"video_manager"} yield errors = [ - record for record in caplog.get_records('call') - if record.levelno >= logging.ERROR and not record.module in EXCLUDED_MODULES + record + for record in caplog.get_records("call") + if record.levelno >= logging.ERROR and record.module not in EXCLUDED_MODULES ] assert not errors, "Test failed due to presence of one or more logs with ERROR severity." diff --git a/tests/test_api.py b/tests/test_api.py index 1ddb5596..07559253 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -18,65 +17,69 @@ when calling `detect()` or `detect_scenes()`. """ -# pylint: disable=import-outside-toplevel, redefined-outer-name, unused-argument - def test_api_detect(test_video_file: str): """Demonstrate usage of the `detect()` function to process a complete video.""" - from scenedetect import detect, ContentDetector + from scenedetect import ContentDetector, detect + scene_list = detect(test_video_file, ContentDetector()) for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_detect_start_end_time(test_video_file: str): """Demonstrate usage of the `detect()` function to process a subset of a video.""" - from scenedetect import detect, ContentDetector + from scenedetect import ContentDetector, detect + # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). # See test_api_timecode_types() for examples of each format. scene_list = detect(test_video_file, ContentDetector(), start_time=10.5, end_time=15.9) for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_detect_stats(test_video_file: str): """Demonstrate usage of the `detect()` function to generate a statsfile.""" - from scenedetect import detect, ContentDetector + from scenedetect import ContentDetector, detect + detect(test_video_file, ContentDetector(), stats_file_path="frame_metrics.csv") def test_api_scene_manager(test_video_file: str): """Demonstrate how to use a SceneManager to implement a function similar to `detect()`.""" - from scenedetect import SceneManager, ContentDetector, open_video + from scenedetect import ContentDetector, SceneManager, open_video + video = open_video(test_video_file) scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_scene_manager_start_end_time(test_video_file: str): """Demonstrate how to use a SceneManager to process a subset of the input video.""" - from scenedetect import SceneManager, ContentDetector, open_video + from scenedetect import ContentDetector, SceneManager, open_video + video = open_video(test_video_file) scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) # Times can be seconds (float), frames (int), or timecode 'HH:MM:SSS.nnn' (str). # See test_api_timecode_types() for examples of each format. - start_time = 200 # Start at frame (int) 200 + start_time = 200 # Start at frame (int) 200 end_time = 15.0 # End at 15 seconds (float) video.seek(start_time) scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print('Scene %d: %s - %s' % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) def test_api_timecode_types(): """Demonstrate all different types of timecodes that can be used.""" from scenedetect import FrameTimecode + base_timecode = FrameTimecode(timecode=0, fps=10.0) # Frames (int) timecode = base_timecode + 1 @@ -85,29 +88,31 @@ def test_api_timecode_types(): timecode = base_timecode + 1.0 assert timecode.get_frames() == 10 # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') - timecode = base_timecode + '00:00:01.500' + timecode = base_timecode + "00:00:01.500" assert timecode.get_frames() == 15 # Seconds (str, 'SSSs' or 'SSSS.SSSs') - timecode = base_timecode + '1.5s' + timecode = base_timecode + "1.5s" assert timecode.get_frames() == 15 def test_api_stats_manager(test_video_file: str): """Demonstrate using a StatsManager to save per-frame statistics to disk.""" - from scenedetect import SceneManager, StatsManager, ContentDetector, open_video + from scenedetect import ContentDetector, SceneManager, StatsManager, open_video + video = open_video(test_video_file) scene_manager = SceneManager(stats_manager=StatsManager()) scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. - filename = '%s.stats.csv' % test_video_file + filename = "%s.stats.csv" % test_video_file scene_manager.stats_manager.save_to_csv(csv_file=filename) def test_api_scene_manager_callback(test_video_file: str): """Demonstrate how to use a callback with the SceneManager detect_scenes method.""" import numpy - from scenedetect import SceneManager, ContentDetector, open_video + + from scenedetect import ContentDetector, SceneManager, open_video # Callback to invoke on the first frame of every new scene detection. def on_new_scene(frame_img: numpy.ndarray, frame_num: int): @@ -125,7 +130,8 @@ def test_api_device_callback(test_video_file: str): wrapping it with a `VideoCaptureAdapter.`""" import cv2 import numpy - from scenedetect import SceneManager, ContentDetector, VideoCaptureAdapter + + from scenedetect import ContentDetector, SceneManager, VideoCaptureAdapter # Callback to invoke on the first frame of every new scene detection. def on_new_scene(frame_img: numpy.ndarray, frame_num: int): diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index eeae7620..4a77f2cb 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.backend.opencv Tests +"""PySceneDetect scenedetect.backend.opencv Tests This file includes unit tests for the scenedetect.backend.opencv module that implements the VideoStreamCv2 ('opencv') backend. These tests validate behaviour specific to this backend. @@ -21,7 +20,7 @@ import cv2 from scenedetect import ContentDetector, SceneManager -from scenedetect.backends.opencv import VideoStreamCv2, VideoCaptureAdapter +from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 GROUND_TRUTH_CAPTURE_ADAPTER_TEST = [1, 90, 210] GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [30, 180, 394] diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index bfcc4bfb..8e27a495 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.backend.pyav Tests +"""PySceneDetect scenedetect.backend.pyav Tests This file includes unit tests for the scenedetect.backend.pyav module that implements the VideoStreamAv ('pyav') backend. These tests validate behaviour specific to this backend. @@ -24,9 +23,9 @@ def test_video_stream_pyav_bytesio(test_video_file: str): """Test that VideoStreamAv works with a BytesIO input in addition to a path.""" # Mode must be binary! - video_file = open(test_video_file, mode='rb') - stream = VideoStreamAv(path_or_io=video_file, threading_mode=None) - assert stream.is_seekable - stream.seek(50) - for _ in range(10): - assert stream.read() is not False + with open(test_video_file, mode="rb") as video_file: + stream = VideoStreamAv(path_or_io=video_file, threading_mode=None) + assert stream.is_seekable + stream.seek(50) + for _ in range(10): + assert stream.read() is not False diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py index 2c7b5064..b4111aba 100644 --- a/tests/test_backwards_compat.py +++ b/tests/test_backwards_compat.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -21,7 +20,7 @@ import logging import os -from scenedetect import SceneManager, StatsManager, VideoManager, ContentDetector +from scenedetect import ContentDetector, SceneManager, StatsManager, VideoManager from scenedetect.platform import init_logger @@ -35,25 +34,27 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) # Suppress errors generated by using deprecated classes/arguments below. init_logger(log_level=logging.CRITICAL) video_manager = VideoManager([test_video_file]) - stats_file_path = test_video_file + '.csv' + stats_file_path = test_video_file + ".csv" stats_manager = StatsManager() scene_manager = SceneManager(stats_manager) scene_manager.add_detector(ContentDetector()) base_timecode = video_manager.get_base_timecode() scene_list = [] try: - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 10.0 # 00:00:10.000 + start_time = base_timecode + 20 # 00:00:00.667 + end_time = base_timecode + 10.0 # 00:00:10.000 if os.path.exists(stats_file_path): - with open(stats_file_path, 'r') as stats_file: + with open(stats_file_path) as stats_file: stats_manager.load_from_csv(stats_file) # ContentDetector requires at least 1 frame before it can calculate any metrics. - assert stats_manager.metrics_exist(start_time.get_frames() + 1, - [ContentDetector.FRAME_SCORE_KEY]) + assert stats_manager.metrics_exist( + start_time.get_frames() + 1, [ContentDetector.FRAME_SCORE_KEY] + ) # Correct end frame # for presentation duration. - assert stats_manager.metrics_exist(end_time.get_frames() - 1, - [ContentDetector.FRAME_SCORE_KEY]) + assert stats_manager.metrics_exist( + end_time.get_frames() - 1, [ContentDetector.FRAME_SCORE_KEY] + ) video_manager.set_duration(start_time=start_time, end_time=end_time) video_manager.set_downscale_factor() @@ -66,18 +67,21 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) # Correct end frame # for presentation duration. assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1 - print('List of scenes obtained:') + print("List of scenes obtained:") for i, scene in enumerate(scene_list): - print(' Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( - i + 1, - scene[0].get_timecode(), - scene[0].get_frames(), - scene[1].get_timecode(), - scene[1].get_frames(), - )) + print( + " Scene %2d: Start %s / Frame %d, End %s / Frame %d" + % ( + i + 1, + scene[0].get_timecode(), + scene[0].get_frames(), + scene[1].get_timecode(), + scene[1].get_frames(), + ) + ) if stats_manager.is_save_required(): - with open(stats_file_path, 'w') as stats_file: + with open(stats_file_path, "w") as stats_file: stats_manager.save_to_csv(stats_file, base_timecode=base_timecode) finally: video_manager.release() @@ -87,7 +91,7 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) def test_backwards_compatibility_with_stats(test_video_file: str): """Runs equivalent code to `tests/api_test.py` from v0.5 twice to also exercise loading a statsfile from disk.""" - stats_file_path = test_video_file + '.csv' + stats_file_path = test_video_file + ".csv" if os.path.exists(stats_file_path): os.remove(stats_file_path) scenes = validate_backwards_compatibility(test_video_file, stats_file_path) diff --git a/tests/test_cli.py b/tests/test_cli.py index fffa0a56..dbd9ef90 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -13,12 +12,12 @@ import glob import os -import typing as ty import subprocess -import pytest +import typing as ty from pathlib import Path import cv2 +import pytest from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available @@ -42,24 +41,28 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. -SCENEDETECT_CMD = 'python -m scenedetect' +SCENEDETECT_CMD = "python -m scenedetect" ALL_DETECTORS = [ - 'detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist', 'detect-hash' + "detect-content", + "detect-threshold", + "detect-adaptive", + "detect-hist", + "detect-hash", ] -ALL_BACKENDS = ['opencv', 'pyav'] +ALL_BACKENDS = ["opencv", "pyav"] -DEFAULT_VIDEO_PATH = 'tests/resources/goldeneye.mp4' +DEFAULT_VIDEO_PATH = "tests/resources/goldeneye.mp4" DEFAULT_VIDEO_NAME = Path(DEFAULT_VIDEO_PATH).stem -DEFAULT_BACKEND = 'opencv' -DEFAULT_STATSFILE = 'statsfile.csv' -DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. -DEFAULT_DETECTOR = 'detect-content' -DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. -DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. +DEFAULT_BACKEND = "opencv" +DEFAULT_STATSFILE = "statsfile.csv" +DEFAULT_TIME = "-s 2s -d 4s" # Seek forward a bit but limit the amount we process. +DEFAULT_DETECTOR = "detect-content" +DEFAULT_CONFIG_FILE = "scenedetect.cfg" # Ensure we default to a "blank" config file. +DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. def invoke_scenedetect( - args: str = '', + args: str = "", output_dir: ty.Optional[str] = None, config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, **kwargs, @@ -91,11 +94,11 @@ def invoke_scenedetect( value_dict.update(**kwargs) command = SCENEDETECT_CMD if output_dir: - command += ' -o %s' % output_dir + command += " -o %s" % output_dir if config_file: - command += ' -c %s' % config_file - command += ' ' + args.format(**value_dict) - return subprocess.call(command.strip().split(' ')) + command += " -c %s" % config_file + command += " " + args.format(**value_dict) + return subprocess.call(command.strip().split(" ")) def test_cli_no_args(): @@ -105,10 +108,10 @@ def test_cli_no_args(): def test_cli_default_detector(): """Test `scenedetect` command invoked without a detector.""" - assert invoke_scenedetect('-i {VIDEO} time {TIME}', config_file=None) == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME}", config_file=None) == 0 -@pytest.mark.parametrize('info_command', ['help', 'about', 'version']) +@pytest.mark.parametrize("info_command", ["help", "about", "version"]) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about).""" assert invoke_scenedetect(info_command) == 0 @@ -116,10 +119,10 @@ def test_cli_info_command(info_command): def test_cli_time_validate_options(): """Validate behavior of setting parameters via the `time` command.""" - base_command = '-i {VIDEO} time {TIME} {DETECTOR}' + base_command = "-i {VIDEO} time {TIME} {DETECTOR}" # Ensure cannot set end and duration together. - assert invoke_scenedetect(base_command, TIME='-s 2.0 -d 6.0 -e 8.0') != 0 - assert invoke_scenedetect(base_command, TIME='-s 2.0 -e 8.0 -d 6.0 ') != 0 + assert invoke_scenedetect(base_command, TIME="-s 2.0 -d 6.0 -e 8.0") != 0 + assert invoke_scenedetect(base_command, TIME="-s 2.0 -e 8.0 -d 6.0 ") != 0 def test_cli_time_end(): @@ -142,10 +145,11 @@ def test_cli_time_end(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + - test_case.split(), - text=True) + SCENEDETECT_CMD.split(" ") + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True, + ) assert EXPECTED in output, test_case @@ -169,10 +173,11 @@ def test_cli_time_start(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + - test_case.split(), - text=True) + SCENEDETECT_CMD.split(" ") + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True, + ) assert EXPECTED in output, test_case @@ -213,10 +218,11 @@ def test_cli_time_scene_boundary(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + - test_case.split(), - text=True) + SCENEDETECT_CMD.split(" ") + + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] + + test_case.split(), + text=True, + ) assert EXPECTED in output, test_case @@ -224,10 +230,12 @@ def test_cli_time_end_of_video(): """Validate frame number/timecode alignment at the end of the video. The end timecode includes presentation time and therefore should represent the full length of the video.""" output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + - ['-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-n', 'time', '-s', '1872'], - text=True) - assert """ + SCENEDETECT_CMD.split(" ") + + ["-i", DEFAULT_VIDEO_PATH, "detect-content", "list-scenes", "-n", "time", "-s", "1872"], + text=True, + ) + assert ( + """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -235,31 +243,39 @@ def test_cli_time_end_of_video(): | 2 | 1917 | 00:01:19.913 | 1966 | 00:01:21.999 | | 3 | 1967 | 00:01:21.999 | 1980 | 00:01:22.582 | ----------------------------------------------------------------------- -""" in output +""" + in output + ) assert "00:01:19.913,00:01:21.999" in output -@pytest.mark.parametrize('detector_command', ALL_DETECTORS) +@pytest.mark.parametrize("detector_command", ALL_DETECTORS) def test_cli_detector(detector_command: str): """Test each detection algorithm.""" # Ensure all detectors work without a statsfile. - assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR}', DETECTOR=detector_command) == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR}", DETECTOR=detector_command) == 0 -@pytest.mark.parametrize('detector_command', ALL_DETECTORS) +@pytest.mark.parametrize("detector_command", ALL_DETECTORS) def test_cli_detector_with_stats(tmp_path, detector_command: str): """Test each detection algorithm with a statsfile.""" # Run with a statsfile twice to ensure the file is populated with those metrics and reloaded. - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}', - output_dir=tmp_path, - DETECTOR=detector_command, - ) == 0 - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}', - output_dir=tmp_path, - DETECTOR=detector_command, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", + output_dir=tmp_path, + DETECTOR=detector_command, + ) + == 0 + ) + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR}", + output_dir=tmp_path, + DETECTOR=detector_command, + ) + == 0 + ) # TODO: Check for existence of statsfile by trying to load it with the library, # and ensuring that we got some frames. @@ -267,20 +283,29 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" # Regular invocation - assert invoke_scenedetect( - '-i {VIDEO} time {TIME} {DETECTOR} list-scenes', - output_dir=tmp_path, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", + output_dir=tmp_path, + ) + == 0 + ) # Add statsfile - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes', - output_dir=tmp_path, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes", + output_dir=tmp_path, + ) + == 0 + ) # Suppress output file - assert invoke_scenedetect( - '-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n', - output_dir=tmp_path, - ) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n", + output_dir=tmp_path, + ) + == 0 + ) # TODO: Check for output files from regular invocation. # TODO: Delete scene list and ensure is not recreated using -n. @@ -289,56 +314,86 @@ def test_cli_list_scenes(tmp_path: Path): def test_cli_split_video_ffmpeg(tmp_path: Path): """Test `split-video` command using ffmpeg.""" # Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER. - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video', output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video", output_dir=tmp_path + ) + == 0 + ) entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) - assert (len(entries) == DEFAULT_NUM_SCENES), entries + assert len(entries) == DEFAULT_NUM_SCENES, entries [entry.unlink() for entry in entries] - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c', output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c", output_dir=tmp_path + ) + == 0 + ) entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) - assert (len(entries) == DEFAULT_NUM_SCENES) + assert len(entries) == DEFAULT_NUM_SCENES [entry.unlink() for entry in entries] - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER', - output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER", + output_dir=tmp_path, + ) + == 0 + ) entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*")) - assert (len(entries) == DEFAULT_NUM_SCENES), entries + assert len(entries) == DEFAULT_NUM_SCENES, entries [entry.unlink() for entry in entries] # -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero) assert invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a \"-c:v libx264\"", - output_dir=tmp_path) + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a "-c:v libx264"', + output_dir=tmp_path, + ) @pytest.mark.skipif(condition=not is_mkvmerge_available(), reason="mkvmerge is not available") def test_cli_split_video_mkvmerge(tmp_path: Path): """Test `split-video` command using mkvmerge.""" - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m', output_dir=tmp_path) == 0 - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c', output_dir=tmp_path) == 0 - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', - output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m", output_dir=tmp_path + ) + == 0 + ) + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c", output_dir=tmp_path + ) + == 0 + ) + assert ( + invoke_scenedetect( + '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', + output_dir=tmp_path, + ) + == 0 + ) # -a/--args and -m/--mkvmerge are mutually exclusive assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -a "-c:v libx264"', - output_dir=tmp_path) + output_dir=tmp_path, + ) # TODO: Check for existence of split video files. def test_cli_save_images(tmp_path: Path): """Test `save-images` command.""" - assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images', output_dir=tmp_path) == 0 + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images", output_dir=tmp_path + ) + == 0 + ) # Open one of the created images and make sure it has the correct resolution. # TODO: Also need to test that the right number of images was generated, and compare with # expected frames from the actual video. - images = glob.glob(os.path.join(tmp_path, '*.jpg')) + images = glob.glob(os.path.join(tmp_path, "*.jpg")) assert images image = cv2.imread(images[0]) assert image.shape == (544, 1280, 3) @@ -347,11 +402,15 @@ def test_cli_save_images(tmp_path: Path): # TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. def test_cli_save_images_rotation(rotated_video_file, tmp_path): """Test that `save-images` command rotates images correctly with the default backend.""" - assert invoke_scenedetect( - '-i {VIDEO} {DETECTOR} time {TIME} save-images', - VIDEO=rotated_video_file, - output_dir=tmp_path) == 0 - images = glob.glob(os.path.join(tmp_path, '*.jpg')) + assert ( + invoke_scenedetect( + "-i {VIDEO} {DETECTOR} time {TIME} save-images", + VIDEO=rotated_video_file, + output_dir=tmp_path, + ) + == 0 + ) + images = glob.glob(os.path.join(tmp_path, "*.jpg")) assert images image = cv2.imread(images[0]) # Note same resolution as in test_cli_save_images but rotated 90 degrees. @@ -360,42 +419,51 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path): def test_cli_export_html(tmp_path: Path): """Test `export-html` command.""" - base_command = '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}' - assert invoke_scenedetect( - base_command, COMMAND='save-images export-html', output_dir=tmp_path) == 0 - assert invoke_scenedetect( - base_command, COMMAND='export-html --no-images', output_dir=tmp_path) == 0 + base_command = "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}" + assert ( + invoke_scenedetect(base_command, COMMAND="save-images export-html", output_dir=tmp_path) + == 0 + ) + assert ( + invoke_scenedetect(base_command, COMMAND="export-html --no-images", output_dir=tmp_path) + == 0 + ) # TODO: Check for existence of HTML & image files. -@pytest.mark.parametrize('backend_type', ALL_BACKENDS) +@pytest.mark.parametrize("backend_type", ALL_BACKENDS) def test_cli_backend(backend_type: str): """Test setting the `-b`/`--backend` argument.""" - assert invoke_scenedetect( - '-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}', BACKEND=backend_type) == 0 + assert ( + invoke_scenedetect("-i {VIDEO} -b {BACKEND} time {TIME} {DETECTOR}", BACKEND=backend_type) + == 0 + ) def test_cli_backend_unsupported(): """Ensure setting an invalid backend returns an error.""" - assert invoke_scenedetect( - '-i {VIDEO} -b {BACKEND} {DETECTOR}', BACKEND='unknown_backend_type') != 0 + assert ( + invoke_scenedetect("-i {VIDEO} -b {BACKEND} {DETECTOR}", BACKEND="unknown_backend_type") + != 0 + ) def test_cli_load_scenes(): """Ensure we can load scenes both with and without the cut row.""" - assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes') == 0 - assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes") == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 # Specifying a detector with load-scenes should be disallowed. assert invoke_scenedetect( - '-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv') + "-i {VIDEO} time {TIME} {DETECTOR} load-scenes -i {VIDEO_NAME}-Scenes.csv" + ) # Specifying load-scenes several times should be disallowed. assert invoke_scenedetect( - '-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv' + "-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv load-scenes -i {VIDEO_NAME}-Scenes.csv" ) # If `-s`/`--skip-cuts` is specified, the resulting scene list should still be compatible with # the `load-scenes` command. - assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s') == 0 - assert invoke_scenedetect('-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv') == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s") == 0 + assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 def test_cli_load_scenes_with_time_frames(): @@ -406,24 +474,27 @@ def test_cli_load_scenes_with_time_frames(): 2,91 3,211 """ - with open('test_scene_list.csv', 'w') as f: + with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) output = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + [ - '-i', + SCENEDETECT_CMD.split(" ") + + [ + "-i", DEFAULT_VIDEO_PATH, - 'load-scenes', - '-i', - 'test_scene_list.csv', - 'time', - '-s', - '2s', - '-e', - '10s', - 'list-scenes', + "load-scenes", + "-i", + "test_scene_list.csv", + "time", + "-s", + "2s", + "-e", + "10s", + "list-scenes", ], - text=True) - assert """ + text=True, + ) + assert ( + """ ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -431,7 +502,9 @@ def test_cli_load_scenes_with_time_frames(): | 2 | 91 | 00:00:03.754 | 210 | 00:00:08.759 | | 3 | 211 | 00:00:08.759 | 240 | 00:00:10.010 | ----------------------------------------------------------------------- -""" in output +""" + in output + ) assert "00:00:03.754,00:00:08.759" in output @@ -443,21 +516,45 @@ def test_cli_load_scenes_round_trip(): 2,91 3,211 """ - with open('test_scene_list.csv', 'w') as f: + with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) ground_truth = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + [ - '-i', DEFAULT_VIDEO_PATH, 'detect-content', 'list-scenes', '-f', 'testout.csv', 'time', - '-s', '200', '-e', '400' + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "detect-content", + "list-scenes", + "-f", + "testout.csv", + "time", + "-s", + "200", + "-e", + "400", ], - text=True) + text=True, + ) loaded_first_pass = subprocess.check_output( - SCENEDETECT_CMD.split(' ') + [ - '-i', DEFAULT_VIDEO_PATH, 'load-scenes', '-i', 'testout.csv', 'time', '-s', '200', '-e', - '400', 'list-scenes', '-f', 'testout2.csv' + SCENEDETECT_CMD.split(" ") + + [ + "-i", + DEFAULT_VIDEO_PATH, + "load-scenes", + "-i", + "testout.csv", + "time", + "-s", + "200", + "-e", + "400", + "list-scenes", + "-f", + "testout2.csv", ], - text=True) - SPLIT_POINT = ' | Scene # | Start Frame | Start Time | End Frame | End Time |' + text=True, + ) + SPLIT_POINT = " | Scene # | Start Frame | Start Time | End Frame | End Time |" assert ground_truth.split(SPLIT_POINT)[1] == loaded_first_pass.split(SPLIT_POINT)[1] - with open('testout.csv') as first, open('testout2.csv') as second: + with open("testout.csv") as first, open("testout2.csv") as second: assert first.readlines() == second.readlines() diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 38152b01..0df1f95a 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,22 +9,28 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect Scene Detection Tests +"""PySceneDetect Scene Detection Tests These tests ensure that the detection algorithms deliver consistent results by using known ground truths of scene cut locations in the test case material. """ -from dataclasses import dataclass import os import typing as ty +from dataclasses import dataclass import pytest -from scenedetect import detect, SceneManager, FrameTimecode, StatsManager, SceneDetector -from scenedetect.detectors import * +from scenedetect import FrameTimecode, SceneDetector, SceneManager, StatsManager, detect from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.detectors import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, +) FAST_CUT_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( AdaptiveDetector, @@ -44,20 +49,23 @@ # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: - """ Returns the absolute path to a (relative) path of a file that + """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError(""" + raise FileNotFoundError( + """ Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" % relative_path) +""" + % relative_path + ) return abs_path @@ -82,7 +90,8 @@ def detect(self): video_path=self.path, detector=self.detector, start_time=self.start_time, - end_time=self.end_time) + end_time=self.end_time, + ) def get_fast_cut_test_cases(): @@ -96,8 +105,11 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=15), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), - id="%s/default" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS + scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], + ), + id="%s/default" % detector_type.__name__, + ) + for detector_type in FAST_CUT_DETECTORS ] # goldeneye.mp4 with min_scene_len = 30 test_cases += [ @@ -107,8 +119,11 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=30), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365]), - id="%s/m=30" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS + scene_boundaries=[1199, 1260, 1334, 1365], + ), + id="%s/m=30" % detector_type.__name__, + ) + for detector_type in FAST_CUT_DETECTORS ] return test_cases @@ -124,16 +139,20 @@ def get_fade_in_out_test_cases(): detector=ThresholdDetector(), start_time=0, end_time=500, - scene_boundaries=[0, 15, 198, 376]), - id="threshold_testvideo_default"), + scene_boundaries=[0, 15, 198, 376], + ), + id="threshold_testvideo_default", + ), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), detector=ThresholdDetector(), start_time=0, end_time=250, - scene_boundaries=[0, 84, 167]), - id="threshold_fades_default"), + scene_boundaries=[0, 84, 167], + ), + id="threshold_fades_default", + ), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), @@ -144,8 +163,10 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 84, 167, 245]), - id="threshold_fades_floor"), + scene_boundaries=[0, 84, 167, 245], + ), + id="threshold_fades_floor", + ), pytest.param( TestCase( path=get_absolute_path("resources/fades.mp4"), @@ -156,8 +177,10 @@ def get_fade_in_out_test_cases(): ), start_time=0, end_time=250, - scene_boundaries=[0, 42, 125, 209]), - id="threshold_fades_ceil"), + scene_boundaries=[0, 42, 125, 209], + ), + id="threshold_fades_ceil", + ), ] @@ -181,7 +204,7 @@ def test_detect_fades(test_case: TestCase): def test_detectors_with_stats(test_video_file): - """ Test all detectors functionality with a StatsManager. """ + """Test all detectors functionality with a StatsManager.""" # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). for detector in ALL_DETECTORS: video = VideoStreamCv2(test_video_file) @@ -189,7 +212,7 @@ def test_detectors_with_stats(test_video_file): scene_manager = SceneManager(stats_manager=stats) scene_manager.add_detector(detector()) scene_manager.auto_downscale = True - end_time = FrameTimecode('00:00:08', video.frame_rate) + end_time = FrameTimecode("00:00:08", video.frame_rate) scene_manager.detect_scenes(video=video, end_time=end_time) initial_scene_len = len(scene_manager.get_scene_list()) assert initial_scene_len > 0, "Test case must have at least one scene." diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index aa5c5386..39b25125 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.timecode Tests +"""PySceneDetect scenedetect.timecode Tests This file includes unit tests for the scenedetect.timecode module (specifically, the FrameTimecode object, used for representing frame-accurate timestamps and time values). @@ -21,18 +20,15 @@ or string HH:MM:SS[.nnn]. timecode format. """ -# pylint: disable=invalid-name, expression-not-assigned, unneeded-not, pointless-statement - # Third-Party Library Imports import pytest # Standard Library Imports -from scenedetect.frame_timecode import FrameTimecode -from scenedetect.frame_timecode import MAX_FPS_DELTA +from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode def test_framerate(): - ''' Test FrameTimecode constructor argument "fps". ''' + """Test FrameTimecode constructor argument "fps".""" # Not passing fps results in TypeError. with pytest.raises(TypeError): FrameTimecode() @@ -65,7 +61,7 @@ def test_framerate(): def test_timecode_numeric(): - ''' Test FrameTimecode constructor argument "timecode" with numeric arguments. ''' + """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" with pytest.raises(ValueError): FrameTimecode(timecode=-1, fps=1) with pytest.raises(ValueError): @@ -81,67 +77,67 @@ def test_timecode_numeric(): def test_timecode_string(): - ''' Test FrameTimecode constructor argument "timecode" with string arguments. ''' + """Test FrameTimecode constructor argument "timecode" with string arguments.""" # Invalid strings: with pytest.raises(ValueError): - FrameTimecode(timecode='-1', fps=1) + FrameTimecode(timecode="-1", fps=1) with pytest.raises(ValueError): - FrameTimecode(timecode='-1.0', fps=1.0) + FrameTimecode(timecode="-1.0", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='-0.1', fps=1.0) + FrameTimecode(timecode="-0.1", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='1.9x', fps=1) + FrameTimecode(timecode="1.9x", fps=1) with pytest.raises(ValueError): - FrameTimecode(timecode='1x', fps=1.0) + FrameTimecode(timecode="1x", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='1.9.9', fps=1.0) + FrameTimecode(timecode="1.9.9", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode='1.0-', fps=1.0) + FrameTimecode(timecode="1.0-", fps=1.0) # Frame number integer [int->str] ('%d', integer number as string) - assert FrameTimecode(timecode='0', fps=1).frame_num == 0 - assert FrameTimecode(timecode='1', fps=1).frame_num == 1 - assert FrameTimecode(timecode='10', fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="0", fps=1).frame_num == 0 + assert FrameTimecode(timecode="1", fps=1).frame_num == 1 + assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 # Seconds format [float->str] ('%f', number as string) - assert FrameTimecode(timecode='0.0', fps=1).frame_num == 0 - assert FrameTimecode(timecode='1.0', fps=1).frame_num == 1 - assert FrameTimecode(timecode='10.0', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.0000000000', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.100', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='1.100', fps=10.0).frame_num == 11 + assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0 + assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1 + assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) - assert FrameTimecode(timecode='0s', fps=1).frame_num == 0 - assert FrameTimecode(timecode='1s', fps=1).frame_num == 1 - assert FrameTimecode(timecode='10s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.0s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.0000000000s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='10.100s', fps=1.0).frame_num == 10 - assert FrameTimecode(timecode='1.100s', fps=10.0).frame_num == 11 + assert FrameTimecode(timecode="0s", fps=1).frame_num == 0 + assert FrameTimecode(timecode="1s", fps=1).frame_num == 1 + assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10 + assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) - assert FrameTimecode(timecode='00:00:01', fps=1).frame_num == 1 - assert FrameTimecode(timecode='00:00:01.9999', fps=1).frame_num == 2 - assert FrameTimecode(timecode='00:00:02.0000', fps=1).frame_num == 2 - assert FrameTimecode(timecode='00:00:02.0001', fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1 + assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 - assert FrameTimecode(timecode='00:00:01', fps=10).frame_num == 10 - assert FrameTimecode(timecode='00:00:00.5', fps=10).frame_num == 5 - assert FrameTimecode(timecode='00:00:00.100', fps=10).frame_num == 1 - assert FrameTimecode(timecode='00:00:00.001', fps=1000).frame_num == 1 + assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 + assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 + assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 + assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1 - assert FrameTimecode(timecode='00:00:59.999', fps=1).frame_num == 60 - assert FrameTimecode(timecode='00:01:00.000', fps=1).frame_num == 60 - assert FrameTimecode(timecode='00:01:00.001', fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60 - assert FrameTimecode(timecode='00:59:59.999', fps=1).frame_num == 3600 - assert FrameTimecode(timecode='01:00:00.000', fps=1).frame_num == 3600 - assert FrameTimecode(timecode='01:00:00.001', fps=1).frame_num == 3600 + assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 def test_get_frames(): - ''' Test FrameTimecode get_frames() method. ''' + """Test FrameTimecode get_frames() method.""" assert FrameTimecode(timecode=1, fps=1.0).get_frames(), 1 assert FrameTimecode(timecode=1000, fps=60.0).get_frames(), 1000 assert FrameTimecode(timecode=1000000000, fps=29.97).get_frames(), 1000000000 @@ -150,106 +146,108 @@ def test_get_frames(): assert FrameTimecode(timecode=1000.0, fps=60.0).get_frames(), int(1000.0 * 60.0) assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int(1000000000.0 * 29.97) - assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_frames(), 2 - assert FrameTimecode(timecode='00:00:00.5', fps=10).get_frames(), 5 - assert FrameTimecode(timecode='00:00:01', fps=10).get_frames(), 10 - assert FrameTimecode(timecode='00:01:00.000', fps=1).get_frames(), 60 + assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_frames(), 2 + assert FrameTimecode(timecode="00:00:00.5", fps=10).get_frames(), 5 + assert FrameTimecode(timecode="00:00:01", fps=10).get_frames(), 10 + assert FrameTimecode(timecode="00:01:00.000", fps=1).get_frames(), 60 def test_get_seconds(): - ''' Test FrameTimecode get_seconds() method. ''' + """Test FrameTimecode get_seconds() method.""" assert FrameTimecode(timecode=1, fps=1.0).get_seconds(), pytest.approx(1.0 / 1.0) assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx(1000 / 60.0) - assert FrameTimecode( - timecode=1000000000, fps=29.97).get_seconds(), pytest.approx(1000000000 / 29.97) + assert FrameTimecode(timecode=1000000000, fps=29.97).get_seconds(), pytest.approx( + 1000000000 / 29.97 + ) assert FrameTimecode(timecode=1.0, fps=1.0).get_seconds(), pytest.approx(1.0) assert FrameTimecode(timecode=1000.0, fps=60.0).get_seconds(), pytest.approx(1000.0) - assert FrameTimecode( - timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx(1000000000.0) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx( + 1000000000.0 + ) - assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_seconds(), pytest.approx(2.0) - assert FrameTimecode(timecode='00:00:00.5', fps=10).get_seconds(), pytest.approx(0.5) - assert FrameTimecode(timecode='00:00:01', fps=10).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode='00:01:00.000', fps=1).get_seconds(), pytest.approx(60.0) + assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_seconds(), pytest.approx(2.0) + assert FrameTimecode(timecode="00:00:00.5", fps=10).get_seconds(), pytest.approx(0.5) + assert FrameTimecode(timecode="00:00:01", fps=10).get_seconds(), pytest.approx(1.0) + assert FrameTimecode(timecode="00:01:00.000", fps=1).get_seconds(), pytest.approx(60.0) def test_get_timecode(): - ''' Test FrameTimecode get_timecode() method. ''' - assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == '00:00:01.000' - assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == '00:01:00.117' - assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == '01:00:00.234' + """Test FrameTimecode get_timecode() method.""" + assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == "00:00:01.000" + assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117" + assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234" - assert FrameTimecode(timecode='00:00:02.0000', fps=1).get_timecode() == '00:00:02.000' - assert FrameTimecode(timecode='00:00:00.5', fps=10).get_timecode() == '00:00:00.500' - assert FrameTimecode(timecode='00:00:01.501', fps=10).get_timecode() == '00:00:01.500' - assert FrameTimecode(timecode='00:01:00.000', fps=1).get_timecode() == '00:01:00.000' + assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" + assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" + assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.500" + assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" def test_equality(): - ''' Test FrameTimecode equality (==, __eq__) operator. ''' + """Test FrameTimecode equality (==, __eq__) operator.""" x = FrameTimecode(timecode=1.0, fps=10.0) assert x == x assert x == FrameTimecode(timecode=1.0, fps=10.0) - assert not x != FrameTimecode(timecode=1.0, fps=10.0) + assert x == FrameTimecode(timecode=1.0, fps=10.0) + assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) - assert not x == FrameTimecode(timecode=10.0, fps=10.0) # Comparing FrameTimecodes with different framerates raises a TypeError. with pytest.raises(TypeError): - x == FrameTimecode(timecode=1.0, fps=100.0) + assert x == FrameTimecode(timecode=1.0, fps=100.0) with pytest.raises(TypeError): - x == FrameTimecode(timecode=1.0, fps=10.1) + assert x == FrameTimecode(timecode=1.0, fps=10.1) assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) - assert x == '00:00:01' - assert x == '00:00:01.0' - assert x == '00:00:01.00' - assert x == '00:00:01.000' - assert x == '00:00:01.0000' - assert x == '00:00:01.00000' + assert x == "00:00:01" + assert x == "00:00:01.0" + assert x == "00:00:01.00" + assert x == "00:00:01.000" + assert x == "00:00:01.0000" + assert x == "00:00:01.00000" assert x == 10 assert x == 1.0 with pytest.raises(ValueError): - x == '0x' + assert x == "0x" with pytest.raises(ValueError): - x == 'x00:00:00.000' + assert x == "x00:00:00.000" with pytest.raises(TypeError): - x == [0] + assert x == [0] with pytest.raises(TypeError): - x == (0,) + assert x == (0,) with pytest.raises(TypeError): - x == [0, 1, 2, 3] + assert x == [0, 1, 2, 3] with pytest.raises(TypeError): - x == {0: 0} + assert x == {0: 0} - assert FrameTimecode(timecode='00:00:00.5', fps=10) == '00:00:00.500' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.500' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.501' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.502' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.508' - assert FrameTimecode(timecode='00:00:01.500', fps=10) == '00:00:01.509' - assert FrameTimecode(timecode='00:00:01.519', fps=10) == '00:00:01.510' + assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.501" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.502" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.508" + assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.509" + assert FrameTimecode(timecode="00:00:01.519", fps=10) == "00:00:01.510" def test_addition(): - ''' Test FrameTimecode addition (+/+=, __add__/__iadd__) operator. ''' + """Test FrameTimecode addition (+/+=, __add__/__iadd__) operator.""" x = FrameTimecode(timecode=1.0, fps=10.0) assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) assert x + 1 == FrameTimecode(1.1, x) assert x + 10 == 20 assert x + 10 == 2.0 - assert x + 10 == '00:00:02.000' + assert x + 10 == "00:00:02.000" with pytest.raises(TypeError): - FrameTimecode('00:00:02.000', fps=20.0) == x + 10 + assert FrameTimecode("00:00:02.000", fps=20.0) == x + 10 def test_subtraction(): - ''' Test FrameTimecode subtraction (-/-=, __sub__) operator. ''' + """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" x = FrameTimecode(timecode=1.0, fps=10.0) assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) assert x - 2 == FrameTimecode(0.8, x) @@ -264,12 +262,12 @@ def test_subtraction(): assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) with pytest.raises(TypeError): - FrameTimecode('00:00:02.000', fps=20.0) == x - 10 + assert FrameTimecode("00:00:02.000", fps=20.0) == x - 10 @pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) def test_identity(frame_num, fps): - ''' Test FrameTimecode values, when used in init return the same values ''' + """Test FrameTimecode values, when used in init return the same values""" frame_time_code = FrameTimecode(frame_num, fps=fps) assert FrameTimecode(frame_time_code) == frame_time_code assert FrameTimecode(frame_time_code.get_frames(), fps=fps) == frame_time_code diff --git a/tests/test_platform.py b/tests/test_platform.py index 4f90ff1e..319a54ea 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,31 +9,32 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.platform Tests +"""PySceneDetect scenedetect.platform Tests This file includes unit tests for the scenedetect.platform module, containing all platform/library/OS-specific compatibility fixes. """ import platform + import pytest from scenedetect.platform import CommandTooLong, invoke_command def test_invoke_command(): - """ Ensures the function exists and is callable without throwing - an exception. """ - if platform.system() == 'Windows': - invoke_command(['cmd']) + """Ensures the function exists and is callable without throwing + an exception.""" + if platform.system() == "Windows": + invoke_command(["cmd"]) else: - invoke_command(['echo']) + invoke_command(["echo"]) def test_long_command(): - """ [Windows Only] Ensures that a command string too large to be handled + """[Windows Only] Ensures that a command string too large to be handled is translated to the correct exception for error handling. """ - if platform.system() == 'Windows': + if platform.system() == "Windows": with pytest.raises(CommandTooLong): - invoke_command('x' * 2**15) + invoke_command("x" * 2**15) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index c974398d..9e19f4c1 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -16,8 +15,6 @@ which applies SceneDetector algorithms on VideoStream backends. """ -# pylint: disable=invalid-name - import glob import os import os.path @@ -38,8 +35,8 @@ def test_scene_list(test_video_file): sm.add_detector(ContentDetector()) video_fps = video.frame_rate - start_time = FrameTimecode('00:00:05', video_fps) - end_time = FrameTimecode('00:00:15', video_fps) + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) assert end_time.get_frames() > start_time.get_frames() @@ -93,22 +90,27 @@ def test_save_images(test_video_file): sm = SceneManager() sm.add_detector(ContentDetector()) - image_name_glob = 'scenedetect.tempfile.*.jpg' - image_name_template = ('scenedetect.tempfile.' - '$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.' - '$TIMESTAMP_MS.$TIMECODE') + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile." + "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." + "$TIMESTAMP_MS.$TIMECODE" + ) try: video_fps = video.frame_rate - scene_list = [(FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)]] + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] image_filenames = save_images( scene_list=scene_list, video=video, num_images=3, - image_extension='jpg', - image_name_template=image_name_template) + image_extension="jpg", + image_name_template=image_name_template, + ) # Ensure images got created, and the proper number got created. total_images = 0 @@ -128,19 +130,22 @@ def test_save_images(test_video_file): def test_save_images_zero_width_scene(test_video_file): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" video = VideoStreamCv2(test_video_file) - image_name_glob = 'scenedetect.tempfile.*.jpg' - image_name_template = 'scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER' + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" try: video_fps = video.frame_rate - scene_list = [(FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)]] + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)] + ] NUM_IMAGES = 10 image_filenames = save_images( scene_list=scene_list, video=video, num_images=10, - image_extension='jpg', - image_name_template=image_name_template) + image_extension="jpg", + image_name_template=image_name_template, + ) assert len(image_filenames) == 3 assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) total_images = 0 @@ -156,8 +161,7 @@ def test_save_images_zero_width_scene(test_video_file): # TODO: This would be more readable if the callbacks were defined within the test case, e.g. # split up the callback function and callback lambda test cases. -# pylint: disable=unused-argument, unnecessary-lambda -class FakeCallback(object): +class FakeCallback: """Fake callback used for testing. Tracks the frame numbers the callback was invoked with.""" def __init__(self): @@ -180,9 +184,6 @@ def _callback(self, image, frame_num): self.scene_list.append(frame_num) -# pylint: enable=unused-argument, unnecessary-lambda - - def test_detect_scenes_callback(test_video_file): """Test SceneManager detect_scenes method with a callback function. @@ -195,13 +196,14 @@ def test_detect_scenes_callback(test_video_file): fake_callback = FakeCallback() video_fps = video.frame_rate - start_time = FrameTimecode('00:00:05', video_fps) - end_time = FrameTimecode('00:00:15', video_fps) + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) video.seek(start_time) sm.auto_downscale = True _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_lambda()) + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() + ) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] @@ -231,13 +233,14 @@ def test_detect_scenes_callback_adaptive(test_video_file): fake_callback = FakeCallback() video_fps = video.frame_rate - start_time = FrameTimecode('00:00:05', video_fps) - end_time = FrameTimecode('00:00:15', video_fps) + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) video.seek(start_time) sm.auto_downscale = True _ = sm.detect_scenes( - video=video, end_time=end_time, callback=fake_callback.get_callback_lambda()) + video=video, end_time=end_time, callback=fake_callback.get_callback_lambda() + ) scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 9c2f0af6..3701fe5c 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.stats_manager Tests +"""PySceneDetect scenedetect.stats_manager Tests This file includes unit tests for the scenedetect.stats_manager module (specifically, the StatsManager object, used to coordinate caching of frame metrics to/from a CSV @@ -27,44 +26,42 @@ These files will be deleted, if possible, after the tests are completed running. """ -#pylint: disable=protected-access - import csv import os import random import pytest -from scenedetect.scene_manager import SceneManager -from scenedetect.frame_timecode import FrameTimecode from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.detectors import ContentDetector - -from scenedetect.stats_manager import StatsManager -from scenedetect.stats_manager import StatsFileCorrupt - -from scenedetect.stats_manager import COLUMN_NAME_FRAME_NUMBER -from scenedetect.stats_manager import COLUMN_NAME_TIMECODE +from scenedetect.frame_timecode import FrameTimecode +from scenedetect.scene_manager import SceneManager +from scenedetect.stats_manager import ( + COLUMN_NAME_FRAME_NUMBER, + COLUMN_NAME_TIMECODE, + StatsFileCorrupt, + StatsManager, +) # TODO(v1.0): use https://docs.pytest.org/en/6.2.x/tmpdir.html -TEST_STATS_FILES = ['TEST_STATS_FILE'] * 4 +TEST_STATS_FILES = ["TEST_STATS_FILE"] * 4 TEST_STATS_FILES = [ - '%s_%012d.csv' % (stats_file, random.randint(0, 10**12)) for stats_file in TEST_STATS_FILES + "%s_%012d.csv" % (stats_file, random.randint(0, 10**12)) for stats_file in TEST_STATS_FILES ] def teardown_module(): - """ Removes any created stats files, if any. """ + """Removes any created stats files, if any.""" for stats_file in TEST_STATS_FILES: if os.path.exists(stats_file): os.remove(stats_file) def test_metrics(): - """ Test StatsManager metric registration/setting/getting with a set of pre-defined + """Test StatsManager metric registration/setting/getting with a set of pre-defined key-value pairs (metric_dict). """ - metric_dict = {'some_metric': 1.2345, 'another_metric': 6.7890} + metric_dict = {"some_metric": 1.2345, "another_metric": 6.7890} metric_keys = list(metric_dict.keys()) stats = StatsManager() @@ -85,12 +82,13 @@ def test_metrics(): assert stats.metrics_exist(frame_key, metric_keys) assert stats.metrics_exist(frame_key, metric_keys[1:]) - assert stats.get_metrics( - frame_key, metric_keys) == [metric_dict[metric_key] for metric_key in metric_keys] + assert stats.get_metrics(frame_key, metric_keys) == [ + metric_dict[metric_key] for metric_key in metric_keys + ] def test_detector_metrics(test_video_file): - """ Test passing StatsManager to a SceneManager and using it for storing the frame metrics + """Test passing StatsManager to a SceneManager and using it for storing the frame metrics from a ContentDetector. """ video = VideoStreamCv2(test_video_file) @@ -98,7 +96,7 @@ def test_detector_metrics(test_video_file): scene_manager = SceneManager(stats_manager) scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode('00:00:05', video_fps) + duration = FrameTimecode("00:00:05", video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video=video, duration=duration) # Check that metrics were written to the StatsManager. @@ -106,8 +104,9 @@ def test_detector_metrics(test_video_file): def test_load_empty_stats(): - """ Test loading an empty stats file, ensuring it results in no errors. """ - open(TEST_STATS_FILES[0], 'w').close() + """Test loading an empty stats file, ensuring it results in no errors.""" + with open(TEST_STATS_FILES[0], "w"): + pass stats_manager = StatsManager() stats_manager.load_from_csv(TEST_STATS_FILES[0]) @@ -119,14 +118,13 @@ def test_save_no_detect_scenes(): def test_load_hardcoded_file(): - """ Test loading a stats file with some hard-coded data generated by this test case. """ + """Test loading a stats file with some hard-coded data generated by this test case.""" stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], 'w') as stats_file: - - stats_writer = csv.writer(stats_file, lineterminator='\n') + with open(TEST_STATS_FILES[0], "w") as stats_file: + stats_writer = csv.writer(stats_file, lineterminator="\n") - some_metric_key = 'some_metric' + some_metric_key = "some_metric" some_metric_value = 1.2 some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) @@ -135,20 +133,20 @@ def test_load_hardcoded_file(): # Write out a valid file. stats_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, some_metric_key]) stats_writer.writerow( - [some_frame_key + 1, - some_frame_timecode.get_timecode(), - str(some_metric_value)]) + [some_frame_key + 1, some_frame_timecode.get_timecode(), str(some_metric_value)] + ) stats_manager.load_from_csv(TEST_STATS_FILES[0]) # Check that we decoded the correct values. assert stats_manager.metrics_exist(some_frame_key, [some_metric_key]) - assert stats_manager.get_metrics(some_frame_key, - [some_metric_key])[0] == pytest.approx(some_metric_value) + assert stats_manager.get_metrics(some_frame_key, [some_metric_key])[0] == pytest.approx( + some_metric_value + ) def test_save_load_from_video(test_video_file): - """ Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and + """Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and loading the file back to ensure the loaded frame metrics agree with those that were saved. """ video = VideoStreamCv2(test_video_file) @@ -158,7 +156,7 @@ def test_save_load_from_video(test_video_file): scene_manager.add_detector(ContentDetector()) video_fps = video.frame_rate - duration = FrameTimecode('00:00:05', video_fps) + duration = FrameTimecode("00:00:05", video_fps) scene_manager.auto_downscale = True scene_manager.detect_scenes(video, duration=duration) @@ -181,14 +179,14 @@ def test_save_load_from_video(test_video_file): def test_load_corrupt_stats(): - """ Test loading a corrupted stats file created by outputting data in the wrong format. """ + """Test loading a corrupted stats file created by outputting data in the wrong format.""" stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], 'wt') as stats_file: - stats_writer = csv.writer(stats_file, lineterminator='\n') + with open(TEST_STATS_FILES[0], "w") as stats_file: + stats_writer = csv.writer(stats_file, lineterminator="\n") - some_metric_key = 'some_metric' + some_metric_key = "some_metric" some_metric_value = str(1.2) some_frame_key = 100 base_timecode = FrameTimecode(0, 29.97) @@ -200,7 +198,8 @@ def test_load_corrupt_stats(): # Swapped timecode & frame number. stats_writer.writerow([COLUMN_NAME_TIMECODE, COLUMN_NAME_FRAME_NUMBER, some_metric_key]) stats_writer.writerow( - [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value]) + [some_frame_key, some_frame_timecode.get_timecode(), some_metric_value] + ) stats_file.close() diff --git a/tests/test_video_splitter.py b/tests/test_video_splitter.py index 2cd77cbb..7fefefbb 100644 --- a/tests/test_video_splitter.py +++ b/tests/test_video_splitter.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -12,14 +11,17 @@ # """Tests for scenedetect.video_splitter module.""" -# pylint: disable=no-self-use,missing-function-docstring - from pathlib import Path + import pytest from scenedetect import open_video -from scenedetect.video_splitter import (split_video_ffmpeg, is_ffmpeg_available, SceneMetadata, - VideoMetadata) +from scenedetect.video_splitter import ( + SceneMetadata, + VideoMetadata, + is_ffmpeg_available, + split_video_ffmpeg, +) @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") @@ -35,7 +37,7 @@ def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) - assert (len(entries) == len(scenes)) + assert len(entries) == len(scenes) @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") @@ -55,7 +57,7 @@ def name_formatter(video: VideoMetadata, scene: SceneMetadata): assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) == 0 video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) - assert (len(entries) == len(scenes)) + assert len(entries) == len(scenes) # TODO: Add tests for `split_video_mkvmerge`. diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 7e952881..c3cc5127 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # PySceneDetect: Python-Based Video Scene Detector # ------------------------------------------------------------------- @@ -10,27 +9,24 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -""" PySceneDetect scenedetect.video_stream Tests +"""PySceneDetect scenedetect.video_stream Tests This file includes unit tests for the scenedetect.video_stream module, as well as the video backends implemented in scenedetect.backends. These tests enforce a consistent interface across all supported backends, and verify that they are functionally equivalent where possible. """ -# pylint: disable=no-self-use,missing-function-docstring - +import os.path from dataclasses import dataclass from typing import List, Type -import os.path import numpy import pytest -from scenedetect.video_stream import VideoStream, SeekError +from scenedetect.backends import VideoStreamAv, VideoStreamMoviePy from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.backends import VideoStreamAv -from scenedetect.backends import VideoStreamMoviePy from scenedetect.video_manager import VideoManager +from scenedetect.video_stream import SeekError, VideoStream # Accuracy a framerate is checked to for testing purposes. FRAMERATE_TOLERANCE = 0.001 @@ -48,7 +44,7 @@ def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: if roi: - assert False # TODO + raise RuntimeError("TODO") assert frame_a.shape == frame_b.shape num_pixels = frame_a.shape[0] * frame_a.shape[1] return numpy.sum(numpy.abs(frame_b - frame_a)) / num_pixels @@ -56,26 +52,30 @@ def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: # TODO: Reduce code duplication here and in `conftest.py` def get_absolute_path(relative_path: str) -> str: - """ Returns the absolute path to a (relative) path of a file that + """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. Throws FileNotFoundError if the file could not be found. """ abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError(""" + raise FileNotFoundError( + """ Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset -""" % relative_path) +""" + % relative_path + ) return abs_path @dataclass class VideoParameters: """Properties for each input a VideoStream is tested against.""" + path: str height: int width: int @@ -120,12 +120,17 @@ def get_test_video_params() -> List[VideoParameters]: pytest.mark.parametrize( "vs_type", list( - filter(lambda x: x is not None, [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - VideoManager, - ]))), + filter( + lambda x: x is not None, + [ + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + VideoManager, + ], + ) + ), + ), pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), ] @@ -141,10 +146,11 @@ def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameter assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) assert stream.duration.get_frames() == test_video.total_frames file_name = os.path.basename(test_video.path) - last_dot_pos = file_name.rfind('.') + last_dot_pos = file_name.rfind(".") assert stream.name == file_name[:last_dot_pos] - assert stream.aspect_ratio == pytest.approx(test_video.aspect_ratio, - PIXEL_ASPECT_RATIO_TOLERANCE) + assert stream.aspect_ratio == pytest.approx( + test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE + ) def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" @@ -191,7 +197,8 @@ def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoPara assert stream.frame_number == i assert stream.position == stream.base_timecode + (i - 1) assert stream.position_ms == pytest.approx( - 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS) + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS + ) def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" @@ -214,12 +221,14 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.frame_number == 200 assert stream.position == stream.base_timecode + 199 assert stream.position_ms == pytest.approx( - 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS) + 1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS + ) stream.read() assert stream.frame_number == 201 assert stream.position == stream.base_timecode + 200 assert stream.position_ms == pytest.approx( - 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS) + 1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS + ) # Seek to a time in seconds (float). stream.seek(2.0) @@ -228,7 +237,8 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): # starts counting from zero. This should eventually be changed. assert stream.position == (stream.base_timecode + 2.0) - 1 assert stream.position_ms == pytest.approx( - 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate) + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate + ) stream.read() assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) assert stream.position == stream.base_timecode + 2.0 @@ -241,7 +251,8 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): # starts counting from zero. This should eventually be changed. assert stream.position == (stream.base_timecode + 2.0) - 1 assert stream.position_ms == pytest.approx( - 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate) + 2000.0 - (1000.0 / stream.frame_rate), abs=1000.0 / stream.frame_rate + ) stream.read() assert stream.frame_number == 1 + round(stream.frame_rate * 2.0) assert stream.position == stream.base_timecode + 2.0 @@ -265,7 +276,8 @@ def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameter assert stream.frame_number == i assert stream.position == stream.base_timecode + (i - 1) assert stream.position_ms == pytest.approx( - 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS) + 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS + ) stream.seek(0) assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -297,7 +309,7 @@ def test_read_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters) def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters): """Validate calling `seek()` to offset past end of video.""" if vs_type == VideoManager: - pytest.skip(reason='VideoManager does not have compliant end-of-video seek behaviour.') + pytest.skip(reason="VideoManager does not have compliant end-of-video seek behaviour.") stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit # frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF, @@ -335,13 +347,13 @@ def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParamet def test_invalid_path(vs_type: Type[VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): - _ = vs_type('this_path_should_not_exist.mp4') + _ = vs_type("this_path_should_not_exist.mp4") def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoManager: - pytest.skip(reason='VideoManager does not support handling corrupt videos.') + pytest.skip(reason="VideoManager does not support handling corrupt videos.") stream = vs_type(corrupt_video_file) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6e58c1a9..d2e4c835 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,11 @@ Releases ## PySceneDetect 0.6 +### 0.6.5 (TBD) + + - [bugfix] Fix new detectors not working with `default-detector` config option + - [bugfix] Fix SyntaxWarning due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/pull/295) [#400](https://github.com/Breakthrough/PySceneDetect/issues/35) + ### 0.6.4 (June 10, 2024) #### Release Notes @@ -30,6 +35,11 @@ Feedback on the new detection methods and their default values is most welcome. - [bugfix] Fix crash when decoded frames have incorrect resolution and log error instead [#319](https://github.com/Breakthrough/PySceneDetect/issues/319) - [bugfix] Update default ffmpeg stream mapping from `-map 0` to `-map 0:v:0 -map 0:a? -map 0:s?` [#392](https://github.com/Breakthrough/PySceneDetect/issues/392) +#### 0.6.4.1 (TBD) + + - [bugfix] Fix `default-detector` config option not working with new detectors + - [bugfix] Fix SyntaxWarning due to incorrect string escaping in command-line (#400) + ### 0.6.3 (March 9, 2024) diff --git a/website/pages/contributing.md b/website/pages/contributing.md index f45b753f..c9662e3d 100644 --- a/website/pages/contributing.md +++ b/website/pages/contributing.md @@ -12,8 +12,8 @@ Development of PySceneDetect happens on [github.com/Breakthrough/PySceneDetect]( The following checklist covers the basics of pre-submission requirements: - Code passes all unit tests (run `pytest`) - - Code is formatted (run `python -m yapf -i -r scenedetect/ tests/` to format in place) - - Generally follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) + - Code passes static analysis and formatting checks (`ruff check` and `ruff format`) + - Follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) Note that PySceneDetect is released under the BSD 3-Clause license, and submitted code should comply with this license (see [License & Copyright Information](copyright.md) for details). From 71c1992ed285599e8c7aba8cb2ce53a816487509 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 7 Sep 2024 21:26:48 -0400 Subject: [PATCH 124/407] [dist] Complete merging develop with main. --- .github/workflows/build-windows.yml | 1 - .github/workflows/build.yml | 1 - .github/workflows/codeql.yml | 2 -- .github/workflows/generate-docs.yml | 8 ++++---- website/pages/docs.md | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 56d4b832..efc2449c 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -17,7 +17,6 @@ on: - tests/** branches: - main - - develop - 'releases/**' tags: - v*-release diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc79ea7a..69cdcfbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,6 @@ on: - tests/** branches: - main - - develop - 'releases/**' tags: - v*-release diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9532dec3..8499d1aa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,7 +5,6 @@ on: push: branches: - main - - develop - releases/** paths: - scenedetect/** @@ -13,7 +12,6 @@ on: pull_request: branches: - main - - develop - releases/* paths: - scenedetect/** diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 1f9d2590..2f41c6d7 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -4,7 +4,7 @@ name: Generate Documentation on: push: branches: - - develop # docs/develop + - main # docs/head - 'releases/**' # docs/** paths: - 'docs/**' @@ -33,10 +33,10 @@ jobs: run: | echo "scenedetect_docs_dest=$(echo ${{ github.ref_name }} | cut -b 10-)" >> "$GITHUB_ENV" - - name: Set Destination (Develop) - if: ${{ contains(github.ref_name, 'develop') }} + - name: Set Destination (Head) + if: ${{ contains(github.ref_name, 'main') }} run: | - echo "scenedetect_docs_dest=develop" >> "$GITHUB_ENV" + echo "scenedetect_docs_dest=head" >> "$GITHUB_ENV" - name: Check Destination if: ${{ env.scenedetect_docs_dest == '' }} diff --git a/website/pages/docs.md b/website/pages/docs.md index c45123ba..381881e6 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -11,4 +11,4 @@ ## In Development - * [develop](develop/) + * [head](head/) From c675bcd964de1ad9e9fd45c993bde559a4994a24 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 7 Sep 2024 21:36:01 -0400 Subject: [PATCH 125/407] [docs] Move in-development changelog to bottom. --- website/pages/changelog.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index d2e4c835..14a31ae2 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,11 +4,6 @@ Releases ## PySceneDetect 0.6 -### 0.6.5 (TBD) - - - [bugfix] Fix new detectors not working with `default-detector` config option - - [bugfix] Fix SyntaxWarning due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/pull/295) [#400](https://github.com/Breakthrough/PySceneDetect/issues/35) - ### 0.6.4 (June 10, 2024) #### Release Notes @@ -583,3 +578,15 @@ Both the Windows installer and portable distributions now include signed executa * first public release * [feature] threshold-based fade in/out detection + + +---------------------------------------------------------------- + + +Development +========================================================== + +## PySceneDetect 0.6.5 (TBD) + + - [bugfix] Fix new detectors not working with `default-detector` config option + - [bugfix] Fix SyntaxWarning due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/pull/295) [#400](https://github.com/Breakthrough/PySceneDetect/issues/35) From 21d8c291feb60177335c2bc3e1c6e632bc7c7217 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 7 Sep 2024 21:37:35 -0400 Subject: [PATCH 126/407] [docs] Fix broken changelog link. --- website/pages/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 14a31ae2..e4348b7e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -589,4 +589,4 @@ Development ## PySceneDetect 0.6.5 (TBD) - [bugfix] Fix new detectors not working with `default-detector` config option - - [bugfix] Fix SyntaxWarning due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/pull/295) [#400](https://github.com/Breakthrough/PySceneDetect/issues/35) + - [bugfix] Fix SyntaxWarning due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) From bfed4bdb88ea004a84050195ebc45fcca6810e4c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 15 Sep 2024 21:48:39 -0400 Subject: [PATCH 127/407] [docs] Simplify API documentation introductions. --- docs/api.rst | 10 ++-------- tests/test_api.py | 6 +----- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index ab34f97a..7bbd3dba 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,10 +3,6 @@ ``scenedetect`` 🎬 Package *********************************************************************** -======================================================================= -Overview -======================================================================= - The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Quickstart`_ and `Example`_ sections below for some common use cases and integrations. The `scenedetect` package contains several modules: * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). This module also contains functionality to export information about scenes in various formats: :func:`save_images ` to save images for each scene, :func:`write_scene_list ` to save scene/cut info as CSV, and :func:`write_scene_list_html ` to export scenes in viewable HTML format. @@ -53,7 +49,7 @@ Most types/functions are also available directly from the `scenedetect` package .. _scenedetect-quickstart: ======================================================================= -Quickstart +Examples ======================================================================= To get started, the :func:`scenedetect.detect` function takes a path to a video and a :ref:`scene detector object`, and returns a list of start/end timecodes. For detecting fast cuts (shot changes), we use the :class:`ContentDetector `: @@ -83,9 +79,7 @@ Now that we know where each scene is, we can also :ref:`split the input video `_ file. +For more customizable scene cut/shot detection pipelines, start with :ref:`the SceneManager examples`. Recipes for common use cases can be :ref:`found on Github `_. .. _scenedetect-detailed_example: diff --git a/tests/test_api.py b/tests/test_api.py index 07559253..c353a96b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,11 +11,7 @@ # """PySceneDetect API Tests -These tests function as demonstrations of the PySceneDetect API. These tests provide examples -of common use cases, which can be integrated into applications, or used from an interactive -Python environment. When processing longer videos, it is useful to set `show_progress=True` -when calling `detect()` or `detect_scenes()`. -""" +These tests demonstrate common workflow patterns used when integrating the PySceneDetect API.""" def test_api_detect(test_video_file: str): From b3b56607a2c7fc3e4265f3d17083d56b87a605a8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 15 Sep 2024 22:01:28 -0400 Subject: [PATCH 128/407] [docs] Update outdated documentation. --- docs/api.rst | 33 ++------------------------------- docs/api/migration_guide.rst | 2 +- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 7bbd3dba..cc292893 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -79,38 +79,9 @@ Now that we know where each scene is, we can also :ref:`split the input video `_. - - -.. _scenedetect-detailed_example: - -======================================================================= -Example -======================================================================= - -In this example, we create a function ``find_scenes()`` which will load a video, detect the scenes, and return a list of tuples containing the (start, end) timecodes of each detected scene. Note that you can modify the `threshold` argument to modify the sensitivity of the :class:`ContentDetector `, or use other detection algorithms (e.g. :class:`ThresholdDetector `, :class:`AdaptiveDetector `). - -.. code:: python - - from scenedetect import SceneManager, open_video, ContentDetector - - def find_scenes(video_path, threshold=27.0): - video = open_video(video_path) - scene_manager = SceneManager() - scene_manager.add_detector( - ContentDetector(threshold=threshold)) - # Detect all scenes in video from current position to end. - scene_manager.detect_scenes(video) - # `get_scene_list` returns a list of start/end timecode pairs - # for each scene that was found. - return scene_manager.get_scene_list() - -Using a :class:`SceneManager ` directly allows tweaking the Parameters passed to :meth:`detect_scenes ` including setting a limit to the number of frames to process, which is useful for live streams/camera devices. You can also combine detection algorithms or create new ones from scratch. - -For a more advanced example of using the PySceneDetect API to with a stats file (to save per-frame metrics to disk and/or speed up multiple passes of the same video), take a look at the :ref:`example in the SceneManager reference`. - -In addition to module-level examples, demonstrations of some common use cases can be found in the `tests/test_api.py `_ file. + example of using the PySceneDetect API to with a stats file (to save per-frame metrics to disk and/or speed up multiple passes of the same video), take a look at the :ref:`example in the SceneManager +Recipes for common use cases can be :ref:`found on Github `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:`examples in the SceneManager reference `. ======================================================================= Functions diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index f24baefa..7f7df42e 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -5,7 +5,7 @@ Migration Guide --------------------------------------------------------------- -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Quickstart ` and :ref:`Example ` sections first, as they should cover the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. +This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. From 6247ddae6ee3e7f357a5336a467d7f75fa1577ac Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 15 Sep 2024 22:05:33 -0400 Subject: [PATCH 129/407] [docs] Fix broken RST links. --- docs/api.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index cc292893..b2a4b591 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -79,9 +79,8 @@ Now that we know where each scene is, we can also :ref:`split the input video `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:` SceneManager usage examples `. -Recipes for common use cases can be :ref:`found on Github `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:`examples in the SceneManager reference `. ======================================================================= Functions From d460a95b70d7974e81355df594fc05199fbd9a37 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 15 Sep 2024 23:41:02 -0400 Subject: [PATCH 130/407] [docs] Update api.rst. --- docs/api.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index b2a4b591..4d65f493 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -79,8 +79,7 @@ Now that we know where each scene is, we can also :ref:`split the input video `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:` SceneManager usage examples `. - +Recipes for common use cases can be `found on Github `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:`SceneManager usage examples `. ======================================================================= Functions From a99aed18c17bbc549201b9e2608cc7e22e5ea1c1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 19 Sep 2024 23:59:26 -0400 Subject: [PATCH 131/407] [cli] Move output command state out of CliContext To simplify things, all output commands can be treated similarily as they all act on the result of the processing pipeline. This allows new commands to be added without needing to explicitly define their values in CliContext, and makes the values that do remain there much more meaningful. It also allows commands to be specified multiple times gracefully and with different options, so for example `save-images` can now be run twice with different encoding parameters. --- scenedetect/__main__.py | 8 +- scenedetect/_cli/__init__.py | 213 +++++++++++++---- scenedetect/_cli/commands.py | 204 ++++++++++++++++ scenedetect/_cli/config.py | 7 +- scenedetect/_cli/context.py | 409 ++++++--------------------------- scenedetect/_cli/controller.py | 227 +++--------------- scenedetect/video_splitter.py | 12 +- 7 files changed, 495 insertions(+), 585 deletions(-) create mode 100644 scenedetect/_cli/commands.py diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 7c9ec1b9..ea6d6b0a 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -22,10 +22,10 @@ def main(): """PySceneDetect command-line interface (CLI) entry point.""" - cli_ctx = CliContext() + context = CliContext() try: # Process command line arguments and subcommands to initialize the context. - scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. + scenedetect.main(obj=context) # Parse CLI arguments with registered callbacks. except SystemExit as exit: help_command = any(arg in sys.argv for arg in ["-h", "--help"]) if help_command or exit.code != 0: @@ -38,12 +38,12 @@ def main(): # no progress bars get created, we instead create a fake context manager. This is done here # to avoid needing a separate context manager at each point a progress bar is created. log_redirect = ( - FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm(loggers=[logger]) + FakeTqdmLoggingRedirect() if context.quiet_mode else logging_redirect_tqdm(loggers=[logger]) ) with log_redirect: try: - run_scenedetect(cli_ctx) + run_scenedetect(context) except KeyboardInterrupt: logger.info("Stopped.") if __debug__: diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 18047181..86767dcb 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -25,8 +25,17 @@ import click import scenedetect -from scenedetect._cli.config import CHOICE_MAP, CONFIG_FILE_PATH, CONFIG_MAP -from scenedetect._cli.context import USER_CONFIG, CliContext +import scenedetect._cli.commands as cli_commands +from scenedetect._cli.config import ( + CHOICE_MAP, + CONFIG_FILE_PATH, + CONFIG_MAP, + DEFAULT_JPG_QUALITY, + DEFAULT_WEBP_QUALITY, + USER_CONFIG, + TimecodeFormat, +) +from scenedetect._cli.context import CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.detectors import ( AdaptiveDetector, @@ -35,7 +44,8 @@ HistogramDetector, ThresholdDetector, ) -from scenedetect.platform import get_system_version_info +from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info +from scenedetect.scene_manager import Interpolation _PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" @@ -958,13 +968,20 @@ def export_html_command( image_height: ty.Optional[int], ): """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_export_html( - filename=filename, - no_images=no_images, - image_width=image_width, - image_height=image_height, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + ctx.ensure_input_open() + no_images = no_images or ctx.config.get_value("export-html", "no-images") + if not ctx.save_images and not no_images: + raise click.BadArgumentUsage( + "export-html requires that save-images precedes it or --no-images is specified." + ) + export_html_args = { + "html_name_format": ctx.config.get_value("export-html", "filename", filename), + "image_width": ctx.config.get_value("export-html", "image-width", image_width), + "image_height": ctx.config.get_value("export-html", "image-height", image_height), + } + ctx.add_command(cli_commands.export_html, export_html_args) @click.command("list-scenes", cls=_Command) @@ -1018,14 +1035,23 @@ def list_scenes_command( skip_cuts: bool, ): """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default).""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_list_scenes( - output=output, - filename=filename, - no_output_file=no_output_file, - quiet=quiet, - skip_cuts=skip_cuts, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + ctx.ensure_input_open() + no_output_file = no_output_file or ctx.config.get_value("list-scenes", "no-output-file") + scene_list_dir = ctx.config.get_value("list-scenes", "output", output, ignore_default=True) + scene_list_name_format = ctx.config.get_value("list-scenes", "filename", filename) + list_scenes_args = { + "cut_format": TimecodeFormat[ctx.config.get_value("list-scenes", "cut-format").upper()], + "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), + "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), + "scene_list_output": not no_output_file, + "scene_list_name_format": scene_list_name_format, + "skip_cuts": skip_cuts or ctx.config.get_value("list-scenes", "skip-cuts"), + "output_dir": scene_list_dir, + "quiet": quiet or ctx.config.get_value("list-scenes", "quiet") or ctx.quiet_mode, + } + ctx.add_command(cli_commands.list_scenes, list_scenes_args) @click.command("split-video", cls=_Command) @@ -1134,18 +1160,73 @@ def split_video_command( {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_split_video( - output=output, - filename=filename, - quiet=quiet, - copy=copy, - high_quality=high_quality, - rate_factor=rate_factor, - preset=preset, - args=args, - mkvmerge=mkvmerge, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + ctx.ensure_input_open() + check_split_video_requirements(use_mkvmerge=mkvmerge) + if "%" in ctx.video_stream.path or "://" in ctx.video_stream.path: + error = "The split-video command is incompatible with image sequences/URLs." + raise click.BadParameter(error, param_hint="split-video") + + # We only load the config values for these flags/options if none of the other + # encoder flags/options were set via the CLI to avoid any conflicting options + # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). + if not (mkvmerge or copy or high_quality or args or rate_factor or preset): + mkvmerge = ctx.config.get_value("split-video", "mkvmerge") + copy = ctx.config.get_value("split-video", "copy") + high_quality = ctx.config.get_value("split-video", "high-quality") + rate_factor = ctx.config.get_value("split-video", "rate-factor") + preset = ctx.config.get_value("split-video", "preset") + args = ctx.config.get_value("split-video", "args") + + # Disallow certain combinations of options. + if mkvmerge or copy: + command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" + if high_quality: + raise click.BadParameter( + "high-quality (-hq) cannot be used with %s" % (command), + param_hint="split-video", + ) + if args: + raise click.BadParameter( + "args (-a) cannot be used with %s" % (command), param_hint="split-video" + ) + if rate_factor: + raise click.BadParameter( + "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" + ) + if preset: + raise click.BadParameter( + "preset (-p) cannot be used with %s" % (command), param_hint="split-video" + ) + + # mkvmerge-Specific Options + if mkvmerge and copy: + logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") + + # ffmpeg-Specific Options + if copy: + args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" + elif not args: + if rate_factor is None: + rate_factor = 22 if not high_quality else 17 + if preset is None: + preset = "veryfast" if not high_quality else "slow" + args = ( + "-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" + ) + if filename: + logger.info("Output file name format: %s", filename) + + split_video_args = { + "name_format": ctx.config.get_value("split-video", "filename", filename), + "use_mkvmerge": mkvmerge, + "output_dir": ctx.config.get_value("split-video", "output", output, ignore_default=True), + "show_output": not quiet, + "ffmpeg_args": args, + } + ctx.add_command(cli_commands.split_video, split_video_args) @click.command("save-images", cls=_Command) @@ -1279,21 +1360,65 @@ def save_images_command( {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_save_images( - num_images=num_images, - output=output, - filename=filename, - jpeg=jpeg, - webp=webp, - quality=quality, - png=png, - compression=compression, - frame_margin=frame_margin, - scale=scale, - height=height, - width=width, + ctx = ctx.obj + assert isinstance(ctx, CliContext) + ctx.ensure_input_open() + if "://" in ctx.video_stream.path: + error_str = "\nThe save-images command is incompatible with URLs." + logger.error(error_str) + raise click.BadParameter(error_str, param_hint="save-images") + num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) + if num_flags > 1: + logger.error(".") + raise click.BadParameter("Only one image type can be specified.", param_hint="save-images") + elif num_flags == 0: + image_format = ctx.config.get_value("save-images", "format").lower() + jpeg = image_format == "jpeg" + webp = image_format == "webp" + png = image_format == "png" + + if not any((scale, height, width)): + scale = ctx.config.get_value("save-images", "scale") + height = ctx.config.get_value("save-images", "height") + width = ctx.config.get_value("save-images", "width") + scale_method = Interpolation[ctx.config.get_value("save-images", "scale-method").upper()] + quality = ( + (DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY) + if ctx.config.is_default("save-images", "quality") + else ctx.config.get_value("save-images", "quality") ) + compression = ctx.config.get_value("save-images", "compression", compression) + image_extension = "jpg" if jpeg else "png" if png else "webp" + valid_params = get_cv2_imwrite_params() + if image_extension not in valid_params or valid_params[image_extension] is None: + error_strs = [ + "Image encoder type `%s` not supported." % image_extension.upper(), + "The specified encoder type could not be found in the current OpenCV module.", + "To enable this output format, please update the installed version of OpenCV.", + "If you build OpenCV, ensure the the proper dependencies are enabled. ", + ] + logger.debug("\n".join(error_strs)) + raise click.BadParameter("\n".join(error_strs), param_hint="save-images") + output = ctx.config.get_value("save-images", "output", output, ignore_default=True) + + save_images_args = { + "encoder_param": compression if png else quality, + "frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin), + "height": height, + "image_extension": image_extension, + "image_name_template": ctx.config.get_value("save-images", "filename", filename), + "interpolation": scale_method, + "num_images": ctx.config.get_value("save-images", "num-images", num_images), + "output_dir": output, + "scale": scale, + "show_progress": ctx.quiet_mode, + "width": width, + } + ctx.add_command(cli_commands.save_images, save_images_args) + + # Record that we added a save-images command to the pipeline so we can allow export-html + # to run afterwards (it is dependent on the output). + ctx.save_images = True # ---------------------------------------------------------------------- diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py new file mode 100644 index 00000000..077ac00e --- /dev/null +++ b/scenedetect/_cli/commands.py @@ -0,0 +1,204 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Logic for the PySceneDetect command.""" + +import logging +import typing as ty +from string import Template + +import scenedetect.scene_manager as scene_manager +from scenedetect._cli.context import CliContext +from scenedetect.frame_timecode import FrameTimecode +from scenedetect.platform import get_and_create_path +from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge + +logger = logging.getLogger("pyscenedetect") + +SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] + +CutList = ty.List[FrameTimecode] + + +def list_scenes( + context: CliContext, + scenes: SceneList, + cuts: CutList, + scene_list_output: bool, + scene_list_name_format: str, + output_dir: str, + skip_cuts: bool, + quiet: bool, + display_scenes: bool, + display_cuts: bool, + cut_format: str, + **kwargs, +): + """Handles the `list-scenes` command.""" + # Write scene list CSV to if required. + if scene_list_output: + scene_list_filename = Template(scene_list_name_format).safe_substitute( + VIDEO_NAME=context.video_stream.name + ) + if not scene_list_filename.lower().endswith(".csv"): + scene_list_filename += ".csv" + scene_list_path = get_and_create_path( + scene_list_filename, + output_dir, + ) + logger.info("Writing scene list to CSV file:\n %s", scene_list_path) + with open(scene_list_path, "w") as scene_list_file: + scene_manager.write_scene_list( + output_csv_file=scene_list_file, + scene_list=scenes, + include_cut_list=not skip_cuts, + cut_list=cuts, + ) + # Suppress output if requested. + if quiet: + return + # Print scene list. + if display_scenes: + logger.info( + """Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- +%s +-----------------------------------------------------------------------""", + "\n".join( + [ + " | %5d | %11d | %s | %11d | %s |" + % ( + i + 1, + start_time.get_frames() + 1, + start_time.get_timecode(), + end_time.get_frames(), + end_time.get_timecode(), + ) + for i, (start_time, end_time) in enumerate(scenes) + ] + ), + ) + # Print cut list. + if cuts and display_cuts: + logger.info( + "Comma-separated timecode list:\n %s", + ",".join([cut_format.format(cut) for cut in cuts]), + ) + + +def save_images( + context: CliContext, + scenes: SceneList, + num_images: int, + frame_margin: int, + image_extension: str, + encoder_param: int, + image_name_template: str, + output_dir: ty.Optional[str], + show_progress: bool, + scale: int, + height: int, + width: int, + interpolation: scene_manager.Interpolation, + **kwargs, +): + """Handles the `save-images` command.""" + logger.info(f"Saving images to {output_dir} with format {image_extension}") + logger.debug(f"encoder param: {encoder_param}") + images = scene_manager.save_images( + scene_list=scenes, + video=context.video_stream, + num_images=num_images, + frame_margin=frame_margin, + image_extension=image_extension, + encoder_param=encoder_param, + image_name_template=image_name_template, + output_dir=output_dir, + show_progress=show_progress, + scale=scale, + height=height, + width=width, + interpolation=interpolation, + ) + context.save_images_result = (images, output_dir) + + +def export_html( + context: CliContext, + scenes: SceneList, + cuts: CutList, + image_width: int, + image_height: int, + html_name_format: str, + **kwargs, +): + """Handles the `export-html` command.""" + save_images_result = context.save_images_result + # Command can override global output directory setting. + output_dir = save_images_result[1] if save_images_result[1] is not None else context.output_dir + html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + + if not html_filename.lower().endswith(".html"): + html_filename += ".html" + html_path = get_and_create_path(html_filename, output_dir) + logger.info("Exporting to html file:\n %s:", html_path) + scene_manager.write_scene_list_html( + output_html_filename=html_path, + scene_list=scenes, + cut_list=cuts, + image_filenames=save_images_result[0], + image_width=image_width, + image_height=image_height, + ) + + +def split_video( + context: CliContext, + scenes: SceneList, + name_format: str, + use_mkvmerge: bool, + output_dir: str, + show_output: bool, + ffmpeg_args: str, + **kwargs, +): + """Handles the `split-video` command.""" + # Add proper extension to filename template if required. + dot_pos = name_format.rfind(".") + extension_length = 0 if dot_pos < 0 else len(name_format) - (dot_pos + 1) + # If using mkvmerge, force extension to .mkv. + if use_mkvmerge and not name_format.endswith(".mkv"): + name_format += ".mkv" + # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. + elif not 2 <= extension_length <= 4: + name_format += ".mp4" + if use_mkvmerge: + split_video_mkvmerge( + input_video_path=context.video_stream.path, + scene_list=scenes, + output_dir=output_dir, + output_file_template=name_format, + show_output=show_output, + ) + else: + split_video_ffmpeg( + input_video_path=context.video_stream.path, + scene_list=scenes, + output_dir=output_dir, + output_file_template=name_format, + arg_override=ffmpeg_args, + show_progress=not context.quiet_mode, + show_output=show_output, + ) + if scenes: + logger.info("Video splitting completed, scenes written to disk.") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 929587ad..b8aa733d 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -30,7 +30,7 @@ from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS -VALID_PYAV_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] class OptionParseFailure(Exception): @@ -354,7 +354,7 @@ def format(self, timecode: FrameTimecode) -> str: CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { "backend-pyav": { - "threading_mode": [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], }, "detect-content": { "filter-mode": [mode.name.lower() for mode in FlashFilter.Mode], @@ -621,3 +621,6 @@ def get_help_string( ): return "" return " [default: %s]" % (str(CONFIG_MAP[command][option])) + + +USER_CONFIG = ConfigRegistry(throw_exception=False) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index de0e95a0..a9e02a46 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -21,11 +21,8 @@ from scenedetect import AVAILABLE_BACKENDS, open_video from scenedetect._cli.config import ( CHOICE_MAP, - DEFAULT_JPG_QUALITY, - DEFAULT_WEBP_QUALITY, ConfigLoadFailure, ConfigRegistry, - TimecodeFormat, ) from scenedetect.detectors import ( AdaptiveDetector, @@ -35,7 +32,7 @@ ThresholdDetector, ) from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -from scenedetect.platform import get_cv2_imwrite_params, init_logger +from scenedetect.platform import init_logger from scenedetect.scene_detector import FlashFilter, SceneDetector from scenedetect.scene_manager import Interpolation, SceneManager from scenedetect.stats_manager import StatsManager @@ -46,6 +43,10 @@ USER_CONFIG = ConfigRegistry(throw_exception=False) +SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] + +CutList = ty.List[FrameTimecode] + def parse_timecode( value: ty.Optional[str], frame_rate: float, correct_pts: bool = False @@ -71,11 +72,6 @@ def parse_timecode( ) from ex -def contains_sequence_or_url(video_path: str) -> bool: - """Checks if the video path is a URL or image sequence.""" - return "%" in video_path or "://" in video_path - - def check_split_video_requirements(use_mkvmerge: bool) -> None: """Validates that the proper tool is available on the system to perform the `split-video` command. @@ -102,85 +98,71 @@ def check_split_video_requirements(use_mkvmerge: bool) -> None: raise click.BadParameter(error_str, param_hint="split-video") -class CliContext: - """Context of the command-line interface and config file parameters passed between sub-commands. +class AppState: + def __init__(self): + self.video_stream: VideoStream = None + self.scene_manager: SceneManager = None + self.stats_manager: StatsManager = None + self.output: str = None + self.quiet_mode: bool = None + self.stats_file_path: str = None + self.drop_short_scenes: bool = None + self.merge_last_scene: bool = None + self.min_scene_len: FrameTimecode = None + self.frame_skip: int = None + self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None + self.start_time: FrameTimecode = None # time -s/--start + self.end_time: FrameTimecode = None # time -e/--end + self.duration: FrameTimecode = None # time -d/--duration + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.save_images: bool = False # True if the save-images command was specified + # Result of save-images function output stored for use by export-html + self.save_images_result: ty.Any = (None, None) - Handles validation of options taken in from the CLI *and* configuration files. - After processing the main program options via `handle_options`, the CLI will then call - the respective `handle_*` method for each command. Once all commands have been - processed, the main program actions are executed by passing this object to the - `run_scenedetect` function in `scenedetect.cli.controller`. +class CliContext: + """The state of the application representing what video will be processed, how, and what to do + with the result. This includes handling all input options via command line and config file. + Once the CLI creates a context, it is executed by passing it to the + `scenedetect._cli.controller.run_scenedetect` function. """ def __init__(self): - self.config = USER_CONFIG - self.video_stream: VideoStream = None + # State: + self.config: ConfigRegistry = USER_CONFIG + self.quiet_mode: bool = None self.scene_manager: SceneManager = None self.stats_manager: StatsManager = None - self.added_detector: bool = False - - # Global `scenedetect` Options - self.output_dir: str = None # -o/--output - self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet - self.stats_file_path: str = None # -s/--stats - self.drop_short_scenes: bool = None # --drop-short-scenes - self.merge_last_scene: bool = None # --merge-last-scene - self.min_scene_len: FrameTimecode = None # -m/--min-scene-len - self.frame_skip: int = None # -fs/--frame-skip - self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = ( - None # [global] default-detector - ) + self.save_images: bool = False # True if the save-images command was specified + self.save_images_result: ty.Any = (None, None) # Result of save-images used by export-html - # `time` Command Options - self.time: bool = False + # Input: + self.video_stream: VideoStream = None + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name self.start_time: FrameTimecode = None # time -s/--start self.end_time: FrameTimecode = None # time -e/--end self.duration: FrameTimecode = None # time -d/--duration - - # `save-images` Command Options - self.save_images: bool = False - self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_dir: str = None # save-images -o/--output - self.image_param: int = None # save-images -q/--quality if -j/-w, - # otherwise -c/--compression if -p - self.image_name_format: str = None # save-images -f/--name-format - self.num_images: int = None # save-images -n/--num-images - self.frame_margin: int = 1 # save-images -m/--frame-margin - self.scale: float = None # save-images -s/--scale - self.height: int = None # save-images -h/--height - self.width: int = None # save-images -w/--width - self.scale_method: Interpolation = None # [save-images] scale-method - - # `split-video` Command Options - self.split_video: bool = False - self.split_mkvmerge: bool = None # split-video -m/--mkvmerge - self.split_args: str = None # split-video -a/--args, -c/--copy - self.split_dir: str = None # split-video -o/--output - self.split_name_format: str = None # split-video -f/--filename - self.split_quiet: bool = None # split-video -q/--quiet - - # `list-scenes` Command Options - self.list_scenes: bool = False - self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_dir: str = None # list-scenes -o/--output - self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output-file - self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = True # [list-scenes] display-cuts - self.display_scenes: bool = True # [list-scenes] display-scenes - self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format - - # `export-html` Command Options - self.export_html: bool = False - self.html_name_format: str = None # export-html -f/--filename - self.html_include_images: bool = None # export-html --no-images - self.image_width: int = None # export-html -w/--image-width - self.image_height: int = None # export-html -h/--image-height - - # `load-scenes` Command Options - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.frame_skip: int = None + + # Options: + self.drop_short_scenes: bool = None + self.merge_last_scene: bool = None + self.min_scene_len: FrameTimecode = None + self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None + self.output_dir: str = None + self.stats_file_path: str = None + + # Output Commands (e.g. split-video, save-images): + # Commands to run after the detection pipeline. Stored as (callback, args) and invoked with + # the results of the detection pipeline by the controller. + self.commands: ty.List[ty.Tuple[ty.Callable, ty.Dict[str, ty.Any]]] = [] + + def add_command(self, command: ty.Callable, command_args: dict): + """Add `command` to the processing pipeline. Will be invoked after processing the input + the `context`, the resulting `scenes` and `cuts`, and `command_args`.""" + self.commands.append((command, command_args)) # # Command Handlers @@ -216,6 +198,8 @@ def handle_options( # TODO(v1.0): Make the stats value optional (e.g. allow -s only), and allow use of # $VIDEO_NAME macro in the name. Default to $VIDEO_NAME.csv. + # The `scenedetect` command was just started, let's initialize logging and try to load any + # config files that were specified. try: init_failure = not self.config.initialized init_log = self.config.get_init_log() @@ -339,7 +323,7 @@ def get_detect_content_params( filter_mode: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-content command options and return args to construct one with.""" - self._ensure_input_open() + self.ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 @@ -381,7 +365,7 @@ def get_detect_adaptive_params( min_delta_hsv: ty.Optional[float] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" - self._ensure_input_open() + self.ensure_input_open() # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: @@ -435,7 +419,7 @@ def get_detect_threshold_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" - self._ensure_input_open() + self.ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 @@ -457,8 +441,8 @@ def get_detect_threshold_params( def handle_load_scenes(self, input: ty.AnyStr, start_col_name: ty.Optional[str]): """Handle `load-scenes` command options.""" - self._ensure_input_open() - if self.added_detector: + self.ensure_input_open() + if self.scene_manager.get_num_detectors() > 0: raise click.ClickException("The load-scenes command cannot be used with detectors.") if self.load_scenes_input: raise click.ClickException("The load-scenes command must only be specified once.") @@ -479,7 +463,7 @@ def get_detect_hist_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" - self._ensure_input_open() + self.ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 else: @@ -503,7 +487,7 @@ def get_detect_hash_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" - self._ensure_input_open() + self.ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 else: @@ -520,256 +504,9 @@ def get_detect_hash_params( "threshold": self.config.get_value("detect-hash", "threshold", threshold), } - def handle_export_html( - self, - filename: ty.Optional[ty.AnyStr], - no_images: bool, - image_width: ty.Optional[int], - image_height: ty.Optional[int], - ): - """Handle `export-html` command options.""" - self._ensure_input_open() - if self.export_html: - self._on_duplicate_command("export_html") - - no_images = no_images or self.config.get_value("export-html", "no-images") - self.html_include_images = not no_images - - self.html_name_format = self.config.get_value("export-html", "filename", filename) - self.image_width = self.config.get_value("export-html", "image-width", image_width) - self.image_height = self.config.get_value("export-html", "image-height", image_height) - - if not self.save_images and not no_images: - raise click.BadArgumentUsage( - "The export-html command requires that the save-images command\n" - "is specified before it, unless --no-images is specified." - ) - logger.info("HTML file name format:\n %s", filename) - - self.export_html = True - - def handle_list_scenes( - self, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - no_output_file: bool, - quiet: bool, - skip_cuts: bool, - ): - """Handle `list-scenes` command options.""" - self._ensure_input_open() - if self.list_scenes: - self._on_duplicate_command("list-scenes") - - self.display_cuts = self.config.get_value("list-scenes", "display-cuts") - self.display_scenes = self.config.get_value("list-scenes", "display-scenes") - self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts") - self.cut_format = TimecodeFormat[self.config.get_value("list-scenes", "cut-format").upper()] - self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") - no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") - - self.scene_list_dir = self.config.get_value( - "list-scenes", "output", output, ignore_default=True - ) - self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) - if self.scene_list_name_format is not None and not no_output_file: - logger.info("Scene list filename format:\n %s", self.scene_list_name_format) - self.scene_list_output = not no_output_file - if self.scene_list_dir is not None: - logger.info("Scene list output directory:\n %s", self.scene_list_dir) - - self.list_scenes = True - - def handle_split_video( - self, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - quiet: bool, - copy: bool, - high_quality: bool, - rate_factor: ty.Optional[int], - preset: ty.Optional[str], - args: ty.Optional[str], - mkvmerge: bool, - ): - """Handle `split-video` command options.""" - self._ensure_input_open() - if self.split_video: - self._on_duplicate_command("split-video") - - check_split_video_requirements(use_mkvmerge=mkvmerge) - - if contains_sequence_or_url(self.video_stream.path): - error_str = "The split-video command is incompatible with image sequences/URLs." - raise click.BadParameter(error_str, param_hint="split-video") - - ## - ## Common Arguments/Options - ## - - self.split_video = True - self.split_quiet = quiet or self.config.get_value("split-video", "quiet") - self.split_dir = self.config.get_value("split-video", "output", output, ignore_default=True) - if self.split_dir is not None: - logger.info("Video output path set: \n%s", self.split_dir) - self.split_name_format = self.config.get_value("split-video", "filename", filename) - - # We only load the config values for these flags/options if none of the other - # encoder flags/options were set via the CLI to avoid any conflicting options - # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). - if not (mkvmerge or copy or high_quality or args or rate_factor or preset): - mkvmerge = self.config.get_value("split-video", "mkvmerge") - copy = self.config.get_value("split-video", "copy") - high_quality = self.config.get_value("split-video", "high-quality") - rate_factor = self.config.get_value("split-video", "rate-factor") - preset = self.config.get_value("split-video", "preset") - args = self.config.get_value("split-video", "args") - - # Disallow certain combinations of flags/options. - if mkvmerge or copy: - command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" - if high_quality: - raise click.BadParameter( - "high-quality (-hq) cannot be used with %s" % (command), - param_hint="split-video", - ) - if args: - raise click.BadParameter( - "args (-a) cannot be used with %s" % (command), param_hint="split-video" - ) - if rate_factor: - raise click.BadParameter( - "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" - ) - if preset: - raise click.BadParameter( - "preset (-p) cannot be used with %s" % (command), param_hint="split-video" - ) - - ## - ## mkvmerge-Specific Arguments/Options - ## - if mkvmerge: - if copy: - logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") - self.split_mkvmerge = True - logger.info("Using mkvmerge for video splitting.") - return - - ## - ## ffmpeg-Specific Arguments/Options - ## - if copy: - args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" - elif not args: - if rate_factor is None: - rate_factor = 22 if not high_quality else 17 - if preset is None: - preset = "veryfast" if not high_quality else "slow" - args = ( - "-map 0:v:0 -map 0:a? -map 0:s? " - f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" - ) - - logger.info("ffmpeg arguments: %s", args) - self.split_args = args - if filename: - logger.info("Output file name format: %s", filename) - - def handle_save_images( - self, - num_images: ty.Optional[int], - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - jpeg: bool, - webp: bool, - quality: ty.Optional[int], - png: bool, - compression: ty.Optional[int], - frame_margin: ty.Optional[int], - scale: ty.Optional[float], - height: ty.Optional[int], - width: ty.Optional[int], - ): - """Handle `save-images` command options.""" - self._ensure_input_open() - if self.save_images: - self._on_duplicate_command("save-images") - - if "://" in self.video_stream.path: - error_str = "\nThe save-images command is incompatible with URLs." - logger.error(error_str) - raise click.BadParameter(error_str, param_hint="save-images") - - num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) - if num_flags > 1: - logger.error("Multiple image type flags set for save-images command.") - raise click.BadParameter( - "Only one image type (JPG/PNG/WEBP) can be specified.", param_hint="save-images" - ) - # Only use config params for image format if one wasn't specified. - elif num_flags == 0: - image_format = self.config.get_value("save-images", "format").lower() - jpeg = image_format == "jpeg" - webp = image_format == "webp" - png = image_format == "png" - - # Only use config params for scale/height/width if none of them are specified explicitly. - if scale is None and height is None and width is None: - self.scale = self.config.get_value("save-images", "scale") - self.height = self.config.get_value("save-images", "height") - self.width = self.config.get_value("save-images", "width") - else: - self.scale = scale - self.height = height - self.width = width - - self.scale_method = Interpolation[ - self.config.get_value("save-images", "scale-method").upper() - ] - - default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY - quality = ( - default_quality - if self.config.is_default("save-images", "quality") - else self.config.get_value("save-images", "quality") - ) - - compression = self.config.get_value("save-images", "compression", compression) - self.image_param = compression if png else quality - - self.image_extension = "jpg" if jpeg else "png" if png else "webp" - valid_params = get_cv2_imwrite_params() - if self.image_extension not in valid_params or valid_params[self.image_extension] is None: - error_strs = [ - "Image encoder type `%s` not supported." % self.image_extension.upper(), - "The specified encoder type could not be found in the current OpenCV module.", - "To enable this output format, please update the installed version of OpenCV.", - "If you build OpenCV, ensure the the proper dependencies are enabled. ", - ] - logger.debug("\n".join(error_strs)) - raise click.BadParameter("\n".join(error_strs), param_hint="save-images") - - self.image_dir = self.config.get_value("save-images", "output", output, ignore_default=True) - - self.image_name_format = self.config.get_value("save-images", "filename", filename) - self.num_images = self.config.get_value("save-images", "num-images", num_images) - self.frame_margin = self.config.get_value("save-images", "frame-margin", frame_margin) - - image_type = ("jpeg" if jpeg else self.image_extension).upper() - image_param_type = "Compression" if png else "Quality" - image_param_type = " [%s: %d]" % (image_param_type, self.image_param) - logger.info("Image output format set: %s%s", image_type, image_param_type) - if self.image_dir is not None: - logger.info("Image output directory set:\n %s", os.path.abspath(self.image_dir)) - - self.save_images = True - def handle_time(self, start, duration, end): """Handle `time` command options.""" - self._ensure_input_open() - if self.time: - self._on_duplicate_command("time") + self.ensure_input_open() if duration is not None and end is not None: raise click.BadParameter( "Only one of --duration/-d or --end/-e can be specified, not both.", @@ -786,7 +523,6 @@ def handle_time(self, start, duration, end): self.duration = parse_timecode(duration, self.video_stream.frame_rate) if self.start_time and self.end_time and (self.start_time + 1) > self.end_time: raise click.BadParameter("-e/--end time must be greater than -s/--start") - self.time = True # # Private Methods @@ -829,11 +565,10 @@ def add_detector(self, detector): """Add Detector: Adds a detection algorithm to the CliContext's SceneManager.""" if self.load_scenes_input: raise click.ClickException("The load-scenes command cannot be used with detectors.") - self._ensure_input_open() + self.ensure_input_open() self.scene_manager.add_detector(detector) - self.added_detector = True - def _ensure_input_open(self) -> None: + def ensure_input_open(self): """Ensure self.video_stream was initialized (i.e. -i/--input was specified), otherwise raises an exception. Should only be used from commands that require an input video to process the options (e.g. those that require a timecode). @@ -841,6 +576,8 @@ def _ensure_input_open(self) -> None: Raises: click.BadParameter: self.video_stream was not initialized. """ + # TODO: Do we still need to do this for each command? Originally this was added for the + # help command to function correctly. if self.video_stream is None: raise click.ClickException("No input video (-i/--input) was specified.") diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index eae039d4..4147d43f 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -16,18 +16,11 @@ import os import time import typing as ty -from string import Template -from scenedetect._cli.context import CliContext, check_split_video_requirements +from scenedetect._cli.context import CliContext from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path -from scenedetect.scene_manager import ( - get_scenes_from_cuts, - save_images, - write_scene_list, - write_scene_list_html, -) -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge +from scenedetect.scene_manager import get_scenes_from_cuts from scenedetect.video_stream import SeekError logger = logging.getLogger("pyscenedetect") @@ -50,41 +43,56 @@ def run_scenedetect(context: CliContext): logger.debug("No input specified.") return + if context.commands: + logger.debug("Commands to run after processing:") + for func, args in context.commands: + logger.debug("%s(%s)", func.__name__, args) + if context.load_scenes_input: # Skip detection if load-scenes was used. logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) if context.stats_file_path: logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") - scene_list, cut_list = _load_scenes(context) - scene_list = _postprocess_scene_list(context, scene_list) - logger.info("Loaded %d scenes.", len(scene_list)) + scenes, cuts = _load_scenes(context) + scenes = _postprocess_scene_list(context, scenes) + logger.info("Loaded %d scenes.", len(scenes)) else: # Perform scene detection on input. - scene_list, cut_list = _detect(context) - scene_list = _postprocess_scene_list(context, scene_list) + scenes, cuts = _detect(context) + scenes = _postprocess_scene_list(context, scenes) # Handle -s/--stats option. _save_stats(context) - if scene_list: + if scenes: logger.info( "Detected %d scenes, average shot length %.1f seconds.", - len(scene_list), - sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) - / float(len(scene_list)), + len(scenes), + sum([(end_time - start_time).get_seconds() for start_time, end_time in scenes]) + / float(len(scenes)), ) else: logger.info("No scenes detected.") - # Handle list-scenes command. - _list_scenes(context, scene_list, cut_list) + # Handle post-processing commands the user wants to run (see scenedetect._cli.commands). + for handler, kwargs in context.commands: + # TODO: This override should be handled inside the config manager get_value function. + if "output_dir" in kwargs and kwargs["output_dir"] is None: + kwargs["output_dir"] = context.output_dir + handler(context=context, scenes=scenes, cuts=cuts, **kwargs) + - # Handle save-images command. - image_filenames = _save_images(context, scene_list) +def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: + # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, + # it will be merged with the previous one. + if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: + if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: + new_last_scene = (scene_list[-2][0], scene_list[-1][1]) + scene_list = scene_list[:-2] + [new_last_scene] - # Handle export-html command. - _export_html(context, scene_list, cut_list, image_filenames) + # Handle --drop-short-scenes. + if context.drop_short_scenes and context.min_scene_len > 0: + scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] - # Handle split-video command. - _split_video(context, scene_list) + return scene_list def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: @@ -159,158 +167,6 @@ def _save_stats(context: CliContext) -> None: logger.debug("No frame metrics updated, skipping update of the stats file.") -def _list_scenes(context: CliContext, scene_list: SceneList, cut_list: CutList) -> None: - """Handles the `list-scenes` command.""" - if not context.list_scenes: - return - # Write scene list CSV to if required. - if context.scene_list_output: - scene_list_filename = Template(context.scene_list_name_format).safe_substitute( - VIDEO_NAME=context.video_stream.name - ) - if not scene_list_filename.lower().endswith(".csv"): - scene_list_filename += ".csv" - scene_list_path = get_and_create_path( - scene_list_filename, - context.scene_list_dir if context.scene_list_dir is not None else context.output_dir, - ) - logger.info("Writing scene list to CSV file:\n %s", scene_list_path) - with open(scene_list_path, "w") as scene_list_file: - write_scene_list( - output_csv_file=scene_list_file, - scene_list=scene_list, - include_cut_list=not context.skip_cuts, - cut_list=cut_list, - ) - # Suppress output if requested. - if context.list_scenes_quiet: - return - # Print scene list. - if context.display_scenes: - logger.info( - """Scene List: ------------------------------------------------------------------------ - | Scene # | Start Frame | Start Time | End Frame | End Time | ------------------------------------------------------------------------ -%s ------------------------------------------------------------------------""", - "\n".join( - [ - " | %5d | %11d | %s | %11d | %s |" - % ( - i + 1, - start_time.get_frames() + 1, - start_time.get_timecode(), - end_time.get_frames(), - end_time.get_timecode(), - ) - for i, (start_time, end_time) in enumerate(scene_list) - ] - ), - ) - # Print cut list. - if cut_list and context.display_cuts: - logger.info( - "Comma-separated timecode list:\n %s", - ",".join([context.cut_format.format(cut) for cut in cut_list]), - ) - - -def _save_images( - context: CliContext, scene_list: SceneList -) -> ty.Optional[ty.Dict[int, ty.List[str]]]: - """Handles the `save-images` command.""" - if not context.save_images: - return None - # Command can override global output directory setting. - output_dir = context.output_dir if context.image_dir is None else context.image_dir - return save_images( - scene_list=scene_list, - video=context.video_stream, - num_images=context.num_images, - frame_margin=context.frame_margin, - image_extension=context.image_extension, - encoder_param=context.image_param, - image_name_template=context.image_name_format, - output_dir=output_dir, - show_progress=not context.quiet_mode, - scale=context.scale, - height=context.height, - width=context.width, - interpolation=context.scale_method, - ) - - -def _export_html( - context: CliContext, - scene_list: SceneList, - cut_list: CutList, - image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]], -) -> None: - """Handles the `export-html` command.""" - if not context.export_html: - return - # Command can override global output directory setting. - output_dir = context.output_dir if context.image_dir is None else context.image_dir - html_filename = Template(context.html_name_format).safe_substitute( - VIDEO_NAME=context.video_stream.name - ) - if not html_filename.lower().endswith(".html"): - html_filename += ".html" - html_path = get_and_create_path(html_filename, output_dir) - logger.info("Exporting to html file:\n %s:", html_path) - if not context.html_include_images: - image_filenames = None - write_scene_list_html( - html_path, - scene_list, - cut_list, - image_filenames=image_filenames, - image_width=context.image_width, - image_height=context.image_height, - ) - - -def _split_video(context: CliContext, scene_list: SceneList) -> None: - """Handles the `split-video` command.""" - if not context.split_video: - return - output_path_template = context.split_name_format - # Add proper extension to filename template if required. - dot_pos = output_path_template.rfind(".") - extension_length = 0 if dot_pos < 0 else len(output_path_template) - (dot_pos + 1) - # If using mkvmerge, force extension to .mkv. - if context.split_mkvmerge and not output_path_template.endswith(".mkv"): - output_path_template += ".mkv" - # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. - elif not 2 <= extension_length <= 4: - output_path_template += ".mp4" - # Ensure the appropriate tool is available before handling split-video. - check_split_video_requirements(context.split_mkvmerge) - # Command can override global output directory setting. - output_dir = context.output_dir if context.split_dir is None else context.split_dir - if context.split_mkvmerge: - split_video_mkvmerge( - input_video_path=context.video_stream.path, - scene_list=scene_list, - output_dir=output_dir, - output_file_template=output_path_template, - show_output=not (context.quiet_mode or context.split_quiet), - ) - else: - split_video_ffmpeg( - input_video_path=context.video_stream.path, - scene_list=scene_list, - output_dir=output_dir, - output_file_template=output_path_template, - arg_override=context.split_args, - show_progress=not context.quiet_mode, - show_output=not (context.quiet_mode or context.split_quiet), - ) - if scene_list: - logger.info("Video splitting completed, scenes written to disk.") - - def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) @@ -353,18 +209,3 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: return get_scenes_from_cuts( cut_list=cut_list, start_pos=start_time, end_pos=end_time ), cut_list - - -def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: - # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, - # it will be merged with the previous one. - if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: - if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: - new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] - - # Handle --drop-short-scenes. - if context.drop_short_scenes and context.min_scene_len > 0: - scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] - - return scene_list diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 8b41834d..0fbb9720 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -193,9 +193,9 @@ def split_video_mkvmerge( if not scene_list: return 0 - logger.info( - "Splitting input video using mkvmerge, output path template:\n %s", output_file_template - ) + logger.info("Splitting video with mkvmerge, output path template:\n %s", output_file_template) + if output_dir: + logger.info("Output folder:\n %s", output_file_template) if video_name is None: video_name = Path(input_video_path).stem @@ -301,9 +301,9 @@ def split_video_ffmpeg( if not scene_list: return 0 - logger.info( - "Splitting input video using ffmpeg, output path template:\n %s", output_file_template - ) + logger.info("Splitting video with ffmpeg, output path template:\n %s", output_file_template) + if output_dir: + logger.info("Output folder:\n %s", output_file_template) if video_name is None: video_name = Path(input_video_path).stem From ded37087072b945d7c3c6a20c7870847b4456d6e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 28 Sep 2024 17:55:41 -0400 Subject: [PATCH 132/407] [cli] Centralize preconditions in CliContext --- scenedetect/_cli/__init__.py | 109 +++++++++++------- scenedetect/_cli/commands.py | 93 ++++++++------- scenedetect/_cli/config.py | 3 - scenedetect/_cli/context.py | 202 +++++++++------------------------ scenedetect/_cli/controller.py | 22 +--- scenedetect/scene_manager.py | 54 +++++---- 6 files changed, 203 insertions(+), 280 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 86767dcb..ac7e0976 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -20,6 +20,8 @@ import inspect import logging +import os +import os.path import typing as ty import click @@ -32,10 +34,9 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, - USER_CONFIG, TimecodeFormat, ) -from scenedetect._cli.context import CliContext, check_split_video_requirements +from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.detectors import ( AdaptiveDetector, @@ -315,8 +316,10 @@ def scenedetect( Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_options( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + ctx.handle_options( input_path=input, output=output, framerate=framerate, @@ -344,7 +347,6 @@ def scenedetect( @click.pass_context def help_command(ctx: click.Context, command_name: str): """Print help for command (`help [command]`).""" - assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.parent.command, click.MultiCommand) parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) @@ -368,7 +370,6 @@ def help_command(ctx: click.Context, command_name: str): @click.pass_context def about_command(ctx: click.Context): """Print license/copyright info.""" - assert isinstance(ctx.obj, CliContext) click.echo("") click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) click.echo(click.style(" About PySceneDetect %s" % _PROGRAM_VERSION, fg="yellow")) @@ -381,7 +382,6 @@ def about_command(ctx: click.Context): @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" - assert isinstance(ctx.obj, CliContext) click.echo("") click.echo(get_system_version_info()) ctx.exit() @@ -431,12 +431,23 @@ def time_command( {scenedetect_with_video} time --start 0 --end 1000 """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_time( - start=start, - duration=duration, - end=end, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + if duration is not None and end is not None: + raise click.BadParameter( + "Only one of --duration/-d or --end/-e can be specified, not both.", + param_hint="time", + ) + logger.debug("Setting video time:\n start: %s, duration: %s, end: %s", start, duration, end) + # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to + # match the default start number used by `ffmpeg` when saving frames as images. As such, + # we must correct start time if set as frames. See the test_cli_time* tests for for details. + ctx.start_time = ctx.parse_timecode(start, correct_pts=True) + ctx.end_time = ctx.parse_timecode(end) + ctx.duration = ctx.parse_timecode(duration) + if ctx.start_time and ctx.end_time and (ctx.start_time + 1) > ctx.end_time: + raise click.BadParameter("-e/--end time must be greater than -s/--start") @click.command("detect-content", cls=_Command) @@ -535,8 +546,9 @@ def detect_content_command( {scenedetect_with_video} detect-content --threshold 27.5 """ - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_content_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_content_params( threshold=threshold, luma_only=luma_only, min_scene_len=min_scene_len, @@ -544,8 +556,7 @@ def detect_content_command( kernel_size=kernel_size, filter_mode=filter_mode, ) - logger.debug("Adding detector: ContentDetector(%s)", detector_args) - ctx.obj.add_detector(ContentDetector(**detector_args)) + ctx.add_detector(ContentDetector, detector_args) @click.command("detect-adaptive", cls=_Command) @@ -646,8 +657,9 @@ def detect_adaptive_command( {scenedetect_with_video} detect-adaptive --threshold 3.2 """ - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_adaptive_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_adaptive_params( threshold=threshold, min_content_val=min_content_val, min_delta_hsv=min_delta_hsv, @@ -657,8 +669,7 @@ def detect_adaptive_command( weights=weights, kernel_size=kernel_size, ) - logger.debug("Adding detector: AdaptiveDetector(%s)", detector_args) - ctx.obj.add_detector(AdaptiveDetector(**detector_args)) + ctx.add_detector(AdaptiveDetector, detector_args) @click.command("detect-threshold", cls=_Command) @@ -725,15 +736,15 @@ def detect_threshold_command( {scenedetect_with_video} detect-threshold --threshold 15 """ - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_threshold_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_threshold_params( threshold=threshold, fade_bias=fade_bias, add_last_scene=add_last_scene, min_scene_len=min_scene_len, ) - logger.debug("Adding detector: ThresholdDetector(%s)", detector_args) - ctx.obj.add_detector(ThresholdDetector(**detector_args)) + ctx.add_detector(ThresholdDetector, detector_args) @click.command("detect-hist", cls=_Command) @@ -795,14 +806,12 @@ def detect_hist_command( {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 """ - assert isinstance(ctx.obj, CliContext) - - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_hist_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_hist_params( threshold=threshold, bins=bins, min_scene_len=min_scene_len ) - logger.debug("Adding detector: HistogramDetector(%s)", detector_args) - ctx.obj.add_detector(HistogramDetector(**detector_args)) + ctx.add_detector(HistogramDetector, detector_args) @click.command("detect-hash", cls=_Command) @@ -880,14 +889,12 @@ def detect_hash_command( {scenedetect_with_video} detect-hash --size 32 --lowpass 3 """ - assert isinstance(ctx.obj, CliContext) - - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_hash_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_hash_params( threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len ) - logger.debug("Adding detector: HashDetector(%s)", detector_args) - ctx.obj.add_detector(HashDetector(**detector_args)) + ctx.add_detector(HashDetector, detector_args) @click.command("load-scenes", cls=_Command) @@ -921,9 +928,23 @@ def load_scenes_command( {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" """ - assert isinstance(ctx.obj, CliContext) - logger.debug("Loading scenes from %s (start_col_name = %s)", input, start_col_name) - ctx.obj.handle_load_scenes(input=input, start_col_name=start_col_name) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + logger.debug("Will load scenes from %s (start_col_name = %s)", input, start_col_name) + if ctx.scene_manager.get_num_detectors() > 0: + raise click.ClickException("The load-scenes command cannot be used with detectors.") + if ctx.load_scenes_input: + raise click.ClickException("The load-scenes command must only be specified once.") + input = os.path.abspath(input) + if not os.path.exists(input): + raise click.BadParameter( + f"Could not load scenes, file does not exist: {input}", param_hint="-i/--input" + ) + ctx.load_scenes_input = input + ctx.load_scenes_column_name = ctx.config.get_value( + "load-scenes", "start-col-name", start_col_name + ) @click.command("export-html", cls=_Command) @@ -970,7 +991,7 @@ def export_html_command( """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" ctx = ctx.obj assert isinstance(ctx, CliContext) - ctx.ensure_input_open() + no_images = no_images or ctx.config.get_value("export-html", "no-images") if not ctx.save_images and not no_images: raise click.BadArgumentUsage( @@ -1037,7 +1058,7 @@ def list_scenes_command( """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default).""" ctx = ctx.obj assert isinstance(ctx, CliContext) - ctx.ensure_input_open() + no_output_file = no_output_file or ctx.config.get_value("list-scenes", "no-output-file") scene_list_dir = ctx.config.get_value("list-scenes", "output", output, ignore_default=True) scene_list_name_format = ctx.config.get_value("list-scenes", "filename", filename) @@ -1162,7 +1183,7 @@ def split_video_command( """ ctx = ctx.obj assert isinstance(ctx, CliContext) - ctx.ensure_input_open() + check_split_video_requirements(use_mkvmerge=mkvmerge) if "%" in ctx.video_stream.path or "://" in ctx.video_stream.path: error = "The split-video command is incompatible with image sequences/URLs." @@ -1362,7 +1383,7 @@ def save_images_command( """ ctx = ctx.obj assert isinstance(ctx, CliContext) - ctx.ensure_input_open() + if "://" in ctx.video_stream.path: error_str = "\nThe save-images command is incompatible with URLs." logger.error(error_str) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 077ac00e..83a23bf2 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -9,23 +9,59 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Logic for the PySceneDetect command.""" +"""Logic for PySceneDetect commands that operate on the result of the processing pipeline. + +In addition to the the arguments registered with the command, commands will be called with the +current command-line context, as well as the processing result (scenes and cuts). +""" import logging import typing as ty from string import Template -import scenedetect.scene_manager as scene_manager from scenedetect._cli.context import CliContext -from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path +from scenedetect.scene_manager import ( + CutList, + Interpolation, + SceneList, + write_scene_list, + write_scene_list_html, +) +from scenedetect.scene_manager import ( + save_images as save_images_impl, +) from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge logger = logging.getLogger("pyscenedetect") -SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] -CutList = ty.List[FrameTimecode] +def export_html( + context: CliContext, + scenes: SceneList, + cuts: CutList, + image_width: int, + image_height: int, + html_name_format: str, +): + """Handles the `export-html` command.""" + (image_filenames, output_dir) = ( + context.save_images_result + if context.save_images_result is not None + else (None, context.output_dir) + ) + html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + if not html_filename.lower().endswith(".html"): + html_filename += ".html" + html_path = get_and_create_path(html_filename, output_dir) + write_scene_list_html( + output_html_filename=html_path, + scene_list=scenes, + cut_list=cuts, + image_filenames=image_filenames, + image_width=image_width, + image_height=image_height, + ) def list_scenes( @@ -40,7 +76,6 @@ def list_scenes( display_scenes: bool, display_cuts: bool, cut_format: str, - **kwargs, ): """Handles the `list-scenes` command.""" # Write scene list CSV to if required. @@ -56,7 +91,7 @@ def list_scenes( ) logger.info("Writing scene list to CSV file:\n %s", scene_list_path) with open(scene_list_path, "w") as scene_list_file: - scene_manager.write_scene_list( + write_scene_list( output_csv_file=scene_list_file, scene_list=scenes, include_cut_list=not skip_cuts, @@ -99,6 +134,7 @@ def list_scenes( def save_images( context: CliContext, scenes: SceneList, + cuts: CutList, num_images: int, frame_margin: int, image_extension: str, @@ -109,13 +145,12 @@ def save_images( scale: int, height: int, width: int, - interpolation: scene_manager.Interpolation, - **kwargs, + interpolation: Interpolation, ): """Handles the `save-images` command.""" - logger.info(f"Saving images to {output_dir} with format {image_extension}") - logger.debug(f"encoder param: {encoder_param}") - images = scene_manager.save_images( + del cuts # save-images only uses scenes. + + images = save_images_impl( scene_list=scenes, video=context.video_stream, num_images=num_images, @@ -130,49 +165,23 @@ def save_images( width=width, interpolation=interpolation, ) + # Save the result for use by `export-html` if required. context.save_images_result = (images, output_dir) -def export_html( - context: CliContext, - scenes: SceneList, - cuts: CutList, - image_width: int, - image_height: int, - html_name_format: str, - **kwargs, -): - """Handles the `export-html` command.""" - save_images_result = context.save_images_result - # Command can override global output directory setting. - output_dir = save_images_result[1] if save_images_result[1] is not None else context.output_dir - html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) - - if not html_filename.lower().endswith(".html"): - html_filename += ".html" - html_path = get_and_create_path(html_filename, output_dir) - logger.info("Exporting to html file:\n %s:", html_path) - scene_manager.write_scene_list_html( - output_html_filename=html_path, - scene_list=scenes, - cut_list=cuts, - image_filenames=save_images_result[0], - image_width=image_width, - image_height=image_height, - ) - - def split_video( context: CliContext, scenes: SceneList, + cuts: CutList, name_format: str, use_mkvmerge: bool, output_dir: str, show_output: bool, ffmpeg_args: str, - **kwargs, ): """Handles the `split-video` command.""" + del cuts # split-video only uses scenes. + # Add proper extension to filename template if required. dot_pos = name_format.rfind(".") extension_length = 0 if dot_pos < 0 else len(name_format) - (dot_pos + 1) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index b8aa733d..5e03e5d2 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -621,6 +621,3 @@ def get_help_string( ): return "" return " [default: %s]" % (str(CONFIG_MAP[command][option])) - - -USER_CONFIG = ConfigRegistry(throw_exception=False) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index a9e02a46..551eee21 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -12,7 +12,6 @@ """Context of which command-line options and config settings the user provided.""" import logging -import os import typing as ty import click @@ -42,34 +41,7 @@ logger = logging.getLogger("pyscenedetect") USER_CONFIG = ConfigRegistry(throw_exception=False) - -SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] - -CutList = ty.List[FrameTimecode] - - -def parse_timecode( - value: ty.Optional[str], frame_rate: float, correct_pts: bool = False -) -> FrameTimecode: - """Parses a user input string into a FrameTimecode assuming the given framerate. - - If value is None, None will be returned instead of processing the value. - - Raises: - click.BadParameter - """ - if value is None: - return None - try: - if correct_pts and value.isdigit(): - value = int(value) - if value >= 1: - value -= 1 - return FrameTimecode(timecode=value, fps=frame_rate) - except ValueError as ex: - raise click.BadParameter( - "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" - ) from ex +"""The user config, which can be overriden by command-line. If not found, will be default config.""" def check_split_video_requirements(use_mkvmerge: bool) -> None: @@ -98,29 +70,6 @@ def check_split_video_requirements(use_mkvmerge: bool) -> None: raise click.BadParameter(error_str, param_hint="split-video") -class AppState: - def __init__(self): - self.video_stream: VideoStream = None - self.scene_manager: SceneManager = None - self.stats_manager: StatsManager = None - self.output: str = None - self.quiet_mode: bool = None - self.stats_file_path: str = None - self.drop_short_scenes: bool = None - self.merge_last_scene: bool = None - self.min_scene_len: FrameTimecode = None - self.frame_skip: int = None - self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None - self.start_time: FrameTimecode = None # time -s/--start - self.end_time: FrameTimecode = None # time -e/--end - self.duration: FrameTimecode = None # time -d/--duration - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name - self.save_images: bool = False # True if the save-images command was specified - # Result of save-images function output stored for use by export-html - self.save_images_result: ty.Any = (None, None) - - class CliContext: """The state of the application representing what video will be processed, how, and what to do with the result. This includes handling all input options via command line and config file. @@ -159,14 +108,48 @@ def __init__(self): # the results of the detection pipeline by the controller. self.commands: ty.List[ty.Tuple[ty.Callable, ty.Dict[str, ty.Any]]] = [] - def add_command(self, command: ty.Callable, command_args: dict): - """Add `command` to the processing pipeline. Will be invoked after processing the input - the `context`, the resulting `scenes` and `cuts`, and `command_args`.""" + def add_command(self, command: ty.Callable, command_args: ty.Dict[str, ty.Any]): + """Add `command` to the processing pipeline. Will be called after processing the input.""" + if "output_dir" in command_args and command_args["output_dir"] is None: + command_args["output_dir"] = self.output_dir + logger.debug("Adding command: %s(%s)", command.__name__, command_args) self.commands.append((command, command_args)) - # - # Command Handlers - # + def add_detector(self, detector: ty.Type[SceneDetector], detector_args: ty.Dict[str, ty.Any]): + """Instantiate and add `detector` to the processing pipeline.""" + if self.load_scenes_input: + raise click.ClickException("The load-scenes command cannot be used with detectors.") + logger.debug("Adding detector: %s(%s)", detector.__name__, detector_args) + self.scene_manager.add_detector(detector(**detector_args)) + + def ensure_detector(self): + """Ensures at least one detector has been instantiated, otherwise adds a default one.""" + if self.scene_manager.get_num_detectors() == 0: + logger.debug("No detector specified, adding default detector.") + (detector_type, detector_args) = self.default_detector + self.add_detector(detector_type, detector_args) + + def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> FrameTimecode: + """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` + is None it will be passed through without processing. + + Raises: + click.BadParameter, click.ClickException + """ + if value is None: + return None + try: + if self.video_stream is None: + raise click.ClickException("No input video (-i/--input) was specified.") + if correct_pts and value.isdigit(): + value = int(value) + if value >= 1: + value -= 1 + return FrameTimecode(timecode=value, fps=self.video_stream.frame_rate) + except ValueError as ex: + raise click.BadParameter( + "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" + ) from ex def handle_options( self, @@ -261,11 +244,10 @@ def handle_options( if self.output_dir: logger.info("Output directory set:\n %s", self.output_dir) - self.min_scene_len = parse_timecode( + self.min_scene_len = self.parse_timecode( min_scene_len if min_scene_len is not None else self.config.get_value("global", "min-scene-len"), - self.video_stream.frame_rate, ) self.drop_short_scenes = drop_short_scenes or self.config.get_value( "global", "drop-short-scenes" @@ -313,6 +295,10 @@ def handle_options( ] self.scene_manager = scene_manager + # + # Detector Parameters + # + def get_detect_content_params( self, threshold: ty.Optional[float] = None, @@ -322,9 +308,7 @@ def get_detect_content_params( kernel_size: ty.Optional[int] = None, filter_mode: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: - """Handle detect-content command options and return args to construct one with.""" - self.ensure_input_open() - + """Get a dict containing user options to construct a ContentDetector with.""" if self.drop_short_scenes: min_scene_len = 0 else: @@ -333,7 +317,7 @@ def get_detect_content_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-content", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num if weights is not None: try: @@ -365,7 +349,6 @@ def get_detect_adaptive_params( min_delta_hsv: ty.Optional[float] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" - self.ensure_input_open() # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: @@ -391,7 +374,7 @@ def get_detect_adaptive_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num if weights is not None: try: @@ -419,7 +402,6 @@ def get_detect_threshold_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" - self.ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 @@ -429,7 +411,7 @@ def get_detect_threshold_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { "add_final_scene": add_last_scene @@ -439,23 +421,6 @@ def get_detect_threshold_params( "threshold": self.config.get_value("detect-threshold", "threshold", threshold), } - def handle_load_scenes(self, input: ty.AnyStr, start_col_name: ty.Optional[str]): - """Handle `load-scenes` command options.""" - self.ensure_input_open() - if self.scene_manager.get_num_detectors() > 0: - raise click.ClickException("The load-scenes command cannot be used with detectors.") - if self.load_scenes_input: - raise click.ClickException("The load-scenes command must only be specified once.") - input = os.path.abspath(input) - if not os.path.exists(input): - raise click.BadParameter( - f"Could not load scenes, file does not exist: {input}", param_hint="-i/--input" - ) - self.load_scenes_input = input - self.load_scenes_column_name = self.config.get_value( - "load-scenes", "start-col-name", start_col_name - ) - def get_detect_hist_params( self, threshold: ty.Optional[float] = None, @@ -463,7 +428,7 @@ def get_detect_hist_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" - self.ensure_input_open() + if self.drop_short_scenes: min_scene_len = 0 else: @@ -472,7 +437,7 @@ def get_detect_hist_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num return { "bins": self.config.get_value("detect-hist", "bins", bins), "min_scene_len": min_scene_len, @@ -487,7 +452,7 @@ def get_detect_hash_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" - self.ensure_input_open() + if self.drop_short_scenes: min_scene_len = 0 else: @@ -496,7 +461,7 @@ def get_detect_hash_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), "min_scene_len": min_scene_len, @@ -504,26 +469,6 @@ def get_detect_hash_params( "threshold": self.config.get_value("detect-hash", "threshold", threshold), } - def handle_time(self, start, duration, end): - """Handle `time` command options.""" - self.ensure_input_open() - if duration is not None and end is not None: - raise click.BadParameter( - "Only one of --duration/-d or --end/-e can be specified, not both.", - param_hint="time", - ) - logger.debug( - "Setting video time:\n start: %s, duration: %s, end: %s", start, duration, end - ) - # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to - # match the default start number used by `ffmpeg` when saving frames as images. As such, - # we must correct start time if set as frames. See the test_cli_time* tests for for details. - self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True) - self.end_time = parse_timecode(end, self.video_stream.frame_rate) - self.duration = parse_timecode(duration, self.video_stream.frame_rate) - if self.start_time and self.end_time and (self.start_time + 1) > self.end_time: - raise click.BadParameter("-e/--end time must be greater than -s/--start") - # # Private Methods # @@ -561,26 +506,6 @@ def _initialize_logging( # Initialize logger with the set CLI args / user configuration. init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) - def add_detector(self, detector): - """Add Detector: Adds a detection algorithm to the CliContext's SceneManager.""" - if self.load_scenes_input: - raise click.ClickException("The load-scenes command cannot be used with detectors.") - self.ensure_input_open() - self.scene_manager.add_detector(detector) - - def ensure_input_open(self): - """Ensure self.video_stream was initialized (i.e. -i/--input was specified), - otherwise raises an exception. Should only be used from commands that require an - input video to process the options (e.g. those that require a timecode). - - Raises: - click.BadParameter: self.video_stream was not initialized. - """ - # TODO: Do we still need to do this for each command? Originally this was added for the - # help command to function correctly. - if self.video_stream is None: - raise click.ClickException("No input video (-i/--input) was specified.") - def _open_video_stream( self, input_path: ty.AnyStr, framerate: ty.Optional[float], backend: ty.Optional[str] ): @@ -642,22 +567,3 @@ def _open_video_stream( raise click.BadParameter( "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" ) from None - - def _on_duplicate_command(self, command: str) -> None: - """Called when a command is duplicated to stop parsing and raise an error. - - Arguments: - command: Command that was duplicated for error context. - - Raises: - click.BadParameter - """ - error_strs = [] - error_strs.append("Error: Command %s specified multiple times." % command) - error_strs.append("The %s command may appear only one time.") - - logger.error("\n".join(error_strs)) - raise click.BadParameter( - "\n Command %s may only be specified once." % command, - param_hint="%s command" % command, - ) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 4147d43f..313997fd 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -20,15 +20,11 @@ from scenedetect._cli.context import CliContext from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path -from scenedetect.scene_manager import get_scenes_from_cuts +from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts from scenedetect.video_stream import SeekError logger = logging.getLogger("pyscenedetect") -SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] - -CutList = ty.List[FrameTimecode] - def run_scenedetect(context: CliContext): """Perform main CLI application control logic. Run once all command-line options and @@ -43,11 +39,6 @@ def run_scenedetect(context: CliContext): logger.debug("No input specified.") return - if context.commands: - logger.debug("Commands to run after processing:") - for func, args in context.commands: - logger.debug("%s(%s)", func.__name__, args) - if context.load_scenes_input: # Skip detection if load-scenes was used. logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) @@ -74,9 +65,6 @@ def run_scenedetect(context: CliContext): # Handle post-processing commands the user wants to run (see scenedetect._cli.commands). for handler, kwargs in context.commands: - # TODO: This override should be handled inside the config manager get_value function. - if "output_dir" in kwargs and kwargs["output_dir"] is None: - kwargs["output_dir"] = context.output_dir handler(context=context, scenes=scenes, cuts=cuts, **kwargs) @@ -96,13 +84,9 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: - # Use default detector if one was not specified. - if context.scene_manager.get_num_detectors() == 0: - detector_type, detector_args = context.default_detector - logger.debug("Using default detector: %s(%s)" % (detector_type.__name__, detector_args)) - context.scene_manager.add_detector(detector_type(**detector_args)) - perf_start_time = time.time() + + context.ensure_detector() if context.start_time is not None: logger.debug("Seeking to start time...") try: diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index dc3bba04..f844ba57 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -106,6 +106,12 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): logger = logging.getLogger("pyscenedetect") +SceneList = List[Tuple[FrameTimecode, FrameTimecode]] +"""Type hint for a list of scenes in the form (start time, end time).""" + +CutList = List[FrameTimecode] +"""Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" + # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current # value doesn't seem to have caused any issues at least. @@ -158,11 +164,11 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI def get_scenes_from_cuts( - cut_list: Iterable[FrameTimecode], + cut_list: CutList, start_pos: Union[int, FrameTimecode], end_pos: Union[int, FrameTimecode], base_timecode: Optional[FrameTimecode] = None, -) -> List[Tuple[FrameTimecode, FrameTimecode]]: +) -> SceneList: """Returns a list of tuples of start/end FrameTimecodes for each scene based on a list of detected scene cuts/breaks. @@ -207,9 +213,9 @@ def get_scenes_from_cuts( def write_scene_list( output_csv_file: TextIO, - scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], + scene_list: SceneList, include_cut_list: bool = True, - cut_list: Optional[Iterable[FrameTimecode]] = None, + cut_list: Optional[CutList] = None, ) -> None: """Writes the given list of scenes to an output file handle in CSV format. @@ -263,14 +269,14 @@ def write_scene_list( def write_scene_list_html( - output_html_filename, - scene_list, - cut_list=None, - css=None, - css_class="mytable", - image_filenames=None, - image_width=None, - image_height=None, + output_html_filename: str, + scene_list: SceneList, + cut_list: Optional[CutList] = None, + css: str = None, + css_class: str = "mytable", + image_filenames: Optional[Dict[int, List[str]]] = None, + image_width: Optional[int] = None, + image_height: Optional[int] = None, ): """Writes the given list of scenes to an output file handle in html format. @@ -287,6 +293,7 @@ def write_scene_list_html( image_width: Optional desired width of images in table in pixels image_height: Optional desired height of images in table in pixels """ + logger.info("Exporting scenes to html:\n %s:", output_html_filename) if not css: css = """ table.mytable { @@ -386,11 +393,9 @@ def write_scene_list_html( # -# TODO(v1.0): Refactor to take a SceneList object; consider moving this and save scene list -# to a better spot, or just move them to scene_list.py. -# +# TODO(v1.0): Consider moving all post-processing functionality into a separate submodule. def save_images( - scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + scene_list: SceneList, video: VideoStream, num_images: int = 3, frame_margin: int = 1, @@ -474,7 +479,7 @@ def save_images( # Setup flags and init progress bar if available. completed = True - logger.info("Generating output images (%d per scene)...", num_images) + logger.info(f"Saving {num_images} images per scene to {output_dir}, format {image_extension}") progress_bar = None if show_progress: progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) @@ -537,6 +542,7 @@ def save_images( video.seek(image_timecode) frame_im = video.read() if frame_im is not None: + # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. file_path = "%s.%s" % ( filename_template.safe_substitute( @@ -740,7 +746,7 @@ def clear_detectors(self) -> None: def get_scene_list( self, base_timecode: Optional[FrameTimecode] = None, start_in_scene: bool = False - ) -> List[Tuple[FrameTimecode, FrameTimecode]]: + ) -> SceneList: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: @@ -779,7 +785,7 @@ def _get_cutting_list(self) -> List[int]: # Ensure all cuts are unique by using a set to remove all duplicates. return [self._base_timecode + cut for cut in sorted(set(self._cutting_list))] - def _get_event_list(self) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def _get_event_list(self) -> SceneList: if not self._event_list: return [] assert self._base_timecode is not None @@ -1065,8 +1071,10 @@ def _decode_thread( # def get_cut_list( - self, base_timecode: Optional[FrameTimecode] = None, show_warning: bool = True - ) -> List[FrameTimecode]: + self, + base_timecode: Optional[FrameTimecode] = None, + show_warning: bool = True, + ) -> CutList: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing @@ -1092,9 +1100,7 @@ def get_cut_list( logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") return self._get_cutting_list() - def get_event_list( - self, base_timecode: Optional[FrameTimecode] = None - ) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def get_event_list(self, base_timecode: Optional[FrameTimecode] = None) -> SceneList: """[DEPRECATED] DO NOT USE. Get a list of start/end timecodes of sparse detection events. From ac693a811e9dd8a8b1fa1be1ce92f84f0fec9880 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 28 Sep 2024 22:25:00 -0400 Subject: [PATCH 133/407] [docs] Add missing references in module docs. Make some deprecated types hidden from generated docs. --- docs/api.rst | 44 ++++++++++++++++++----------------- docs/api/migration_guide.rst | 2 +- docs/index.rst | 1 - scenedetect/scene_manager.py | 4 ++++ scenedetect/stats_manager.py | 12 ++++++++-- scenedetect/video_splitter.py | 2 +- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 4d65f493..5278aad0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,17 +3,23 @@ ``scenedetect`` 🎬 Package *********************************************************************** -The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Quickstart`_ and `Example`_ sections below for some common use cases and integrations. The `scenedetect` package contains several modules: +The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Getting Started`_ section below for some common use cases and integrations. The `scenedetect` package contains several modules: + + * :ref:`scenedetect 🎬 `: Includes the :func:`scenedetect.detect ` function which takes a path and a :ref:`detector ` to find scene transitions (:ref:`example `), and :func:`scenedetect.open_video ` for video input * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). This module also contains functionality to export information about scenes in various formats: :func:`save_images ` to save images for each scene, :func:`write_scene_list ` to save scene/cut info as CSV, and :func:`write_scene_list_html ` to export scenes in viewable HTML format. * :ref:`scenedetect.detectors 🕵️ `: Detection algorithms: - * :mod:`ContentDetector `: detects fast changes/cuts in video content. + * :mod:`ContentDetector `: detects fast cuts using weighted average of HSV changes + + * :mod:`ThresholdDetector `: finds fades in/out using average pixel intensity changes in RGB + + * :mod:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes - * :mod:`ThresholdDetector `: detects changes in video brightness/intensity. + * :mod:`HistogramDetector ` finds fast cuts using HSV histogram changes - * :mod:`AdaptiveDetector `: similar to `ContentDetector` but may result in less false negatives during rapid camera movement. + * :mod:`HashDetector `: finds fast cuts using perceptual image hashing * :ref:`scenedetect.video_stream 🎥 `: Video input is handled through the :class:`VideoStream ` interface. Implementations for common video libraries are provided in :mod:`scenedetect.backends`: @@ -30,7 +36,7 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.scene_detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. - * :ref:`scenedetect.stats_manager 🧮 `: Contains :class:`StatsManager ` class for caching frame metrics and loading/saving them to disk in CSV format for analysis. Also used as a persistent cache to make multiple passes on the same video significantly faster. + * :ref:`scenedetect.stats_manager 🧮 `: Contains :class:`StatsManager ` class for caching frame metrics and loading/saving them to disk in CSV format for analysis. * :ref:`scenedetect.platform 🐱‍💻 `: Logging and utility functions. @@ -49,38 +55,33 @@ Most types/functions are also available directly from the `scenedetect` package .. _scenedetect-quickstart: ======================================================================= -Examples +Getting Started ======================================================================= -To get started, the :func:`scenedetect.detect` function takes a path to a video and a :ref:`scene detector object`, and returns a list of start/end timecodes. For detecting fast cuts (shot changes), we use the :class:`ContentDetector `: +PySceneDetect makes it very easy to find scene transitions in a video with the :func:`scenedetect.detect` function: .. code:: python from scenedetect import detect, ContentDetector - scene_list = detect("my_video.mp4", ContentDetector()) + path = "video.mp4" + scenes = detect(path, ContentDetector()) + for (scene_start, scene_end) in scenes: + print(f'{scene_start}-{scene_end}') -``scene_list`` is now a list of :class:`FrameTimecode ` pairs representing the start/end of each scene (try calling ``print(scene_list)``). Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. +``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. -Next, let's print the scene list in a more readable format by iterating over it: - -.. code:: python - - for i, scene in enumerate(scene_list): - print("Scene %2d: Start %s / Frame %d, End %s / Frame %d" % ( - i+1, - scene[0].get_timecode(), scene[0].get_frames(), - scene[1].get_timecode(), scene[1].get_frames(),)) - -Now that we know where each scene is, we can also :ref:`split the input video ` automatically using `ffmpeg` (`mkvmerge` is also supported): +Here, we use :mod:`ContentDetector ` to detect fast cuts. There are :ref:`many detector types ` which can be used to find fast cuts and fades in/out. PySceneDetect can also export scene data in various formats, and can :ref:`split the input video ` automatically if `ffmpeg` is available: .. code:: python from scenedetect import detect, ContentDetector, split_video_ffmpeg scene_list = detect("my_video.mp4", ContentDetector()) - split_video_ffmpeg("my_video.mp4", scene_list) + split_video_ffmpeg("my_video.mp4", scenes) Recipes for common use cases can be `found on Github `_ including limiting detection time and storing per-frame metrics. For advanced workflows, start with the :ref:`SceneManager usage examples `. +.. _scenedetect-functions: + ======================================================================= Functions ======================================================================= @@ -106,6 +107,7 @@ Module Reference api/scene_detector api/video_stream api/platform + api/migration_guide ======================================================================= diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index 7f7df42e..11da142a 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -5,7 +5,7 @@ Migration Guide --------------------------------------------------------------- -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. +This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. diff --git a/docs/index.rst b/docs/index.rst index 7e820574..1aca59b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,7 +54,6 @@ Table of Contents api/scene_detector api/video_stream api/platform - api/migration_guide ======================================================================= Indices and Tables diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index dc3bba04..c3508b0b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -1086,6 +1086,8 @@ def get_cut_list( List of FrameTimecode objects denoting the points in time where a scene change was detected in the input video, which can also be passed to external tools for automated splitting of the input into individual scenes. + + :meta private: """ # TODO(v0.7): Use the warnings module to turn this into a warning. if show_warning: @@ -1108,6 +1110,8 @@ def get_event_list( Returns: List of pairs of FrameTimecode objects denoting the detected scenes. + + :meta private: """ # TODO(v0.7): Use the warnings module to turn this into a warning. logger.error("`get_event_list()` is deprecated and will be removed in a future release.") diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index b028e244..d32fe0bf 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -49,13 +49,19 @@ class FrameMetricRegistered(Exception): - """[DEPRECATED - DO NOT USE] No longer used.""" + """[DEPRECATED - DO NOT USE] No longer used. + + :meta private: + """ pass class FrameMetricNotRegistered(Exception): - """[DEPRECATED - DO NOT USE] No longer used.""" + """[DEPRECATED - DO NOT USE] No longer used. + + :meta private: + """ pass @@ -236,6 +242,8 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: Raises: StatsFileCorrupt: Stats file is corrupt and can't be loaded, or wrong file was specified. + + :meta private: """ # TODO: Make this an error, then make load_from_csv() a no-op, and finally, remove it. logger.warning("load_from_csv() is deprecated and will be removed in a future release.") diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 8b41834d..2ee95527 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -174,7 +174,7 @@ def split_video_mkvmerge( is supported by this function. video_name (str): Name of the video to be substituted in output_file_template for $VIDEO_NAME. If not specified, will be obtained from the filename. - show_output: If False, adds the --quiet flag when invoking `mkvmerge`.. + show_output: If False, adds the --quiet flag when invoking `mkvmerge`. suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. Returns: Return code of invoking mkvmerge (0 on success). If scene_list is empty, will From 1154c0103ac1d2721758cc235b8cdc3744daa2ec Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 29 Sep 2024 21:05:37 -0400 Subject: [PATCH 134/407] [cli] Simplify parsing of default values --- scenedetect/__main__.py | 2 +- scenedetect/_cli/__init__.py | 6 +++--- scenedetect/_cli/config.py | 11 ++++------- scenedetect/_cli/context.py | 36 +++++++++++++++++------------------- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index ea6d6b0a..5a8f7e4c 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -52,7 +52,7 @@ def main(): if __debug__: raise else: - logger.critical("Unhandled exception:", exc_info=ex) + logger.critical("ERROR: Unhandled exception:", exc_info=ex) raise SystemExit(1) from None diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ac7e0976..208e4d1d 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1060,7 +1060,7 @@ def list_scenes_command( assert isinstance(ctx, CliContext) no_output_file = no_output_file or ctx.config.get_value("list-scenes", "no-output-file") - scene_list_dir = ctx.config.get_value("list-scenes", "output", output, ignore_default=True) + scene_list_dir = ctx.config.get_value("list-scenes", "output", output) scene_list_name_format = ctx.config.get_value("list-scenes", "filename", filename) list_scenes_args = { "cut_format": TimecodeFormat[ctx.config.get_value("list-scenes", "cut-format").upper()], @@ -1243,7 +1243,7 @@ def split_video_command( split_video_args = { "name_format": ctx.config.get_value("split-video", "filename", filename), "use_mkvmerge": mkvmerge, - "output_dir": ctx.config.get_value("split-video", "output", output, ignore_default=True), + "output_dir": ctx.config.get_value("split-video", "output", output), "show_output": not quiet, "ffmpeg_args": args, } @@ -1420,7 +1420,7 @@ def save_images_command( ] logger.debug("\n".join(error_strs)) raise click.BadParameter("\n".join(error_strs), param_hint="save-images") - output = ctx.config.get_value("save-images", "output", output, ignore_default=True) + output = ctx.config.get_value("save-images", "output", output) save_images_args = { "encoder_param": compression if png else quality, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 5e03e5d2..3ea5babe 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -306,7 +306,7 @@ def format(self, timecode: FrameTimecode) -> str: "display-cuts": True, "display-scenes": True, "filename": "$VIDEO_NAME-Scenes.csv", - "output": "", + "output": None, "no-output-file": False, "quiet": False, "skip-cuts": False, @@ -320,7 +320,7 @@ def format(self, timecode: FrameTimecode) -> str: "frame-skip": 0, "merge-last-scene": False, "min-scene-len": TimecodeValue("0.6s"), - "output": "", + "output": None, "verbosity": "info", }, "save-images": { @@ -330,7 +330,7 @@ def format(self, timecode: FrameTimecode) -> str: "frame-margin": 1, "height": 0, "num-images": 3, - "output": "", + "output": None, "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), "scale": 1.0, "scale-method": "linear", @@ -342,7 +342,7 @@ def format(self, timecode: FrameTimecode) -> str: "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, "mkvmerge": False, - "output": "", + "output": None, "preset": "veryfast", "quiet": False, "rate-factor": RangeValue(22, min_val=0, max_val=100), @@ -580,7 +580,6 @@ def get_value( command: str, option: str, override: Optional[ConfigValue] = None, - ignore_default: bool = False, ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] @@ -590,8 +589,6 @@ def get_value( value = self._config[command][option] else: value = CONFIG_MAP[command][option] - if ignore_default: - return None if issubclass(type(value), ValidatedValue): return value.value return value diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 551eee21..1062cc71 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -187,7 +187,7 @@ def handle_options( init_failure = not self.config.initialized init_log = self.config.get_init_log() quiet = not init_failure and quiet - self._initialize_logging(quiet=quiet, verbosity=verbosity, logfile=logfile) + self._initialize_logging(quiet, verbosity, logfile) # Configuration file was specified via CLI argument -c/--config. if config and not init_failure: @@ -229,20 +229,16 @@ def handle_options( param_hint="frame skip + stats file", ) - # Handle the case where -i/--input was not specified (e.g. for the `help` command). + # Handle case where -i/--input was not specified (e.g. for the `help` command). if input_path is None: return - # Have to load the input video to obtain a time base before parsing timecodes. - self._open_video_stream( - input_path=input_path, - framerate=framerate, - backend=self.config.get_value("global", "backend", backend, ignore_default=True), - ) + # Load the input video to obtain a time base for parsing timecodes. + self._open_video_stream(input_path, framerate, backend) - self.output_dir = output if output else self.config.get_value("global", "output") + self.output_dir = self.config.get_value("global", "output", output) if self.output_dir: - logger.info("Output directory set:\n %s", self.output_dir) + logger.debug("Output directory set:\n %s", self.output_dir) self.min_scene_len = self.parse_timecode( min_scene_len @@ -507,7 +503,10 @@ def _initialize_logging( init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) def _open_video_stream( - self, input_path: ty.AnyStr, framerate: ty.Optional[float], backend: ty.Optional[str] + self, + input_path: ty.AnyStr, + framerate: ty.Optional[float], + backend: ty.Optional[str], ): if "%" in input_path and backend != "opencv": raise click.BadParameter( @@ -517,14 +516,13 @@ def _open_video_stream( if framerate is not None and framerate < MAX_FPS_DELTA: raise click.BadParameter("Invalid framerate specified!", param_hint="-f/--framerate") try: - if backend is None: - backend = self.config.get_value("global", "backend") - else: - if backend not in AVAILABLE_BACKENDS: - raise click.BadParameter( - "Specified backend %s is not available on this system!" % backend, - param_hint="-b/--backend", - ) + backend = self.config.get_value("global", "backend", backend) + if backend not in AVAILABLE_BACKENDS: + raise click.BadParameter( + "Specified backend %s is not available on this system!" % backend, + param_hint="-b/--backend", + ) + # Open the video with the specified backend, loading any required config settings. if backend == "pyav": self.video_stream = open_video( From e58f0e3bccc96e7a5d1453af8a2246d59f759339 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 1 Oct 2024 22:51:48 -0400 Subject: [PATCH 135/407] [stats] Allow save_to_csv to work with pathlib.Path. --- scenedetect/stats_manager.py | 7 ++--- tests/test_backend_opencv.py | 20 +-------------- tests/test_backwards_compat.py | 17 ++---------- tests/test_cli.py | 25 +++++++++--------- tests/test_detectors.py | 2 +- tests/test_scene_manager.py | 2 +- tests/test_stats_manager.py | 47 +++++++++++++++------------------- tests/test_video_splitter.py | 37 ++++++++++++++++++-------- tests/test_video_stream.py | 4 +-- 9 files changed, 70 insertions(+), 91 deletions(-) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index d32fe0bf..320f726e 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -25,6 +25,7 @@ import os.path import typing as ty from logging import getLogger +from pathlib import Path # TODO: Replace below imports with `ty.` prefix. from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union @@ -167,7 +168,7 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: Union[str, bytes, TextIO], + csv_file: Union[str, bytes, Path, TextIO], base_timecode: Optional[FrameTimecode] = None, force_save=True, ) -> None: @@ -191,7 +192,7 @@ def save_to_csv( # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. - if isinstance(csv_file, (str, bytes)): + if isinstance(csv_file, (str, bytes, Path)): with open(csv_file, "w") as file: self.save_to_csv(csv_file=file, force_save=force_save) return @@ -250,7 +251,7 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: # If we get a path instead of an open file handle, check that it exists, and if so, # recursively call ourselves again but with file set instead of path. - if isinstance(csv_file, (str, bytes)): + if isinstance(csv_file, (str, bytes, Path)): if os.path.exists(csv_file): with open(csv_file) as file: return self.load_from_csv(csv_file=file) diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index 4a77f2cb..3f106ab8 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -23,7 +23,7 @@ from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 GROUND_TRUTH_CAPTURE_ADAPTER_TEST = [1, 90, 210] -GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [30, 180, 394] +GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [180, 394] def test_open_image_sequence(test_image_sequence: str): @@ -50,21 +50,3 @@ def test_capture_adapter(test_movie_clip: str): scenes = scene_manager.get_scene_list() assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST) assert [start.get_frames() for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST - - -def test_capture_adapter_callback(test_video_file: str): - """Test that the VideoCaptureAdapter works with SceneManager and a callback.""" - - callback_frames = [] - - def on_new_scene(_, frame_num: int): - nonlocal callback_frames - callback_frames.append(frame_num) - - cap = cv2.VideoCapture(test_video_file) - assert cap.isOpened() - adapter = VideoCaptureAdapter(cap) - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector()) - scene_manager.detect_scenes(video=adapter, callback=on_new_scene) - assert callback_frames == GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py index b4111aba..7e9036e3 100644 --- a/tests/test_backwards_compat.py +++ b/tests/test_backwards_compat.py @@ -41,8 +41,8 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) base_timecode = video_manager.get_base_timecode() scene_list = [] try: - start_time = base_timecode + 20 # 00:00:00.667 - end_time = base_timecode + 10.0 # 00:00:10.000 + start_time = base_timecode + 4.0 + end_time = base_timecode + 8.0 if os.path.exists(stats_file_path): with open(stats_file_path) as stats_file: @@ -67,19 +67,6 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str) # Correct end frame # for presentation duration. assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1 - print("List of scenes obtained:") - for i, scene in enumerate(scene_list): - print( - " Scene %2d: Start %s / Frame %d, End %s / Frame %d" - % ( - i + 1, - scene[0].get_timecode(), - scene[0].get_frames(), - scene[1].get_timecode(), - scene[1].get_frames(), - ) - ) - if stats_manager.is_save_required(): with open(stats_file_path, "w") as stats_file: stats_manager.save_to_csv(stats_file, base_timecode=base_timecode) diff --git a/tests/test_cli.py b/tests/test_cli.py index dbd9ef90..fcadb9bd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,6 +59,10 @@ DEFAULT_DETECTOR = "detect-content" DEFAULT_CONFIG_FILE = "scenedetect.cfg" # Ensure we default to a "blank" config file. DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params. +DEFAULT_FFMPEG_ARGS = ( + "-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency" +) +"""Only encodes a small crop of the frame and tuned for performance to speed up tests.""" def invoke_scenedetect( @@ -313,13 +317,13 @@ def test_cli_list_scenes(tmp_path: Path): @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") def test_cli_split_video_ffmpeg(tmp_path: Path): """Test `split-video` command using ffmpeg.""" + # Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER. - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video", output_dir=tmp_path - ) - == 0 + command = f"{SCENEDETECT_CMD} -i {DEFAULT_VIDEO_PATH} -o {tmp_path} time {DEFAULT_TIME} {DEFAULT_DETECTOR} split-video -a".split( + " " ) + command.append(DEFAULT_FFMPEG_ARGS) + assert subprocess.call(command) == 0 entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*")) assert len(entries) == DEFAULT_NUM_SCENES, entries [entry.unlink() for entry in entries] @@ -334,20 +338,15 @@ def test_cli_split_video_ffmpeg(tmp_path: Path): assert len(entries) == DEFAULT_NUM_SCENES [entry.unlink() for entry in entries] - assert ( - invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER", - output_dir=tmp_path, - ) - == 0 - ) + command += ["-f", "abc$VIDEO_NAME-123$SCENE_NUMBER"] + assert subprocess.call(command) == 0 entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*")) assert len(entries) == DEFAULT_NUM_SCENES, entries [entry.unlink() for entry in entries] # -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero) assert invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a "-c:v libx264"', + '-i {VIDEO} {DETECTOR} split-video -c -a "-c:v libx264"', output_dir=tmp_path, ) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 0df1f95a..109872be 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -212,7 +212,7 @@ def test_detectors_with_stats(test_video_file): scene_manager = SceneManager(stats_manager=stats) scene_manager.add_detector(detector()) scene_manager.auto_downscale = True - end_time = FrameTimecode("00:00:08", video.frame_rate) + end_time = FrameTimecode("00:00:05", video.frame_rate) scene_manager.detect_scenes(video=video, end_time=end_time) initial_scene_len = len(scene_manager.get_scene_list()) assert initial_scene_len > 0, "Test case must have at least one scene." diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 9e19f4c1..16683bce 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -36,7 +36,7 @@ def test_scene_list(test_video_file): video_fps = video.frame_rate start_time = FrameTimecode("00:00:05", video_fps) - end_time = FrameTimecode("00:00:15", video_fps) + end_time = FrameTimecode("00:00:10", video_fps) assert end_time.get_frames() > start_time.get_frames() diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 3701fe5c..6d47d748 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -29,6 +29,7 @@ import csv import os import random +from pathlib import Path import pytest @@ -43,19 +44,6 @@ StatsManager, ) -# TODO(v1.0): use https://docs.pytest.org/en/6.2.x/tmpdir.html -TEST_STATS_FILES = ["TEST_STATS_FILE"] * 4 -TEST_STATS_FILES = [ - "%s_%012d.csv" % (stats_file, random.randint(0, 10**12)) for stats_file in TEST_STATS_FILES -] - - -def teardown_module(): - """Removes any created stats files, if any.""" - for stats_file in TEST_STATS_FILES: - if os.path.exists(stats_file): - os.remove(stats_file) - def test_metrics(): """Test StatsManager metric registration/setting/getting with a set of pre-defined @@ -103,25 +91,28 @@ def test_detector_metrics(test_video_file): assert stats_manager.get_metrics(0, ContentDetector.METRIC_KEYS) -def test_load_empty_stats(): +def test_load_empty_stats(tmp_path: Path): """Test loading an empty stats file, ensuring it results in no errors.""" - with open(TEST_STATS_FILES[0], "w"): + path = tmp_path.joinpath("stats.csv") + with open(path, "w"): pass stats_manager = StatsManager() - stats_manager.load_from_csv(TEST_STATS_FILES[0]) + stats_manager.load_from_csv(path) -def test_save_no_detect_scenes(): +def test_save_no_detect_scenes(tmp_path: Path): """Test saving without calling detect_scenes.""" + path = tmp_path.joinpath("stats.csv") stats_manager = StatsManager() - stats_manager.save_to_csv(TEST_STATS_FILES[0]) + stats_manager.save_to_csv(path) -def test_load_hardcoded_file(): +def test_load_hardcoded_file(tmp_path: Path): """Test loading a stats file with some hard-coded data generated by this test case.""" + path = tmp_path.joinpath("stats.csv") stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], "w") as stats_file: + with open(path, "w") as stats_file: stats_writer = csv.writer(stats_file, lineterminator="\n") some_metric_key = "some_metric" @@ -136,7 +127,7 @@ def test_load_hardcoded_file(): [some_frame_key + 1, some_frame_timecode.get_timecode(), str(some_metric_value)] ) - stats_manager.load_from_csv(TEST_STATS_FILES[0]) + stats_manager.load_from_csv(path) # Check that we decoded the correct values. assert stats_manager.metrics_exist(some_frame_key, [some_metric_key]) @@ -145,7 +136,7 @@ def test_load_hardcoded_file(): ) -def test_save_load_from_video(test_video_file): +def test_save_load_from_video(test_video_file, tmp_path: Path): """Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and loading the file back to ensure the loaded frame metrics agree with those that were saved. """ @@ -161,13 +152,14 @@ def test_save_load_from_video(test_video_file): scene_manager.auto_downscale = True scene_manager.detect_scenes(video, duration=duration) - stats_manager.save_to_csv(csv_file=TEST_STATS_FILES[0]) + path = tmp_path.joinpath("stats.csv") + stats_manager.save_to_csv(csv_file=path) metrics = stats_manager.metric_keys stats_manager_new = StatsManager() - stats_manager_new.load_from_csv(TEST_STATS_FILES[0]) + stats_manager_new.load_from_csv(path) # Compare the first 5 frames. Frame 0 won't have any metrics for this detector. for frame in range(1, 5 + 1): @@ -178,12 +170,13 @@ def test_save_load_from_video(test_video_file): assert metric_val == pytest.approx(new_metrics[i]) -def test_load_corrupt_stats(): +def test_load_corrupt_stats(tmp_path: Path): """Test loading a corrupted stats file created by outputting data in the wrong format.""" stats_manager = StatsManager() - with open(TEST_STATS_FILES[0], "w") as stats_file: + path = tmp_path.joinpath("stats.csv") + with open(path, "w") as stats_file: stats_writer = csv.writer(stats_file, lineterminator="\n") some_metric_key = "some_metric" @@ -204,4 +197,4 @@ def test_load_corrupt_stats(): stats_file.close() with pytest.raises(StatsFileCorrupt): - stats_manager.load_from_csv(TEST_STATS_FILES[0]) + stats_manager.load_from_csv(path) diff --git a/tests/test_video_splitter.py b/tests/test_video_splitter.py index 7fefefbb..d0c4c9c0 100644 --- a/tests/test_video_splitter.py +++ b/tests/test_video_splitter.py @@ -23,17 +23,25 @@ split_video_ffmpeg, ) +FFMPEG_ARGS = ( + "-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency" +) +"""Only encodes a small crop of the frame and tuned for performance to speed up tests.""" + @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): video = open_video(test_movie_clip) - # Extract three hard-coded scenes for testing, each 60 frames. + # Extract three hard-coded scenes for testing, each 30 frames. scenes = [ - (video.base_timecode + 60, video.base_timecode + 120), - (video.base_timecode + 120, video.base_timecode + 180), - (video.base_timecode + 180, video.base_timecode + 240), + (video.base_timecode + 30, video.base_timecode + 60), + (video.base_timecode + 60, video.base_timecode + 90), + (video.base_timecode + 90, video.base_timecode + 120), ] - assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path) == 0 + assert ( + split_video_ffmpeg(test_movie_clip, scenes, output_dir=tmp_path, arg_override=FFMPEG_ARGS) + == 0 + ) # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) @@ -43,18 +51,27 @@ def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): video = open_video(test_movie_clip) - # Extract three hard-coded scenes for testing, each 60 frames. + # Extract three hard-coded scenes for testing, each 30 frames. scenes = [ - (video.base_timecode + 60, video.base_timecode + 120), - (video.base_timecode + 120, video.base_timecode + 180), - (video.base_timecode + 180, video.base_timecode + 240), + (video.base_timecode + 30, video.base_timecode + 60), + (video.base_timecode + 60, video.base_timecode + 90), + (video.base_timecode + 90, video.base_timecode + 120), ] # Custom filename formatter: def name_formatter(video: VideoMetadata, scene: SceneMetadata): return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" - assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) == 0 + assert ( + split_video_ffmpeg( + test_movie_clip, + scenes, + output_dir=tmp_path, + arg_override=FFMPEG_ARGS, + formatter=name_formatter, + ) + == 0 + ) video_name = Path(test_movie_clip).stem entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) assert len(entries) == len(scenes) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index c3cc5127..1d2074b0 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -358,6 +358,6 @@ def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): stream = vs_type(corrupt_video_file) # OpenCV usually fails to read the video at frame 45, so we make sure all backends can - # get to 100 without reporting a failure. - for frame in range(100): + # get to 60 without reporting a failure. + for frame in range(60): assert stream.read() is not False, "Failed on frame %d!" % frame From 81e6a56a2bd2c43a53b76b864d4e451bce4f15c0 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 6 Oct 2024 00:05:51 -0400 Subject: [PATCH 136/407] [cli] Move output command state out of CliContext (#426) * [cli] Move output command state out of CliContext To simplify things, all output commands can be treated similarily as they all act on the result of the processing pipeline. This allows new commands to be added without needing to explicitly define their values in CliContext, and makes the values that do remain there much more meaningful. It also allows commands to be specified multiple times gracefully and with different options, so for example `save-images` can now be run twice with different encoding parameters. * [cli] Centralize preconditions in CliContext * [cli] Simplify parsing of default values --- scenedetect/__main__.py | 10 +- scenedetect/_cli/__init__.py | 310 +++++++++++++----- scenedetect/_cli/commands.py | 213 +++++++++++++ scenedetect/_cli/config.py | 15 +- scenedetect/_cli/context.py | 563 ++++++--------------------------- scenedetect/_cli/controller.py | 231 ++------------ scenedetect/scene_manager.py | 54 ++-- scenedetect/video_splitter.py | 12 +- 8 files changed, 618 insertions(+), 790 deletions(-) create mode 100644 scenedetect/_cli/commands.py diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 7c9ec1b9..5a8f7e4c 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -22,10 +22,10 @@ def main(): """PySceneDetect command-line interface (CLI) entry point.""" - cli_ctx = CliContext() + context = CliContext() try: # Process command line arguments and subcommands to initialize the context. - scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks. + scenedetect.main(obj=context) # Parse CLI arguments with registered callbacks. except SystemExit as exit: help_command = any(arg in sys.argv for arg in ["-h", "--help"]) if help_command or exit.code != 0: @@ -38,12 +38,12 @@ def main(): # no progress bars get created, we instead create a fake context manager. This is done here # to avoid needing a separate context manager at each point a progress bar is created. log_redirect = ( - FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm(loggers=[logger]) + FakeTqdmLoggingRedirect() if context.quiet_mode else logging_redirect_tqdm(loggers=[logger]) ) with log_redirect: try: - run_scenedetect(cli_ctx) + run_scenedetect(context) except KeyboardInterrupt: logger.info("Stopped.") if __debug__: @@ -52,7 +52,7 @@ def main(): if __debug__: raise else: - logger.critical("Unhandled exception:", exc_info=ex) + logger.critical("ERROR: Unhandled exception:", exc_info=ex) raise SystemExit(1) from None diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 18047181..208e4d1d 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -20,13 +20,23 @@ import inspect import logging +import os +import os.path import typing as ty import click import scenedetect -from scenedetect._cli.config import CHOICE_MAP, CONFIG_FILE_PATH, CONFIG_MAP -from scenedetect._cli.context import USER_CONFIG, CliContext +import scenedetect._cli.commands as cli_commands +from scenedetect._cli.config import ( + CHOICE_MAP, + CONFIG_FILE_PATH, + CONFIG_MAP, + DEFAULT_JPG_QUALITY, + DEFAULT_WEBP_QUALITY, + TimecodeFormat, +) +from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS from scenedetect.detectors import ( AdaptiveDetector, @@ -35,7 +45,8 @@ HistogramDetector, ThresholdDetector, ) -from scenedetect.platform import get_system_version_info +from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info +from scenedetect.scene_manager import Interpolation _PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" @@ -305,8 +316,10 @@ def scenedetect( Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_options( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + ctx.handle_options( input_path=input, output=output, framerate=framerate, @@ -334,7 +347,6 @@ def scenedetect( @click.pass_context def help_command(ctx: click.Context, command_name: str): """Print help for command (`help [command]`).""" - assert isinstance(ctx.obj, CliContext) assert isinstance(ctx.parent.command, click.MultiCommand) parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) @@ -358,7 +370,6 @@ def help_command(ctx: click.Context, command_name: str): @click.pass_context def about_command(ctx: click.Context): """Print license/copyright info.""" - assert isinstance(ctx.obj, CliContext) click.echo("") click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) click.echo(click.style(" About PySceneDetect %s" % _PROGRAM_VERSION, fg="yellow")) @@ -371,7 +382,6 @@ def about_command(ctx: click.Context): @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" - assert isinstance(ctx.obj, CliContext) click.echo("") click.echo(get_system_version_info()) ctx.exit() @@ -421,12 +431,23 @@ def time_command( {scenedetect_with_video} time --start 0 --end 1000 """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_time( - start=start, - duration=duration, - end=end, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + if duration is not None and end is not None: + raise click.BadParameter( + "Only one of --duration/-d or --end/-e can be specified, not both.", + param_hint="time", + ) + logger.debug("Setting video time:\n start: %s, duration: %s, end: %s", start, duration, end) + # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to + # match the default start number used by `ffmpeg` when saving frames as images. As such, + # we must correct start time if set as frames. See the test_cli_time* tests for for details. + ctx.start_time = ctx.parse_timecode(start, correct_pts=True) + ctx.end_time = ctx.parse_timecode(end) + ctx.duration = ctx.parse_timecode(duration) + if ctx.start_time and ctx.end_time and (ctx.start_time + 1) > ctx.end_time: + raise click.BadParameter("-e/--end time must be greater than -s/--start") @click.command("detect-content", cls=_Command) @@ -525,8 +546,9 @@ def detect_content_command( {scenedetect_with_video} detect-content --threshold 27.5 """ - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_content_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_content_params( threshold=threshold, luma_only=luma_only, min_scene_len=min_scene_len, @@ -534,8 +556,7 @@ def detect_content_command( kernel_size=kernel_size, filter_mode=filter_mode, ) - logger.debug("Adding detector: ContentDetector(%s)", detector_args) - ctx.obj.add_detector(ContentDetector(**detector_args)) + ctx.add_detector(ContentDetector, detector_args) @click.command("detect-adaptive", cls=_Command) @@ -636,8 +657,9 @@ def detect_adaptive_command( {scenedetect_with_video} detect-adaptive --threshold 3.2 """ - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_adaptive_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_adaptive_params( threshold=threshold, min_content_val=min_content_val, min_delta_hsv=min_delta_hsv, @@ -647,8 +669,7 @@ def detect_adaptive_command( weights=weights, kernel_size=kernel_size, ) - logger.debug("Adding detector: AdaptiveDetector(%s)", detector_args) - ctx.obj.add_detector(AdaptiveDetector(**detector_args)) + ctx.add_detector(AdaptiveDetector, detector_args) @click.command("detect-threshold", cls=_Command) @@ -715,15 +736,15 @@ def detect_threshold_command( {scenedetect_with_video} detect-threshold --threshold 15 """ - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_threshold_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_threshold_params( threshold=threshold, fade_bias=fade_bias, add_last_scene=add_last_scene, min_scene_len=min_scene_len, ) - logger.debug("Adding detector: ThresholdDetector(%s)", detector_args) - ctx.obj.add_detector(ThresholdDetector(**detector_args)) + ctx.add_detector(ThresholdDetector, detector_args) @click.command("detect-hist", cls=_Command) @@ -785,14 +806,12 @@ def detect_hist_command( {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 """ - assert isinstance(ctx.obj, CliContext) - - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_hist_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_hist_params( threshold=threshold, bins=bins, min_scene_len=min_scene_len ) - logger.debug("Adding detector: HistogramDetector(%s)", detector_args) - ctx.obj.add_detector(HistogramDetector(**detector_args)) + ctx.add_detector(HistogramDetector, detector_args) @click.command("detect-hash", cls=_Command) @@ -870,14 +889,12 @@ def detect_hash_command( {scenedetect_with_video} detect-hash --size 32 --lowpass 3 """ - assert isinstance(ctx.obj, CliContext) - - assert isinstance(ctx.obj, CliContext) - detector_args = ctx.obj.get_detect_hash_params( + ctx = ctx.obj + assert isinstance(ctx, CliContext) + detector_args = ctx.get_detect_hash_params( threshold=threshold, size=size, lowpass=lowpass, min_scene_len=min_scene_len ) - logger.debug("Adding detector: HashDetector(%s)", detector_args) - ctx.obj.add_detector(HashDetector(**detector_args)) + ctx.add_detector(HashDetector, detector_args) @click.command("load-scenes", cls=_Command) @@ -911,9 +928,23 @@ def load_scenes_command( {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" """ - assert isinstance(ctx.obj, CliContext) - logger.debug("Loading scenes from %s (start_col_name = %s)", input, start_col_name) - ctx.obj.handle_load_scenes(input=input, start_col_name=start_col_name) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + logger.debug("Will load scenes from %s (start_col_name = %s)", input, start_col_name) + if ctx.scene_manager.get_num_detectors() > 0: + raise click.ClickException("The load-scenes command cannot be used with detectors.") + if ctx.load_scenes_input: + raise click.ClickException("The load-scenes command must only be specified once.") + input = os.path.abspath(input) + if not os.path.exists(input): + raise click.BadParameter( + f"Could not load scenes, file does not exist: {input}", param_hint="-i/--input" + ) + ctx.load_scenes_input = input + ctx.load_scenes_column_name = ctx.config.get_value( + "load-scenes", "start-col-name", start_col_name + ) @click.command("export-html", cls=_Command) @@ -958,13 +989,20 @@ def export_html_command( image_height: ty.Optional[int], ): """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_export_html( - filename=filename, - no_images=no_images, - image_width=image_width, - image_height=image_height, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + no_images = no_images or ctx.config.get_value("export-html", "no-images") + if not ctx.save_images and not no_images: + raise click.BadArgumentUsage( + "export-html requires that save-images precedes it or --no-images is specified." + ) + export_html_args = { + "html_name_format": ctx.config.get_value("export-html", "filename", filename), + "image_width": ctx.config.get_value("export-html", "image-width", image_width), + "image_height": ctx.config.get_value("export-html", "image-height", image_height), + } + ctx.add_command(cli_commands.export_html, export_html_args) @click.command("list-scenes", cls=_Command) @@ -1018,14 +1056,23 @@ def list_scenes_command( skip_cuts: bool, ): """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default).""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_list_scenes( - output=output, - filename=filename, - no_output_file=no_output_file, - quiet=quiet, - skip_cuts=skip_cuts, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + no_output_file = no_output_file or ctx.config.get_value("list-scenes", "no-output-file") + scene_list_dir = ctx.config.get_value("list-scenes", "output", output) + scene_list_name_format = ctx.config.get_value("list-scenes", "filename", filename) + list_scenes_args = { + "cut_format": TimecodeFormat[ctx.config.get_value("list-scenes", "cut-format").upper()], + "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), + "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), + "scene_list_output": not no_output_file, + "scene_list_name_format": scene_list_name_format, + "skip_cuts": skip_cuts or ctx.config.get_value("list-scenes", "skip-cuts"), + "output_dir": scene_list_dir, + "quiet": quiet or ctx.config.get_value("list-scenes", "quiet") or ctx.quiet_mode, + } + ctx.add_command(cli_commands.list_scenes, list_scenes_args) @click.command("split-video", cls=_Command) @@ -1134,18 +1181,73 @@ def split_video_command( {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_split_video( - output=output, - filename=filename, - quiet=quiet, - copy=copy, - high_quality=high_quality, - rate_factor=rate_factor, - preset=preset, - args=args, - mkvmerge=mkvmerge, - ) + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + check_split_video_requirements(use_mkvmerge=mkvmerge) + if "%" in ctx.video_stream.path or "://" in ctx.video_stream.path: + error = "The split-video command is incompatible with image sequences/URLs." + raise click.BadParameter(error, param_hint="split-video") + + # We only load the config values for these flags/options if none of the other + # encoder flags/options were set via the CLI to avoid any conflicting options + # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). + if not (mkvmerge or copy or high_quality or args or rate_factor or preset): + mkvmerge = ctx.config.get_value("split-video", "mkvmerge") + copy = ctx.config.get_value("split-video", "copy") + high_quality = ctx.config.get_value("split-video", "high-quality") + rate_factor = ctx.config.get_value("split-video", "rate-factor") + preset = ctx.config.get_value("split-video", "preset") + args = ctx.config.get_value("split-video", "args") + + # Disallow certain combinations of options. + if mkvmerge or copy: + command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" + if high_quality: + raise click.BadParameter( + "high-quality (-hq) cannot be used with %s" % (command), + param_hint="split-video", + ) + if args: + raise click.BadParameter( + "args (-a) cannot be used with %s" % (command), param_hint="split-video" + ) + if rate_factor: + raise click.BadParameter( + "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" + ) + if preset: + raise click.BadParameter( + "preset (-p) cannot be used with %s" % (command), param_hint="split-video" + ) + + # mkvmerge-Specific Options + if mkvmerge and copy: + logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") + + # ffmpeg-Specific Options + if copy: + args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" + elif not args: + if rate_factor is None: + rate_factor = 22 if not high_quality else 17 + if preset is None: + preset = "veryfast" if not high_quality else "slow" + args = ( + "-map 0:v:0 -map 0:a? -map 0:s? " + f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" + ) + if filename: + logger.info("Output file name format: %s", filename) + + split_video_args = { + "name_format": ctx.config.get_value("split-video", "filename", filename), + "use_mkvmerge": mkvmerge, + "output_dir": ctx.config.get_value("split-video", "output", output), + "show_output": not quiet, + "ffmpeg_args": args, + } + ctx.add_command(cli_commands.split_video, split_video_args) @click.command("save-images", cls=_Command) @@ -1279,21 +1381,65 @@ def save_images_command( {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_save_images( - num_images=num_images, - output=output, - filename=filename, - jpeg=jpeg, - webp=webp, - quality=quality, - png=png, - compression=compression, - frame_margin=frame_margin, - scale=scale, - height=height, - width=width, + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + if "://" in ctx.video_stream.path: + error_str = "\nThe save-images command is incompatible with URLs." + logger.error(error_str) + raise click.BadParameter(error_str, param_hint="save-images") + num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) + if num_flags > 1: + logger.error(".") + raise click.BadParameter("Only one image type can be specified.", param_hint="save-images") + elif num_flags == 0: + image_format = ctx.config.get_value("save-images", "format").lower() + jpeg = image_format == "jpeg" + webp = image_format == "webp" + png = image_format == "png" + + if not any((scale, height, width)): + scale = ctx.config.get_value("save-images", "scale") + height = ctx.config.get_value("save-images", "height") + width = ctx.config.get_value("save-images", "width") + scale_method = Interpolation[ctx.config.get_value("save-images", "scale-method").upper()] + quality = ( + (DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY) + if ctx.config.is_default("save-images", "quality") + else ctx.config.get_value("save-images", "quality") ) + compression = ctx.config.get_value("save-images", "compression", compression) + image_extension = "jpg" if jpeg else "png" if png else "webp" + valid_params = get_cv2_imwrite_params() + if image_extension not in valid_params or valid_params[image_extension] is None: + error_strs = [ + "Image encoder type `%s` not supported." % image_extension.upper(), + "The specified encoder type could not be found in the current OpenCV module.", + "To enable this output format, please update the installed version of OpenCV.", + "If you build OpenCV, ensure the the proper dependencies are enabled. ", + ] + logger.debug("\n".join(error_strs)) + raise click.BadParameter("\n".join(error_strs), param_hint="save-images") + output = ctx.config.get_value("save-images", "output", output) + + save_images_args = { + "encoder_param": compression if png else quality, + "frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin), + "height": height, + "image_extension": image_extension, + "image_name_template": ctx.config.get_value("save-images", "filename", filename), + "interpolation": scale_method, + "num_images": ctx.config.get_value("save-images", "num-images", num_images), + "output_dir": output, + "scale": scale, + "show_progress": ctx.quiet_mode, + "width": width, + } + ctx.add_command(cli_commands.save_images, save_images_args) + + # Record that we added a save-images command to the pipeline so we can allow export-html + # to run afterwards (it is dependent on the output). + ctx.save_images = True # ---------------------------------------------------------------------- diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py new file mode 100644 index 00000000..83a23bf2 --- /dev/null +++ b/scenedetect/_cli/commands.py @@ -0,0 +1,213 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Logic for PySceneDetect commands that operate on the result of the processing pipeline. + +In addition to the the arguments registered with the command, commands will be called with the +current command-line context, as well as the processing result (scenes and cuts). +""" + +import logging +import typing as ty +from string import Template + +from scenedetect._cli.context import CliContext +from scenedetect.platform import get_and_create_path +from scenedetect.scene_manager import ( + CutList, + Interpolation, + SceneList, + write_scene_list, + write_scene_list_html, +) +from scenedetect.scene_manager import ( + save_images as save_images_impl, +) +from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge + +logger = logging.getLogger("pyscenedetect") + + +def export_html( + context: CliContext, + scenes: SceneList, + cuts: CutList, + image_width: int, + image_height: int, + html_name_format: str, +): + """Handles the `export-html` command.""" + (image_filenames, output_dir) = ( + context.save_images_result + if context.save_images_result is not None + else (None, context.output_dir) + ) + html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + if not html_filename.lower().endswith(".html"): + html_filename += ".html" + html_path = get_and_create_path(html_filename, output_dir) + write_scene_list_html( + output_html_filename=html_path, + scene_list=scenes, + cut_list=cuts, + image_filenames=image_filenames, + image_width=image_width, + image_height=image_height, + ) + + +def list_scenes( + context: CliContext, + scenes: SceneList, + cuts: CutList, + scene_list_output: bool, + scene_list_name_format: str, + output_dir: str, + skip_cuts: bool, + quiet: bool, + display_scenes: bool, + display_cuts: bool, + cut_format: str, +): + """Handles the `list-scenes` command.""" + # Write scene list CSV to if required. + if scene_list_output: + scene_list_filename = Template(scene_list_name_format).safe_substitute( + VIDEO_NAME=context.video_stream.name + ) + if not scene_list_filename.lower().endswith(".csv"): + scene_list_filename += ".csv" + scene_list_path = get_and_create_path( + scene_list_filename, + output_dir, + ) + logger.info("Writing scene list to CSV file:\n %s", scene_list_path) + with open(scene_list_path, "w") as scene_list_file: + write_scene_list( + output_csv_file=scene_list_file, + scene_list=scenes, + include_cut_list=not skip_cuts, + cut_list=cuts, + ) + # Suppress output if requested. + if quiet: + return + # Print scene list. + if display_scenes: + logger.info( + """Scene List: +----------------------------------------------------------------------- + | Scene # | Start Frame | Start Time | End Frame | End Time | +----------------------------------------------------------------------- +%s +-----------------------------------------------------------------------""", + "\n".join( + [ + " | %5d | %11d | %s | %11d | %s |" + % ( + i + 1, + start_time.get_frames() + 1, + start_time.get_timecode(), + end_time.get_frames(), + end_time.get_timecode(), + ) + for i, (start_time, end_time) in enumerate(scenes) + ] + ), + ) + # Print cut list. + if cuts and display_cuts: + logger.info( + "Comma-separated timecode list:\n %s", + ",".join([cut_format.format(cut) for cut in cuts]), + ) + + +def save_images( + context: CliContext, + scenes: SceneList, + cuts: CutList, + num_images: int, + frame_margin: int, + image_extension: str, + encoder_param: int, + image_name_template: str, + output_dir: ty.Optional[str], + show_progress: bool, + scale: int, + height: int, + width: int, + interpolation: Interpolation, +): + """Handles the `save-images` command.""" + del cuts # save-images only uses scenes. + + images = save_images_impl( + scene_list=scenes, + video=context.video_stream, + num_images=num_images, + frame_margin=frame_margin, + image_extension=image_extension, + encoder_param=encoder_param, + image_name_template=image_name_template, + output_dir=output_dir, + show_progress=show_progress, + scale=scale, + height=height, + width=width, + interpolation=interpolation, + ) + # Save the result for use by `export-html` if required. + context.save_images_result = (images, output_dir) + + +def split_video( + context: CliContext, + scenes: SceneList, + cuts: CutList, + name_format: str, + use_mkvmerge: bool, + output_dir: str, + show_output: bool, + ffmpeg_args: str, +): + """Handles the `split-video` command.""" + del cuts # split-video only uses scenes. + + # Add proper extension to filename template if required. + dot_pos = name_format.rfind(".") + extension_length = 0 if dot_pos < 0 else len(name_format) - (dot_pos + 1) + # If using mkvmerge, force extension to .mkv. + if use_mkvmerge and not name_format.endswith(".mkv"): + name_format += ".mkv" + # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. + elif not 2 <= extension_length <= 4: + name_format += ".mp4" + if use_mkvmerge: + split_video_mkvmerge( + input_video_path=context.video_stream.path, + scene_list=scenes, + output_dir=output_dir, + output_file_template=name_format, + show_output=show_output, + ) + else: + split_video_ffmpeg( + input_video_path=context.video_stream.path, + scene_list=scenes, + output_dir=output_dir, + output_file_template=name_format, + arg_override=ffmpeg_args, + show_progress=not context.quiet_mode, + show_output=show_output, + ) + if scenes: + logger.info("Video splitting completed, scenes written to disk.") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 929587ad..3ea5babe 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -30,7 +30,7 @@ from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS -VALID_PYAV_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] class OptionParseFailure(Exception): @@ -306,7 +306,7 @@ def format(self, timecode: FrameTimecode) -> str: "display-cuts": True, "display-scenes": True, "filename": "$VIDEO_NAME-Scenes.csv", - "output": "", + "output": None, "no-output-file": False, "quiet": False, "skip-cuts": False, @@ -320,7 +320,7 @@ def format(self, timecode: FrameTimecode) -> str: "frame-skip": 0, "merge-last-scene": False, "min-scene-len": TimecodeValue("0.6s"), - "output": "", + "output": None, "verbosity": "info", }, "save-images": { @@ -330,7 +330,7 @@ def format(self, timecode: FrameTimecode) -> str: "frame-margin": 1, "height": 0, "num-images": 3, - "output": "", + "output": None, "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), "scale": 1.0, "scale-method": "linear", @@ -342,7 +342,7 @@ def format(self, timecode: FrameTimecode) -> str: "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, "mkvmerge": False, - "output": "", + "output": None, "preset": "veryfast", "quiet": False, "rate-factor": RangeValue(22, min_val=0, max_val=100), @@ -354,7 +354,7 @@ def format(self, timecode: FrameTimecode) -> str: CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { "backend-pyav": { - "threading_mode": [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], }, "detect-content": { "filter-mode": [mode.name.lower() for mode in FlashFilter.Mode], @@ -580,7 +580,6 @@ def get_value( command: str, option: str, override: Optional[ConfigValue] = None, - ignore_default: bool = False, ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] @@ -590,8 +589,6 @@ def get_value( value = self._config[command][option] else: value = CONFIG_MAP[command][option] - if ignore_default: - return None if issubclass(type(value), ValidatedValue): return value.value return value diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index de0e95a0..1062cc71 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -12,7 +12,6 @@ """Context of which command-line options and config settings the user provided.""" import logging -import os import typing as ty import click @@ -21,11 +20,8 @@ from scenedetect import AVAILABLE_BACKENDS, open_video from scenedetect._cli.config import ( CHOICE_MAP, - DEFAULT_JPG_QUALITY, - DEFAULT_WEBP_QUALITY, ConfigLoadFailure, ConfigRegistry, - TimecodeFormat, ) from scenedetect.detectors import ( AdaptiveDetector, @@ -35,7 +31,7 @@ ThresholdDetector, ) from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -from scenedetect.platform import get_cv2_imwrite_params, init_logger +from scenedetect.platform import init_logger from scenedetect.scene_detector import FlashFilter, SceneDetector from scenedetect.scene_manager import Interpolation, SceneManager from scenedetect.stats_manager import StatsManager @@ -45,35 +41,7 @@ logger = logging.getLogger("pyscenedetect") USER_CONFIG = ConfigRegistry(throw_exception=False) - - -def parse_timecode( - value: ty.Optional[str], frame_rate: float, correct_pts: bool = False -) -> FrameTimecode: - """Parses a user input string into a FrameTimecode assuming the given framerate. - - If value is None, None will be returned instead of processing the value. - - Raises: - click.BadParameter - """ - if value is None: - return None - try: - if correct_pts and value.isdigit(): - value = int(value) - if value >= 1: - value -= 1 - return FrameTimecode(timecode=value, fps=frame_rate) - except ValueError as ex: - raise click.BadParameter( - "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" - ) from ex - - -def contains_sequence_or_url(video_path: str) -> bool: - """Checks if the video path is a URL or image sequence.""" - return "%" in video_path or "://" in video_path +"""The user config, which can be overriden by command-line. If not found, will be default config.""" def check_split_video_requirements(use_mkvmerge: bool) -> None: @@ -103,88 +71,85 @@ def check_split_video_requirements(use_mkvmerge: bool) -> None: class CliContext: - """Context of the command-line interface and config file parameters passed between sub-commands. - - Handles validation of options taken in from the CLI *and* configuration files. - - After processing the main program options via `handle_options`, the CLI will then call - the respective `handle_*` method for each command. Once all commands have been - processed, the main program actions are executed by passing this object to the - `run_scenedetect` function in `scenedetect.cli.controller`. + """The state of the application representing what video will be processed, how, and what to do + with the result. This includes handling all input options via command line and config file. + Once the CLI creates a context, it is executed by passing it to the + `scenedetect._cli.controller.run_scenedetect` function. """ def __init__(self): - self.config = USER_CONFIG - self.video_stream: VideoStream = None + # State: + self.config: ConfigRegistry = USER_CONFIG + self.quiet_mode: bool = None self.scene_manager: SceneManager = None self.stats_manager: StatsManager = None - self.added_detector: bool = False - - # Global `scenedetect` Options - self.output_dir: str = None # -o/--output - self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet - self.stats_file_path: str = None # -s/--stats - self.drop_short_scenes: bool = None # --drop-short-scenes - self.merge_last_scene: bool = None # --merge-last-scene - self.min_scene_len: FrameTimecode = None # -m/--min-scene-len - self.frame_skip: int = None # -fs/--frame-skip - self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = ( - None # [global] default-detector - ) + self.save_images: bool = False # True if the save-images command was specified + self.save_images_result: ty.Any = (None, None) # Result of save-images used by export-html - # `time` Command Options - self.time: bool = False + # Input: + self.video_stream: VideoStream = None + self.load_scenes_input: str = None # load-scenes -i/--input + self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name self.start_time: FrameTimecode = None # time -s/--start self.end_time: FrameTimecode = None # time -e/--end self.duration: FrameTimecode = None # time -d/--duration + self.frame_skip: int = None + + # Options: + self.drop_short_scenes: bool = None + self.merge_last_scene: bool = None + self.min_scene_len: FrameTimecode = None + self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None + self.output_dir: str = None + self.stats_file_path: str = None + + # Output Commands (e.g. split-video, save-images): + # Commands to run after the detection pipeline. Stored as (callback, args) and invoked with + # the results of the detection pipeline by the controller. + self.commands: ty.List[ty.Tuple[ty.Callable, ty.Dict[str, ty.Any]]] = [] + + def add_command(self, command: ty.Callable, command_args: ty.Dict[str, ty.Any]): + """Add `command` to the processing pipeline. Will be called after processing the input.""" + if "output_dir" in command_args and command_args["output_dir"] is None: + command_args["output_dir"] = self.output_dir + logger.debug("Adding command: %s(%s)", command.__name__, command_args) + self.commands.append((command, command_args)) + + def add_detector(self, detector: ty.Type[SceneDetector], detector_args: ty.Dict[str, ty.Any]): + """Instantiate and add `detector` to the processing pipeline.""" + if self.load_scenes_input: + raise click.ClickException("The load-scenes command cannot be used with detectors.") + logger.debug("Adding detector: %s(%s)", detector.__name__, detector_args) + self.scene_manager.add_detector(detector(**detector_args)) - # `save-images` Command Options - self.save_images: bool = False - self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png - self.image_dir: str = None # save-images -o/--output - self.image_param: int = None # save-images -q/--quality if -j/-w, - # otherwise -c/--compression if -p - self.image_name_format: str = None # save-images -f/--name-format - self.num_images: int = None # save-images -n/--num-images - self.frame_margin: int = 1 # save-images -m/--frame-margin - self.scale: float = None # save-images -s/--scale - self.height: int = None # save-images -h/--height - self.width: int = None # save-images -w/--width - self.scale_method: Interpolation = None # [save-images] scale-method - - # `split-video` Command Options - self.split_video: bool = False - self.split_mkvmerge: bool = None # split-video -m/--mkvmerge - self.split_args: str = None # split-video -a/--args, -c/--copy - self.split_dir: str = None # split-video -o/--output - self.split_name_format: str = None # split-video -f/--filename - self.split_quiet: bool = None # split-video -q/--quiet - - # `list-scenes` Command Options - self.list_scenes: bool = False - self.list_scenes_quiet: bool = None # list-scenes -q/--quiet - self.scene_list_dir: str = None # list-scenes -o/--output - self.scene_list_name_format: str = None # list-scenes -f/--filename - self.scene_list_output: bool = None # list-scenes -n/--no-output-file - self.skip_cuts: bool = None # list-scenes -s/--skip-cuts - self.display_cuts: bool = True # [list-scenes] display-cuts - self.display_scenes: bool = True # [list-scenes] display-scenes - self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format - - # `export-html` Command Options - self.export_html: bool = False - self.html_name_format: str = None # export-html -f/--filename - self.html_include_images: bool = None # export-html --no-images - self.image_width: int = None # export-html -w/--image-width - self.image_height: int = None # export-html -h/--image-height - - # `load-scenes` Command Options - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + def ensure_detector(self): + """Ensures at least one detector has been instantiated, otherwise adds a default one.""" + if self.scene_manager.get_num_detectors() == 0: + logger.debug("No detector specified, adding default detector.") + (detector_type, detector_args) = self.default_detector + self.add_detector(detector_type, detector_args) - # - # Command Handlers - # + def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> FrameTimecode: + """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` + is None it will be passed through without processing. + + Raises: + click.BadParameter, click.ClickException + """ + if value is None: + return None + try: + if self.video_stream is None: + raise click.ClickException("No input video (-i/--input) was specified.") + if correct_pts and value.isdigit(): + value = int(value) + if value >= 1: + value -= 1 + return FrameTimecode(timecode=value, fps=self.video_stream.frame_rate) + except ValueError as ex: + raise click.BadParameter( + "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" + ) from ex def handle_options( self, @@ -216,11 +181,13 @@ def handle_options( # TODO(v1.0): Make the stats value optional (e.g. allow -s only), and allow use of # $VIDEO_NAME macro in the name. Default to $VIDEO_NAME.csv. + # The `scenedetect` command was just started, let's initialize logging and try to load any + # config files that were specified. try: init_failure = not self.config.initialized init_log = self.config.get_init_log() quiet = not init_failure and quiet - self._initialize_logging(quiet=quiet, verbosity=verbosity, logfile=logfile) + self._initialize_logging(quiet, verbosity, logfile) # Configuration file was specified via CLI argument -c/--config. if config and not init_failure: @@ -262,26 +229,21 @@ def handle_options( param_hint="frame skip + stats file", ) - # Handle the case where -i/--input was not specified (e.g. for the `help` command). + # Handle case where -i/--input was not specified (e.g. for the `help` command). if input_path is None: return - # Have to load the input video to obtain a time base before parsing timecodes. - self._open_video_stream( - input_path=input_path, - framerate=framerate, - backend=self.config.get_value("global", "backend", backend, ignore_default=True), - ) + # Load the input video to obtain a time base for parsing timecodes. + self._open_video_stream(input_path, framerate, backend) - self.output_dir = output if output else self.config.get_value("global", "output") + self.output_dir = self.config.get_value("global", "output", output) if self.output_dir: - logger.info("Output directory set:\n %s", self.output_dir) + logger.debug("Output directory set:\n %s", self.output_dir) - self.min_scene_len = parse_timecode( + self.min_scene_len = self.parse_timecode( min_scene_len if min_scene_len is not None else self.config.get_value("global", "min-scene-len"), - self.video_stream.frame_rate, ) self.drop_short_scenes = drop_short_scenes or self.config.get_value( "global", "drop-short-scenes" @@ -329,6 +291,10 @@ def handle_options( ] self.scene_manager = scene_manager + # + # Detector Parameters + # + def get_detect_content_params( self, threshold: ty.Optional[float] = None, @@ -338,9 +304,7 @@ def get_detect_content_params( kernel_size: ty.Optional[int] = None, filter_mode: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: - """Handle detect-content command options and return args to construct one with.""" - self._ensure_input_open() - + """Get a dict containing user options to construct a ContentDetector with.""" if self.drop_short_scenes: min_scene_len = 0 else: @@ -349,7 +313,7 @@ def get_detect_content_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-content", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num if weights is not None: try: @@ -381,7 +345,6 @@ def get_detect_adaptive_params( min_delta_hsv: ty.Optional[float] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" - self._ensure_input_open() # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. if min_delta_hsv is not None: @@ -407,7 +370,7 @@ def get_detect_adaptive_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num if weights is not None: try: @@ -435,7 +398,6 @@ def get_detect_threshold_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" - self._ensure_input_open() if self.drop_short_scenes: min_scene_len = 0 @@ -445,7 +407,7 @@ def get_detect_threshold_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { "add_final_scene": add_last_scene @@ -455,23 +417,6 @@ def get_detect_threshold_params( "threshold": self.config.get_value("detect-threshold", "threshold", threshold), } - def handle_load_scenes(self, input: ty.AnyStr, start_col_name: ty.Optional[str]): - """Handle `load-scenes` command options.""" - self._ensure_input_open() - if self.added_detector: - raise click.ClickException("The load-scenes command cannot be used with detectors.") - if self.load_scenes_input: - raise click.ClickException("The load-scenes command must only be specified once.") - input = os.path.abspath(input) - if not os.path.exists(input): - raise click.BadParameter( - f"Could not load scenes, file does not exist: {input}", param_hint="-i/--input" - ) - self.load_scenes_input = input - self.load_scenes_column_name = self.config.get_value( - "load-scenes", "start-col-name", start_col_name - ) - def get_detect_hist_params( self, threshold: ty.Optional[float] = None, @@ -479,7 +424,7 @@ def get_detect_hist_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" - self._ensure_input_open() + if self.drop_short_scenes: min_scene_len = 0 else: @@ -488,7 +433,7 @@ def get_detect_hist_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num return { "bins": self.config.get_value("detect-hist", "bins", bins), "min_scene_len": min_scene_len, @@ -503,7 +448,7 @@ def get_detect_hash_params( min_scene_len: ty.Optional[str] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" - self._ensure_input_open() + if self.drop_short_scenes: min_scene_len = 0 else: @@ -512,7 +457,7 @@ def get_detect_hash_params( min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + min_scene_len = self.parse_timecode(min_scene_len).frame_num return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), "min_scene_len": min_scene_len, @@ -520,274 +465,6 @@ def get_detect_hash_params( "threshold": self.config.get_value("detect-hash", "threshold", threshold), } - def handle_export_html( - self, - filename: ty.Optional[ty.AnyStr], - no_images: bool, - image_width: ty.Optional[int], - image_height: ty.Optional[int], - ): - """Handle `export-html` command options.""" - self._ensure_input_open() - if self.export_html: - self._on_duplicate_command("export_html") - - no_images = no_images or self.config.get_value("export-html", "no-images") - self.html_include_images = not no_images - - self.html_name_format = self.config.get_value("export-html", "filename", filename) - self.image_width = self.config.get_value("export-html", "image-width", image_width) - self.image_height = self.config.get_value("export-html", "image-height", image_height) - - if not self.save_images and not no_images: - raise click.BadArgumentUsage( - "The export-html command requires that the save-images command\n" - "is specified before it, unless --no-images is specified." - ) - logger.info("HTML file name format:\n %s", filename) - - self.export_html = True - - def handle_list_scenes( - self, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - no_output_file: bool, - quiet: bool, - skip_cuts: bool, - ): - """Handle `list-scenes` command options.""" - self._ensure_input_open() - if self.list_scenes: - self._on_duplicate_command("list-scenes") - - self.display_cuts = self.config.get_value("list-scenes", "display-cuts") - self.display_scenes = self.config.get_value("list-scenes", "display-scenes") - self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts") - self.cut_format = TimecodeFormat[self.config.get_value("list-scenes", "cut-format").upper()] - self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet") - no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file") - - self.scene_list_dir = self.config.get_value( - "list-scenes", "output", output, ignore_default=True - ) - self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename) - if self.scene_list_name_format is not None and not no_output_file: - logger.info("Scene list filename format:\n %s", self.scene_list_name_format) - self.scene_list_output = not no_output_file - if self.scene_list_dir is not None: - logger.info("Scene list output directory:\n %s", self.scene_list_dir) - - self.list_scenes = True - - def handle_split_video( - self, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - quiet: bool, - copy: bool, - high_quality: bool, - rate_factor: ty.Optional[int], - preset: ty.Optional[str], - args: ty.Optional[str], - mkvmerge: bool, - ): - """Handle `split-video` command options.""" - self._ensure_input_open() - if self.split_video: - self._on_duplicate_command("split-video") - - check_split_video_requirements(use_mkvmerge=mkvmerge) - - if contains_sequence_or_url(self.video_stream.path): - error_str = "The split-video command is incompatible with image sequences/URLs." - raise click.BadParameter(error_str, param_hint="split-video") - - ## - ## Common Arguments/Options - ## - - self.split_video = True - self.split_quiet = quiet or self.config.get_value("split-video", "quiet") - self.split_dir = self.config.get_value("split-video", "output", output, ignore_default=True) - if self.split_dir is not None: - logger.info("Video output path set: \n%s", self.split_dir) - self.split_name_format = self.config.get_value("split-video", "filename", filename) - - # We only load the config values for these flags/options if none of the other - # encoder flags/options were set via the CLI to avoid any conflicting options - # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). - if not (mkvmerge or copy or high_quality or args or rate_factor or preset): - mkvmerge = self.config.get_value("split-video", "mkvmerge") - copy = self.config.get_value("split-video", "copy") - high_quality = self.config.get_value("split-video", "high-quality") - rate_factor = self.config.get_value("split-video", "rate-factor") - preset = self.config.get_value("split-video", "preset") - args = self.config.get_value("split-video", "args") - - # Disallow certain combinations of flags/options. - if mkvmerge or copy: - command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" - if high_quality: - raise click.BadParameter( - "high-quality (-hq) cannot be used with %s" % (command), - param_hint="split-video", - ) - if args: - raise click.BadParameter( - "args (-a) cannot be used with %s" % (command), param_hint="split-video" - ) - if rate_factor: - raise click.BadParameter( - "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" - ) - if preset: - raise click.BadParameter( - "preset (-p) cannot be used with %s" % (command), param_hint="split-video" - ) - - ## - ## mkvmerge-Specific Arguments/Options - ## - if mkvmerge: - if copy: - logger.warning("copy mode (-c) ignored due to mkvmerge mode (-m).") - self.split_mkvmerge = True - logger.info("Using mkvmerge for video splitting.") - return - - ## - ## ffmpeg-Specific Arguments/Options - ## - if copy: - args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" - elif not args: - if rate_factor is None: - rate_factor = 22 if not high_quality else 17 - if preset is None: - preset = "veryfast" if not high_quality else "slow" - args = ( - "-map 0:v:0 -map 0:a? -map 0:s? " - f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac" - ) - - logger.info("ffmpeg arguments: %s", args) - self.split_args = args - if filename: - logger.info("Output file name format: %s", filename) - - def handle_save_images( - self, - num_images: ty.Optional[int], - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - jpeg: bool, - webp: bool, - quality: ty.Optional[int], - png: bool, - compression: ty.Optional[int], - frame_margin: ty.Optional[int], - scale: ty.Optional[float], - height: ty.Optional[int], - width: ty.Optional[int], - ): - """Handle `save-images` command options.""" - self._ensure_input_open() - if self.save_images: - self._on_duplicate_command("save-images") - - if "://" in self.video_stream.path: - error_str = "\nThe save-images command is incompatible with URLs." - logger.error(error_str) - raise click.BadParameter(error_str, param_hint="save-images") - - num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]]) - if num_flags > 1: - logger.error("Multiple image type flags set for save-images command.") - raise click.BadParameter( - "Only one image type (JPG/PNG/WEBP) can be specified.", param_hint="save-images" - ) - # Only use config params for image format if one wasn't specified. - elif num_flags == 0: - image_format = self.config.get_value("save-images", "format").lower() - jpeg = image_format == "jpeg" - webp = image_format == "webp" - png = image_format == "png" - - # Only use config params for scale/height/width if none of them are specified explicitly. - if scale is None and height is None and width is None: - self.scale = self.config.get_value("save-images", "scale") - self.height = self.config.get_value("save-images", "height") - self.width = self.config.get_value("save-images", "width") - else: - self.scale = scale - self.height = height - self.width = width - - self.scale_method = Interpolation[ - self.config.get_value("save-images", "scale-method").upper() - ] - - default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY - quality = ( - default_quality - if self.config.is_default("save-images", "quality") - else self.config.get_value("save-images", "quality") - ) - - compression = self.config.get_value("save-images", "compression", compression) - self.image_param = compression if png else quality - - self.image_extension = "jpg" if jpeg else "png" if png else "webp" - valid_params = get_cv2_imwrite_params() - if self.image_extension not in valid_params or valid_params[self.image_extension] is None: - error_strs = [ - "Image encoder type `%s` not supported." % self.image_extension.upper(), - "The specified encoder type could not be found in the current OpenCV module.", - "To enable this output format, please update the installed version of OpenCV.", - "If you build OpenCV, ensure the the proper dependencies are enabled. ", - ] - logger.debug("\n".join(error_strs)) - raise click.BadParameter("\n".join(error_strs), param_hint="save-images") - - self.image_dir = self.config.get_value("save-images", "output", output, ignore_default=True) - - self.image_name_format = self.config.get_value("save-images", "filename", filename) - self.num_images = self.config.get_value("save-images", "num-images", num_images) - self.frame_margin = self.config.get_value("save-images", "frame-margin", frame_margin) - - image_type = ("jpeg" if jpeg else self.image_extension).upper() - image_param_type = "Compression" if png else "Quality" - image_param_type = " [%s: %d]" % (image_param_type, self.image_param) - logger.info("Image output format set: %s%s", image_type, image_param_type) - if self.image_dir is not None: - logger.info("Image output directory set:\n %s", os.path.abspath(self.image_dir)) - - self.save_images = True - - def handle_time(self, start, duration, end): - """Handle `time` command options.""" - self._ensure_input_open() - if self.time: - self._on_duplicate_command("time") - if duration is not None and end is not None: - raise click.BadParameter( - "Only one of --duration/-d or --end/-e can be specified, not both.", - param_hint="time", - ) - logger.debug( - "Setting video time:\n start: %s, duration: %s, end: %s", start, duration, end - ) - # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to - # match the default start number used by `ffmpeg` when saving frames as images. As such, - # we must correct start time if set as frames. See the test_cli_time* tests for for details. - self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True) - self.end_time = parse_timecode(end, self.video_stream.frame_rate) - self.duration = parse_timecode(duration, self.video_stream.frame_rate) - if self.start_time and self.end_time and (self.start_time + 1) > self.end_time: - raise click.BadParameter("-e/--end time must be greater than -s/--start") - self.time = True - # # Private Methods # @@ -825,27 +502,11 @@ def _initialize_logging( # Initialize logger with the set CLI args / user configuration. init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile) - def add_detector(self, detector): - """Add Detector: Adds a detection algorithm to the CliContext's SceneManager.""" - if self.load_scenes_input: - raise click.ClickException("The load-scenes command cannot be used with detectors.") - self._ensure_input_open() - self.scene_manager.add_detector(detector) - self.added_detector = True - - def _ensure_input_open(self) -> None: - """Ensure self.video_stream was initialized (i.e. -i/--input was specified), - otherwise raises an exception. Should only be used from commands that require an - input video to process the options (e.g. those that require a timecode). - - Raises: - click.BadParameter: self.video_stream was not initialized. - """ - if self.video_stream is None: - raise click.ClickException("No input video (-i/--input) was specified.") - def _open_video_stream( - self, input_path: ty.AnyStr, framerate: ty.Optional[float], backend: ty.Optional[str] + self, + input_path: ty.AnyStr, + framerate: ty.Optional[float], + backend: ty.Optional[str], ): if "%" in input_path and backend != "opencv": raise click.BadParameter( @@ -855,14 +516,13 @@ def _open_video_stream( if framerate is not None and framerate < MAX_FPS_DELTA: raise click.BadParameter("Invalid framerate specified!", param_hint="-f/--framerate") try: - if backend is None: - backend = self.config.get_value("global", "backend") - else: - if backend not in AVAILABLE_BACKENDS: - raise click.BadParameter( - "Specified backend %s is not available on this system!" % backend, - param_hint="-b/--backend", - ) + backend = self.config.get_value("global", "backend", backend) + if backend not in AVAILABLE_BACKENDS: + raise click.BadParameter( + "Specified backend %s is not available on this system!" % backend, + param_hint="-b/--backend", + ) + # Open the video with the specified backend, loading any required config settings. if backend == "pyav": self.video_stream = open_video( @@ -905,22 +565,3 @@ def _open_video_stream( raise click.BadParameter( "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" ) from None - - def _on_duplicate_command(self, command: str) -> None: - """Called when a command is duplicated to stop parsing and raise an error. - - Arguments: - command: Command that was duplicated for error context. - - Raises: - click.BadParameter - """ - error_strs = [] - error_strs.append("Error: Command %s specified multiple times." % command) - error_strs.append("The %s command may appear only one time.") - - logger.error("\n".join(error_strs)) - raise click.BadParameter( - "\n Command %s may only be specified once." % command, - param_hint="%s command" % command, - ) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index eae039d4..313997fd 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -16,26 +16,15 @@ import os import time import typing as ty -from string import Template -from scenedetect._cli.context import CliContext, check_split_video_requirements +from scenedetect._cli.context import CliContext from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path -from scenedetect.scene_manager import ( - get_scenes_from_cuts, - save_images, - write_scene_list, - write_scene_list_html, -) -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge +from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts from scenedetect.video_stream import SeekError logger = logging.getLogger("pyscenedetect") -SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] - -CutList = ty.List[FrameTimecode] - def run_scenedetect(context: CliContext): """Perform main CLI application control logic. Run once all command-line options and @@ -55,46 +44,49 @@ def run_scenedetect(context: CliContext): logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) if context.stats_file_path: logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.") - scene_list, cut_list = _load_scenes(context) - scene_list = _postprocess_scene_list(context, scene_list) - logger.info("Loaded %d scenes.", len(scene_list)) + scenes, cuts = _load_scenes(context) + scenes = _postprocess_scene_list(context, scenes) + logger.info("Loaded %d scenes.", len(scenes)) else: # Perform scene detection on input. - scene_list, cut_list = _detect(context) - scene_list = _postprocess_scene_list(context, scene_list) + scenes, cuts = _detect(context) + scenes = _postprocess_scene_list(context, scenes) # Handle -s/--stats option. _save_stats(context) - if scene_list: + if scenes: logger.info( "Detected %d scenes, average shot length %.1f seconds.", - len(scene_list), - sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) - / float(len(scene_list)), + len(scenes), + sum([(end_time - start_time).get_seconds() for start_time, end_time in scenes]) + / float(len(scenes)), ) else: logger.info("No scenes detected.") - # Handle list-scenes command. - _list_scenes(context, scene_list, cut_list) + # Handle post-processing commands the user wants to run (see scenedetect._cli.commands). + for handler, kwargs in context.commands: + handler(context=context, scenes=scenes, cuts=cuts, **kwargs) - # Handle save-images command. - image_filenames = _save_images(context, scene_list) - # Handle export-html command. - _export_html(context, scene_list, cut_list, image_filenames) +def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: + # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, + # it will be merged with the previous one. + if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: + if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: + new_last_scene = (scene_list[-2][0], scene_list[-1][1]) + scene_list = scene_list[:-2] + [new_last_scene] - # Handle split-video command. - _split_video(context, scene_list) + # Handle --drop-short-scenes. + if context.drop_short_scenes and context.min_scene_len > 0: + scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] + return scene_list -def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: - # Use default detector if one was not specified. - if context.scene_manager.get_num_detectors() == 0: - detector_type, detector_args = context.default_detector - logger.debug("Using default detector: %s(%s)" % (detector_type.__name__, detector_args)) - context.scene_manager.add_detector(detector_type(**detector_args)) +def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: perf_start_time = time.time() + + context.ensure_detector() if context.start_time is not None: logger.debug("Seeking to start time...") try: @@ -159,158 +151,6 @@ def _save_stats(context: CliContext) -> None: logger.debug("No frame metrics updated, skipping update of the stats file.") -def _list_scenes(context: CliContext, scene_list: SceneList, cut_list: CutList) -> None: - """Handles the `list-scenes` command.""" - if not context.list_scenes: - return - # Write scene list CSV to if required. - if context.scene_list_output: - scene_list_filename = Template(context.scene_list_name_format).safe_substitute( - VIDEO_NAME=context.video_stream.name - ) - if not scene_list_filename.lower().endswith(".csv"): - scene_list_filename += ".csv" - scene_list_path = get_and_create_path( - scene_list_filename, - context.scene_list_dir if context.scene_list_dir is not None else context.output_dir, - ) - logger.info("Writing scene list to CSV file:\n %s", scene_list_path) - with open(scene_list_path, "w") as scene_list_file: - write_scene_list( - output_csv_file=scene_list_file, - scene_list=scene_list, - include_cut_list=not context.skip_cuts, - cut_list=cut_list, - ) - # Suppress output if requested. - if context.list_scenes_quiet: - return - # Print scene list. - if context.display_scenes: - logger.info( - """Scene List: ------------------------------------------------------------------------ - | Scene # | Start Frame | Start Time | End Frame | End Time | ------------------------------------------------------------------------ -%s ------------------------------------------------------------------------""", - "\n".join( - [ - " | %5d | %11d | %s | %11d | %s |" - % ( - i + 1, - start_time.get_frames() + 1, - start_time.get_timecode(), - end_time.get_frames(), - end_time.get_timecode(), - ) - for i, (start_time, end_time) in enumerate(scene_list) - ] - ), - ) - # Print cut list. - if cut_list and context.display_cuts: - logger.info( - "Comma-separated timecode list:\n %s", - ",".join([context.cut_format.format(cut) for cut in cut_list]), - ) - - -def _save_images( - context: CliContext, scene_list: SceneList -) -> ty.Optional[ty.Dict[int, ty.List[str]]]: - """Handles the `save-images` command.""" - if not context.save_images: - return None - # Command can override global output directory setting. - output_dir = context.output_dir if context.image_dir is None else context.image_dir - return save_images( - scene_list=scene_list, - video=context.video_stream, - num_images=context.num_images, - frame_margin=context.frame_margin, - image_extension=context.image_extension, - encoder_param=context.image_param, - image_name_template=context.image_name_format, - output_dir=output_dir, - show_progress=not context.quiet_mode, - scale=context.scale, - height=context.height, - width=context.width, - interpolation=context.scale_method, - ) - - -def _export_html( - context: CliContext, - scene_list: SceneList, - cut_list: CutList, - image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]], -) -> None: - """Handles the `export-html` command.""" - if not context.export_html: - return - # Command can override global output directory setting. - output_dir = context.output_dir if context.image_dir is None else context.image_dir - html_filename = Template(context.html_name_format).safe_substitute( - VIDEO_NAME=context.video_stream.name - ) - if not html_filename.lower().endswith(".html"): - html_filename += ".html" - html_path = get_and_create_path(html_filename, output_dir) - logger.info("Exporting to html file:\n %s:", html_path) - if not context.html_include_images: - image_filenames = None - write_scene_list_html( - html_path, - scene_list, - cut_list, - image_filenames=image_filenames, - image_width=context.image_width, - image_height=context.image_height, - ) - - -def _split_video(context: CliContext, scene_list: SceneList) -> None: - """Handles the `split-video` command.""" - if not context.split_video: - return - output_path_template = context.split_name_format - # Add proper extension to filename template if required. - dot_pos = output_path_template.rfind(".") - extension_length = 0 if dot_pos < 0 else len(output_path_template) - (dot_pos + 1) - # If using mkvmerge, force extension to .mkv. - if context.split_mkvmerge and not output_path_template.endswith(".mkv"): - output_path_template += ".mkv" - # Otherwise, if using ffmpeg, only add an extension if one doesn't exist. - elif not 2 <= extension_length <= 4: - output_path_template += ".mp4" - # Ensure the appropriate tool is available before handling split-video. - check_split_video_requirements(context.split_mkvmerge) - # Command can override global output directory setting. - output_dir = context.output_dir if context.split_dir is None else context.split_dir - if context.split_mkvmerge: - split_video_mkvmerge( - input_video_path=context.video_stream.path, - scene_list=scene_list, - output_dir=output_dir, - output_file_template=output_path_template, - show_output=not (context.quiet_mode or context.split_quiet), - ) - else: - split_video_ffmpeg( - input_video_path=context.video_stream.path, - scene_list=scene_list, - output_dir=output_dir, - output_file_template=output_path_template, - arg_override=context.split_args, - show_progress=not context.quiet_mode, - show_output=not (context.quiet_mode or context.split_quiet), - ) - if scene_list: - logger.info("Video splitting completed, scenes written to disk.") - - def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) @@ -353,18 +193,3 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: return get_scenes_from_cuts( cut_list=cut_list, start_pos=start_time, end_pos=end_time ), cut_list - - -def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: - # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, - # it will be merged with the previous one. - if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: - if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: - new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] - - # Handle --drop-short-scenes. - if context.drop_short_scenes and context.min_scene_len > 0: - scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] - - return scene_list diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index c3508b0b..945f85bd 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -106,6 +106,12 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): logger = logging.getLogger("pyscenedetect") +SceneList = List[Tuple[FrameTimecode, FrameTimecode]] +"""Type hint for a list of scenes in the form (start time, end time).""" + +CutList = List[FrameTimecode] +"""Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" + # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current # value doesn't seem to have caused any issues at least. @@ -158,11 +164,11 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI def get_scenes_from_cuts( - cut_list: Iterable[FrameTimecode], + cut_list: CutList, start_pos: Union[int, FrameTimecode], end_pos: Union[int, FrameTimecode], base_timecode: Optional[FrameTimecode] = None, -) -> List[Tuple[FrameTimecode, FrameTimecode]]: +) -> SceneList: """Returns a list of tuples of start/end FrameTimecodes for each scene based on a list of detected scene cuts/breaks. @@ -207,9 +213,9 @@ def get_scenes_from_cuts( def write_scene_list( output_csv_file: TextIO, - scene_list: Iterable[Tuple[FrameTimecode, FrameTimecode]], + scene_list: SceneList, include_cut_list: bool = True, - cut_list: Optional[Iterable[FrameTimecode]] = None, + cut_list: Optional[CutList] = None, ) -> None: """Writes the given list of scenes to an output file handle in CSV format. @@ -263,14 +269,14 @@ def write_scene_list( def write_scene_list_html( - output_html_filename, - scene_list, - cut_list=None, - css=None, - css_class="mytable", - image_filenames=None, - image_width=None, - image_height=None, + output_html_filename: str, + scene_list: SceneList, + cut_list: Optional[CutList] = None, + css: str = None, + css_class: str = "mytable", + image_filenames: Optional[Dict[int, List[str]]] = None, + image_width: Optional[int] = None, + image_height: Optional[int] = None, ): """Writes the given list of scenes to an output file handle in html format. @@ -287,6 +293,7 @@ def write_scene_list_html( image_width: Optional desired width of images in table in pixels image_height: Optional desired height of images in table in pixels """ + logger.info("Exporting scenes to html:\n %s:", output_html_filename) if not css: css = """ table.mytable { @@ -386,11 +393,9 @@ def write_scene_list_html( # -# TODO(v1.0): Refactor to take a SceneList object; consider moving this and save scene list -# to a better spot, or just move them to scene_list.py. -# +# TODO(v1.0): Consider moving all post-processing functionality into a separate submodule. def save_images( - scene_list: List[Tuple[FrameTimecode, FrameTimecode]], + scene_list: SceneList, video: VideoStream, num_images: int = 3, frame_margin: int = 1, @@ -474,7 +479,7 @@ def save_images( # Setup flags and init progress bar if available. completed = True - logger.info("Generating output images (%d per scene)...", num_images) + logger.info(f"Saving {num_images} images per scene to {output_dir}, format {image_extension}") progress_bar = None if show_progress: progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) @@ -537,6 +542,7 @@ def save_images( video.seek(image_timecode) frame_im = video.read() if frame_im is not None: + # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. file_path = "%s.%s" % ( filename_template.safe_substitute( @@ -740,7 +746,7 @@ def clear_detectors(self) -> None: def get_scene_list( self, base_timecode: Optional[FrameTimecode] = None, start_in_scene: bool = False - ) -> List[Tuple[FrameTimecode, FrameTimecode]]: + ) -> SceneList: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: @@ -779,7 +785,7 @@ def _get_cutting_list(self) -> List[int]: # Ensure all cuts are unique by using a set to remove all duplicates. return [self._base_timecode + cut for cut in sorted(set(self._cutting_list))] - def _get_event_list(self) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def _get_event_list(self) -> SceneList: if not self._event_list: return [] assert self._base_timecode is not None @@ -1065,8 +1071,10 @@ def _decode_thread( # def get_cut_list( - self, base_timecode: Optional[FrameTimecode] = None, show_warning: bool = True - ) -> List[FrameTimecode]: + self, + base_timecode: Optional[FrameTimecode] = None, + show_warning: bool = True, + ) -> CutList: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. Unlike get_scene_list, the cutting list returns a list of FrameTimecodes representing @@ -1094,9 +1102,7 @@ def get_cut_list( logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") return self._get_cutting_list() - def get_event_list( - self, base_timecode: Optional[FrameTimecode] = None - ) -> List[Tuple[FrameTimecode, FrameTimecode]]: + def get_event_list(self, base_timecode: Optional[FrameTimecode] = None) -> SceneList: """[DEPRECATED] DO NOT USE. Get a list of start/end timecodes of sparse detection events. diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 2ee95527..bbca1f1c 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -193,9 +193,9 @@ def split_video_mkvmerge( if not scene_list: return 0 - logger.info( - "Splitting input video using mkvmerge, output path template:\n %s", output_file_template - ) + logger.info("Splitting video with mkvmerge, output path template:\n %s", output_file_template) + if output_dir: + logger.info("Output folder:\n %s", output_file_template) if video_name is None: video_name = Path(input_video_path).stem @@ -301,9 +301,9 @@ def split_video_ffmpeg( if not scene_list: return 0 - logger.info( - "Splitting input video using ffmpeg, output path template:\n %s", output_file_template - ) + logger.info("Splitting video with ffmpeg, output path template:\n %s", output_file_template) + if output_dir: + logger.info("Output folder:\n %s", output_file_template) if video_name is None: video_name = Path(input_video_path).stem From 08eb26e38ce19e3fd17e926f8056a6bcf1128f48 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 6 Oct 2024 00:07:50 -0400 Subject: [PATCH 137/407] [detectors] Fix not compensating for filter buffer length (#420) With the new flash filter, cuts may be placed behind the current frame. This is similar to AdaptiveDetector, but when the new filter was landed, the max look behind property of the detector wasn't updated. Fixes #416. --- scenedetect/detectors/content_detector.py | 4 ++++ scenedetect/scene_detector.py | 7 ++++++- website/pages/changelog.md | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index bfa99ac4..75c06ae9 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -234,3 +234,7 @@ def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: # camera movement. Note that very large kernel sizes can negatively affect accuracy. edges = cv2.Canny(lum, low, high) return cv2.dilate(edges, self._kernel) + + @property + def event_buffer_length(self) -> int: + return self._flash_filter.max_behind diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 6ce50993..4352d1c5 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -172,7 +172,12 @@ def __init__(self, mode: Mode, length: int): self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filte. + self._merge_start = None # Frame number where we started the merge filter. + + @property + def max_behind(self) -> int: + """Maximum number of frames a filtered cut can be behind the current frame.""" + return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if not self._filter_length > 0: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e4348b7e..31a472ef 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -589,4 +589,5 @@ Development ## PySceneDetect 0.6.5 (TBD) - [bugfix] Fix new detectors not working with `default-detector` config option - - [bugfix] Fix SyntaxWarning due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) + - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) + - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) From db952d41e8c99722f9905e12d9fedabd4fe439ff Mon Sep 17 00:00:00 2001 From: Walter Schwenger Date: Sun, 6 Oct 2024 00:38:46 -0400 Subject: [PATCH 138/407] Parsing timecodes of form `MM:SS[.nnn]` (#433) * Updated timecode parsing * Update docs for time command --- docs/cli.rst | 4 ++-- scenedetect/frame_timecode.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 0f426bdb..54d4da26 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -692,9 +692,9 @@ Options Set start/end/duration of input video. -Values can be specified as frames (NNNN), seconds (NNNN.NNs), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: +Values can be specified as frames (NNNN), seconds (NNNN.NNs), or timecode (HH:MM:SS.nnn or MM:SS.nnn). For example, to process only the first minute of a video: - ``scenedetect -i video.mp4 time --end 00:01:00`` + ``scenedetect -i video.mp4 time --end 1:00`` ``scenedetect -i video.mp4 time --duration 60s`` diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index ffb836b4..958c1cd2 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -283,11 +283,18 @@ def _parse_timecode_string(self, input: str) -> int: if timecode < 0: raise ValueError("Timecode frame number must be positive.") return timecode - # Timecode in string format 'HH:MM:SS[.nnn]' + # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") - hrs, mins = int(values[0]), int(values[1]) - secs = float(values[2]) if "." in values[2] else int(values[2]) + # Case of 'HH:MM:SS[.nnn]' + if len(values) == 3: + hrs, mins = int(values[0]), int(values[1]) + secs = float(values[2]) if "." in values[2] else int(values[2]) + # Case of 'MM:SS[.nnn]' + elif len(values) == 2: + hrs = 0 + mins = int(values[0]) + secs = float(values[1]) if "." in values[1] else int(values[1]) 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 * 60) + (mins * 60) From 02bf0afe215d1ebe0db4df0ab6a739b52f6b159e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 18 Oct 2024 21:26:42 -0400 Subject: [PATCH 139/407] [build] Enable Python 3.13. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69cdcfbd..9a05953e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. - os: macos-14 From 7c798728701f3fda29d924b2d1ee70fc0dfe0354 Mon Sep 17 00:00:00 2001 From: Jan Chang <55261974+Janscode@users.noreply.github.com> Date: Sat, 19 Oct 2024 13:49:27 -0700 Subject: [PATCH 140/407] Standardize min scene length message across detectors (#438) --- scenedetect/detectors/adaptive_detector.py | 3 ++- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/hash_detector.py | 3 ++- scenedetect/detectors/histogram_detector.py | 3 ++- scenedetect/detectors/threshold_detector.py | 4 ++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 0cbb4895..5e638a63 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -50,7 +50,8 @@ def __init__( Arguments: adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a new scene (see frame metric adaptive_ratio in stats file). - min_scene_len: Minimum length of any scene. + min_scene_len: Once a cut is detected, this many frames must pass before a new one can + be added to the scene list. Can be an int or FrameTimecode type. window_width: Size of window (number of frames) before and after each frame to average together in order to detect deviations from the mean. Must be at least 1. min_content_val: Minimum threshold (float) that the content_val must exceed in order to diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 75c06ae9..4b8b2e19 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -113,7 +113,7 @@ def __init__( Arguments: threshold: Threshold the average change in pixel intensity must exceed to trigger a cut. min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. + be added to the scene list. Can be an int or FrameTimecode type. weights: Weight to place on each component when calculating frame score (`content_val` in a statsfile, the value `threshold` is compared against). luma_only: If True, only considers changes in the luminance channel of the video. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 36f7e1b5..2ca37afa 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -57,7 +57,8 @@ class HashDetector(SceneDetector): size: Size of square of low frequency data to use for the DCT lowpass: How much high frequency information to filter from the DCT. A value of 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... - min_scene_len: Minimum length of any given scene, in frames (int) or FrameTimecode + min_scene_len: Once a cut is detected, this many frames must pass before a new one can + be added to the scene list. Can be an int or FrameTimecode type. """ def __init__( diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 9e37df09..15f63834 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -38,7 +38,8 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int YUV, and normalized based on the number of bins. Higher dicfferences imply greater change in content, so larger threshold values are less sensitive to cuts. bins: Number of bins to use for the histogram. - min_scene_len: Minimum length of any scene. + min_scene_len: Once a cut is detected, this many frames must pass before a new one can + be added to the scene list. Can be an int or FrameTimecode type. """ super().__init__() # Internally, threshold represents the correlation between two histograms and has values diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index f14d1882..b93987b2 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -82,8 +82,8 @@ def __init__( Arguments: threshold: 8-bit intensity value that each pixel value (R, G, and B) must be <= to in order to trigger a fade in/out. - min_scene_len: FrameTimecode object or integer greater than 0 of the - minimum length, in frames, of a scene (or subsequent scene cut). + min_scene_len: Once a cut is detected, this many frames must pass before a new one can + be added to the scene list. Can be an int or FrameTimecode type. fade_bias: Float between -1.0 and +1.0 representing the percentage of timecode skew for the start of a scene (-1.0 causing a cut at the fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be From f9f8e1d04d5d649ea39884367f270138ac1e7c39 Mon Sep 17 00:00:00 2001 From: Jan Chang <55261974+Janscode@users.noreply.github.com> Date: Sat, 19 Oct 2024 13:49:54 -0700 Subject: [PATCH 141/407] Fixed "Moduke" typo (#437) --- scenedetect/platform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 65aa7f80..089dd886 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -11,7 +11,7 @@ # """``scenedetect.platform`` Module -This moduke contains all platform/library specific compatibility fixes, as well as some utility +This module contains all platform/library specific compatibility fixes, as well as some utility functions to handle logging and invoking external commands. """ From c9186226ecc003ae5598b7fce706c82bbe1c6a73 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 19 Oct 2024 19:20:45 -0400 Subject: [PATCH 142/407] [docs] Fix changelog.md. --- website/pages/changelog.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 31a472ef..f82110e1 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -30,11 +30,6 @@ Feedback on the new detection methods and their default values is most welcome. - [bugfix] Fix crash when decoded frames have incorrect resolution and log error instead [#319](https://github.com/Breakthrough/PySceneDetect/issues/319) - [bugfix] Update default ffmpeg stream mapping from `-map 0` to `-map 0:v:0 -map 0:a? -map 0:s?` [#392](https://github.com/Breakthrough/PySceneDetect/issues/392) -#### 0.6.4.1 (TBD) - - - [bugfix] Fix `default-detector` config option not working with new detectors - - [bugfix] Fix SyntaxWarning due to incorrect string escaping in command-line (#400) - ### 0.6.3 (March 9, 2024) @@ -590,4 +585,6 @@ Development - [bugfix] Fix new detectors not working with `default-detector` config option - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) + - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) + - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) + - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module From d1768e62de32bf88f785731bae63525d80f51ef7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 19 Oct 2024 23:03:29 -0400 Subject: [PATCH 143/407] [cli] Ensure optional CLI flags are consistent --- scenedetect/_cli/__init__.py | 37 +++++++++++++++++++----------------- scenedetect/_cli/context.py | 12 ++++++------ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 208e4d1d..0a876442 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -281,8 +281,8 @@ def scenedetect( config: ty.Optional[ty.AnyStr], framerate: ty.Optional[float], min_scene_len: ty.Optional[str], - drop_short_scenes: bool, - merge_last_scene: bool, + drop_short_scenes: ty.Optional[bool], + merge_last_scene: ty.Optional[bool], backend: ty.Optional[str], downscale: ty.Optional[int], frame_skip: ty.Optional[int], @@ -1028,6 +1028,7 @@ def export_html_command( "-n", is_flag=True, flag_value=True, + default=None, help="Only print scene list.%s" % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), ) @@ -1036,6 +1037,7 @@ def export_html_command( "-q", is_flag=True, flag_value=True, + default=None, help="Suppress printing scene list.%s" % (USER_CONFIG.get_help_string("list-scenes", "quiet")), ) @click.option( @@ -1043,6 +1045,7 @@ def export_html_command( "-s", is_flag=True, flag_value=True, + default=None, help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s" % (USER_CONFIG.get_help_string("list-scenes", "skip-cuts")), ) @@ -1051,26 +1054,26 @@ def list_scenes_command( ctx: click.Context, output: ty.Optional[ty.AnyStr], filename: ty.Optional[ty.AnyStr], - no_output_file: bool, - quiet: bool, - skip_cuts: bool, + no_output_file: ty.Optional[bool], + quiet: ty.Optional[bool], + skip_cuts: ty.Optional[bool], ): """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default).""" ctx = ctx.obj assert isinstance(ctx, CliContext) - no_output_file = no_output_file or ctx.config.get_value("list-scenes", "no-output-file") - scene_list_dir = ctx.config.get_value("list-scenes", "output", output) - scene_list_name_format = ctx.config.get_value("list-scenes", "filename", filename) + create_file = not ctx.config.get_value("list-scenes", "no-output-file", no_output_file) + output_dir = ctx.config.get_value("list-scenes", "output", output) + name_format = ctx.config.get_value("list-scenes", "filename", filename) list_scenes_args = { "cut_format": TimecodeFormat[ctx.config.get_value("list-scenes", "cut-format").upper()], "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), - "scene_list_output": not no_output_file, - "scene_list_name_format": scene_list_name_format, - "skip_cuts": skip_cuts or ctx.config.get_value("list-scenes", "skip-cuts"), - "output_dir": scene_list_dir, - "quiet": quiet or ctx.config.get_value("list-scenes", "quiet") or ctx.quiet_mode, + "scene_list_output": create_file, + "scene_list_name_format": name_format, + "skip_cuts": ctx.config.get_value("list-scenes", "skip-cuts", skip_cuts), + "output_dir": output_dir, + "quiet": ctx.config.get_value("list-scenes", "quiet", quiet) or ctx.quiet_mode, } ctx.add_command(cli_commands.list_scenes, list_scenes_args) @@ -1098,6 +1101,7 @@ def list_scenes_command( "-q", is_flag=True, flag_value=True, + default=False, help="Hide output from external video splitting tool.%s" % (USER_CONFIG.get_help_string("split-video", "quiet")), ) @@ -1189,9 +1193,8 @@ def split_video_command( error = "The split-video command is incompatible with image sequences/URLs." raise click.BadParameter(error, param_hint="split-video") - # We only load the config values for these flags/options if none of the other - # encoder flags/options were set via the CLI to avoid any conflicting options - # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified). + # Overwrite flags if no encoder flags/options were set via the CLI to avoid conflicting options + # (e.g. `--copy` should override any `high-quality = yes` setting in the config file). if not (mkvmerge or copy or high_quality or args or rate_factor or preset): mkvmerge = ctx.config.get_value("split-video", "mkvmerge") copy = ctx.config.get_value("split-video", "copy") @@ -1244,7 +1247,7 @@ def split_video_command( "name_format": ctx.config.get_value("split-video", "filename", filename), "use_mkvmerge": mkvmerge, "output_dir": ctx.config.get_value("split-video", "output", output), - "show_output": not quiet, + "show_output": not ctx.config.get_value("split-video", "quiet", quiet), "ffmpeg_args": args, } ctx.add_command(cli_commands.split_video, split_video_args) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 1062cc71..7843a031 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -160,8 +160,8 @@ def handle_options( downscale: ty.Optional[int], frame_skip: int, min_scene_len: str, - drop_short_scenes: bool, - merge_last_scene: bool, + drop_short_scenes: ty.Optional[bool], + merge_last_scene: ty.Optional[bool], backend: ty.Optional[str], quiet: bool, logfile: ty.Optional[ty.AnyStr], @@ -245,11 +245,11 @@ def handle_options( if min_scene_len is not None else self.config.get_value("global", "min-scene-len"), ) - self.drop_short_scenes = drop_short_scenes or self.config.get_value( - "global", "drop-short-scenes" + self.drop_short_scenes = self.config.get_value( + "global", "drop-short-scenes", drop_short_scenes ) - self.merge_last_scene = merge_last_scene or self.config.get_value( - "global", "merge-last-scene" + self.merge_last_scene = self.config.get_value( + "global", "merge-last-scene", merge_last_scene ) self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip) From 43a02d12a5bffd55d22409ce58f81d6504923c94 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 19 Oct 2024 23:33:03 -0400 Subject: [PATCH 144/407] [cli] Fix type hints for some context fields --- scenedetect/_cli/context.py | 6 +++--- scenedetect/_cli/controller.py | 25 ++++++++++--------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 7843a031..3e3dfda1 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -90,9 +90,9 @@ def __init__(self): self.video_stream: VideoStream = None self.load_scenes_input: str = None # load-scenes -i/--input self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name - self.start_time: FrameTimecode = None # time -s/--start - self.end_time: FrameTimecode = None # time -e/--end - self.duration: FrameTimecode = None # time -d/--duration + self.start_time: ty.Optional[FrameTimecode] = None # time -s/--start + self.end_time: ty.Optional[FrameTimecode] = None # time -e/--end + self.duration: ty.Optional[FrameTimecode] = None # time -d/--duration self.frame_skip: int = None # Options: diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 313997fd..28d52b9d 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -160,19 +160,16 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: csv_headers = next(file_reader) if context.load_scenes_column_name not in csv_headers: csv_headers = next(file_reader) - # Check to make sure column headers are present + # Check to make sure column headers are present and then load the data. if context.load_scenes_column_name not in csv_headers: raise ValueError("specified column header for scene start is not present") - col_idx = csv_headers.index(context.load_scenes_column_name) - cut_list = sorted( FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 for row in file_reader ) - # `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame - # of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector` - # but this part of the API is being reworked and hasn't been used by any detectors yet. + # `SceneDetector` works on cuts, so we have to skip the first scene and place the first + # cut point where the next scenes starts. if cut_list: cut_list = cut_list[1:] @@ -182,14 +179,12 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: cut_list = [cut for cut in cut_list if cut > context.start_time] end_time = context.video_stream.duration - if context.end_time is not None or context.duration is not None: - if context.end_time is not None: - end_time = context.end_time - elif context.duration is not None: - end_time = start_time + context.duration - end_time = min(end_time, context.video_stream.duration) + if context.end_time is not None: + end_time = min(context.end_time, context.video_stream.duration) + elif context.duration is not None: + end_time = min(start_time + context.duration, context.video_stream.duration) + cut_list = [cut for cut in cut_list if cut < end_time] + scene_list = get_scenes_from_cuts(cut_list=cut_list, start_pos=start_time, end_pos=end_time) - return get_scenes_from_cuts( - cut_list=cut_list, start_pos=start_time, end_pos=end_time - ), cut_list + return (scene_list, cut_list) From 3fe6ffc2dd91875eaebe8c66a13be11978029cca Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 20 Oct 2024 16:58:59 -0400 Subject: [PATCH 145/407] [cli] Fix missing default for some flags. --- scenedetect/_cli/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 0a876442..b9e94892 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -209,6 +209,7 @@ def _print_command_help(ctx: click.Context, command: click.Command): "--drop-short-scenes", is_flag=True, flag_value=True, + default=None, help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), ) @@ -216,6 +217,7 @@ def _print_command_help(ctx: click.Context, command: click.Command): "--merge-last-scene", is_flag=True, flag_value=True, + default=None, help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" % (USER_CONFIG.get_help_string("global", "merge-last-scene")), ) From 45d2727952d4ddf6f43b64eb05dc0ee9d1570af4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 20 Oct 2024 21:55:51 -0400 Subject: [PATCH 146/407] [build] Shorten test video duration for Windows builds The value originally should have been 10 seconds. --- .github/workflows/build-windows.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index efc2449c..6ea1b0d1 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -111,7 +111,11 @@ jobs: - name: Test run: | + echo Testing binary ./build/scenedetect version - ./build/scenedetect -i tests/resources/goldeneye.mp4 -b opencv detect-content time --end 00:10:00 - ./build/scenedetect -i tests/resources/goldeneye.mp4 -b pyav detect-content time --end 00:10:00 - ./build/scenedetect -i tests/resources/goldeneye.mp4 detect-content time --end 00:10:00 split-video + echo Test OpenCV + ./build/scenedetect -i tests/resources/goldeneye.mp4 -b opencv detect-content time --end 10s + echo Test PyAV + ./build/scenedetect -i tests/resources/goldeneye.mp4 -b pyav detect-content time --end 10s + echo Test split-video + ffmpeg + ./build/scenedetect -i tests/resources/goldeneye.mp4 detect-content time --end 10s split-video From 3fb8d8a1a6f01373398cb8c49061a7fb9348a1fa Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 22 Oct 2024 21:49:08 -0400 Subject: [PATCH 147/407] [cli] Decode enum types directly in config parser --- scenedetect/_cli/__init__.py | 6 ++---- scenedetect/_cli/config.py | 29 +++++++++++++++++++++++++---- scenedetect/_cli/context.py | 9 +++------ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index b9e94892..bc8a311e 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -34,7 +34,6 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, - TimecodeFormat, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS @@ -46,7 +45,6 @@ ThresholdDetector, ) from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info -from scenedetect.scene_manager import Interpolation _PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" @@ -1068,7 +1066,7 @@ def list_scenes_command( output_dir = ctx.config.get_value("list-scenes", "output", output) name_format = ctx.config.get_value("list-scenes", "filename", filename) list_scenes_args = { - "cut_format": TimecodeFormat[ctx.config.get_value("list-scenes", "cut-format").upper()], + "cut_format": ctx.config.get_value("list-scenes", "cut-format"), "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), "scene_list_output": create_file, @@ -1407,7 +1405,7 @@ def save_images_command( scale = ctx.config.get_value("save-images", "scale") height = ctx.config.get_value("save-images", "height") width = ctx.config.get_value("save-images", "width") - scale_method = Interpolation[ctx.config.get_value("save-images", "scale-method").upper()] + scale_method = ctx.config.get_value("save-images", "scale-method") quality = ( (DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY) if ctx.config.is_default("save-images", "quality") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 3ea5babe..c854a6a2 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -268,7 +268,7 @@ def format(self, timecode: FrameTimecode) -> str: "weights": ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), }, "detect-content": { - "filter-mode": "merge", + "filter-mode": FlashFilter.Mode.MERGE, "kernel-size": KernelSizeValue(-1), "luma-only": False, "min-scene-len": TimecodeValue(0), @@ -302,7 +302,7 @@ def format(self, timecode: FrameTimecode) -> str: "no-images": False, }, "list-scenes": { - "cut-format": "timecode", + "cut-format": TimecodeFormat.TIMECODE, "display-cuts": True, "display-scenes": True, "filename": "$VIDEO_NAME-Scenes.csv", @@ -315,7 +315,7 @@ def format(self, timecode: FrameTimecode) -> str: "backend": "opencv", "default-detector": "detect-adaptive", "downscale": 0, - "downscale-method": "linear", + "downscale-method": Interpolation.LINEAR, "drop-short-scenes": False, "frame-skip": 0, "merge-last-scene": False, @@ -333,7 +333,7 @@ def format(self, timecode: FrameTimecode) -> str: "output": None, "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), "scale": 1.0, - "scale-method": "linear", + "scale-method": Interpolation.LINEAR, "width": 0, }, "split-video": { @@ -442,6 +442,27 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: value_type = "number" out_map[command][option] = config.getfloat(command, option) continue + elif isinstance(CONFIG_MAP[command][option], Enum): + config_value = ( + config.get(command, option).replace("\n", " ").strip().upper() + ) + try: + parsed = CONFIG_MAP[command][option].__class__[config_value] + out_map[command][option] = parsed + except TypeError: + errors.append( + "Invalid [%s] value for %s: %s. Must be one of: %s." + % ( + command, + option, + config.get(command, option), + ", ".join( + str(choice) for choice in CHOICE_MAP[command][option] + ), + ) + ) + continue + except ValueError as _: errors.append( "Invalid [%s] value for %s: %s is not a valid %s." diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 3e3dfda1..3e247259 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -286,9 +286,8 @@ def handle_options( except ValueError as ex: logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="downscale factor") from None - scene_manager.interpolation = Interpolation[ - self.config.get_value("global", "downscale-method").upper() - ] + scene_manager.interpolation = self.config.get_value("global", "downscale-method") + self.scene_manager = scene_manager # @@ -328,9 +327,7 @@ def get_detect_content_params( "luma_only": luma_only or self.config.get_value("detect-content", "luma-only"), "min_scene_len": min_scene_len, "threshold": self.config.get_value("detect-content", "threshold", threshold), - "filter_mode": FlashFilter.Mode[ - self.config.get_value("detect-content", "filter-mode", filter_mode).upper() - ], + "filter_mode": self.config.get_value("detect-content", "filter-mode", filter_mode), } def get_detect_adaptive_params( From 64f1baedc0b403c9201b528d74652ba8fc72b35c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 3 Nov 2024 21:05:51 -0500 Subject: [PATCH 148/407] [scene_manager] Fix incorrect frame check #455 We cannot check if the frame is boolean due to ambiguity, so we must test both cases. #454 #455 --- scenedetect/scene_manager.py | 2 +- website/pages/changelog.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 945f85bd..43bc46a9 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -541,7 +541,7 @@ def save_images( for j, image_timecode in enumerate(scene_timecodes): video.seek(image_timecode) frame_im = video.read() - if frame_im is not None: + if frame_im is not None and frame_im is not False: # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. file_path = "%s.%s" % ( diff --git a/website/pages/changelog.md b/website/pages/changelog.md index f82110e1..d29fec9a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -584,6 +584,7 @@ Development ## PySceneDetect 0.6.5 (TBD) - [bugfix] Fix new detectors not working with `default-detector` config option + - [bugfix] Fix crash when using `save-images` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) From 770d5144efdbec47fbef1819ec69c18361dd1e46 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 10 Nov 2024 20:19:59 -0500 Subject: [PATCH 149/407] [cli] Add new save-qp command (#448) * [cli] Add new `save-qp` command (#388) * [cli] Ensure `save-qp` command shifts frame numbers and add tests --- scenedetect.cfg | 12 ++++++ scenedetect/_cli/__init__.py | 81 +++++++++++++++++++++++++++++------- scenedetect/_cli/commands.py | 23 ++++++++++ scenedetect/_cli/config.py | 5 +++ tests/test_cli.py | 61 +++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 16 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index bde791de..c736138a 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -283,6 +283,18 @@ #start-col-name = Start Frame +[save-qp] + +# Filename format of QP file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.qp + +# Folder to output QP file to. Overrides [global] output option. +#output = /usr/tmp/images + +# Disable shifting frame numbers by start time (yes/no). +#disable-shift = no + + # # BACKEND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index bc8a311e..f1a03f77 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -346,7 +346,7 @@ def scenedetect( ) @click.pass_context def help_command(ctx: click.Context, command_name: str): - """Print help for command (`help [command]`).""" + """Print full help reference.""" assert isinstance(ctx.parent.command, click.MultiCommand) parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) @@ -989,6 +989,9 @@ def export_html_command( image_height: ty.Optional[int], ): """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" + # TODO: Rename this command to save-html to align with other export commands. This will require + # that we allow `export-html` as an alias on the CLI and via the config file for a few versions + # as to not break existing workflows. ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1011,7 +1014,7 @@ def export_html_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output if set.%s" + help="Output directory to save videos to. Overrides global option -o/--output.%s" % (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)), ) @click.option( @@ -1084,7 +1087,7 @@ def list_scenes_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output if set.%s" + help="Output directory to save videos to. Overrides global option -o/--output.%s" % (USER_CONFIG.get_help_string("split-video", "output", show_default=False)), ) @click.option( @@ -1259,7 +1262,7 @@ def split_video_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory for images. Overrides global option -o/--output if set.%s" + help="Output directory for images. Overrides global option -o/--output.%s" % (USER_CONFIG.get_help_string("save-images", "output", show_default=False)), ) @click.option( @@ -1445,30 +1448,76 @@ def save_images_command( ctx.save_images = True +@click.command("save-qp", cls=_Command) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-qp", "filename")), +) +@click.option( + "--output", + "-o", + metavar="DIR", + type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), + help="Output directory to save QP file to. Overrides global option -o/--output.%s" + % (USER_CONFIG.get_help_string("save-qp", "output", show_default=False)), +) +@click.option( + "--disable-shift", + "-d", + is_flag=True, + flag_value=True, + default=None, + help="Disable shifting frame numbers by start time.%s" + % (USER_CONFIG.get_help_string("save-qp", "disable-shift")), +) +@click.pass_context +def save_qp_command( + ctx: click.Context, + filename: ty.Optional[ty.AnyStr], + output: ty.Optional[ty.AnyStr], + disable_shift: ty.Optional[bool], +): + """Save cuts as keyframes (I-frames) for video encoding. + + The resulting QP file can be used with the `--qpfile` argument in x264/x265.""" + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + save_qp_args = { + "filename_format": ctx.config.get_value("save-qp", "filename", filename), + "output_dir": ctx.config.get_value("save-qp", "output", output), + "shift_start": not ctx.config.get_value("save-qp", "disable-shift", disable_shift), + } + ctx.add_command(cli_commands.save_qp, save_qp_args) + + # ---------------------------------------------------------------------- -# Commands Omitted From Help List +# CLI Sub-Command Registration # ---------------------------------------------------------------------- -# Info Commands +# Informational scenedetect.add_command(about_command) scenedetect.add_command(help_command) scenedetect.add_command(version_command) -# ---------------------------------------------------------------------- -# Commands Added To Help List -# ---------------------------------------------------------------------- - -# Input / Output -scenedetect.add_command(export_html_command) -scenedetect.add_command(list_scenes_command) +# Input scenedetect.add_command(load_scenes_command) -scenedetect.add_command(save_images_command) -scenedetect.add_command(split_video_command) scenedetect.add_command(time_command) -# Detection Algorithms +# Detectors scenedetect.add_command(detect_adaptive_command) scenedetect.add_command(detect_content_command) scenedetect.add_command(detect_hash_command) scenedetect.add_command(detect_hist_command) scenedetect.add_command(detect_threshold_command) + +# Output +scenedetect.add_command(export_html_command) +scenedetect.add_command(save_qp_command) +scenedetect.add_command(list_scenes_command) +scenedetect.add_command(save_images_command) +scenedetect.add_command(split_video_command) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 83a23bf2..2271f429 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -64,6 +64,29 @@ def export_html( ) +def save_qp( + context: CliContext, + scenes: SceneList, + cuts: CutList, + output_dir: str, + filename_format: str, + shift_start: bool, +): + """Handler for the `save-qp` command.""" + del scenes # We only use cuts for this handler. + qp_path = get_and_create_path( + Template(filename_format).safe_substitute(VIDEO_NAME=context.video_stream.name), + output_dir, + ) + start_frame = context.start_time.frame_num if context.start_time else 0 + offset = start_frame if shift_start else 0 + with open(qp_path, "wt") as qp_file: + qp_file.write(f"{0 if shift_start else start_frame} I -1\n") + # Place another I frame at each detected cut. + qp_file.writelines(f"{cut.frame_num - offset} I -1\n" for cut in cuts) + logger.info(f"QP file written to: {qp_path}") + + def list_scenes( context: CliContext, scenes: SceneList, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index c854a6a2..7e7c06f7 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -336,6 +336,11 @@ def format(self, timecode: FrameTimecode) -> str: "scale-method": Interpolation.LINEAR, "width": 0, }, + "save-qp": { + "disable-shift": False, + "filename": "$VIDEO_NAME.qp", + "output": None, + }, "split-video": { "args": DEFAULT_FFMPEG_ARGS, "copy": False, diff --git a/tests/test_cli.py b/tests/test_cli.py index fcadb9bd..2bcd2435 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -430,6 +430,67 @@ def test_cli_export_html(tmp_path: Path): # TODO: Check for existence of HTML & image files. +def test_cli_save_qp(tmp_path: Path): + """Test `save-qp` command with and without a custom filename format.""" + EXPECTED_QP_CONTENTS = """ +0 I -1 +90 I -1 +""" + for filename in (None, "custom.txt"): + filename_format = f"--filename {filename}" if filename else "" + assert ( + invoke_scenedetect( + f"-i {{VIDEO}} time -e 95 {{DETECTOR}} save-qp {filename_format}", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(filename if filename else f"{DEFAULT_VIDEO_NAME}.qp") + assert os.path.exists(output_path) + assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:] + + +def test_cli_save_qp_start_offset(tmp_path: Path): + """Test `save-qp` command but using a shifted start time.""" + # The QP file should always start from frame 0, so we expect a similar result to the above, but + # with the frame numbers shifted by the start frame. Note that on the command-line, the first + # frame is frame 1, but the first frame in a QP file is indexed by 0. + # + # Since we are starting at frame 51, we must shift all cuts by 50 frames. + EXPECTED_QP_CONTENTS = """ +0 I -1 +40 I -1 +""" + assert ( + invoke_scenedetect( + "-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.qp") + assert os.path.exists(output_path) + assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:] + + +def test_cli_save_qp_no_shift(tmp_path: Path): + """Test `save-qp` command with start time shifting disabled.""" + EXPECTED_QP_CONTENTS = """ +50 I -1 +90 I -1 +""" + assert ( + invoke_scenedetect( + "-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp --disable-shift", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.qp") + assert os.path.exists(output_path) + assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:] + + @pytest.mark.parametrize("backend_type", ALL_BACKENDS) def test_cli_backend(backend_type: str): """Test setting the `-b`/`--backend` argument.""" From 5bf97d77261db70770675cf56858754a89f26c0c Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 10 Nov 2024 20:20:49 -0500 Subject: [PATCH 150/407] [dist] Update Windows Python/deps + Include MoviePy (#446) * [dist] Bump Windows build dependency versions * [dist] Use Python 3.13 for Windows builds and include MoviePy. * [cli] Add more info to version command * [dist] Remove duplicate copy of ffmpeg in distribution. --- .github/workflows/build-windows.yml | 11 ++++++++--- appveyor.yml | 2 +- dist/pre_release.py | 20 ++++++++++++++++++-- dist/requirements_windows.txt | 9 ++++++--- docs/requirements.txt | 4 +--- scenedetect/platform.py | 8 ++++++-- setup.cfg | 2 ++ website/pages/changelog.md | 4 ++++ 8 files changed, 46 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 6ea1b0d1..a6975105 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -27,10 +27,11 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.9"] + python-version: ["3.13"] env: ffmpeg-version: "7.0" + IMAGEIO_FFMPEG_EXE: "" steps: - uses: actions/checkout@v4 @@ -44,8 +45,8 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools - pip install -r dist/requirements_windows.txt pip install -r docs/requirements.txt + pip install --upgrade -r dist/requirements_windows.txt --no-binary imageio-ffmpeg - name: Download Resources run: | @@ -60,13 +61,15 @@ jobs: file: 'ffmpeg-${{ env.ffmpeg-version }}-full_build.7z' - name: Unit Test + shell: bash run: | 7z e ffmpeg-${{ env.ffmpeg-version }}-full_build.7z ffmpeg.exe -r + echo "IMAGEIO_FFMPEG_EXE=`realpath ffmpeg.exe`" >> "$GITHUB_ENV" python -m pytest -vv - name: Build PySceneDetect run: | - python dist/pre_release.py --ignore-installer + python dist/pre_release.py pyinstaller dist/scenedetect.spec - name: Build Documentation @@ -117,5 +120,7 @@ jobs: ./build/scenedetect -i tests/resources/goldeneye.mp4 -b opencv detect-content time --end 10s echo Test PyAV ./build/scenedetect -i tests/resources/goldeneye.mp4 -b pyav detect-content time --end 10s + echo Test moviepy + ./build/scenedetect -i tests/resources/goldeneye.mp4 -b moviepy detect-content time --end 10s echo Test split-video + ffmpeg ./build/scenedetect -i tests/resources/goldeneye.mp4 detect-content time --end 10s split-video diff --git a/appveyor.yml b/appveyor.yml index 72c6a84f..99e83841 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -46,7 +46,7 @@ install: - echo * * BUILDING WINDOWS EXE * * - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # Build Windows .EXE and create portable .ZIP - - python dist/pre_release.py + - python dist/pre_release.py --release - pyinstaller dist/scenedetect.spec - sphinx-build -b singlehtml docs dist/scenedetect/docs - mkdir dist\scenedetect\thirdparty diff --git a/dist/pre_release.py b/dist/pre_release.py index 11d00154..eab54bb4 100644 --- a/dist/pre_release.py +++ b/dist/pre_release.py @@ -1,4 +1,20 @@ -# -*- coding: utf-8 -*- +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# + +# Pre-release script to run before invoking `pyinstaller`: +# +# python dist/pre_release.py +# pyinstaller dist/scenedetect.spec +# import os import sys sys.path.append(os.path.abspath(".")) @@ -8,7 +24,7 @@ VERSION = scenedetect.__version__ -run_version_check = ("--ignore-installer" not in sys.argv) +run_version_check = ("--release" in sys.argv) if run_version_check: installer_aip = '' diff --git a/dist/requirements_windows.txt b/dist/requirements_windows.txt index a4b1f675..a5debd55 100644 --- a/dist/requirements_windows.txt +++ b/dist/requirements_windows.txt @@ -1,9 +1,12 @@ # PySceneDetect Requirements for Windows Build -av==10.0 +av==13.1.0 click>=8.0 +opencv-python-headless==4.10.0.84 + +imageio-ffmpeg +moviepy numpy -opencv-python-headless==4.10.0.82 platformdirs pyinstaller pytest -tqdm \ No newline at end of file +tqdm diff --git a/docs/requirements.txt b/docs/requirements.txt index a367e367..051c203c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,2 @@ +# These are requirements only for the docs. Sphinx == 7.0.1 -opencv-python -numpy -av diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 089dd886..244b9cbe 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -296,18 +296,20 @@ def get_system_version_info() -> str: Used for the `scenedetect version -a` command. """ - output_template = "{:<12} {}" + output_template = "{:<16} {}" line_separator = "-" * 60 not_found_str = "Not Installed" out_lines = [] # System (Python, OS) + output_template = "{:<16} {}" out_lines += ["System Info", line_separator] out_lines += [ output_template.format(name, version) for name, version in ( ("OS", "%s" % platform.platform()), - ("Python", "%d.%d.%d" % sys.version_info[0:3]), + ("Python", "%s %s" % (platform.python_implementation(), platform.python_version())), + ("Architecture", " + ".join(platform.architecture())), ) ] @@ -317,6 +319,8 @@ def get_system_version_info() -> str: "av", "click", "cv2", + "imageio", + "imageio_ffmpeg", "moviepy", "numpy", "platformdirs", diff --git a/setup.cfg b/setup.cfg index 75451308..e35c5861 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,8 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Topic :: Multimedia :: Video Topic :: Multimedia :: Video :: Conversion Topic :: Multimedia :: Video :: Non-Linear Editor diff --git a/website/pages/changelog.md b/website/pages/changelog.md index d29fec9a..da38b521 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -589,3 +589,7 @@ Development - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module + - [general] Updates to Windows distributions: + - The MoviePy backend is now included with Windows distributions + - Bundled Python interpreter is now Python 3.13 + - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 From 2bf1172fa10971788f1185d55dc51e4984cec836 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 10 Nov 2024 20:29:26 -0500 Subject: [PATCH 151/407] [export-html] Add new `--show` flag, remove dependency on `save-images` (#447) * [export-html] Add --show option to display result in browser (#442) * [export-html] Invoke `save-images` automatically --- scenedetect.cfg | 3 ++ scenedetect/_cli/__init__.py | 54 +++++++++++++++++++++++------------- scenedetect/_cli/commands.py | 8 +++++- scenedetect/_cli/config.py | 1 + website/pages/changelog.md | 12 +++++--- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index c736138a..83c1d925 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -240,6 +240,9 @@ # Filename format of created HTML file. Can use $VIDEO_NAME in the name. #filename = $VIDEO_NAME-Scenes.html +# Automatically open resulting HTML when processing is complete. +#show = no + # Override element width/height. #image-height = 0 #image-width = 0 diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index f1a03f77..174c4599 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -959,9 +959,10 @@ def load_scenes_command( ) @click.option( "--no-images", + "-n", is_flag=True, flag_value=True, - help="Export the scene list including or excluding the saved images.%s" + help="Do not include images with the result.%s" % (USER_CONFIG.get_help_string("export-html", "no-images")), ) @click.option( @@ -980,6 +981,15 @@ def load_scenes_command( help="Height in pixels of the images in the resulting HTML table.%s" % (USER_CONFIG.get_help_string("export-html", "image-height", show_default=False)), ) +@click.option( + "--show", + "-s", + is_flag=True, + flag_value=True, + default=None, + help="Automatically open resulting HTML when processing is complete.%s" + % (USER_CONFIG.get_help_string("export-html", "show")), +) @click.pass_context def export_html_command( ctx: click.Context, @@ -987,23 +997,27 @@ def export_html_command( no_images: bool, image_width: ty.Optional[int], image_height: ty.Optional[int], + show: bool, ): - """Export scene list to HTML file. Requires save-images unless --no-images is specified.""" + """Export scene list to HTML file. + + To customize image generation, specify the `save-images` command before `export-html`. This command always uses the result of the preceeding `save-images` command, or runs it with the default config values unless `--no-images` is set. + """ # TODO: Rename this command to save-html to align with other export commands. This will require # that we allow `export-html` as an alias on the CLI and via the config file for a few versions # as to not break existing workflows. ctx = ctx.obj assert isinstance(ctx, CliContext) - - no_images = no_images or ctx.config.get_value("export-html", "no-images") - if not ctx.save_images and not no_images: - raise click.BadArgumentUsage( - "export-html requires that save-images precedes it or --no-images is specified." - ) + include_images = not ctx.config.get_value("export-html", "no-images", no_images) + # Make sure a save-images command is in the pipeline for us to use the results from. + if include_images and not ctx.save_images: + save_images_command.callback() export_html_args = { "html_name_format": ctx.config.get_value("export-html", "filename", filename), "image_width": ctx.config.get_value("export-html", "image-width", image_width), "image_height": ctx.config.get_value("export-html", "image-height", image_height), + "include_images": include_images, + "show": ctx.config.get_value("export-html", "show", show), } ctx.add_command(cli_commands.export_html, export_html_args) @@ -1362,18 +1376,18 @@ def split_video_command( @click.pass_context def save_images_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - num_images: ty.Optional[int], - jpeg: bool, - webp: bool, - quality: ty.Optional[int], - png: bool, - compression: ty.Optional[int], - frame_margin: ty.Optional[int], - scale: ty.Optional[float], - height: ty.Optional[int], - width: ty.Optional[int], + output: ty.Optional[ty.AnyStr] = None, + filename: ty.Optional[ty.AnyStr] = None, + num_images: ty.Optional[int] = None, + jpeg: bool = False, + webp: bool = False, + quality: ty.Optional[int] = None, + png: bool = False, + compression: ty.Optional[int] = None, + frame_margin: ty.Optional[int] = None, + scale: ty.Optional[float] = None, + height: ty.Optional[int] = None, + width: ty.Optional[int] = None, ): """Create images for each detected scene. diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 2271f429..17b6b5c9 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -17,6 +17,7 @@ import logging import typing as ty +import webbrowser from string import Template from scenedetect._cli.context import CliContext @@ -43,6 +44,8 @@ def export_html( image_width: int, image_height: int, html_name_format: str, + include_images: bool, + show: bool, ): """Handles the `export-html` command.""" (image_filenames, output_dir) = ( @@ -50,6 +53,7 @@ def export_html( if context.save_images_result is not None else (None, context.output_dir) ) + html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) if not html_filename.lower().endswith(".html"): html_filename += ".html" @@ -58,10 +62,12 @@ def export_html( output_html_filename=html_path, scene_list=scenes, cut_list=cuts, - image_filenames=image_filenames, + image_filenames=image_filenames if include_images else None, image_width=image_width, image_height=image_height, ) + if show: + webbrowser.open(html_path) def save_qp( diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 7e7c06f7..2236ac43 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -300,6 +300,7 @@ def format(self, timecode: FrameTimecode) -> str: "image-height": 0, "image-width": 0, "no-images": False, + "show": False, }, "list-scenes": { "cut-format": TimecodeFormat.TIMECODE, diff --git a/website/pages/changelog.md b/website/pages/changelog.md index da38b521..3fc83772 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -584,12 +584,16 @@ Development ## PySceneDetect 0.6.5 (TBD) - [bugfix] Fix new detectors not working with `default-detector` config option - - [bugfix] Fix crash when using `save-images` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) - - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) + - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module + - [feature] Add new `--show` flag to `export-html` command to launch browser after processing (#442) + - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters + - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it - [general] Updates to Windows distributions: - The MoviePy backend is now included with Windows distributions - Bundled Python interpreter is now Python 3.13 - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 + - [improvement] `save_to_csv` now works with paths from `pathlib` + - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) + - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) + From 1bcc7eeec2e207409888ba9897b3c4eaff4924a0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 11 Nov 2024 20:57:48 -0500 Subject: [PATCH 152/407] [build] Add missing depencencies for docs builder --- .github/workflows/generate-docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 2f41c6d7..5744d530 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -47,6 +47,7 @@ jobs: run: | python -m pip install --upgrade pip build wheel virtualenv pip install -r docs/requirements.txt + pip install -r dist/requirements_windows.txt git config --global user.name github-actions git config --global user.email github-actions@github.com From 9d0af3cf494040c2142617a02435764d8beff6d3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 11 Nov 2024 22:28:12 -0500 Subject: [PATCH 153/407] [docs] Update + run docs/generate_cli_docs.py --- docs/cli.rst | 127 +++++++++--- scenedetect/_cli/__init__.py | 388 +++++++++++++++++++---------------- 2 files changed, 311 insertions(+), 204 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 54d4da26..64a11446 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -143,7 +143,7 @@ Detectors ``detect-adaptive`` ======================================================================== -Perform adaptive detection algorithm on input video. +Find fast cuts using diffs in HSL colorspace (rolling average). Two-pass algorithm that first calculates frame scores with :ref:`detect-content `, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. @@ -214,19 +214,19 @@ Options ``detect-content`` ======================================================================== -Perform content detection algorithm on input video. +Find fast cuts using differences in HSL (filtered). For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds :option:`-t/--threshold <-t>`. Frame scores are saved under the "content_val" column in a statsfile. Scores are calculated from several components which are also recorded in the statsfile: - - *delta_hue*: Difference between pixel hue values of adjacent frames. + - *delta_hue*: Difference between pixel hue values of adjacent frames. - - *delta_sat*: Difference between pixel saturation values of adjacent frames. + - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. Once calculated, these components are multiplied by the specified :option:`-w/--weights <-w>` to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. @@ -246,7 +246,7 @@ Options .. option:: -t VAL, --threshold VAL - Threshold (float) that frame score must exceed to trigger a cut. Refers to "content_val" in stats file. + The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file. Default: ``27.0`` @@ -258,7 +258,7 @@ Options .. option:: -l, --luma-only - Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting "-w 0 0 1 0". + Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w="0 0 1 0". .. option:: -k N, --kernel-size N @@ -268,7 +268,13 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. + +.. option:: -f MODE, --filter-mode MODE + + Mode used to enforce :option:`-m/--min-scene-len <-m>` option. Can be one of: merge, suppress. + + Default: ``Mode.MERGE`` .. _command-detect-hash: @@ -283,12 +289,13 @@ Find fast cuts using perceptual hashing. The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. -Saved as the `hash_dist` metric in a statsfile. +Saved as the ``hash_dist`` metric in a statsfile. Examples ------------------------------------------------------------------------ + ``scenedetect -i video.mp4 detect-hash`` ``scenedetect -i video.mp4 detect-hash --size 32 --lowpass 3`` @@ -297,6 +304,7 @@ Examples Options ------------------------------------------------------------------------ + .. option:: -t VAL, --threshold VAL Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are more sensitive to changes. @@ -317,7 +325,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global min-scene-len (-m) setting. TIMECODE can be specified as exact number of frames, a time in seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. .. _command-detect-hist: @@ -332,12 +340,13 @@ Find fast cuts by differencing YUV histograms. Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. -Saved as the `hist_diff` metric in a statsfile. +Saved as the ``hist_diff`` metric in a statsfile. Examples ------------------------------------------------------------------------ + ``scenedetect -i video.mp4 detect-hist`` ``scenedetect -i video.mp4 detect-hist --threshold 0.1 --bins 240`` @@ -346,6 +355,7 @@ Examples Options ------------------------------------------------------------------------ + .. option:: -t VAL, --threshold VAL Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower values are more sensitive to changes. @@ -354,13 +364,13 @@ Options .. option:: -b NUM, --bins NUM - The number of bins to use for the histogram calculation + The number of bins to use for the histogram calculation. - Default: ``16`` + Default: ``256`` .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global min-scene-len (-m) setting. TIMECODE can be specified as exact number of frames, a time in seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. .. _command-detect-threshold: @@ -371,7 +381,7 @@ Options ``detect-threshold`` ======================================================================== -Perform threshold detection algorithm on input video. +Find fade in/out using averaging. Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. @@ -425,7 +435,9 @@ Commands ``export-html`` ======================================================================== -Export scene list to HTML file. Requires save-images unless --no-images is specified. +Export scene list to HTML file. + +To customize image generation, specify the :ref:`save-images ` command before :ref:`export-html `. This command always uses the result of the preceeding :ref:`save-images ` command, or runs it with the default config values unless ``--no-images`` is set. Options @@ -438,9 +450,9 @@ Options Default: ``$VIDEO_NAME-Scenes.html`` -.. option:: --no-images +.. option:: -n, --no-images - Export the scene list including or excluding the saved images. + Do not include images with the result. .. option:: -w pixels, --image-width pixels @@ -450,6 +462,10 @@ Options Height in pixels of the images in the resulting HTML table. +.. option:: -s, --show + + Automatically open resulting HTML when processing is complete. + .. _command-list-scenes: @@ -462,13 +478,26 @@ Options Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). +Examples +------------------------------------------------------------------------ + + +Default: + + ``scenedetect -i video.mp4 list-scenes`` + +Without cut list (RFC 4180 compliant CSV): + + ``scenedetect -i video.mp4 list-scenes --skip-cuts`` + + Options ------------------------------------------------------------------------ .. option:: -o DIR, --output DIR - Output directory to save videos to. Overrides global option :option:`-o/--output ` if set. + Output directory to save videos to. Overrides global option :option:`-o/--output `. .. option:: -f NAME, --filename NAME @@ -532,16 +561,14 @@ Options ``save-images`` ======================================================================== -Create images for each detected scene. - -Images can be resized +Extract images from each detected scene. Examples ------------------------------------------------------------------------ - ``scenedetect -i video.mp4 save-images`` + ``scenedetect -i video.mp4 save-images --num-images 5`` ``scenedetect -i video.mp4 save-images --width 1024`` @@ -554,7 +581,7 @@ Options .. option:: -o DIR, --output DIR - Output directory for images. Overrides global option :option:`-o/--output ` if set. + Output directory for images. Overrides global option :option:`-o/--output `. .. option:: -f NAME, --filename NAME @@ -611,6 +638,38 @@ Options Width (pixels) of images. +.. _command-save-qp: + +.. program:: scenedetect save-qp + + +``save-qp`` +======================================================================== + +Save cuts as keyframes (I-frames) for video encoding. + +The resulting QP file can be used with the ``--qpfile`` argument in x264/x265. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.qp`` + +.. option:: -o DIR, --output DIR + + Output directory to save QP file to. Overrides global option :option:`-o/--output `. + +.. option:: -d, --disable-shift + + Disable shifting frame numbers by start time. + + .. _command-split-video: .. program:: scenedetect split-video @@ -626,10 +685,16 @@ Examples ------------------------------------------------------------------------ +Default: + ``scenedetect -i video.mp4 split-video`` +Codec-copy mode (not frame accurate): + ``scenedetect -i video.mp4 split-video --copy`` +Customized filenames: + ``scenedetect -i video.mp4 split-video --filename \$VIDEO_NAME-Clip-\$SCENE_NUMBER`` @@ -639,7 +704,7 @@ Options .. option:: -o DIR, --output DIR - Output directory to save videos to. Overrides global option :option:`-o/--output ` if set. + Output directory to save videos to. Overrides global option :option:`-o/--output `. .. option:: -f NAME, --filename NAME @@ -653,7 +718,7 @@ Options .. option:: -c, --copy - Copy instead of re-encode. Faster but less precise. Equivalent to: :option:`--args="-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy" <--args>` + Copy instead of re-encode. Faster but less precise. .. option:: -hq, --high-quality @@ -692,11 +757,11 @@ Options Set start/end/duration of input video. -Values can be specified as frames (NNNN), seconds (NNNN.NNs), or timecode (HH:MM:SS.nnn or MM:SS.nnn). For example, to process only the first minute of a video: +Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - ``scenedetect -i video.mp4 time --end 1:00`` + ``scenedetect -i video.mp4 time --end 00:01:00`` - ``scenedetect -i video.mp4 time --duration 60s`` + ``scenedetect -i video.mp4 time --duration 60.0`` Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: @@ -709,7 +774,7 @@ Options .. option:: -s TIMECODE, --start TIMECODE - Time in video to start detection. TIMECODE can be specified as number of frames (:option:`--start=100 <--start>` for frame 100), time in seconds (:option:`--start=100.0 <--start>` for 100 seconds), or timecode (:option:`--start=00:01:40 <--start>` for 1m40s). + Time in video to start detection. TIMECODE can be specified as seconds (:option:`--start=100.0 <--start>`), frames (:option:`--start=100 <--start>`), or timecode (:option:`--start=00:01:40.000 <--start>`). .. option:: -d TIMECODE, --duration TIMECODE diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 174c4599..c26b6263 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -46,15 +46,15 @@ ) from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info -_PROGRAM_VERSION = scenedetect.__version__ +PROGRAM_VERSION = scenedetect.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" logger = logging.getLogger("pyscenedetect") -_LINE_SEPARATOR = "-" * 72 +LINE_SEPARATOR = "-" * 72 # About & copyright message string shown for the 'about' CLI command (scenedetect about). -_ABOUT_STRING = """ +ABOUT_STRING = """ Site: http://scenedetect.com/ Docs: https://www.scenedetect.com/docs/ Code: https://github.com/Breakthrough/PySceneDetect/ @@ -88,7 +88,7 @@ """ -class _Command(click.Command): +class Command(click.Command): """Custom formatting for commands.""" def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: @@ -96,14 +96,14 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non if ctx.parent: formatter.write(click.style("`%s` Command" % ctx.command.name, fg="cyan")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg="cyan")) + formatter.write(click.style(LINE_SEPARATOR, fg="cyan")) formatter.write_paragraph() else: - formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) + formatter.write(click.style(LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() formatter.write(click.style("PySceneDetect Help", fg="yellow")) formatter.write_paragraph() - formatter.write(click.style(_LINE_SEPARATOR, fg="yellow")) + formatter.write(click.style(LINE_SEPARATOR, fg="yellow")) formatter.write_paragraph() self.format_usage(ctx, formatter) @@ -130,13 +130,13 @@ def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> N formatter.write_text(epilog) -class _CommandGroup(_Command, click.Group): +class CommandGroup(Command, click.Group): """Custom formatting for command groups.""" pass -def _print_command_help(ctx: click.Context, command: click.Command): +def print_command_help(ctx: click.Context, command: click.Command): """Print help/usage for a given command. Modifies `ctx` in-place.""" ctx.info_name = command.name ctx.command = command @@ -144,12 +144,40 @@ def _print_command_help(ctx: click.Context, command: click.Command): click.echo(command.get_help(ctx)) +SCENEDETECT_COMMAND_HELP = """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: + + {scenedetect_with_video} [detector] [commands] + +For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. + +Examples: + +Split video wherever a new scene is detected: + + {scenedetect_with_video} split-video + +Save scene list in CSV format with images at the start, middle, and end of each scene: + + {scenedetect_with_video} list-scenes save-images + +Skip the first 10 seconds of the input video: + + {scenedetect_with_video} time --start 10s detect-content + +Show summary of all options and commands: + + {scenedetect} --help + +Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once.""" + + @click.group( - cls=_CommandGroup, + cls=CommandGroup, chain=True, context_settings=dict(help_option_names=["-h", "--help"]), invoke_without_command=True, epilog="""Type "scenedetect [command] --help" for command usage. See https://scenedetect.com/docs/ for online docs.""", + help=SCENEDETECT_COMMAND_HELP, ) # *NOTE*: Although input is required, we cannot mark it as `required=True`, otherwise we will reject # commands of the form `scenedetect detect-content --help`. @@ -290,32 +318,6 @@ def scenedetect( logfile: ty.Optional[ty.AnyStr], quiet: bool, ): - """PySceneDetect is a scene cut/transition detection program. PySceneDetect takes an input video, runs detection on it, and uses the resulting scene information to generate output. The syntax for using PySceneDetect is: - - {scenedetect_with_video} [detector] [commands] - - For [detector] use `detect-adaptive` or `detect-content` to find fast cuts, and `detect-threshold` for fades in/out. If [detector] is not specified, a default detector will be used. - - Examples: - - Split video wherever a new scene is detected: - - {scenedetect_with_video} split-video - - Save scene list in CSV format with images at the start, middle, and end of each scene: - - {scenedetect_with_video} list-scenes save-images - - Skip the first 10 seconds of the input video: - - {scenedetect_with_video} time --start 10s detect-content - - Show summary of all options and commands: - - {scenedetect} --help - - Global options (e.g. -i/--input, -c/--config) must be specified before any commands and their options. The order of commands is not strict, but each command must only be specified once. - """ ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -338,7 +340,7 @@ def scenedetect( ) -@click.command("help", cls=_Command) +@click.command("help", cls=Command) @click.argument( "command_name", required=False, @@ -358,27 +360,27 @@ def help_command(ctx: click.Context, command_name: str): ] raise click.BadParameter("\n".join(error_strs), param_hint="command") click.echo("") - _print_command_help(ctx, parent_command.get_command(ctx, command_name)) + print_command_help(ctx, parent_command.get_command(ctx, command_name)) else: click.echo(ctx.parent.get_help()) for command in sorted(all_commands): - _print_command_help(ctx, parent_command.get_command(ctx, command)) + print_command_help(ctx, parent_command.get_command(ctx, command)) ctx.exit() -@click.command("about", cls=_Command, add_help_option=False) +@click.command("about", cls=Command, add_help_option=False) @click.pass_context def about_command(ctx: click.Context): """Print license/copyright info.""" click.echo("") - click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) - click.echo(click.style(" About PySceneDetect %s" % _PROGRAM_VERSION, fg="yellow")) - click.echo(click.style(_LINE_SEPARATOR, fg="cyan")) - click.echo(_ABOUT_STRING) + click.echo(click.style(LINE_SEPARATOR, fg="cyan")) + click.echo(click.style(" About PySceneDetect %s" % PROGRAM_VERSION, fg="yellow")) + click.echo(click.style(LINE_SEPARATOR, fg="cyan")) + click.echo(ABOUT_STRING) ctx.exit() -@click.command("version", cls=_Command, add_help_option=False) +@click.command("version", cls=Command, add_help_option=False) @click.pass_context def version_command(ctx: click.Context): """Print PySceneDetect version.""" @@ -387,7 +389,21 @@ def version_command(ctx: click.Context): ctx.exit() -@click.command("time", cls=_Command) +TIME_COMMAND_HELP = """Set start/end/duration of input video. + +Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: + + {scenedetect_with_video} time --end 00:01:00 + + {scenedetect_with_video} time --duration 60.0 + +Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: + + {scenedetect_with_video} time --start 0 --end 1000 +""" + + +@click.command("time", cls=Command, help=TIME_COMMAND_HELP) @click.option( "--start", "-s", @@ -419,18 +435,6 @@ def time_command( duration: ty.Optional[str], end: ty.Optional[str], ): - """Set start/end/duration of input video. - - Values can be specified as seconds (SSSS.nn), frames (NNNN), or timecode (HH:MM:SS.nnn). For example, to process only the first minute of a video: - - {scenedetect_with_video} time --end 00:01:00 - - {scenedetect_with_video} time --duration 60.0 - - Note that --end and --duration are mutually exclusive (i.e. only one of the two can be set). Lastly, the following is an example using absolute frame numbers to process frames 0 through 1000: - - {scenedetect_with_video} time --start 0 --end 1000 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -450,7 +454,31 @@ def time_command( raise click.BadParameter("-e/--end time must be greater than -s/--start") -@click.command("detect-content", cls=_Command) +DETECT_CONTENT_HELP = """Find fast cuts using differences in HSL (filtered). + +For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. + +Scores are calculated from several components which are also recorded in the statsfile: + + - *delta_hue*: Difference between pixel hue values of adjacent frames. + + - *delta_sat*: Difference between pixel saturation values of adjacent frames. + + - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. + + - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. + +Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. + +Examples: + + {scenedetect_with_video} detect-content + + {scenedetect_with_video} detect-content --threshold 27.5 +""" + + +@click.command("detect-content", cls=Command, help=DETECT_CONTENT_HELP) @click.option( "--threshold", "-t", @@ -524,28 +552,6 @@ def detect_content_command( min_scene_len: ty.Optional[str], filter_mode: ty.Optional[str], ): - """Find fast cuts using differences in HSL (filtered). - - For each frame, a score from 0 to 255.0 is calculated which represents the difference in content between the current and previous frame (higher = more different). A cut is generated when a frame score exceeds -t/--threshold. Frame scores are saved under the "content_val" column in a statsfile. - - Scores are calculated from several components which are also recorded in the statsfile: - - - *delta_hue*: Difference between pixel hue values of adjacent frames. - - - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. - - Once calculated, these components are multiplied by the specified -w/--weights to calculate the final frame score ("content_val"). Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, "--weights 1.0 0.5 1.0 0.2 --threshold 32" is a good starting point for trying edge detection. The final sum is normalized by the weight of all components, so they need not equal 100%. Edge detection is disabled by default to improve performance. - - Examples: - - {scenedetect_with_video} detect-content - - {scenedetect_with_video} detect-content --threshold 27.5 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_content_params( @@ -559,7 +565,19 @@ def detect_content_command( ctx.add_detector(ContentDetector, detector_args) -@click.command("detect-adaptive", cls=_Command) +DETECT_ADAPTIVE_HELP = """Find fast cuts using diffs in HSL colorspace (rolling average). + +Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. + +Examples: + + {scenedetect_with_video} detect-adaptive + + {scenedetect_with_video} detect-adaptive --threshold 3.2 +""" + + +@click.command("detect-adaptive", cls=Command, help=DETECT_ADAPTIVE_HELP) @click.option( "--threshold", "-t", @@ -647,16 +665,6 @@ def detect_adaptive_command( kernel_size: ty.Optional[int], min_scene_len: ty.Optional[str], ): - """Find fast cuts using diffs in HSL colorspace (rolling average). - - Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. - - Examples: - - {scenedetect_with_video} detect-adaptive - - {scenedetect_with_video} detect-adaptive --threshold 3.2 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_adaptive_params( @@ -672,7 +680,19 @@ def detect_adaptive_command( ctx.add_detector(AdaptiveDetector, detector_args) -@click.command("detect-threshold", cls=_Command) +DETECT_THRESHOLD_HELP = """Find fade in/out using averaging. + +Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. + +Examples: + + {scenedetect_with_video} detect-threshold + + {scenedetect_with_video} detect-threshold --threshold 15 +""" + + +@click.command("detect-threshold", cls=Command, help=DETECT_THRESHOLD_HELP) @click.option( "--threshold", "-t", @@ -726,16 +746,6 @@ def detect_threshold_command( add_last_scene: bool, min_scene_len: ty.Optional[str], ): - """Find fade in/out using averaging. - - Detects fade-in and fade-out events using average pixel values. Resulting cuts are placed between adjacent fade-out and fade-in events. - - Examples: - - {scenedetect_with_video} detect-threshold - - {scenedetect_with_video} detect-threshold --threshold 15 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_threshold_params( @@ -747,7 +757,21 @@ def detect_threshold_command( ctx.add_detector(ThresholdDetector, detector_args) -@click.command("detect-hist", cls=_Command) +DETECT_HIST_HELP = """Find fast cuts by differencing YUV histograms. + +Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. + +Saved as the `hist_diff` metric in a statsfile. + +Examples: + + {scenedetect_with_video} detect-hist + + {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 +""" + + +@click.command("detect-hist", cls=Command, help=DETECT_HIST_HELP) @click.option( "--threshold", "-t", @@ -794,18 +818,6 @@ def detect_hist_command( bins: ty.Optional[int], min_scene_len: ty.Optional[str], ): - """Find fast cuts by differencing YUV histograms. - - Uses Y channel after converting each frame to YUV to create a histogram of each frame. Histograms between frames are compared to determine a score for how similar they are. - - Saved as the `hist_diff` metric in a statsfile. - - Examples: - - {scenedetect_with_video} detect-hist - - {scenedetect_with_video} detect-hist --threshold 0.1 --bins 240 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_hist_params( @@ -814,7 +826,21 @@ def detect_hist_command( ctx.add_detector(HistogramDetector, detector_args) -@click.command("detect-hash", cls=_Command) +DETECT_HASH_HELP = """Find fast cuts using perceptual hashing. + +The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. + +Saved as the `hash_dist` metric in a statsfile. + +Examples: + + {scenedetect_with_video} detect-hash + + {scenedetect_with_video} detect-hash --size 32 --lowpass 3 +""" + + +@click.command("detect-hash", cls=Command, help=DETECT_HASH_HELP) @click.option( "--threshold", "-t", @@ -877,18 +903,6 @@ def detect_hash_command( lowpass: ty.Optional[int], min_scene_len: ty.Optional[str], ): - """Find fast cuts using perceptual hashing. - - The perceptual hash is taken of adjacent frames, and used to calculate the hamming distance between them. The distance is then normalized by the squared size of the hash, and compared to the threshold. - - Saved as the `hash_dist` metric in a statsfile. - - Examples: - - {scenedetect_with_video} detect-hash - - {scenedetect_with_video} detect-hash --size 32 --lowpass 3 - """ ctx = ctx.obj assert isinstance(ctx, CliContext) detector_args = ctx.get_detect_hash_params( @@ -897,7 +911,17 @@ def detect_hash_command( ctx.add_detector(HashDetector, detector_args) -@click.command("load-scenes", cls=_Command) +LOAD_SCENES_HELP = """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). + +Examples: + + {scenedetect_with_video} load-scenes -i scenes.csv + + {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" +""" + + +@click.command("load-scenes", cls=Command, help=LOAD_SCENES_HELP) @click.option( "--input", "-i", @@ -920,14 +944,6 @@ def detect_hash_command( def load_scenes_command( ctx: click.Context, input: ty.Optional[str], start_col_name: ty.Optional[str] ): - """Load scenes from CSV instead of detecting. Can be used with CSV generated by `list-scenes`. Scenes are loaded using the specified column as cut locations (frame number or timecode). - - Examples: - - {scenedetect_with_video} load-scenes -i scenes.csv - - {scenedetect_with_video} load-scenes -i scenes.csv --start-col-name "Start Timecode" - """ ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -947,7 +963,13 @@ def load_scenes_command( ) -@click.command("export-html", cls=_Command) +EXPORT_HTML_HELP = """Export scene list to HTML file. + +To customize image generation, specify the `save-images` command before `export-html`. This command always uses the result of the preceeding `save-images` command, or runs it with the default config values unless `--no-images` is set. +""" + + +@click.command("export-html", cls=Command, help=EXPORT_HTML_HELP) @click.option( "--filename", "-f", @@ -999,10 +1021,6 @@ def export_html_command( image_height: ty.Optional[int], show: bool, ): - """Export scene list to HTML file. - - To customize image generation, specify the `save-images` command before `export-html`. This command always uses the result of the preceeding `save-images` command, or runs it with the default config values unless `--no-images` is set. - """ # TODO: Rename this command to save-html to align with other export commands. This will require # that we allow `export-html` as an alias on the CLI and via the config file for a few versions # as to not break existing workflows. @@ -1022,7 +1040,21 @@ def export_html_command( ctx.add_command(cli_commands.export_html, export_html_args) -@click.command("list-scenes", cls=_Command) +LIST_SCENES_HELP = """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). + +Examples: + +Default: + + {scenedetect_with_video} list-scenes + +Without cut list (RFC 4180 compliant CSV): + + {scenedetect_with_video} list-scenes --skip-cuts +""" + + +@click.command("list-scenes", cls=Command, help=LIST_SCENES_HELP) @click.option( "--output", "-o", @@ -1075,7 +1107,6 @@ def list_scenes_command( quiet: ty.Optional[bool], skip_cuts: ty.Optional[bool], ): - """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default).""" ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1095,7 +1126,25 @@ def list_scenes_command( ctx.add_command(cli_commands.list_scenes, list_scenes_args) -@click.command("split-video", cls=_Command) +SPLIT_VIDEO_HELP = """Split input video using ffmpeg or mkvmerge. + +Examples: + +Default: + + {scenedetect_with_video} split-video + +Codec-copy mode (not frame accurate): + + {scenedetect_with_video} split-video --copy + +Customized filenames: + + {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER +""" + + +@click.command("split-video", cls=Command, help=SPLIT_VIDEO_HELP) @click.option( "--output", "-o", @@ -1192,16 +1241,6 @@ def split_video_command( args: ty.Optional[str], mkvmerge: bool, ): - """Split input video using ffmpeg or mkvmerge. - - Examples: - - {scenedetect_with_video} split-video - - {scenedetect_with_video} split-video --copy - - {scenedetect_with_video} split-video --filename \\$VIDEO_NAME-Clip-\\$SCENE_NUMBER - """ ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1270,7 +1309,19 @@ def split_video_command( ctx.add_command(cli_commands.split_video, split_video_args) -@click.command("save-images", cls=_Command) +SAVE_IMAGES_HELP = """Extract images from each detected scene. + +Examples: + + {scenedetect_with_video} save-images --num-images 5 + + {scenedetect_with_video} save-images --width 1024 + + {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER +""" + + +@click.command("save-images", cls=Command, help=SAVE_IMAGES_HELP) @click.option( "--output", "-o", @@ -1389,18 +1440,6 @@ def save_images_command( height: ty.Optional[int] = None, width: ty.Optional[int] = None, ): - """Create images for each detected scene. - - Images can be resized - - Examples: - - {scenedetect_with_video} save-images - - {scenedetect_with_video} save-images --width 1024 - - {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER - """ ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1462,7 +1501,13 @@ def save_images_command( ctx.save_images = True -@click.command("save-qp", cls=_Command) +SAVE_QP_HELP = """Save cuts as keyframes (I-frames) for video encoding. + +The resulting QP file can be used with the `--qpfile` argument in x264/x265. +""" + + +@click.command("save-qp", cls=Command, help=SAVE_QP_HELP) @click.option( "--filename", "-f", @@ -1495,9 +1540,6 @@ def save_qp_command( output: ty.Optional[ty.AnyStr], disable_shift: ty.Optional[bool], ): - """Save cuts as keyframes (I-frames) for video encoding. - - The resulting QP file can be used with the `--qpfile` argument in x264/x265.""" ctx = ctx.obj assert isinstance(ctx, CliContext) From 2c55a550ce89d9319acb337b95e17e4991ec6861 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 12 Nov 2024 21:48:44 -0500 Subject: [PATCH 154/407] [build] Add build check to ensure CLI docs are up to date #457 --- .github/workflows/check-docs.yml | 51 +++++++++++++++++++++++++++++ .github/workflows/generate-docs.yml | 9 +++++ docs/cli.rst | 1 + docs/generate_cli_docs.py | 1 + 4 files changed, 62 insertions(+) create mode 100644 .github/workflows/check-docs.yml diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml new file mode 100644 index 00000000..41a20bfb --- /dev/null +++ b/.github/workflows/check-docs.yml @@ -0,0 +1,51 @@ +# Checks that the CLI docs are up-to-date. If this fails on your PR, there may be some changes +# to the command-line docs that were not updated. Run `python docs/generate_cli_docs.py` from +# the root PySceneDetect source folder and commit the changes to resolve the issue. +name: Check Documentation + +on: + schedule: + - cron: '0 0 * * *' + pull_request: + paths: + - docs/** + - scenedetect/** + push: + paths: + - docs/** + - scenedetect/** + branches: + - main + - 'releases/**' + tags: + - v*-release + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip build wheel virtualenv + pip install -r docs/requirements.txt + pip install -r dist/requirements_windows.txt + + + - name: Check CLI Documentation + shell: bash + run: | + if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then + echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" + echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + exit 1 + fi diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 5744d530..1dbfb384 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -51,6 +51,15 @@ jobs: git config --global user.name github-actions git config --global user.email github-actions@github.com + - name: Check CLI Documentation + shell: bash + run: | + if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then + echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" + echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + exit 1 + fi + - name: Generate Docs run: | sphinx-build -b html docs build diff --git a/docs/cli.rst b/docs/cli.rst index 64a11446..6e757b6b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1,3 +1,4 @@ +.. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified. ************************************************************************ ``scenedetect`` 🎬 Command diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index f2c85c5d..77cb31e7 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -267,6 +267,7 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: def main(): help, commands = create_help() help = patch_help(help, commands) + help = ".. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified.\n" + help with open("docs/cli.rst", "wb") as f: f.write(help.encode()) From e1d86d9c413b5f429b3f0009e7f525c3908d4f1d Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 19 Nov 2024 21:28:10 -0500 Subject: [PATCH 155/407] [scene_manager] Allow setting delimiters for CSV output (#429) * [cli] Add ability to customize delimiters for CSV output (#423) * [cli] Add tests for CSV delimiters and enforce constraints for delimiter lengths --- scenedetect.cfg | 8 ++++ scenedetect/_cli/__init__.py | 2 + scenedetect/_cli/commands.py | 4 ++ scenedetect/_cli/config.py | 62 ++++++++++++++++++++--------- scenedetect/_cli/context.py | 2 +- scenedetect/scene_manager.py | 11 +++++- tests/test_cli.py | 76 +++++++++++++++++++++++++++++++++--- website/pages/changelog.md | 4 +- 8 files changed, 142 insertions(+), 27 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 83c1d925..33a83d43 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -269,6 +269,14 @@ # Display list of cut points generated from scene boundaries (yes/no). #display-cuts = yes +# Separator to use between columns in output file. Must be single (escaped) +# ASCII character. +#col-separator = , + +# Separator to use between rows in output file. Must be (escaped) ASCII +# characters. +#row-separator = \n + # Format to use for list of cut points (frames, seconds, timecode). #cut-format = timecode diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index c26b6263..0f02bd6c 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1114,6 +1114,7 @@ def list_scenes_command( output_dir = ctx.config.get_value("list-scenes", "output", output) name_format = ctx.config.get_value("list-scenes", "filename", filename) list_scenes_args = { + "col_separator": ctx.config.get_value("list-scenes", "col-separator"), "cut_format": ctx.config.get_value("list-scenes", "cut-format"), "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), @@ -1122,6 +1123,7 @@ def list_scenes_command( "skip_cuts": ctx.config.get_value("list-scenes", "skip-cuts", skip_cuts), "output_dir": output_dir, "quiet": ctx.config.get_value("list-scenes", "quiet", quiet) or ctx.quiet_mode, + "row_separator": ctx.config.get_value("list-scenes", "row-separator"), } ctx.add_command(cli_commands.list_scenes, list_scenes_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 17b6b5c9..857507d3 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -105,6 +105,8 @@ def list_scenes( display_scenes: bool, display_cuts: bool, cut_format: str, + col_separator: str, + row_separator: str, ): """Handles the `list-scenes` command.""" # Write scene list CSV to if required. @@ -125,6 +127,8 @@ def list_scenes( scene_list=scenes, include_cut_list=not skip_cuts, cut_list=cuts, + col_separator=col_separator, + row_separator=row_separator, ) # Suppress output if requested. if quiet: diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 2236ac43..135fbe89 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -60,6 +60,12 @@ def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue """ raise NotImplementedError() + def __repr__(self) -> str: + return str(self.value) + + def __str__(self) -> str: + return str(self.value) + class TimecodeValue(ValidatedValue): """Validator for timecode values in seconds (100.0), frames (100), or HH:MM:SS. @@ -75,12 +81,6 @@ def __init__(self, value: Union[int, float, str]): def value(self) -> Union[int, float, str]: return self._value - def __repr__(self) -> str: - return str(self.value) - - def __str__(self) -> str: - return str(self.value) - @staticmethod def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": try: @@ -121,12 +121,6 @@ def max_val(self) -> Union[int, float]: """Maximum value of the range.""" return self._max_val - def __repr__(self) -> str: - return str(self.value) - - def __str__(self) -> str: - return str(self.value) - @staticmethod def from_config(config_value: str, default: "RangeValue") -> "RangeValue": try: @@ -163,9 +157,6 @@ def __init__(self, value: Union[str, ContentDetector.Components]): def value(self) -> Tuple[float, float, float, float]: return self._value - def __repr__(self) -> str: - return str(self.value) - def __str__(self) -> str: return "%.3f, %.3f, %.3f, %.3f" % self.value @@ -199,9 +190,6 @@ def __init__(self, value: int): def value(self) -> int: return self._value - def __repr__(self) -> str: - return str(self.value) - def __str__(self) -> str: if self.value is None: return "auto" @@ -217,6 +205,42 @@ def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeVal ) from ex +class EscapedString(ValidatedValue): + """Strings that can contain escape sequences, e.g. the literal \n.""" + + def __init__(self, value: str, length_limit: int = 0): + self._value = value.encode("utf-8").decode("unicode_escape") + if length_limit and len(self._value) > length_limit: + raise OptionParseFailure(f"Value must be no longer than {length_limit} characters.") + + @property + def value(self) -> str: + """Get the value after validation.""" + return self._value + + @staticmethod + def from_config( + config_value: str, default: "EscapedString", length_limit: int = 0 + ) -> "EscapedString": + try: + return EscapedString(config_value, length_limit) + except (UnicodeDecodeError, UnicodeEncodeError) as ex: + raise OptionParseFailure( + "Value must be valid UTF-8 string with escape characters." + ) from ex + + +class EscapedChar(EscapedString): + """Strings that can contain escape sequences but can be a maximum of 1 character in length.""" + + def __init__(self, value: str): + super().__init__(value, length_limit=1) + + @staticmethod + def from_config(config_value: str, default: "EscapedString") -> "EscapedChar": + return EscapedString.from_config(config_value, default, length_limit=1) + + class TimecodeFormat(Enum): """Format to display timecodes.""" @@ -304,10 +328,12 @@ def format(self, timecode: FrameTimecode) -> str: }, "list-scenes": { "cut-format": TimecodeFormat.TIMECODE, + "col-separator": EscapedChar(","), "display-cuts": True, "display-scenes": True, "filename": "$VIDEO_NAME-Scenes.csv", "output": None, + "row-separator": EscapedString("\n"), "no-output-file": False, "quiet": False, "skip-cuts": False, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 3e247259..bd8f88d6 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -215,7 +215,7 @@ def handle_options( raise click.Abort() if self.config.config_dict: - logger.debug("Current configuration:\n%s", str(self.config.config_dict)) + logger.debug("Current configuration:\n%s", str(self.config.config_dict).encode("utf-8")) logger.debug("Parsing program options.") if stats is not None and frame_skip: diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 43bc46a9..23be9665 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -216,7 +216,9 @@ def write_scene_list( scene_list: SceneList, include_cut_list: bool = True, cut_list: Optional[CutList] = None, -) -> None: + col_separator: str = ",", + row_separator: str = "\n", +): """Writes the given list of scenes to an output file handle in CSV format. Arguments: @@ -227,8 +229,13 @@ def write_scene_list( cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames in the video that need to be split to generate individual scenes). If not specified, the cut list is generated using the start times of each scene following the first one. + col_separator: Delimiter to use between values. Must be single character. + row_separator: Line terminator to use between rows. + + Raises: + TypeError: "delimiter" must be a 1-character string """ - csv_writer = csv.writer(output_csv_file, lineterminator="\n") + csv_writer = csv.writer(output_csv_file, delimiter=col_separator, lineterminator=row_separator) # If required, output the cutting list as the first row (i.e. before the header row). if include_cut_list: csv_writer.writerow( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2bcd2435..9a0f0339 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -294,15 +294,38 @@ def test_cli_list_scenes(tmp_path: Path): ) == 0 ) - # Add statsfile + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") + assert os.path.exists(output_path) + EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754 +Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds) +1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752 +2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252 +""" + assert output_path.read_text() == EXPECTED_CSV_OUTPUT + + +def test_cli_list_scenes_skip_cuts(tmp_path: Path): + """Test `list-scenes` command with the -s/--skip-cuts option for RFC 4180 compliance.""" + # Regular invocation assert ( invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} list-scenes", + "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -s", output_dir=tmp_path, ) == 0 ) - # Suppress output file + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") + assert os.path.exists(output_path) + EXPECTED_CSV_OUTPUT = """Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds) +1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752 +2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252 +""" + assert output_path.read_text() == EXPECTED_CSV_OUTPUT + + +def test_cli_list_scenes_no_output(tmp_path: Path): + """Test `list-scenes` command with the -n flag.""" + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") assert ( invoke_scenedetect( "-i {VIDEO} time {TIME} {DETECTOR} list-scenes -n", @@ -310,8 +333,51 @@ def test_cli_list_scenes(tmp_path: Path): ) == 0 ) - # TODO: Check for output files from regular invocation. - # TODO: Delete scene list and ensure is not recreated using -n. + assert not os.path.exists(output_path) + + +def test_cli_list_scenes_custom_delimiter(tmp_path: Path): + """Test `list-scenes` command with custom delimiters set in a config file.""" + config_path = tmp_path.joinpath("config.cfg") + config_path.write_text(""" +[list-scenes] +col-separator = | +row-separator = \\t +""") + assert ( + invoke_scenedetect( + f"-i {{VIDEO}} -c {config_path} time {{TIME}} {{DETECTOR}} list-scenes", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") + assert os.path.exists(output_path) + EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754 +Scene Number,Start Frame,Start Timecode,Start Time (seconds),End Frame,End Timecode,End Time (seconds),Length (frames),Length (timecode),Length (seconds) +1,49,00:00:02.002,2.002,90,00:00:03.754,3.754,42,00:00:01.752,1.752 +2,91,00:00:03.754,3.754,144,00:00:06.006,6.006,54,00:00:02.252,2.252 +""" + EXPECTED_CSV_OUTPUT = EXPECTED_CSV_OUTPUT.replace(",", "|").replace("\n", "\t") + assert output_path.read_text() == EXPECTED_CSV_OUTPUT + + +def test_cli_list_scenes_rejects_multichar_col_separator(tmp_path: Path): + """Test `list-scenes` command with custom delimiters set in a config file.""" + config_path = tmp_path.joinpath("config.cfg") + config_path.write_text(""" +[list-scenes] +col-separator = || +""") + assert ( + invoke_scenedetect( + f"-i {{VIDEO}} -c {config_path} time {{TIME}} {{DETECTOR}} list-scenes", + output_dir=tmp_path, + ) + != 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") + assert not os.path.exists(output_path) @pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 3fc83772..865fccf6 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -596,4 +596,6 @@ Development - [improvement] `save_to_csv` now works with paths from `pathlib` - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) - + - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` + - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) From 2f8833603d7c3b38b9af1ac67a861dea45ea9891 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 22 Nov 2024 22:52:21 -0500 Subject: [PATCH 156/407] [general] Use ellipsis instead of raising exceptions where possible --- scenedetect/_cli/config.py | 4 ++-- scenedetect/video_stream.py | 30 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 135fbe89..c53dcb7c 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -48,7 +48,7 @@ class ValidatedValue(ABC): @abstractmethod def value(self) -> Any: """Get the value after validation.""" - raise NotImplementedError() + ... @staticmethod @abstractmethod @@ -58,7 +58,7 @@ def from_config(config_value: str, default: "ValidatedValue") -> "ValidatedValue Raises: OptionParseFailure: Value from config file did not meet validation constraints. """ - raise NotImplementedError() + ... def __repr__(self) -> str: return str(self.value) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 8d188daf..2537174b 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -49,6 +49,8 @@ class SeekError(Exception): The stream is guaranteed to be left in a valid state, but the position may be reset.""" + ... + class VideoOpenFailure(Exception): """Raised by a backend if opening a video fails.""" @@ -98,7 +100,7 @@ def base_timecode(self) -> FrameTimecode: def BACKEND_NAME() -> str: """Unique name used to identify this backend. Should be a static property in derived classes (`BACKEND_NAME = 'backend_identifier'`).""" - raise NotImplementedError + ... # # Abstract Properties @@ -108,43 +110,43 @@ def BACKEND_NAME() -> str: @abstractmethod def path(self) -> Union[bytes, str]: """Video or device path.""" - raise NotImplementedError + ... @property @abstractmethod def name(self) -> Union[bytes, str]: """Name of the video, without extension, or device.""" - raise NotImplementedError + ... @property @abstractmethod def is_seekable(self) -> bool: """True if seek() is allowed, False otherwise.""" - raise NotImplementedError + ... @property @abstractmethod def frame_rate(self) -> float: """Frame rate in frames/sec.""" - raise NotImplementedError + ... @property @abstractmethod def duration(self) -> Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - raise NotImplementedError + ... @property @abstractmethod def frame_size(self) -> Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - raise NotImplementedError + ... @property @abstractmethod def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" - raise NotImplementedError + ... @property @abstractmethod @@ -153,14 +155,14 @@ def position(self) -> FrameTimecode: This can be interpreted as presentation time stamp, thus frame 1 corresponds to the presentation time 0. Returns 0 even if `frame_number` is 1.""" - raise NotImplementedError + ... @property @abstractmethod def position_ms(self) -> float: """Current position within stream as a float of the presentation time in milliseconds. The first frame has a PTS of 0.""" - raise NotImplementedError + ... @property @abstractmethod @@ -168,7 +170,7 @@ def frame_number(self) -> int: """Current position within stream as the frame number. Will return 0 until the first frame is `read`.""" - raise NotImplementedError + ... # # Abstract Methods @@ -186,12 +188,12 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. If decode = False, a bool indicating if advancing to the the next frame succeeded. """ - raise NotImplementedError + ... @abstractmethod def reset(self) -> None: """Close and re-open the VideoStream (equivalent to seeking back to beginning).""" - raise NotImplementedError + ... @abstractmethod def seek(self, target: Union[FrameTimecode, float, int]) -> None: @@ -213,4 +215,4 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None: SeekError: An error occurs while seeking, or seeking is not supported. ValueError: `target` is not a valid value (i.e. it is negative). """ - raise NotImplementedError + ... From 95d20ddca57bb8cba77354697cc092643bd04afb Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sat, 23 Nov 2024 17:09:13 -0500 Subject: [PATCH 157/407] [backends] Fix behavior of MoviePy backend (#462) * [backends] Fix behavior of MoviePy backend There are still some fixes required in MoviePy itself to make corrupt video tests pass. * [backends] Fix MoviePy 2.0 EOF behavior Skip corrupt video test on MoviePy awaiting Zulko/moviepy#2253 * [build] Enable Moviepy tests for builds and document workarounds for #461 --- .github/workflows/build.yml | 6 +++++ scenedetect/_cli/controller.py | 15 +++++++++-- scenedetect/backends/moviepy.py | 47 ++++++++++++++++++++++----------- tests/test_video_stream.py | 16 +++++++++-- 4 files changed, 64 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a05953e..c21ce269 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,6 +62,12 @@ jobs: pip install av opencv-python-headless --only-binary :all: pip install -r requirements_headless.txt + - name: Install MoviePy + # TODO: We can only run MoviePy tests on systems that have ffmpeg. + if: ${{ runner.arch == 'X64' }} + run: | + pip install moviepy + - name: Checkout test resources run: | git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 28d52b9d..d9947d92 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -16,8 +16,10 @@ import os import time import typing as ty +import warnings from scenedetect._cli.context import CliContext +from scenedetect.backends import VideoStreamCv2, VideoStreamMoviePy from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts @@ -39,6 +41,12 @@ def run_scenedetect(context: CliContext): logger.debug("No input specified.") return + # Suppress warnings when reading past EOF in MoviePy (#461). + if VideoStreamMoviePy and isinstance(context.video_stream, VideoStreamMoviePy): + is_debug = context.config.get_value("global", "verbosity") != "debug" + if not is_debug: + warnings.filterwarnings("ignore", module="moviepy") + if context.load_scenes_input: # Skip detection if load-scenes was used. logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input) @@ -49,7 +57,10 @@ def run_scenedetect(context: CliContext): logger.info("Loaded %d scenes.", len(scenes)) else: # Perform scene detection on input. - scenes, cuts = _detect(context) + result = _detect(context) + if result is None: + return + scenes, cuts = result scenes = _postprocess_scene_list(context, scenes) # Handle -s/--stats option. _save_stats(context) @@ -110,7 +121,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: # Handle case where video failure is most likely due to multiple audio tracks (#179). # TODO(#380): Ensure this does not erroneusly fire. - if num_frames <= 0 and context.video_stream.BACKEND_NAME == "opencv": + if num_frames <= 0 and isinstance(context.video_stream, VideoStreamCv2): logger.critical( "Failed to read any frames from video file. This could be caused by the video" " having multiple audio tracks. If so, try installing the PyAV backend:\n" diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index e85f37c4..c3fb0935 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -174,13 +174,19 @@ def seek(self, target: Union[FrameTimecode, float, int]): SeekError: An error occurs while seeking, or seeking is not supported. ValueError: `target` is not a valid value (i.e. it is negative). """ + success = False if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: - self._reader.get_frame(target.get_seconds()) + self._last_frame = self._reader.get_frame(target.get_seconds()) + if hasattr(self._reader, "last_read") and target >= self.duration: + raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") + self._frame_number = min( + target.frame_num, + FrameTimecode(self._reader.infos["duration"], self.frame_rate).frame_num - 1, + ) + success = True except OSError as ex: - # Leave the object in a valid state. - self.reset() # TODO(#380): Other backends do not currently throw an exception if attempting to seek # past EOF. We need to ensure consistency for seeking past end of video with respect to # errors and behaviour, and should probably gracefully stop at the last frame instead @@ -188,15 +194,18 @@ def seek(self, target: Union[FrameTimecode, float, int]): if target >= self.duration: raise SeekError("Target frame is beyond end of video!") from ex raise - self._last_frame = self._reader.lastread - self._frame_number = target.frame_num + finally: + # Leave the object in a valid state on any errors. + if not success: + self.reset() - def reset(self): + def reset(self, print_infos=False): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - self._reader.initialize() - self._last_frame = self._reader.read_frame() + self._last_frame = False + self._last_frame_rgb = None self._frame_number = 0 self._eof = False + self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. @@ -210,21 +219,27 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b If decode = False, a bool indicating if advancing to the the next frame succeeded. """ if not advance: + last_frame_valid = self._last_frame is not None and self._last_frame is not False + if not last_frame_valid: + return False if self._last_frame_rgb is None: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) return self._last_frame_rgb - if not hasattr(self._reader, "lastread"): + if not hasattr(self._reader, "lastread") or self._eof: return False - self._last_frame = self._reader.lastread - self._reader.read_frame() - if self._last_frame is self._reader.lastread: - # Didn't decode a new frame, must have hit EOF. + has_last_read = hasattr(self._reader, "last_read") + # In MoviePy 2.0 there is a separate property we need to read named differently (#461). + self._last_frame = self._reader.last_read if has_last_read else self._reader.lastread + # Read the *next* frame for the following call to read, and to check for EOF. + frame = self._reader.read_frame() + if frame is self._last_frame: if self._eof: return False self._eof = True self._frame_number += 1 if decode: - if self._last_frame is not None: + last_frame_valid = self._last_frame is not None and self._last_frame is not False + if last_frame_valid: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) - return self._last_frame_rgb - return True + return self._last_frame_rgb + return not self._eof diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 1d2074b0..894d60f6 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -42,6 +42,12 @@ MOVIEPY_WARNING_FILTER = "ignore:.*Using the last valid frame instead.:UserWarning" +def get_moviepy_major_version() -> int: + import moviepy + + return int(moviepy.__version__.split(".")[0]) + + def calculate_frame_delta(frame_a, frame_b, roi=None) -> float: if roi: raise RuntimeError("TODO") @@ -354,10 +360,16 @@ def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoManager: pytest.skip(reason="VideoManager does not support handling corrupt videos.") + if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: + # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. + # See https://github.com/Zulko/moviepy/pull/2253 for a PR that attempts to more gracefully + # handle this case, however even once that is fixed, we will be unable to run this test + # on certain versions of MoviePy. + pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253") stream = vs_type(corrupt_video_file) - # OpenCV usually fails to read the video at frame 45, so we make sure all backends can - # get to 60 without reporting a failure. + # OpenCV usually fails to read the video at frame 45, but the remaining frames all seem to + # decode just fine. Make sure all backends can get to 60 without reporting a failure. for frame in range(60): assert stream.read() is not False, "Failed on frame %d!" % frame From a477f9daca38ecb24570a7b0b795f7df36256edd Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sat, 23 Nov 2024 21:17:49 -0500 Subject: [PATCH 158/407] [scene_manager] Fix save_images not working with UTF-8 paths #450 (#460) --- scenedetect/scene_manager.py | 10 ++++++++-- tests/test_cli.py | 34 +++++++++++++++++++++++++++------- website/pages/changelog.md | 13 +++++++------ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 23be9665..ccc8d0ab 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -86,6 +86,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import sys import threading from enum import Enum +from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional, TextIO, Tuple, Union import cv2 @@ -587,8 +588,13 @@ def save_images( frame_im = cv2.resize( frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value ) - - cv2.imwrite(get_and_create_path(file_path, output_dir), frame_im, imwrite_param) + path = Path(get_and_create_path(file_path, output_dir)) + (is_ok, encoded) = cv2.imencode(f".{image_extension}", frame_im, imwrite_param) + if is_ok: + encoded.tofile(path) + else: + logger.error(f"Failed to encode image for {file_path}") + # else: completed = False break diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a0f0339..446437cb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,6 +17,7 @@ from pathlib import Path import cv2 +import numpy as np import pytest from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available @@ -455,17 +456,35 @@ def test_cli_save_images(tmp_path: Path): ) == 0 ) + images = [image for image in tmp_path.glob("*.jpg")] + # Should detect two scenes and generate 3 images per scene with above params. + assert len(images) == 6 # Open one of the created images and make sure it has the correct resolution. - # TODO: Also need to test that the right number of images was generated, and compare with - # expected frames from the actual video. - images = glob.glob(os.path.join(tmp_path, "*.jpg")) - assert images image = cv2.imread(images[0]) assert image.shape == (544, 1280, 3) +def test_cli_save_images_path_handling(tmp_path: Path): + """Test `save-images` ability to handle UTF-8 paths.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images -f %s" + % ("電腦檔案-$SCENE_NUMBER-$IMAGE_NUMBER"), + output_dir=tmp_path, + ) + == 0 + ) + images = [image for image in tmp_path.glob("電腦檔案-*.jpg")] + # Should detect two scenes and generate 3 images per scene with above params. + assert len(images) == 6 + # Check the created images can be read and have the correct size. + # We can't use `cv2.imread` here since it doesn't seem to work correctly with UTF-8 paths. + image = cv2.imdecode(np.fromfile(images[0], dtype=np.uint8), cv2.IMREAD_UNCHANGED) + assert image.shape == (544, 1280, 3) + + # TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. -def test_cli_save_images_rotation(rotated_video_file, tmp_path): +def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): """Test that `save-images` command rotates images correctly with the default backend.""" assert ( invoke_scenedetect( @@ -475,8 +494,9 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path): ) == 0 ) - images = glob.glob(os.path.join(tmp_path, "*.jpg")) - assert images + images = [image for image in tmp_path.glob("*.jpg")] + # Should detect two scenes and generate 3 images per scene with above params. + assert len(images) == 6 image = cv2.imread(images[0]) # Note same resolution as in test_cli_save_images but rotated 90 degrees. assert image.shape == (1280, 544, 3) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 865fccf6..9c24b2e5 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -583,10 +583,14 @@ Development ## PySceneDetect 0.6.5 (TBD) - - [bugfix] Fix new detectors not working with `default-detector` config option - - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) + - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) + - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) + - [feature] Add new `--show` flag to `export-html` command to launch browser after processing [#442](https://github.com/Breakthrough/PySceneDetect/issues/442) - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - - [feature] Add new `--show` flag to `export-html` command to launch browser after processing (#442) + - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix new detectors not working with `default-detector` config option - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it - [general] Updates to Windows distributions: @@ -594,8 +598,5 @@ Development - Bundled Python interpreter is now Python 3.13 - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 - [improvement] `save_to_csv` now works with paths from `pathlib` - - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` - - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) From 5565edc7746941fb70392f99d4ba17bab7b7cc26 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 23 Nov 2024 21:24:49 -0500 Subject: [PATCH 159/407] [dist] Update default config file template. --- scenedetect.cfg | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 33a83d43..205c614f 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -22,22 +22,20 @@ # [global] -# Output directory for written files. If unset, defaults to working directory. -#output = /usr/tmp/scenedetect/ # Default detector to use. # Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist #default-detector = detect-adaptive -# Video backend interface, must be one of: opencv, pyav. +# Video backend interface, must be one of: opencv, pyav, moviepy. #backend = opencv -# Downscale frame using a ratio of N. Set to 1 for no downscaling. If unset, -# applied automatically based on input video resolution. Must be an integer value. -#downscale = 1 +# Verbosity of console output (debug, info, warning, error, or none). +# Set to none for the same behavior as specifying -q/--quiet. +#verbosity = debug -# Method to use for downscaling (nearest, linear, cubic, area, lanczos4). -#downscale-method = linear +# Output directory for written files. Defaults to working directory. +#output = /usr/tmp/scenedetect/ # Minimum length of a given scene. #min-scene-len = 0.6s @@ -49,9 +47,12 @@ # Drop scenes shorter than min-scene-len instead of merging (yes/no). #drop-short-scenes = no -# Verbosity of console output (debug, info, warning, error, or none). -# Set to none for the same behavior as specifying -q/--quiet. -#verbosity = debug +# Downscale frame before processing. Set to 1 for no downscaling. +# By default, downscale will be calculated automatically. +#downscale = 1 + +# Method to use for downscaling (nearest, linear, cubic, area, lanczos4). +#downscale-method = linear # Amount of frames to skip between performing scene detection. Not recommended. #frame-skip = 0 From 95091f8c3a125a50a6c2f0b276049f975915d59f Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 24 Nov 2024 17:13:32 -0500 Subject: [PATCH 160/407] [save-images] Add multithreaded version of save-images (#456) * [save-images] Add multithreaded version of save-images This improves performance by over 50% in some cases. This should also fix #450 since we use the Path module for files now instead of OpenCV's imwrite. * [save-images] Add new ImageExtractor class The save_images function was getting quite complex and difficult to maintain, especially with the multithreaded version. This breaks it out into an object with smaller functions. The existing save_images function can be implemented using this new object. * [save-images] Make all threads exception-safe Ensure errors are re-raised safely from worker threads by using non-blocking puts and monitoring a common error queue. * [save-images] Add new config option for setting threading mode --- scenedetect.cfg | 13 +- scenedetect/__init__.py | 17 +- scenedetect/_cli/__init__.py | 3 +- scenedetect/_cli/commands.py | 6 +- scenedetect/_cli/config.py | 1 + scenedetect/scene_manager.py | 403 +++++++++++++++++++++++++++++++---- tests/test_scene_manager.py | 136 +++++++----- website/pages/changelog.md | 9 +- 8 files changed, 480 insertions(+), 108 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index 205c614f..2cb1037b 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -220,22 +220,25 @@ # Image quality (jpeg/webp). Default is 95 for jpeg, 100 for webp #quality = 95 -# Compression amount for png images (0 to 9). Does not affect quality. +# Compression amount for png images (0 to 9). Only affects size, not quality. #compression = 3 -# Number of frames to skip at beginning/end of scene. +# Number of frames to ignore around each scene cut when selecting frames. #frame-margin = 1 -# Factor to resize images by (0.5 = half, 1.0 = same, 2.0 = double). +# Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double). #scale = 1.0 -# Override image height and/or width. Mutually exclusive with scale. +# Resize to specified height, width, or both. Mutually exclusive with scale. #height = 0 #width = 0 -# Method to use for image scaling (nearest, linear, cubic, area, lanczos4). +# Method to use for scaling (nearest, linear, cubic, area, lanczos4). #scale-method = linear +# Use separate threads for encoding and disk IO. Can improve performance. +#threading = yes + [export-html] # Filename format of created HTML file. Can use $VIDEO_NAME in the name. diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 544be977..1463bc8f 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -15,8 +15,8 @@ :class:`SceneManager `. """ +import typing as ty from logging import getLogger -from typing import List, Optional, Tuple, Union # OpenCV is a required package, but we don't have it as an explicit dependency since we # need to support both opencv-python and opencv-python-headless. Include some additional @@ -30,6 +30,7 @@ ) from ex # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. +# Note that order of importants is important! from scenedetect.platform import init_logger # noqa: I001 from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream, VideoOpenFailure @@ -50,7 +51,7 @@ VideoCaptureAdapter, ) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt -from scenedetect.scene_manager import SceneManager, save_images +from scenedetect.scene_manager import SceneManager, save_images, SceneList, CutList, Interpolation from scenedetect.video_manager import VideoManager # [DEPRECATED] DO NOT USE. # Used for module identification and when printing version & about info @@ -63,7 +64,7 @@ def open_video( path: str, - framerate: Optional[float] = None, + framerate: ty.Optional[float] = None, backend: str = "opencv", **kwargs, ) -> VideoStream: @@ -117,12 +118,12 @@ def open_video( def detect( video_path: str, detector: SceneDetector, - stats_file_path: Optional[str] = None, + stats_file_path: ty.Optional[str] = None, show_progress: bool = False, - start_time: Optional[Union[str, float, int]] = None, - end_time: Optional[Union[str, float, int]] = None, + start_time: ty.Optional[ty.Union[str, float, int]] = None, + end_time: ty.Optional[ty.Union[str, float, int]] = None, start_in_scene: bool = False, -) -> List[Tuple[FrameTimecode, FrameTimecode]]: +) -> SceneList: """Perform scene detection on a given video `path` using the specified `detector`. Arguments: @@ -143,7 +144,7 @@ def detect( will always be included until the first fade-out event is detected. Returns: - List of scenes (pairs of :class:`FrameTimecode` objects). + List of scenes as pairs of (start, end) :class:`FrameTimecode` objects. Raises: :class:`VideoOpenFailure`: `video_path` could not be opened. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 0f02bd6c..cfe5fe84 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1493,7 +1493,8 @@ def save_images_command( "num_images": ctx.config.get_value("save-images", "num-images", num_images), "output_dir": output, "scale": scale, - "show_progress": ctx.quiet_mode, + "show_progress": not ctx.quiet_mode, + "threading": ctx.config.get_value("save-images", "threading"), "width": width, } ctx.add_command(cli_commands.save_images, save_images_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 857507d3..9f6b5d32 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -29,9 +29,7 @@ write_scene_list, write_scene_list_html, ) -from scenedetect.scene_manager import ( - save_images as save_images_impl, -) +from scenedetect.scene_manager import save_images as save_images_impl from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge logger = logging.getLogger("pyscenedetect") @@ -179,6 +177,7 @@ def save_images( height: int, width: int, interpolation: Interpolation, + threading: bool, ): """Handles the `save-images` command.""" del cuts # save-images only uses scenes. @@ -197,6 +196,7 @@ def save_images( height=height, width=width, interpolation=interpolation, + threading=threading, ) # Save the result for use by `export-html` if required. context.save_images_result = (images, output_dir) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index c53dcb7c..76327a62 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -361,6 +361,7 @@ def format(self, timecode: FrameTimecode) -> str: "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), "scale": 1.0, "scale-method": Interpolation.LINEAR, + "threading": True, "width": 0, }, "save-qp": { diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index ccc8d0ab..58cf6726 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -85,9 +85,10 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import queue import sys import threading +import typing as ty from enum import Enum from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional, TextIO, Tuple, Union +from string import Template import cv2 import numpy as np @@ -100,17 +101,17 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SimpleTableRow, ) from scenedetect.frame_timecode import FrameTimecode -from scenedetect.platform import Template, get_and_create_path, get_cv2_imwrite_params, tqdm +from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.scene_detector import SceneDetector, SparseSceneDetector from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream logger = logging.getLogger("pyscenedetect") -SceneList = List[Tuple[FrameTimecode, FrameTimecode]] +SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] """Type hint for a list of scenes in the form (start time, end time).""" -CutList = List[FrameTimecode] +CutList = ty.List[FrameTimecode] """Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" # TODO: This value can and should be tuned for performance improvements as much as possible, @@ -166,9 +167,9 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI def get_scenes_from_cuts( cut_list: CutList, - start_pos: Union[int, FrameTimecode], - end_pos: Union[int, FrameTimecode], - base_timecode: Optional[FrameTimecode] = None, + start_pos: ty.Union[int, FrameTimecode], + end_pos: ty.Union[int, FrameTimecode], + base_timecode: ty.Optional[FrameTimecode] = None, ) -> SceneList: """Returns a list of tuples of start/end FrameTimecodes for each scene based on a list of detected scene cuts/breaks. @@ -212,11 +213,14 @@ def get_scenes_from_cuts( return scene_list +# TODO(#463): Move post-processing functionality into separate submodule. + + def write_scene_list( - output_csv_file: TextIO, + output_csv_file: ty.TextIO, scene_list: SceneList, include_cut_list: bool = True, - cut_list: Optional[CutList] = None, + cut_list: ty.Optional[CutList] = None, col_separator: str = ",", row_separator: str = "\n", ): @@ -279,12 +283,12 @@ def write_scene_list( def write_scene_list_html( output_html_filename: str, scene_list: SceneList, - cut_list: Optional[CutList] = None, + cut_list: ty.Optional[CutList] = None, css: str = None, css_class: str = "mytable", - image_filenames: Optional[Dict[int, List[str]]] = None, - image_width: Optional[int] = None, - image_height: Optional[int] = None, + image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]] = None, + image_width: ty.Optional[int] = None, + image_height: ty.Optional[int] = None, ): """Writes the given list of scenes to an output file handle in html format. @@ -400,8 +404,314 @@ def write_scene_list_html( page.save(output_html_filename) -# -# TODO(v1.0): Consider moving all post-processing functionality into a separate submodule. +def _scale_image( + image: cv2.Mat, + aspect_ratio: float, + height: ty.Optional[int], + width: ty.Optional[int], + scale: ty.Optional[float], + interpolation: Interpolation, +) -> cv2.Mat: + # TODO: Combine this resize with the ones below. + if aspect_ratio is not None: + image = cv2.resize( + image, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value + ) + image_height = image.shape[0] + image_width = image.shape[1] + + # Figure out what kind of resizing needs to be done + if height or width: + if height and not width: + factor = height / float(image_height) + width = int(factor * image_width) + if width and not height: + factor = width / float(image_width) + height = int(factor * image_height) + assert height > 0 and width > 0 + image = cv2.resize(image, (width, height), interpolation=interpolation.value) + elif scale: + image = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) + return image + + +class _ImageExtractor: + def __init__( + self, + num_images: int = 3, + frame_margin: int = 1, + image_extension: str = "jpg", + imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, + image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + scale: ty.Optional[float] = None, + height: ty.Optional[int] = None, + width: ty.Optional[int] = None, + interpolation: Interpolation = Interpolation.CUBIC, + ): + """Multi-threaded implementation of save-images functionality. Uses background threads to + handle image encoding and saving images to disk to improve parallelism. + + This object is thread-safe. + + Arguments: + num_images: Number of images to generate for each scene. Minimum is 1. + frame_margin: Number of frames to pad each scene around the beginning + and end (e.g. moves the first/last image into the scene by N frames). + Can set to 0, but will result in some video files failing to extract + the very last frame. + image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). + encoder_param: Quality/compression efficiency, based on type of image: + 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. + 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. + image_name_template: Template to use for output filanames. Can use template variables + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. + *NOTE*: Should not include the image extension (set `image_extension` instead). + scale: Optional factor by which to rescale saved images. A scaling factor of 1 would + not result in rescaling. A value < 1 results in a smaller saved image, while a + value > 1 results in an image larger than the original. This value is ignored if + either the height or width values are specified. + height: Optional value for the height of the saved images. Specifying both the height + and width will resize images to an exact size, regardless of aspect ratio. + Specifying only height will rescale the image to that number of pixels in height + while preserving the aspect ratio. + width: Optional value for the width of the saved images. Specifying both the width + and height will resize images to an exact size, regardless of aspect ratio. + Specifying only width will rescale the image to that number of pixels wide + while preserving the aspect ratio. + interpolation: Type of interpolation to use when resizing images. + """ + self._num_images = num_images + self._frame_margin = frame_margin + self._image_extension = image_extension + self._image_name_template = image_name_template + self._scale = scale + self._height = height + self._width = width + self._interpolation = interpolation + self._imwrite_param = imwrite_param if imwrite_param else {} + + def run( + self, + video: VideoStream, + scene_list: SceneList, + output_dir: ty.Optional[str] = None, + show_progress=False, + ) -> ty.Dict[int, ty.List[str]]: + """Run image extraction on `video` using the current parameters. Thread-safe. + + Arguments: + video: The video to process. + scene_list: The scenes detected in the video. + output_dir: Directory to write files to. + show_progress: If `true` and tqdm is available, shows a progress bar. + """ + # Setup flags and init progress bar if available. + completed = True + logger.info( + f"Saving {self._num_images} images per scene [format={self._image_extension}] {output_dir if output_dir else ''} " + ) + progress_bar = None + if show_progress: + progress_bar = tqdm( + total=len(scene_list) * self._num_images, unit="images", dynamic_ncols=True + ) + + timecode_list = self.generate_timecode_list(scene_list) + image_filenames = {i: [] for i in range(len(timecode_list))} + + filename_template = Template(self._image_name_template) + logger.debug("Writing images with template %s", filename_template.template) + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d" + + def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode): + return "%s.%s" % ( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (scene_number + 1), + IMAGE_NUMBER=image_num_format % (image_number + 1), + FRAME_NUMBER=image_timecode.get_frames(), + TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), + self._image_extension, + ) + + MAX_QUEUED_ENCODE_FRAMES = 4 + MAX_QUEUED_SAVE_IMAGES = 4 + encode_queue = queue.Queue(MAX_QUEUED_ENCODE_FRAMES) + save_queue = queue.Queue(MAX_QUEUED_SAVE_IMAGES) + error_queue = queue.Queue(2) # Queue size must be the same as the # of worker threads! + + def check_error_queue(): + try: + return error_queue.get(block=False) + except queue.Empty: + pass + return None + + def launch_thread(callable, *args, **kwargs): + def capture_errors(callable, *args, **kwargs): + try: + return callable(*args, **kwargs) + # Errors we capture in `error_queue` will be re-raised by this thread. + except: # noqa: E722 + error_queue.put(sys.exc_info()) + return None + + thread = threading.Thread( + target=capture_errors, + args=( + callable, + *args, + ), + kwargs=kwargs, + daemon=True, + ) + thread.start() + return thread + + def checked_put(work_queue: queue.Queue, item: ty.Any): + error = None + while True: + try: + work_queue.put(item, timeout=0.1) + return + except queue.Full: + error = check_error_queue() + if error is not None: + break + continue + raise error[1].with_traceback(error[2]) + + encode_thread = launch_thread( + self.image_encode_thread, + video, + encode_queue, + save_queue, + ) + save_thread = launch_thread(self.image_save_thread, save_queue, progress_bar) + + for i, scene_timecodes in enumerate(timecode_list): + for j, timecode in enumerate(scene_timecodes): + video.seek(timecode) + frame_im = video.read() + if frame_im is not None and frame_im is not False: + file_path = format_filename(i, j, timecode) + image_filenames[i].append(file_path) + checked_put( + encode_queue, (frame_im, get_and_create_path(file_path, output_dir)) + ) + else: + completed = False + break + + checked_put(encode_queue, (None, None)) + encode_thread.join() + checked_put(save_queue, (None, None)) + save_thread.join() + + error = check_error_queue() + if error is not None: + raise error[1].with_traceback(error[2]) + + if progress_bar is not None: + progress_bar.close() + if not completed: + logger.error("Could not generate all output images.") + + return image_filenames + + def image_encode_thread( + self, + video: VideoStream, + encode_queue: queue.Queue, + save_queue: queue.Queue, + ): + aspect_ratio = video.aspect_ratio + if abs(aspect_ratio - 1.0) < 0.01: + aspect_ratio = None + # TODO: Validate that encoder_param is within the proper range. + # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. + while True: + frame_im, dest_path = encode_queue.get() + if frame_im is None: + return + frame_im = self.resize_image( + frame_im, + aspect_ratio, + ) + (is_ok, encoded) = cv2.imencode( + f".{self._image_extension}", frame_im, self._imwrite_param + ) + if not is_ok: + continue + save_queue.put((encoded, dest_path)) + + def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): + while True: + encoded, dest_path = save_queue.get() + if encoded is None: + return + if encoded is not False: + encoded.tofile(Path(dest_path)) + if progress_bar is not None: + progress_bar.update(1) + + def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: + """Generates a list of timecodes for each scene in `scene_list` based on the current config + parameters.""" + framerate = scene_list[0][0].framerate + # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. + return [ + ( + FrameTimecode(int(f), fps=framerate) + for f in ( + # middle frames + a[len(a) // 2] + if (0 < j < self._num_images - 1) or self._num_images == 1 + # first frame + else min(a[0] + self._frame_margin, a[-1]) + if j == 0 + # last frame + else max(a[-1] - self._frame_margin, a[0]) + # for each evenly-split array of frames in the scene list + for j, a in enumerate(np.array_split(r, self._num_images)) + ) + ) + for r in ( + # pad ranges to number of images + r + if 1 + r[-1] - r[0] >= self._num_images + else list(r) + [r[-1]] * (self._num_images - len(r)) + # create range of frames in scene + for r in ( + range( + start.get_frames(), + start.get_frames() + + max( + 1, # guard against zero length scenes + end.get_frames() - start.get_frames(), + ), + ) + # for each scene in scene list + for start, end in scene_list + ) + ) + ] + + def resize_image( + self, + image: cv2.Mat, + aspect_ratio: float, + ) -> cv2.Mat: + return _scale_image( + image, aspect_ratio, self._height, self._width, self._scale, self._interpolation + ) + + def save_images( scene_list: SceneList, video: VideoStream, @@ -410,14 +720,15 @@ def save_images( image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: Optional[str] = None, - show_progress: Optional[bool] = False, - scale: Optional[float] = None, - height: Optional[int] = None, - width: Optional[int] = None, + output_dir: ty.Optional[str] = None, + show_progress: ty.Optional[bool] = False, + scale: ty.Optional[float] = None, + height: ty.Optional[int] = None, + width: ty.Optional[int] = None, interpolation: Interpolation = Interpolation.CUBIC, + threading: bool = True, video_manager=None, -) -> Dict[int, List[str]]: +) -> ty.Dict[int, ty.List[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -454,6 +765,7 @@ def save_images( Specifying only width will rescale the image to that number of pixels wide while preserving the aspect ratio. interpolation: Type of interpolation to use when resizing images. + threading: Offload image encoding and disk IO to background threads to improve performance. video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. Returns: @@ -482,12 +794,27 @@ def save_images( if encoder_param is not None else [] ) - video.reset() + if threading: + extractor = _ImageExtractor( + num_images, + frame_margin, + image_extension, + imwrite_param, + image_name_template, + scale, + height, + width, + interpolation, + ) + return extractor.run(video, scene_list, output_dir, show_progress) + # Setup flags and init progress bar if available. completed = True - logger.info(f"Saving {num_images} images per scene to {output_dir}, format {image_extension}") + logger.info( + f"Saving {num_images} images per scene [format={image_extension}] {output_dir if output_dir else ''} " + ) progress_bar = None if show_progress: progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) @@ -623,7 +950,7 @@ class SceneManager: def __init__( self, - stats_manager: Optional[StatsManager] = None, + stats_manager: ty.Optional[StatsManager] = None, ): """ Arguments: @@ -632,7 +959,7 @@ def __init__( """ self._cutting_list = [] self._event_list = [] - self._detector_list: List[SceneDetector] = [] + self._detector_list: ty.List[SceneDetector] = [] self._sparse_detector_list = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the @@ -641,16 +968,16 @@ def __init__( # TODO(v1.0): This class should own a VideoStream as well, instead of passing one # to the detect_scenes method. If concatenation is required, it can be implemented as # a generic VideoStream wrapper. - self._stats_manager: Optional[StatsManager] = stats_manager + self._stats_manager: ty.Optional[StatsManager] = stats_manager # Position of video that was first passed to detect_scenes. self._start_pos: FrameTimecode = None # Position of video on the last frame processed by detect_scenes. self._last_pos: FrameTimecode = None # Size of the decoded frames. - self._frame_size: Tuple[int, int] = None + self._frame_size: ty.Tuple[int, int] = None self._frame_size_errors: int = 0 - self._base_timecode: Optional[FrameTimecode] = None + self._base_timecode: ty.Optional[FrameTimecode] = None self._downscale: int = 1 self._auto_downscale: bool = True # Interpolation method to use when downscaling. Defaults to linear interpolation @@ -675,7 +1002,7 @@ def interpolation(self, value: Interpolation): self._interpolation = value @property - def stats_manager(self) -> Optional[StatsManager]: + def stats_manager(self) -> ty.Optional[StatsManager]: """Getter for the StatsManager associated with this SceneManager, if any.""" return self._stats_manager @@ -758,7 +1085,7 @@ def clear_detectors(self) -> None: self._sparse_detector_list.clear() def get_scene_list( - self, base_timecode: Optional[FrameTimecode] = None, start_in_scene: bool = False + self, base_timecode: ty.Optional[FrameTimecode] = None, start_in_scene: bool = False ) -> SceneList: """Return a list of tuples of start/end FrameTimecodes for each detected scene. @@ -790,7 +1117,7 @@ def get_scene_list( scene_list = [] return sorted(self._get_event_list() + scene_list) - def _get_cutting_list(self) -> List[int]: + def _get_cutting_list(self) -> ty.List[int]: """Return a sorted list of unique frame numbers of any detected scene cuts.""" if not self._cutting_list: return [] @@ -811,7 +1138,7 @@ def _process_frame( self, frame_num: int, frame_im: np.ndarray, - callback: Optional[Callable[[np.ndarray, int], None]] = None, + callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" @@ -852,12 +1179,12 @@ def stop(self) -> None: def detect_scenes( self, video: VideoStream = None, - duration: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, + duration: ty.Optional[FrameTimecode] = None, + end_time: ty.Optional[FrameTimecode] = None, frame_skip: int = 0, show_progress: bool = False, - callback: Optional[Callable[[np.ndarray, int], None]] = None, - frame_source: Optional[VideoStream] = None, + callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, + frame_source: ty.Optional[VideoStream] = None, ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the number of frames processed. Results can be obtained by calling :meth:`get_scene_list` or @@ -1085,7 +1412,7 @@ def _decode_thread( def get_cut_list( self, - base_timecode: Optional[FrameTimecode] = None, + base_timecode: ty.Optional[FrameTimecode] = None, show_warning: bool = True, ) -> CutList: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. @@ -1115,7 +1442,7 @@ def get_cut_list( logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") return self._get_cutting_list() - def get_event_list(self, base_timecode: Optional[FrameTimecode] = None) -> SceneList: + def get_event_list(self, base_timecode: ty.Optional[FrameTimecode] = None) -> SceneList: """[DEPRECATED] DO NOT USE. Get a list of start/end timecodes of sparse detection events. diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 16683bce..036c29e6 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -18,6 +18,7 @@ import glob import os import os.path +from pathlib import Path from typing import List from scenedetect.backends.opencv import VideoStreamCv2 @@ -84,7 +85,7 @@ def test_get_scene_list_start_in_scene(test_video_file): assert scene_list[0][1] == end_time -def test_save_images(test_video_file): +def test_save_images(test_video_file, tmp_path: Path): """Test scenedetect.scene_manager.save_images function.""" video = VideoStreamCv2(test_video_file) sm = SceneManager() @@ -97,66 +98,101 @@ def test_save_images(test_video_file): "$TIMESTAMP_MS.$TIMECODE" ) - try: - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)] - ] + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=False, + ) - image_filenames = save_images( - scene_list=scene_list, - video=video, - num_images=3, - image_extension="jpg", - image_name_template=image_name_template, - ) + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 - # Ensure images got created, and the proper number got created. - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert os.path.exists(path) - total_images += 1 + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - assert total_images == len(glob.glob(image_name_glob)) - finally: - for path in glob.glob(image_name_glob): - os.remove(path) +def test_save_images_singlethreaded(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images function.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile." + "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." + "$TIMESTAMP_MS.$TIMECODE" + ) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=True, + ) + + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) # TODO: Test other functionality against zero width scenes. -def test_save_images_zero_width_scene(test_video_file): +def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" video = VideoStreamCv2(test_video_file) image_name_glob = "scenedetect.tempfile.*.jpg" image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" - try: - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)] - ] - NUM_IMAGES = 10 - image_filenames = save_images( - scene_list=scene_list, - video=video, - num_images=10, - image_extension="jpg", - image_name_template=image_name_template, - ) - assert len(image_filenames) == 3 - assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert os.path.exists(path) - total_images += 1 - assert total_images == len(glob.glob(image_name_glob)) - finally: - for path in glob.glob(image_name_glob): - os.remove(path) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)] + ] + NUM_IMAGES = 10 + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=10, + image_extension="jpg", + image_name_template=image_name_template, + ) + assert len(image_filenames) == 3 + assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) # TODO: This would be more readable if the callbacks were defined within the test case, e.g. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 9c24b2e5..ccbc2ada 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -588,15 +588,18 @@ Development - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) - [feature] Add new `--show` flag to `export-html` command to launch browser after processing [#442](https://github.com/Breakthrough/PySceneDetect/issues/442) - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/450) + - [improvement] Add new `threading` option to `save-images`/`save_images()` [#456](https://github.com/Breakthrough/PySceneDetect/issues/456) + - Enabled by default, offloads image encoding and disk IO to separate threads + - Improves performance by up to 50% in some cases - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) - [bugfix] Fix new detectors not working with `default-detector` config option - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters - - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it + - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it - [general] Updates to Windows distributions: - The MoviePy backend is now included with Windows distributions - Bundled Python interpreter is now Python 3.13 - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 - [improvement] `save_to_csv` now works with paths from `pathlib` - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module - - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` \ No newline at end of file From 18c7ab86b858b6df6867ab956596da4ee223a7f6 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 24 Nov 2024 22:54:36 -0500 Subject: [PATCH 161/407] [scene_manager] Add crop functionality (#449) * [scene_manager] Add ability to crop input * [scene_manager] Validate crop config params and improve error messaging Make sure exceptions are always thrown in debug mode from the source location. --- docs/cli.rst | 4 + scenedetect.cfg | 12 ++- scenedetect/_cli/__init__.py | 12 ++- scenedetect/_cli/config.py | 60 +++++++++++++-- scenedetect/_cli/context.py | 39 +++++++++- scenedetect/detectors/content_detector.py | 1 - scenedetect/platform.py | 5 +- scenedetect/scene_manager.py | 93 ++++++++++++++++++----- tests/test_cli.py | 18 +++++ tests/test_scene_manager.py | 35 +++++++++ website/pages/changelog.md | 5 +- 11 files changed, 245 insertions(+), 39 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 6e757b6b..ee9bfbe2 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -57,6 +57,10 @@ Options Path to config file. See :ref:`config file reference ` for details. +.. option:: --crop X0 Y0 X1 Y1 + + Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99). + .. option:: -s CSV, --stats CSV Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis. diff --git a/scenedetect.cfg b/scenedetect.cfg index 2cb1037b..321eb4ff 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -27,15 +27,19 @@ # Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist #default-detector = detect-adaptive -# Video backend interface, must be one of: opencv, pyav, moviepy. -#backend = opencv +# Output directory for written files. Defaults to working directory. +#output = /usr/tmp/scenedetect/ # Verbosity of console output (debug, info, warning, error, or none). # Set to none for the same behavior as specifying -q/--quiet. #verbosity = debug -# Output directory for written files. Defaults to working directory. -#output = /usr/tmp/scenedetect/ +# Crop input video to area. Specified as two points in the form X0 Y0 X1 Y1 or +# as (X0 Y0), (X1 Y1). Coordinate (0, 0) is the top-left corner. +#crop = 100 100 200 250 + +# Video backend interface, must be one of: opencv, pyav, moviepy. +#backend = opencv # Minimum length of a given scene. #min-scene-len = 0.6s diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index cfe5fe84..6ed9593b 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -256,6 +256,14 @@ def print_command_help(ctx: click.Context, command: click.Command): help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" % (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), ) +@click.option( + "--crop", + metavar="X0 Y0 X1 Y1", + type=(int, int, int, int), + default=None, + help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).%s" + % (USER_CONFIG.get_help_string("global", "crop", show_default=False)), +) @click.option( "--downscale", "-d", @@ -312,6 +320,7 @@ def scenedetect( drop_short_scenes: ty.Optional[bool], merge_last_scene: ty.Optional[bool], backend: ty.Optional[str], + crop: ty.Optional[ty.Tuple[int, int, int, int]], downscale: ty.Optional[int], frame_skip: ty.Optional[int], verbosity: ty.Optional[str], @@ -326,12 +335,13 @@ def scenedetect( output=output, framerate=framerate, stats_file=stats, - downscale=downscale, frame_skip=frame_skip, min_scene_len=min_scene_len, drop_short_scenes=drop_short_scenes, merge_last_scene=merge_last_scene, backend=backend, + crop=crop, + downscale=downscale, quiet=quiet, logfile=logfile, config=config, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 76327a62..496a40fb 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -135,6 +135,47 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": ) from ex +class CropValue(ValidatedValue): + """Validator for crop region defined as X0 Y0 X1 Y1.""" + + _IGNORE_CHARS = [",", "/", "(", ")"] + """Characters to ignore.""" + + def __init__(self, value: Optional[Union[str, Tuple[int, int, int, int]]] = None): + if isinstance(value, CropValue) or value is None: + self._crop = value + else: + crop = () + if isinstance(value, str): + translation_table = str.maketrans( + {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} + ) + values = value.translate(translation_table).split() + crop = tuple(int(val) for val in values) + elif isinstance(value, tuple): + crop = value + if not len(crop) == 4: + raise ValueError("Crop region must be four numbers of the form X0 Y0 X1 Y1!") + if any(coordinate < 0 for coordinate in crop): + raise ValueError("Crop coordinates must be >= 0") + (x0, y0, x1, y1) = crop + self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + + @property + def value(self) -> Tuple[int, int, int, int]: + return self._crop + + def __str__(self) -> str: + return "[%d, %d], [%d, %d]" % self.value + + @staticmethod + def from_config(config_value: str, default: "CropValue") -> "CropValue": + try: + return CropValue(config_value) + except ValueError as ex: + raise OptionParseFailure(f"{ex}") from ex + + class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" @@ -154,7 +195,7 @@ def __init__(self, value: Union[str, ContentDetector.Components]): self._value = ContentDetector.Components(*(float(val) for val in values)) @property - def value(self) -> Tuple[float, float, float, float]: + def value(self) -> ContentDetector.Components: return self._value def __str__(self) -> str: @@ -340,6 +381,7 @@ def format(self, timecode: FrameTimecode) -> str: }, "global": { "backend": "opencv", + "crop": CropValue(), "default-detector": "detect-adaptive", "downscale": 0, "downscale-method": Interpolation.LINEAR, @@ -484,7 +526,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: out_map[command][option] = parsed except TypeError: errors.append( - "Invalid [%s] value for %s: %s. Must be one of: %s." + "Invalid value for [%s] option %s': %s. Must be one of: %s." % ( command, option, @@ -498,7 +540,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: except ValueError as _: errors.append( - "Invalid [%s] value for %s: %s is not a valid %s." + "Invalid value for [%s] option '%s': %s is not a valid %s." % (command, option, config.get(command, option), value_type) ) continue @@ -514,7 +556,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: ) except OptionParseFailure as ex: errors.append( - "Invalid [%s] value for %s:\n %s\n%s" + "Invalid value for [%s] option '%s': %s\nError: %s" % (command, option, config_value, ex.error) ) continue @@ -526,7 +568,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: if command in CHOICE_MAP and option in CHOICE_MAP[command]: if config_value.lower() not in CHOICE_MAP[command][option]: errors.append( - "Invalid [%s] value for %s: %s. Must be one of: %s." + "Invalid value for [%s] option '%s': %s. Must be one of: %s." % ( command, option, @@ -612,8 +654,12 @@ def _load_from_disk(self, path=None): config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) except ParsingError as ex: + if __debug__: + raise raise ConfigLoadFailure(self._init_log, reason=ex) from None except OSError as ex: + if __debug__: + raise raise ConfigLoadFailure(self._init_log, reason=ex) from None # At this point the config file syntax is correct, but we need to still validate # the parsed options (i.e. that the options have valid values). @@ -638,8 +684,8 @@ def get_value( """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: - return override - if command in self._config and option in self._config[command]: + value = override + elif command in self._config and option in self._config[command]: value = self._config[command][option] else: value = CONFIG_MAP[command][option] diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index bd8f88d6..c9798803 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -22,6 +22,7 @@ CHOICE_MAP, ConfigLoadFailure, ConfigRegistry, + CropValue, ) from scenedetect.detectors import ( AdaptiveDetector, @@ -157,12 +158,13 @@ def handle_options( output: ty.Optional[ty.AnyStr], framerate: float, stats_file: ty.Optional[ty.AnyStr], - downscale: ty.Optional[int], frame_skip: int, min_scene_len: str, drop_short_scenes: ty.Optional[bool], merge_last_scene: ty.Optional[bool], backend: ty.Optional[str], + crop: ty.Optional[ty.Tuple[int, int, int, int]], + downscale: ty.Optional[int], quiet: bool, logfile: ty.Optional[ty.AnyStr], config: ty.Optional[ty.AnyStr], @@ -212,7 +214,7 @@ def handle_options( logger.log(log_level, log_str) if init_failure: logger.critical("Error processing configuration file.") - raise click.Abort() + raise SystemExit(1) if self.config.config_dict: logger.debug("Current configuration:\n%s", str(self.config.config_dict).encode("utf-8")) @@ -285,9 +287,23 @@ def handle_options( scene_manager.downscale = downscale except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="downscale factor") from None + raise click.BadParameter(str(ex), param_hint="downscale factor") from ex scene_manager.interpolation = self.config.get_value("global", "downscale-method") + # If crop was set, make sure it's valid (e.g. it should cover at least a single pixel). + try: + crop = self.config.get_value("global", "crop", CropValue(crop)) + if crop is not None: + (min_x, min_y) = crop[0:2] + frame_size = self.video_stream.frame_size + if min_x >= frame_size[0] or min_y >= frame_size[1]: + region = CropValue(crop) + raise ValueError(f"{region} is outside of video boundary of {frame_size}") + scene_manager.crop = crop + except ValueError as ex: + logger.debug(str(ex)) + raise click.BadParameter(str(ex), param_hint="--crop") from ex + self.scene_manager = scene_manager # @@ -318,6 +334,8 @@ def get_detect_content_params( try: weights = ContentDetector.Components(*weights) except ValueError as ex: + if __debug__: + raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None @@ -373,6 +391,8 @@ def get_detect_adaptive_params( try: weights = ContentDetector.Components(*weights) except ValueError as ex: + if __debug__: + raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None return { @@ -545,20 +565,31 @@ def _open_video_stream( framerate=framerate, backend=backend, ) - logger.debug("Video opened using backend %s", type(self.video_stream).__name__) + logger.debug(f"""Video information: + Backend: {type(self.video_stream).__name__} + Resolution: {self.video_stream.frame_size} + Framerate: {self.video_stream.frame_rate} + Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""") + except FrameRateUnavailable as ex: + if __debug__: + raise raise click.BadParameter( "Failed to obtain framerate for input video. Manually specify framerate with the" " -f/--framerate option, or try re-encoding the file.", param_hint="-i/--input", ) from ex except VideoOpenFailure as ex: + if __debug__: + raise raise click.BadParameter( "Failed to open input video%s: %s" % (" using %s backend" % backend if backend else "", str(ex)), param_hint="-i/--input", ) from ex except OSError as ex: + if __debug__: + raise raise click.BadParameter( "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" ) from None diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 4b8b2e19..1269727c 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -133,7 +133,6 @@ def __init__( self._weights = ContentDetector.LUMA_ONLY_WEIGHTS self._kernel: Optional[numpy.ndarray] = None if kernel_size is not None: - print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 244b9cbe..9e12dbc2 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -330,7 +330,10 @@ def get_system_version_info() -> str: for module_name in third_party_packages: try: module = importlib.import_module(module_name) - out_lines.append(output_template.format(module_name, module.__version__)) + if hasattr(module, "__version__"): + out_lines.append(output_template.format(module_name, module.__version__)) + else: + out_lines.append(output_template.format(module_name, not_found_str)) except ModuleNotFoundError: out_lines.append(output_template.format(module_name, not_found_str)) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 58cf6726..dbdfc563 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -114,6 +114,11 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): CutList = ty.List[FrameTimecode] """Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" +CropRegion = ty.Tuple[int, int, int, int] +"""Type hint for rectangle of the form X0 Y0 X1 Y1 for cropping frames. Coordinates are relative +to source frame without downscaling. +""" + # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current # value doesn't seem to have caused any issues at least. @@ -145,7 +150,7 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> int: +def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> float: """Get the optimal default downscale factor based on a video's resolution (currently only the width in pixels is considered). @@ -159,10 +164,10 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI Returns: int: The default downscale factor to use to achieve at least the target effective_width. """ - assert not (frame_width < 1 or effective_width < 1) + assert frame_width > 0 and effective_width > 0 if frame_width < effective_width: return 1 - return frame_width // effective_width + return frame_width / float(effective_width) def get_scenes_from_cuts( @@ -991,6 +996,7 @@ def __init__( self._frame_buffer = [] self._frame_buffer_size = 0 + self._crop = None @property def interpolation(self) -> Interpolation: @@ -1006,6 +1012,35 @@ def stats_manager(self) -> ty.Optional[StatsManager]: """Getter for the StatsManager associated with this SceneManager, if any.""" return self._stats_manager + @property + def crop(self) -> ty.Optional[CropRegion]: + """Portion of the frame to crop. Tuple of 4 ints in the form (X0, Y0, X1, Y1) where X0, Y0 + describes one point and X1, Y1 is another which describe a rectangle inside of the frame. + Coordinates start from 0 and are inclusive. For example, with a 100x100 pixel video, + (0, 0, 99, 99) covers the entire frame.""" + if self._crop is None: + return None + (x0, y0, x1, y1) = self._crop + return (x0, y0, x1 - 1, y1 - 1) + + @crop.setter + def crop(self, value: CropRegion): + """Raises: + ValueError: All coordinates must be >= 0. + """ + if value is None: + self._crop = None + return + if not (len(value) == 4 and all(isinstance(v, int) for v in value)): + raise TypeError("crop region must be tuple of 4 ints") + # Verify that the provided crop results in a non-empty portion of the frame. + if any(coordinate < 0 for coordinate in value): + raise ValueError("crop coordinates must be >= 0") + (x0, y0, x1, y1) = value + # Internally we store the value in the form used to de-reference the image, which must be + # one-past the end. + self._crop = (min(x0, x1), min(y0, y1), max(x0, x1) + 1, max(y0, y1) + 1) + @property def downscale(self) -> int: """Factor to downscale each frame by. Will always be >= 1, where 1 @@ -1232,6 +1267,33 @@ def detect_scenes( if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: raise ValueError("end_time must be greater than or equal to 0!") + effective_frame_size = video.frame_size + if self._crop: + logger.debug(f"Crop set: top left = {self.crop[0:2]}, bottom right = {self.crop[2:4]}") + x0, y0, x1, y1 = self._crop + min_x, min_y = (min(x0, x1), min(y0, y1)) + max_x, max_y = (max(x0, x1), max(y0, y1)) + frame_width, frame_height = video.frame_size + if min_x >= frame_width or min_y >= frame_height: + raise ValueError("crop starts outside video boundary") + if max_x >= frame_width or max_y >= frame_height: + logger.warning("Warning: crop ends outside of video boundary.") + effective_frame_size = ( + 1 + min(max_x, frame_width) - min_x, + 1 + min(max_y, frame_height) - min_y, + ) + # Calculate downscale factor and log effective resolution. + if self.auto_downscale: + downscale_factor = compute_downscale_factor(max(effective_frame_size)) + else: + downscale_factor = self.downscale + logger.debug( + "Processing resolution: %d x %d, downscale: %1.1f", + int(effective_frame_size[0] / downscale_factor), + int(effective_frame_size[1] / downscale_factor), + downscale_factor, + ) + self._base_timecode = video.base_timecode # TODO: Figure out a better solution for communicating framerate to StatsManager. @@ -1251,19 +1313,6 @@ def detect_scenes( else: total_frames = video.duration.get_frames() - start_frame_num - # Calculate the desired downscale factor and log the effective resolution. - if self.auto_downscale: - downscale_factor = compute_downscale_factor(frame_width=video.frame_size[0]) - else: - downscale_factor = self.downscale - if downscale_factor > 1: - logger.info( - "Downscale factor set to %d, effective resolution: %d x %d", - downscale_factor, - video.frame_size[0] // downscale_factor, - video.frame_size[1] // downscale_factor, - ) - progress_bar = None if show_progress: progress_bar = tqdm( @@ -1320,7 +1369,7 @@ def _decode_thread( self, video: VideoStream, frame_skip: int, - downscale_factor: int, + downscale_factor: float, end_time: FrameTimecode, out_queue: queue.Queue, ): @@ -1361,12 +1410,16 @@ def _decode_thread( # Skip processing frames that have an incorrect size. continue - if downscale_factor > 1: + if self._crop: + (x0, y0, x1, y1) = self._crop + frame_im = frame_im[y0:y1, x0:x1] + + if downscale_factor > 1.0: frame_im = cv2.resize( frame_im, ( - round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor), + max(1, round(frame_im.shape[1] / downscale_factor)), + max(1, round(frame_im.shape[0] / downscale_factor)), ), interpolation=self._interpolation.value, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 446437cb..595d0988 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -116,6 +116,24 @@ def test_cli_default_detector(): assert invoke_scenedetect("-i {VIDEO} time {TIME}", config_file=None) == 0 +def test_cli_crop(): + """Test --crop functionality.""" + assert invoke_scenedetect("-i {VIDEO} --crop 0 0 256 256 time {TIME}", config_file=None) == 0 + + +def test_cli_crop_rejects_invalid(): + """Test --crop rejects invalid options.""" + # Outside of video bounds + assert ( + invoke_scenedetect("-i {VIDEO} --crop 4000 0 8000 100 time {TIME}", config_file=None) != 1 + ) + assert ( + invoke_scenedetect("-i {VIDEO} --crop 0 4000 100 8000 time {TIME}", config_file=None) != 1 + ) + # Negative numbers + assert invoke_scenedetect("-i {VIDEO} --crop 0 0 -256 -256 time {TIME}", config_file=None) != 1 + + @pytest.mark.parametrize("info_command", ["help", "about", "version"]) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about).""" diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 036c29e6..51238d76 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -21,6 +21,8 @@ from pathlib import Path from typing import List +import pytest + from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.detectors import AdaptiveDetector, ContentDetector from scenedetect.frame_timecode import FrameTimecode @@ -291,3 +293,36 @@ def test_detect_scenes_callback_adaptive(test_video_file): scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] + + +def test_detect_scenes_crop(test_video_file): + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.crop = (10, 10, 1900, 1000) + sm.add_detector(ContentDetector()) + + video_fps = video.frame_rate + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) + video.seek(start_time) + sm.auto_downscale = True + + _ = sm.detect_scenes(video=video, end_time=end_time) + scene_list = sm.get_scene_list() + assert [start for start, _ in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL + + +def test_crop_invalid(): + sm = SceneManager() + sm.crop = None + sm.crop = (0, 0, 0, 0) + sm.crop = (1, 1, 0, 0) + sm.crop = (0, 0, 1, 1) + with pytest.raises(TypeError): + sm.crop = 1 + with pytest.raises(TypeError): + sm.crop = (1, 1) + with pytest.raises(TypeError): + sm.crop = (1, 1, 1) + with pytest.raises(ValueError): + sm.crop = (1, 1, 1, -1) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index ccbc2ada..7e47e5e1 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -602,4 +602,7 @@ Development - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 - [improvement] `save_to_csv` now works with paths from `pathlib` - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module - - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` \ No newline at end of file + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` + - [feature] Add ability to crop input video before processing [#302](https://github.com/Breakthrough/PySceneDetect/issues/302) [#449](https://github.com/Breakthrough/PySceneDetect/issues/449) + - [cli] Add `--crop` option to `scenedetect` command and config file to crop video frames before scene detection + - [api] Add `crop` property to `SceneManager` to crop video frames before scene detection \ No newline at end of file From fc9cff5312c1925601bdf8efd2b96643cf671031 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Nov 2024 22:58:26 -0500 Subject: [PATCH 162/407] [docs] Finalize changelog for v0.6.5. --- website/pages/changelog.md | 60 ++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 7e47e5e1..150951e6 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,40 @@ Releases ## PySceneDetect 0.6 +### PySceneDetect 0.6.5 (TBD) + +#### Release Notes + +This release brings crop support, performance improvements to save-images, lots of bugfixes, and improved compatibility with MoviePy 2.0+. + +#### Changelog + + - [feature] Add ability to crop input video before processing [#302](https://github.com/Breakthrough/PySceneDetect/issues/302) [#449](https://github.com/Breakthrough/PySceneDetect/issues/449) + - [cli] Add `--crop` option to `scenedetect` command and config file to crop video frames before scene detection + - [api] Add `crop` property to `SceneManager` to crop video frames before scene detection + - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) + - [feature] Add new `--show` flag to `export-html` command to launch browser after processing [#442](https://github.com/Breakthrough/PySceneDetect/issues/442) + - [improvement] Add new `threading` option to `save-images`/`save_images()` [#456](https://github.com/Breakthrough/PySceneDetect/issues/456) + - Enabled by default, offloads image encoding and disk IO to separate threads + - Improves performance by up to 50% in some cases + - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters + - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it + - [improvement] `save_to_csv` now works with paths from `pathlib` + - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` + - [api] The MoviePy backend now works with MoviePy 2.0+ + - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) + - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) + - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/450) + - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix new detectors not working with `default-detector` config option + - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) + - [general] Updates to Windows distributions: + - The MoviePy backend is now included with Windows distributions + - Bundled Python interpreter is now Python 3.13 + - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 + + ### 0.6.4 (June 10, 2024) #### Release Notes @@ -581,28 +615,4 @@ Both the Windows installer and portable distributions now include signed executa Development ========================================================== -## PySceneDetect 0.6.5 (TBD) - - - [bugfix] Fix `SyntaxWarning` due to incorrect escaping [#400](https://github.com/Breakthrough/PySceneDetect/issues/400) - - [bugfix] Fix `ContentDetector` crash when using callbacks [#416](https://github.com/Breakthrough/PySceneDetect/issues/416) [#420](https://github.com/Breakthrough/PySceneDetect/issues/420) - - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) - - [feature] Add new `--show` flag to `export-html` command to launch browser after processing [#442](https://github.com/Breakthrough/PySceneDetect/issues/442) - - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/450) - - [improvement] Add new `threading` option to `save-images`/`save_images()` [#456](https://github.com/Breakthrough/PySceneDetect/issues/456) - - Enabled by default, offloads image encoding and disk IO to separate threads - - Improves performance by up to 50% in some cases - - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) - - [bugfix] Fix new detectors not working with `default-detector` config option - - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters - - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it - - [general] Updates to Windows distributions: - - The MoviePy backend is now included with Windows distributions - - Bundled Python interpreter is now Python 3.13 - - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 - - [improvement] `save_to_csv` now works with paths from `pathlib` - - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module - - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` - - [feature] Add ability to crop input video before processing [#302](https://github.com/Breakthrough/PySceneDetect/issues/302) [#449](https://github.com/Breakthrough/PySceneDetect/issues/449) - - [cli] Add `--crop` option to `scenedetect` command and config file to crop video frames before scene detection - - [api] Add `crop` property to `SceneManager` to crop video frames before scene detection \ No newline at end of file +## PySceneDetect 0.6.6 (TBD) From 23aecb49fbaa8f0e13e1d7ba664cc8d003d3c6e1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Nov 2024 23:00:05 -0500 Subject: [PATCH 163/407] [docs] Update CLI docs. --- docs/cli.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index ee9bfbe2..f29d9cce 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -57,10 +57,6 @@ Options Path to config file. See :ref:`config file reference ` for details. -.. option:: --crop X0 Y0 X1 Y1 - - Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99). - .. option:: -s CSV, --stats CSV Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis. @@ -89,6 +85,10 @@ Options Default: ``opencv`` +.. option:: --crop X0 Y0 X1 Y1 + + Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99). + .. option:: -d N, --downscale N Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set :option:`-d=1 <-d>` to disable downscaling. From c0459fe3408c765c1708949abebffb33084c518b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Nov 2024 23:12:22 -0500 Subject: [PATCH 164/407] [release] Finalize 0.6.5. --- .github/workflows/build-windows.yml | 2 +- appveyor.yml | 15 +- dist/installer/PySceneDetect.aip | 2105 ++++++++++++++++++++------- scenedetect/__init__.py | 2 +- website/pages/changelog.md | 8 +- website/pages/download.md | 8 +- website/pages/index.md | 2 +- 7 files changed, 1605 insertions(+), 537 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index a6975105..2f788f99 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -30,7 +30,7 @@ jobs: python-version: ["3.13"] env: - ffmpeg-version: "7.0" + ffmpeg-version: "7.1" IMAGEIO_FFMPEG_EXE: "" steps: diff --git a/appveyor.yml b/appveyor.yml index 99e83841..c46aaf71 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -14,7 +14,7 @@ skip_non_tags: true environment: matrix: - - PYTHON: "C:\\Python39-x64" + - PYTHON: "C:\\Python313-x64" # Encrypted AdvancedInstaller License ai_license_secret: secure: MOkULlGPSi0C1Hg2PU1h2SZg/eyQnPQhRJ1XFlavfMKMOoX9hY4pSjpdgW3psSau @@ -34,13 +34,14 @@ install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - - python -m pip install --upgrade pip + - python -m pip install --upgrade pip build wheel virtualenv setuptools - python -m pip install -r docs/requirements.txt - - python -m pip install --upgrade -r dist/requirements_windows.txt + - python -m pip install --upgrade -r dist/requirements_windows.txt --no-binary imageio-ffmpeg # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z - - 7z e ffmpeg-6.0-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r + - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-full_build.7z + - 7z e ffmpeg-7.1-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r + - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING WINDOWS EXE * * @@ -54,7 +55,7 @@ install: - move dist\windows\README* dist\scenedetect\ - move dist\windows\LICENSE* dist\scenedetect\thirdparty\ - move scenedetect\_thirdparty\LICENSE* dist\scenedetect\thirdparty\ - - move dist\ffmpeg\ffmpeg.exe dist\scenedetect\ + - copy dist\ffmpeg\ffmpeg.exe dist\scenedetect\ - move dist\ffmpeg\LICENSE dist\scenedetect\thirdparty\LICENSE-FFMPEG - cd dist/scenedetect - 7z a ../scenedetect-win64.zip * @@ -69,7 +70,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 21.8.1\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.2\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 00d73f4d..f8a7a16a 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -1,5 +1,5 @@ - + @@ -23,10 +23,10 @@ - + - + @@ -44,293 +44,376 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + - - - - + + + + + + + + + - - - + + + + + + - + - + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - + + + + + + + + + + + + + + + + - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + @@ -341,200 +424,222 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -542,23 +647,946 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -574,7 +1602,7 @@ - + @@ -708,8 +1736,6 @@ - - @@ -750,81 +1776,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 1463bc8f..3d818149 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.5-dev1" +__version__ = "0.6.5" init_logger() logger = getLogger("pyscenedetect") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 150951e6..58c89de7 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,7 +4,7 @@ Releases ## PySceneDetect 0.6 -### PySceneDetect 0.6.5 (TBD) +### PySceneDetect 0.6.5 (November 24, 2024) #### Release Notes @@ -34,8 +34,10 @@ This release brings crop support, performance improvements to save-images, lots - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - [general] Updates to Windows distributions: - The MoviePy backend is now included with Windows distributions - - Bundled Python interpreter is now Python 3.13 - - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 + - Python 3.9 -> Python 3.13 + - PyAV 10 -> 13.1.0 + - OpenCV 4.10.0.82 -> 4.10.0.84 + - Ffmpeg 6.0 -> 7.1 ### 0.6.4 (June 10, 2024) diff --git a/website/pages/download.md b/website/pages/download.md index 4d016e4b..c3d01ee6 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi ## Windows Build (64-bit Only)  
    -

    Latest Release: v0.6.4

    -

      Release Date:  June 10, 2024

    -  Installer  (recommended)      -  Portable .zip      +

    Latest Release: v0.6.5

    +

      Release Date:  November 24, 2024

    +  Installer  (recommended)      +  Portable .zip        Getting Started
    diff --git a/website/pages/index.md b/website/pages/index.md index 2b13d5f7..839356d3 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
    -

      Latest Release: v0.6.4 (June 10, 2024)

    +

      Latest Release: v0.6.5 (November 24, 2024)

      Download        Changelog        Documentation        Getting Started
    See the changelog for the latest release notes and known issues. From 3258b279ac6b2d9bd74c8df2a53b6b6b5d05344f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 25 Nov 2024 00:04:31 -0500 Subject: [PATCH 165/407] [docs] Update README.md. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b47d7c86..40819620 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.4 (June 10, 2024) +### Latest Release: v0.6.5 (November 24, 2024) **Website**: [scenedetect.com](https://www.scenedetect.com) From e25e2f98b59c8df51b76889a44bc2e24e4d47605 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 25 Nov 2024 00:07:59 -0500 Subject: [PATCH 166/407] [docs] Add docs for v0.6.5 and set as latest. --- .github/workflows/generate-docs.yml | 2 +- website/pages/docs.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 1dbfb384..247912c6 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -16,7 +16,7 @@ jobs: env: # TODO: Figure out a better way to handle figuring out what version /latest should be, # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.4' + scenedetect_docs_latest: '0.6.5' scenedetect_docs_dest: '' steps: diff --git a/website/pages/docs.md b/website/pages/docs.md index 381881e6..69e2fbab 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,7 @@ ## Stable * [latest](latest/) + * [v0.6.5](0.6.5/) * [v0.6.4](0.6.4/) * [v0.6.3](0.6.3/) * [v0.6.2](0.6.2/) From eee238194ab9ff89cb550b42f5e167b265358934 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 3 Dec 2024 19:36:15 -0500 Subject: [PATCH 167/407] Update scenedetect_package.md --- .github/ISSUE_TEMPLATE/scenedetect_package.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/scenedetect_package.md b/.github/ISSUE_TEMPLATE/scenedetect_package.md index a6f43779..7fbca92c 100644 --- a/.github/ISSUE_TEMPLATE/scenedetect_package.md +++ b/.github/ISSUE_TEMPLATE/scenedetect_package.md @@ -20,7 +20,7 @@ split_video_ffmpeg('my_video.mp4', scene_list) **Environment:** -Run `scenedetect version --all` and include the output. This will describe the environment/OS/platform and versions of dependencies you have installed. +Run `scenedetect version` and include the output. This will describe the environment/OS/platform and versions of dependencies you have installed. **Media/Files:** From 773896e314d044db9f93c42da1c4276b2a76b155 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 3 Dec 2024 19:36:32 -0500 Subject: [PATCH 168/407] Update scenedetect_app.md --- .github/ISSUE_TEMPLATE/scenedetect_app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/scenedetect_app.md b/.github/ISSUE_TEMPLATE/scenedetect_app.md index b5ba0c70..49658a85 100644 --- a/.github/ISSUE_TEMPLATE/scenedetect_app.md +++ b/.github/ISSUE_TEMPLATE/scenedetect_app.md @@ -20,7 +20,7 @@ Copy the output of running the application here. Where possible, generate a debu **Environment:** -The operating system and how you installed PySceneDetect may be relevant to the issue. Please run `scenedetect version --all` and copy the output here, or provide other details on how PySceneDetect was installed. +The operating system and how you installed PySceneDetect may be relevant to the issue. Please run `scenedetect version` and copy the output here, or provide other details on how PySceneDetect was installed. **Media/Files:** From 50d1ecc9e3a9d06f29b52f0686a4c5664fcb6a9b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 11 Dec 2024 22:31:18 -0500 Subject: [PATCH 169/407] [dist] Pin dependencies until bug is fixed. --- .github/workflows/build.yml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c21ce269..8c8fe41d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -107,7 +107,7 @@ jobs: python -m pip uninstall -y scenedetect - name: Upload Package - if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} + if: ${{ matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' }} uses: actions/upload-artifact@v4 with: name: scenedetect-dist diff --git a/requirements.txt b/requirements.txt index 2c45e1a5..e0ce5d3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # # PySceneDetect Requirements # -av>=9.2 +av >=9.2, <14.0 click>=8.0 numpy opencv-python From 92d53cb9b9f3959a532c9c84f9eb75e0ea5b1d12 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 11 Dec 2024 22:43:04 -0500 Subject: [PATCH 170/407] [build] Fix build while #466 is in progress --- .github/workflows/build.yml | 3 +-- requirements.txt | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8c8fe41d..12a884ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,8 +59,7 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip build wheel virtualenv setuptools${{ env.setuptools_version }} - pip install av opencv-python-headless --only-binary :all: - pip install -r requirements_headless.txt + pip install -r requirements_headless.txt --only-binary av,opencv-python-headless - name: Install MoviePy # TODO: We can only run MoviePy tests on systems that have ffmpeg. diff --git a/requirements.txt b/requirements.txt index e0ce5d3a..9f94530c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # # PySceneDetect Requirements # +# TODO(#466): Make av work with av >= 14. av >=9.2, <14.0 click>=8.0 numpy From 2be1a734daa16ee625d2210d54f2ed656226a674 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 11 Dec 2024 22:44:44 -0500 Subject: [PATCH 171/407] [build] Simplify install command for docs builder deps. --- .github/workflows/check-code-format.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index 502579a1..1cb694c2 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -26,8 +26,7 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - python -m pip install av opencv-python-headless --only-binary ":all:" - python -m pip install -r requirements_headless.txt + python -m pip install -r requirements_headless.txt --only-binary av,opencv-python-headless - name: Check Code Format (yapf) if: ${{ hashFiles('.style.yapf') != '' }} From a141fc117f861fb70b4790e3c2f1749bc9755c07 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 11 Dec 2024 22:56:54 -0500 Subject: [PATCH 172/407] [build] Pin headless deps until VideoStreamAv is fixed #466 --- requirements_headless.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements_headless.txt b/requirements_headless.txt index 4dfedd38..a9cef0d2 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -1,7 +1,8 @@ # # PySceneDetect Requirements for Headless Machines # -av>=9.2 +# TODO(#466): Make av work with av >= 14. +av >=9.2, <14.0 click>=8.0 numpy opencv-python-headless From fbffe4c45a2916637faa898fdfe332cc77b110c2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Dec 2024 17:55:38 -0500 Subject: [PATCH 173/407] [release] Rebuild v0.6.5 with correct installer license --- appveyor.yml | 8 +++++--- dist/installer/license65.dat.enc | Bin 416 -> 416 bytes dist/requirements_windows.txt | 13 +++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index c46aaf71..dfbd15ca 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,9 +17,9 @@ environment: - PYTHON: "C:\\Python313-x64" # Encrypted AdvancedInstaller License ai_license_secret: - secure: MOkULlGPSi0C1Hg2PU1h2SZg/eyQnPQhRJ1XFlavfMKMOoX9hY4pSjpdgW3psSau + secure: of3o1pInqCJYwKLFsiadbsRYazCmCuZq7r2roaYvYXmBvm6e6JHsRU47waylTmhm ai_license_salt: - secure: /LlGOUGZk8HQgrW6txtssTt8I6Z6pU7K3XOcqTqr2iKX4vLO3ZTdILgL/6M6u7gWVdRoUYfbxm4JVYjs4hfcmQ== + secure: +NKWwlkEptlThgfeL35pLo7EsnkJc+4WODm8tTg1aO5fc0duQ4r100fHQYj6nzhyUdy3Dhs/mOLkxD8rNbBiEQ== # SignPath Config for Code Signing deploy: @@ -27,6 +27,8 @@ deploy: url: https://app.signpath.io/API/v1/f2efa44c-5b5c-45f2-b44f-8f9dde708313/Integrations/AppVeyor?ProjectSlug=PySceneDetect&SigningPolicySlug=release-signing authorization: secure: FBgWCaxCCKOqc2spYf5NGWSNUGLbT5WeuC5U0k4Of1Ids9n51YWxhGlMyzLbdNBFe64RUcOSzk/N3emlQzbsJg== + on: + APPVEYOR_REPO_TAG: true # keep casing this way for Linux builds where variables are case-sensitive install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * @@ -70,7 +72,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.2\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.3\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer diff --git a/dist/installer/license65.dat.enc b/dist/installer/license65.dat.enc index 380139d5c48307f9ad95ce5b0f4dc5c25bb83cea..9288aaa8979170348a9bbd639446d6e0e0c39d12 100644 GIT binary patch literal 416 zcmV;R0bl-{4jB*cD25QQ^R|zvofs}0f`?$J(O3I9G+~cJiYf6B`1$BHYka7b!RS{!t=7o08Wmd5&h_3{LJGRz3IN z;zAb-qwr31L9@lM-M_7?9mb{1)8yP=q<~G{ss(VNI1YiC4!=X1YvPS2aBEN*JTFPrO{pBSRikT z8?iOfizfG+K-MMYxFtxt*~bD~CE*9Wn8}?v=PK>7WCkO8(;@0WITYe)qsxIzVD~OT<2?IeL284*v zB*>zYGT%qH2c@wHk!#oUCl{z6{*zdnzD8dKk4xnYv?13x=0unypjge_7aq+=#qzj1 z$Y`dK=0~aBWRuYw8X=9G|F^Kn0?@n0itHNe1zq|(AJVc<4ZixZh}~k&_iSl9mDzg} KL}oOSvplSj!O*|} literal 416 zcmV;R0bl;+0`C4e9W;hW1ZfKnSQPjY-25=@T8gj^%WGsHhh;yDFOrsKk37++1s9fm z)}I@jj(J71?fxoU>L*)blV?A!+Lqes@@IImVB;Ot*IW)<3(i|L2hkH#yjH0?W zr{MPio$+oFt2)Prl^OjP4cu)~Lg}h4F8hc|XDX@XJfz^g0PzxGOR}&^Sw6;=%MPnC z>4YM7qiM*q?-vt~6-riv_`89qrX3)eCj?QVU@N{V1@W)UeI$=YnGw>%q2t)d2)q~m zs+)6Rej=8q&lmgNG?Y0Z^PYf%L!e}SLex`h6uy zL%yf1@DZpyS+sIoR%%D$US*TC13)mX4?PIXKG0to#+YZ6MVTvH92{N&lu*0u*4FIo zVRrO|?fPD6Sao}PrFTn!y1#;F(`ts;feOq17HgWYJckbqthG(Lt-bI=8.0 +click==8.1.7 opencv-python-headless==4.10.0.84 +imageio-ffmpeg==0.5.1 +moviepy==2.1.1 +numpy==2.1.3 +platformdirs==4.3.6 +tqdm==4.67.1 -imageio-ffmpeg -moviepy -numpy -platformdirs +# Build-only and test-only requirements. pyinstaller pytest -tqdm From 1e961ccdcbee1ff5d4c36dcd5d8d6655a10a444f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Dec 2024 18:12:27 -0500 Subject: [PATCH 174/407] [release] Release v0.6.5.1 to fix #466 PyPI only release, no changes to Windows builds. --- requirements.txt | 3 +-- requirements_headless.txt | 3 +-- scenedetect/__init__.py | 2 +- scenedetect/backends/pyav.py | 16 +++++++--------- website/pages/changelog.md | 4 ++++ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9f94530c..2c45e1a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,7 @@ # # PySceneDetect Requirements # -# TODO(#466): Make av work with av >= 14. -av >=9.2, <14.0 +av>=9.2 click>=8.0 numpy opencv-python diff --git a/requirements_headless.txt b/requirements_headless.txt index a9cef0d2..4dfedd38 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -1,8 +1,7 @@ # # PySceneDetect Requirements for Headless Machines # -# TODO(#466): Make av work with av >= 14. -av >=9.2, <14.0 +av>=9.2 click>=8.0 numpy opencv-python-headless diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 3d818149..ba111a43 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.5" +__version__ = "0.6.5.1" init_logger() logger = getLogger("pyscenedetect") diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index cba203c7..9a558e7c 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -23,12 +23,7 @@ logger = getLogger("pyscenedetect") -VALID_THREAD_MODES = [ - av.codec.context.ThreadType.NONE, - av.codec.context.ThreadType.SLICE, - av.codec.context.ThreadType.FRAME, - av.codec.context.ThreadType.AUTO, -] +VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] class VideoStreamAv(VideoStream): @@ -88,9 +83,12 @@ def __init__( self._reopened = True if threading_mode: - threading_mode = threading_mode.upper() - if threading_mode not in VALID_THREAD_MODES: - raise ValueError("Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES) + try: + threading_mode = av.codec.context.ThreadType[threading_mode.upper()] + except KeyError as _: + raise ValueError( + "Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES + ) from None if not suppress_output: logger.debug("Restoring default ffmpeg log callbacks.") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 58c89de7..91360ce3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -39,6 +39,10 @@ This release brings crop support, performance improvements to save-images, lots - OpenCV 4.10.0.82 -> 4.10.0.84 - Ffmpeg 6.0 -> 7.1 +#### Python Distribution Changes + + * *v0.6.5.1* - Fix compatibility issues with PyAV 14+ [#466](https://github.com/Breakthrough/PySceneDetect/issues/466) + ### 0.6.4 (June 10, 2024) From d7b09725106a7cf3fd0f296778d73f8763590902 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Dec 2024 18:55:49 -0500 Subject: [PATCH 175/407] [build] Fix version script for Windows builds. --- dist/pre_release.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/pre_release.py b/dist/pre_release.py index eab54bb4..8bec0f1f 100644 --- a/dist/pre_release.py +++ b/dist/pre_release.py @@ -35,10 +35,10 @@ with open("dist/.version_info", "wb") as f: v = VERSION.split(".") - assert 2 <= len(v) <= 3, f"Unrecognized version format: {VERSION}" - if len(v) < 3: + assert 2 <= len(v) <= 4, f"Unrecognized version format: {VERSION}" + while len(v) < 4: v.append("0") - (maj, min, pat, bld) = v[0], v[1], v[2], 0 + (maj, min, pat, bld) = v[0], v[1], v[2], v[3] # If either major or minor have suffixes, assume it's a dev/beta build and set # the final component to 999. if not min.isdigit(): From 69c1922de823b84d646e3161b45182c6709cb60f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 23 Dec 2024 14:03:47 -0500 Subject: [PATCH 176/407] [bugfix] Fix incorrect type hint leading to runtime failures #468 --- scenedetect/__init__.py | 2 +- scenedetect/scene_manager.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index ba111a43..daf9baf1 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.5.1" +__version__ = "0.6.5.2" init_logger() logger = getLogger("pyscenedetect") diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index dbdfc563..d4b1aa97 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -410,13 +410,13 @@ def write_scene_list_html( def _scale_image( - image: cv2.Mat, + image: np.ndarray, aspect_ratio: float, height: ty.Optional[int], width: ty.Optional[int], scale: ty.Optional[float], interpolation: Interpolation, -) -> cv2.Mat: +) -> np.ndarray: # TODO: Combine this resize with the ones below. if aspect_ratio is not None: image = cv2.resize( @@ -709,9 +709,9 @@ def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[F def resize_image( self, - image: cv2.Mat, + image: np.ndarray, aspect_ratio: float, - ) -> cv2.Mat: + ) -> np.ndarray: return _scale_image( image, aspect_ratio, self._height, self._width, self._scale, self._interpolation ) From 9760c0a9b8531bf4f6901e4c7bbad2fe47d52d7e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 23 Dec 2024 20:34:38 -0500 Subject: [PATCH 177/407] [docs] Update changelog for 0.6.5.2 Should have been done for the tagged release but was missed. --- website/pages/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 91360ce3..f816bea5 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -42,6 +42,7 @@ This release brings crop support, performance improvements to save-images, lots #### Python Distribution Changes * *v0.6.5.1* - Fix compatibility issues with PyAV 14+ [#466](https://github.com/Breakthrough/PySceneDetect/issues/466) + * *v0.6.5.2* - Fix for `AttributeError: module 'cv2' has no attribute 'Mat'` [#468](https://github.com/Breakthrough/PySceneDetect/issues/466) ### 0.6.4 (June 10, 2024) From 09f2b64ab41654f12ada9cb3822d840c467dfabe Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 31 Dec 2024 11:51:17 -0500 Subject: [PATCH 178/407] [docs] Add missing docs for FlashFilter --- scenedetect/scene_detector.py | 14 +++++++++++++- scenedetect/scene_manager.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 4352d1c5..18821fd7 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -64,6 +64,8 @@ def is_processing_required(self, frame_num: int) -> bool: True otherwise (i.e. the frame_img passed to process_frame is required to be passed to process_frame for the given frame_num). + + :meta private: """ metric_keys = self.get_metrics() return not metric_keys or not ( @@ -132,6 +134,8 @@ class SparseSceneDetector(SceneDetector): as opposed to just a single cut. An example of a SparseSceneDetector is the MotionDetector. + + :meta private: """ def process_frame( @@ -160,13 +164,22 @@ def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: class FlashFilter: + """Filters fast-cuts to enforce minimum scene length.""" + class Mode(Enum): + """Which mode the filter should use for enforcing minimum scene length.""" + MERGE = 0 """Merge consecutive cuts shorter than filter length.""" SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" def __init__(self, mode: Mode, length: int): + """ + Arguments: + mode: The mode to use when enforcing `length`. + length: Number of frames to use when filtering cuts. + """ self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. self._last_above = None # Last frame above threshold. @@ -176,7 +189,6 @@ def __init__(self, mode: Mode, length: int): @property def max_behind(self) -> int: - """Maximum number of frames a filtered cut can be behind the current frame.""" return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index d4b1aa97..9af8a661 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -191,7 +191,7 @@ def get_scenes_from_cuts( was processed (used to generate last scene's end time). start_frame: The start frame or FrameTimecode of the cut list. Used to generate the first scene's start time. - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. + base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. Returns: List of tuples in the form (start_time, end_time), where both start_time and end_time are FrameTimecode objects representing the exact time/frame where each From d7ed2e7653492f1600b469171e971e718a8189f0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 12 Jan 2025 19:53:06 -0500 Subject: [PATCH 179/407] [build] Disable Python 3.7 on Ubuntu 24 --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12a884ea..6807a714 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,12 +26,16 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: + # TODO: Bump ubuntu 20 to 22 when past EOL date. os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. - os: macos-14 python-version: "3.7" + # ubuntu 24+ does not have Python 3.7 + - os: ubuntu-latest + python-version: "3.7" env: # Version is extracted below and used to find correct package install path. From 2b43b9f9c43212b2180177cb704cf9c01f47e0e4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 19 Jan 2025 21:47:26 -0500 Subject: [PATCH 180/407] [backends] Enable auto-rotate for OpenCV backend Is now disabled in the latest version of OpenCV. See https://github.com/opencv/opencv/issues/26795 for context. Fixes failing rotation tests. --- scenedetect/backends/opencv.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 862a19e2..c9d0d389 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -350,6 +350,7 @@ def _open_capture(self, framerate: Optional[float] = None): self._cap = cap self._frame_rate = framerate self._has_grabbed = False + cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 # TODO(#168): Support non-monotonic timing for `position`. VFR timecode support is a From 23c29835aba510458e8a0ab2f5ac5f4b27735ad0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 19 Jan 2025 21:49:41 -0500 Subject: [PATCH 181/407] [project] Fix formatting --- scenedetect/_thirdparty/simpletable.py | 2 +- tests/test_scene_manager.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py index df01519d..634c216f 100644 --- a/scenedetect/_thirdparty/simpletable.py +++ b/scenedetect/_thirdparty/simpletable.py @@ -287,7 +287,7 @@ def __str__(self): # Set encoding page.append( - '' % self.encoding + '' % self.encoding ) for table in self.tables: diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 51238d76..b59ae75e 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -95,9 +95,7 @@ def test_save_images(test_video_file, tmp_path: Path): image_name_glob = "scenedetect.tempfile.*.jpg" image_name_template = ( - "scenedetect.tempfile." - "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." - "$TIMESTAMP_MS.$TIMECODE" + "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" ) video_fps = video.frame_rate @@ -134,9 +132,7 @@ def test_save_images_singlethreaded(test_video_file, tmp_path: Path): image_name_glob = "scenedetect.tempfile.*.jpg" image_name_template = ( - "scenedetect.tempfile." - "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." - "$TIMESTAMP_MS.$TIMECODE" + "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" ) video_fps = video.frame_rate From 709770a6b9ef3b85633574f0db7777e863740b83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 19:31:45 -0500 Subject: [PATCH 182/407] Bump jinja2 from 3.1.4 to 3.1.5 in /website in the pip group (#471) Bumps the pip group in /website with 1 update: [jinja2](https://github.com/pallets/jinja). Updates `jinja2` from 3.1.4 to 3.1.5 - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index cd132c7f..8455efc2 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.1.4 +jinja2==3.1.5 From 3ee41b50388610d0036168eb28e04bd69f6f9e89 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 20 Jan 2025 21:24:44 -0500 Subject: [PATCH 183/407] [bugfix] Fix CLI crash when running `split-video --mkvmerge` but no output directory was set --- scenedetect/video_splitter.py | 48 ++++++++++++++++------------------- website/pages/changelog.md | 4 ++- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index bbca1f1c..ae883165 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -193,42 +193,38 @@ def split_video_mkvmerge( if not scene_list: return 0 - logger.info("Splitting video with mkvmerge, output path template:\n %s", output_file_template) - if output_dir: - logger.info("Output folder:\n %s", output_file_template) - if video_name is None: video_name = Path(input_video_path).stem - ret_val = 0 - # mkvmerge doesn't support adding scene metadata to filenames. It always adds the scene # number prefixed with a dash to the filenames. template = Template(output_file_template) output_path = template.safe_substitute(VIDEO_NAME=video_name) if output_dir: output_path = Path(output_dir) / output_path - output_path.parent.mkdir(parents=True, exist_ok=True) - + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + logger.info(f"Splitting video with mkvmerge, path template: {output_path}") + + call_list = ["mkvmerge"] + if not show_output: + call_list.append("--quiet") + call_list += [ + "-o", + str(output_path), + "--split", + "parts:%s" + % ",".join( + [ + "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) + for start_time, end_time in scene_list + ] + ), + input_video_path, + ] + total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() + processing_start_time = time.time() + ret_val = 0 try: - call_list = ["mkvmerge"] - if not show_output: - call_list.append("--quiet") - call_list += [ - "-o", - str(output_path), - "--split", - "parts:%s" - % ",".join( - [ - "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list - ] - ), - input_video_path, - ] - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() - processing_start_time = time.time() # TODO: Capture stdout/stderr and show that if the command fails. ret_val = invoke_command(call_list) if show_output: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index f816bea5..d2d5b299 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -622,4 +622,6 @@ Both the Windows installer and portable distributions now include signed executa Development ========================================================== -## PySceneDetect 0.6.6 (TBD) +## PySceneDetect 0.6.6 (In Development) + + - [bugfix] Fix crash when using `-m`/`--mkvmerge` flag with `split-video` command [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) From 58c6c151d0745312bb12dc6a4ea78af9e9aaf308 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 20 Jan 2025 21:53:50 -0500 Subject: [PATCH 184/407] [video_splitter] Fix inconsistent naming in output paths --- scenedetect/_cli/commands.py | 3 +++ scenedetect/video_splitter.py | 11 ++++++++--- tests/test_cli.py | 23 +++++++++++++++++++++-- website/pages/changelog.md | 4 +++- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 9f6b5d32..0ab707a1 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -215,6 +215,9 @@ def split_video( """Handles the `split-video` command.""" del cuts # split-video only uses scenes. + if use_mkvmerge: + name_format = name_format.removesuffix("-$SCENE_NUMBER") + # Add proper extension to filename template if required. dot_pos = name_format.rfind(".") extension_length = 0 if dot_pos < 0 else len(name_format) - (dot_pos + 1) diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index ae883165..819d678e 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -156,8 +156,8 @@ def default_formatter(template: str) -> PathFormatter: def split_video_mkvmerge( input_video_path: str, scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[Path] = None, - output_file_template: str = "$VIDEO_NAME.mkv", + output_dir: ty.Optional[ty.Union[str, Path]] = None, + output_file_template: ty.Optional[ty.Union[str, Path]] = "$VIDEO_NAME.mkv", video_name: ty.Optional[str] = None, show_output: bool = False, suppress_output=None, @@ -202,8 +202,13 @@ def split_video_mkvmerge( output_path = template.safe_substitute(VIDEO_NAME=video_name) if output_dir: output_path = Path(output_dir) / output_path - Path(output_path).parent.mkdir(parents=True, exist_ok=True) + output_path = Path(output_path) logger.info(f"Splitting video with mkvmerge, path template: {output_path}") + # If there is only one scene, mkvmerge omits the suffix for the output. To make the filenames + # consistent with the output when there are multiple scenes present, we append "-001". + if len(scene_list) == 1: + output_path = output_path.with_stem(output_path.stem + "-001") + output_path.parent.mkdir(parents=True, exist_ok=True) call_list = ["mkvmerge"] if not show_output: diff --git a/tests/test_cli.py b/tests/test_cli.py index 595d0988..50b51b4c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -445,25 +445,44 @@ def test_cli_split_video_mkvmerge(tmp_path: Path): ) == 0 ) + for scene in range(DEFAULT_NUM_SCENES): + path = tmp_path / (Path(DEFAULT_VIDEO_PATH).stem + f"-Scene-{1 + scene:03d}.mkv") + path.unlink(missing_ok=False) + # If only one scene (just using a few frames), should keep same output template. + assert ( + invoke_scenedetect( + "-i {VIDEO} -s {STATS} time -e 3 {DETECTOR} split-video -m", output_dir=tmp_path + ) + == 0 + ) + path = tmp_path / (Path(DEFAULT_VIDEO_PATH).stem + "-Scene-001.mkv") + path.unlink(missing_ok=False) + # -m takes precedence over -c assert ( invoke_scenedetect( "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -c", output_dir=tmp_path ) == 0 ) + # Custom filename format + for scene in range(DEFAULT_NUM_SCENES): + path = tmp_path / (Path(DEFAULT_VIDEO_PATH).stem + f"-Scene-{1 + scene:03d}.mkv") + path.unlink(missing_ok=False) assert ( invoke_scenedetect( - '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f "test$VIDEO_NAME"', + "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -f test$VIDEO_NAME", output_dir=tmp_path, ) == 0 ) + for scene in range(DEFAULT_NUM_SCENES): + path = tmp_path / ("test" + Path(DEFAULT_VIDEO_PATH).stem + f"-{1 + scene:03d}.mkv") + path.unlink(missing_ok=False) # -a/--args and -m/--mkvmerge are mutually exclusive assert invoke_scenedetect( '-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -m -a "-c:v libx264"', output_dir=tmp_path, ) - # TODO: Check for existence of split video files. def test_cli_save_images(tmp_path: Path): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index d2d5b299..19083ca9 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -624,4 +624,6 @@ Development ## PySceneDetect 0.6.6 (In Development) - - [bugfix] Fix crash when using `-m`/`--mkvmerge` flag with `split-video` command [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) + - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) + - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option + - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module From 22ff3526d8cc622c6f19057e0ad264ea3d6cb3b1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 20 Jan 2025 23:02:19 -0500 Subject: [PATCH 185/407] [frame_timecode] Fix UnboundedLocalError for strings with too many ":" characters Fixes #476 --- scenedetect/frame_timecode.py | 2 ++ tests/test_frame_timecode.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 958c1cd2..d942bbc0 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -286,6 +286,8 @@ def _parse_timecode_string(self, input: str) -> int: # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") + if len(values) not in (2, 3): + raise ValueError("Invalid timecode (too many separators).") # Case of 'HH:MM:SS[.nnn]' if len(values) == 3: hrs, mins = int(values[0]), int(values[1]) diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 39b25125..14c423e4 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -122,6 +122,13 @@ def test_timecode_string(): assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 + # MM:SS[.nnn] is also allowed + assert FrameTimecode(timecode="00:01", fps=1).frame_num == 1 + assert FrameTimecode(timecode="00:01.9999", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:02.0000", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:02.0001", fps=1).frame_num == 2 + + # Conversion edge cases assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 @@ -135,6 +142,10 @@ def test_timecode_string(): assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 + # Check too many ":" characters (https://github.com/Breakthrough/PySceneDetect/issues/476) + with pytest.raises(ValueError): + FrameTimecode(timecode="01:01:00:00.001", fps=1) + def test_get_frames(): """Test FrameTimecode get_frames() method.""" From 6f1ba3b6e61b7e7ff4cd7388efc67744a8f15cbe Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 1 Feb 2025 00:13:27 -0500 Subject: [PATCH 186/407] [api] Add comments to guide future API changes. --- scenedetect/scene_detector.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 18821fd7..cdcc57e4 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -31,7 +31,13 @@ from scenedetect.stats_manager import StatsManager - +# TODO(v0.7): Add a new base class called just "Detector" to eventually replace SceneDetector. +# +# class Detector: +# def process(buffer: ty.List[ty.Tuple[numpy.ndarray, FrameTimecode]]): +# # Return EventType.CUT, FADE_IN, FADE_OUT, etc... +# pass +# class SceneDetector: """Base class to inherit from when implementing a scene detection algorithm. @@ -125,6 +131,7 @@ def event_buffer_length(self) -> int: return 0 +# TODO(v0.7): Remove this early, no point in keeping it around. class SparseSceneDetector(SceneDetector): """Base class to inherit from when implementing a sparse scene detection algorithm. From 429434d745bc4457ad4737beb64cdf53a7726574 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Feb 2025 11:34:25 -0500 Subject: [PATCH 187/407] [build] Fix formatting --- scenedetect/scene_detector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index cdcc57e4..014bf31a 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -31,6 +31,7 @@ from scenedetect.stats_manager import StatsManager + # TODO(v0.7): Add a new base class called just "Detector" to eventually replace SceneDetector. # # class Detector: From febe4a50357a44709dcf3e554ab7156a7f0332c9 Mon Sep 17 00:00:00 2001 From: welix Date: Sat, 8 Feb 2025 11:35:44 +0900 Subject: [PATCH 188/407] Repace _compute_frame_average with numpy.mean (#480) repace _compute_frame_average with numpy.mean --- scenedetect/detectors/threshold_detector.py | 28 +-------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index b93987b2..4121d9c8 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -25,32 +25,6 @@ logger = getLogger("pyscenedetect") -## -## ThresholdDetector Helper Functions -## - - -def _compute_frame_average(frame: numpy.ndarray) -> float: - """Computes the average pixel value/intensity for all pixels in a frame. - - The value is computed by adding up the 8-bit R, G, and B values for - each pixel, and dividing by the number of pixels multiplied by 3. - - Arguments: - frame: Frame representing the RGB pixels to average. - - Returns: - Average pixel intensity across all 3 channels of `frame` - """ - num_pixel_values = float(frame.shape[0] * frame.shape[1] * frame.shape[2]) - avg_pixel_value = numpy.sum(frame[:, :, :]) / num_pixel_values - return avg_pixel_value - - -## -## ThresholdDetector Class Implementation -## - class ThresholdDetector(SceneDetector): """Detects fast cuts/slow fades in from and out to a given threshold level. @@ -150,7 +124,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int ): frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] else: - frame_avg = _compute_frame_average(frame_img) + frame_avg = numpy.mean(frame_img) if self.stats_manager is not None: self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) From d6b318cfb5f5f09d631c3d9ee02e1a41a73eb54b Mon Sep 17 00:00:00 2001 From: welix Date: Sun, 16 Feb 2025 13:17:48 +0900 Subject: [PATCH 189/407] [benchmark/WIP] Benchmarking pyscenedetect detectors' performance (#484) * create benchmark/ directory for pyscenedetect performance evaluation * implemented evaluator on the BBC dataset --- .gitignore | 4 +++ benchmarks/BBC/.gitkeep | 0 benchmarks/README.md | 50 ++++++++++++++++++++++++++++++++++++++ benchmarks/bbc_dataset.py | 26 ++++++++++++++++++++ benchmarks/benchmark.py | 51 +++++++++++++++++++++++++++++++++++++++ benchmarks/evaluator.py | 35 +++++++++++++++++++++++++++ 6 files changed, 166 insertions(+) create mode 100644 benchmarks/BBC/.gitkeep create mode 100644 benchmarks/README.md create mode 100644 benchmarks/bbc_dataset.py create mode 100644 benchmarks/benchmark.py create mode 100644 benchmarks/evaluator.py diff --git a/.gitignore b/.gitignore index 1171c56e..c6d36daa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ tests/resources/* *.mkv *.m4v *.csv +benchmarks/BCC/*.mp4 +*.txt +benchmarks/RAI/*.mp4 +*.txt # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore diff --git a/benchmarks/BBC/.gitkeep b/benchmarks/BBC/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..a6c5f24a --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,50 @@ +# Benchmarking PySceneDetect +This repository benchmarks the performance of PySceneDetect in terms of both latency and accuracy. +We evaluate it using the standard dataset for video shot detection: [BBC](https://zenodo.org/records/14865504). + +## Dataset Download +### BBC +``` +# annotation +wget -O BBC/fixed.zip https://zenodo.org/records/14873790/files/fixed.zip +unzip BBC/fixed.zip -d BBC +rm -rf BBC/fixed.zip + +# videos +wget -O BBC/videos.zip https://zenodo.org/records/14873790/files/videos.zip +unzip BBC/videos.zip -d BBC +rm -rf BBC/videos.zip +``` + +### Evaluation +To evaluate PySceneDetect on a dataset, run the following command: +``` +python benchmark.py -d --detector +``` +For example, to evaluate ContentDetector on the BBC dataset: +``` +python evaluate.py -d BBC --detector detect-content +``` + +### Result +The performance is computed as recall, precision, f1, and elapsed time. +The following results indicate that ContentDetector achieves the highest performance on the BBC dataset. + +| Detector | Recall | Precision | F1 | Elapsed time (second) | +|:-----------------:|:------:|:---------:|:-----:|:---------------------:| +| AdaptiveDetector | 7.80 | 96.18 | 14.44 | 25.75 | +| ContentDetector | 84.52 | 88.77 | 86.59 | 25.50 | +| HashDetector | 8.57 | 80.27 | 15.48 | 23.78 | +| HistogramDetector | 8.22 | 70.82 | 14.72 | 18.60 | +| ThresholdDetector | 0.00 | 0.00 | 0.00 | 18.95 | + +## Citation +### BBC +``` +@InProceedings{bbc_dataset, + author = {Lorenzo Baraldi and Costantino Grana and Rita Cucchiara}, + title = {A Deep Siamese Network for Scene Detection in Broadcast Videos}, + booktitle = {Proceedings of the 23rd ACM International Conference on Multimedia}, + year = {2015}, +} +``` \ No newline at end of file diff --git a/benchmarks/bbc_dataset.py b/benchmarks/bbc_dataset.py new file mode 100644 index 00000000..d297a5a7 --- /dev/null +++ b/benchmarks/bbc_dataset.py @@ -0,0 +1,26 @@ +import os +import glob + +class BBCDataset: + """ + The BBC Dataset, proposed by Baraldi et al. in A deep siamese network for scene detection in broadcast videos + Link: https://arxiv.org/abs/1510.08893 + The dataset consists of 11 videos (BBC/videos/bbc_01.mp4 to BBC/videos/bbc_11.mp4). + The annotated scenes are provided in corresponding files (BBC/fixed/[i]-scenes.txt). + """ + def __init__(self, dataset_dir: str): + self._video_files = [file for file in sorted(glob.glob(os.path.join(dataset_dir, 'videos', '*.mp4')))] + self._scene_files = [file for file in sorted(glob.glob(os.path.join(dataset_dir, 'fixed', '*-scenes.txt')))] + assert (len(self._video_files) == len(self._scene_files)) + for video_file, scene_file in zip(self._video_files, self._scene_files): + video_id = os.path.basename(video_file).replace('bbc_', '').split('.')[0] + scene_id = os.path.basename(scene_file).split('-')[0] + assert (video_id == scene_id) + + def __getitem__(self, index): + video_file = self._video_files[index] + scene_file = self._scene_files[index] + return video_file, scene_file + + def __len__(self): + return len(self._video_files) diff --git a/benchmarks/benchmark.py b/benchmarks/benchmark.py new file mode 100644 index 00000000..5f658bbe --- /dev/null +++ b/benchmarks/benchmark.py @@ -0,0 +1,51 @@ +import time +import argparse +from bbc_dataset import BBCDataset +from evaluator import Evaluator + +from tqdm import tqdm +from scenedetect import detect +from scenedetect import AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ThresholdDetector + +def _load_detector(detector_name: str): + detector_map = { + 'detect-adaptive': AdaptiveDetector(), + 'detect-content': ContentDetector(), + 'detect-hash': HashDetector(), + 'detect-hist': HistogramDetector(), + 'detect-threshold': ThresholdDetector(), + } + return detector_map[detector_name] + +def _detect_scenes(detector, dataset): + pred_scenes = {} + for video_file, scene_file in tqdm(dataset): + start = time.time() + pred_scene_list = detect(video_file, detector) + elapsed = time.time() - start + + pred_scenes[scene_file] = { + 'video_file': video_file, + 'elapsed': elapsed, + 'pred_scenes': [scene[1].frame_num for scene in pred_scene_list] + } + + return pred_scenes + +def main(args): + dataset = BBCDataset('BBC') + detector = _load_detector(args.detector) + pred_scenes = _detect_scenes(detector, dataset) + evaluator = Evaluator() + result = evaluator.evaluate_performance(pred_scenes) + + print('Detector: {} Recall: {:.2f}, Precision: {:.2f}, F1: {:.2f} Elapsed time: {:.2f}' + .format(args.detector, result['recall'], result['precision'], result['f1'], result['elapsed'])) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Benchmarking PySceneDetect performance.') + parser.add_argument('--detector', type=str, choices=['detect-adaptive', 'detect-content', 'detect-hash', 'detect-hist', 'detect-threshold'], + default='detect-content', help='Detector name. Implemented detectors are listed: https://www.scenedetect.com/docs/latest/cli.html') + args = parser.parse_args() + main(args) \ No newline at end of file diff --git a/benchmarks/evaluator.py b/benchmarks/evaluator.py new file mode 100644 index 00000000..6a8190da --- /dev/null +++ b/benchmarks/evaluator.py @@ -0,0 +1,35 @@ +from statistics import mean + +class Evaluator: + def __init__(self): + pass + + def _load_scenes(self, scene_filename): + with open(scene_filename) as f: + gt_scene_list = [x.strip().split('\t')[1] for x in f.readlines()] + gt_scene_list = [int(x) + 1 for x in gt_scene_list] + return gt_scene_list + + def evaluate_performance(self, pred_scenes): + total_correct = 0 + total_pred = 0 + total_gt = 0 + + for scene_file, pred in pred_scenes.items(): + gt_scene_list = self._load_scenes(scene_file) + pred_list = pred['pred_scenes'] + total_correct += len(set(pred_list) & set(gt_scene_list)) + total_pred += len(pred_list) + total_gt += len(gt_scene_list) + + recall = total_correct / total_gt + precision = total_correct / total_pred + f1 = 2 * recall * precision / (recall + precision) if (recall + precision) != 0 else 0 + avg_elapsed = mean([x['elapsed'] for x in pred_scenes.values()]) + result = { + 'recall': recall * 100, + 'precision': precision * 100, + 'f1': f1 * 100, + 'elapsed': avg_elapsed + } + return result \ No newline at end of file From 518075ab4eb0ce05c1fa5ec867df084f68716ccf Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 16 Feb 2025 22:20:28 -0500 Subject: [PATCH 190/407] [benchmark] Fix detector not being reset between runs --- benchmarks/README.md | 12 +++--- benchmarks/bbc_dataset.py | 20 ++++++--- benchmarks/benchmark.py | 89 ++++++++++++++++++++++++++------------- benchmarks/evaluator.py | 21 +++++---- 4 files changed, 91 insertions(+), 51 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index a6c5f24a..19a16190 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -27,16 +27,16 @@ python evaluate.py -d BBC --detector detect-content ``` ### Result -The performance is computed as recall, precision, f1, and elapsed time. +The performance is computed as recall, precision, f1, and elapsed time. The following results indicate that ContentDetector achieves the highest performance on the BBC dataset. | Detector | Recall | Precision | F1 | Elapsed time (second) | |:-----------------:|:------:|:---------:|:-----:|:---------------------:| -| AdaptiveDetector | 7.80 | 96.18 | 14.44 | 25.75 | -| ContentDetector | 84.52 | 88.77 | 86.59 | 25.50 | -| HashDetector | 8.57 | 80.27 | 15.48 | 23.78 | -| HistogramDetector | 8.22 | 70.82 | 14.72 | 18.60 | -| ThresholdDetector | 0.00 | 0.00 | 0.00 | 18.95 | +| AdaptiveDetector | 87.52 | 97.21 | 92.11 | 27.84 | +| ContentDetector | 85.23 | 89.53 | 87.33 | 26.46 | +| HashDetector | 92.96 | 76.27 | 83.79 | 16.26 | +| HistogramDetector | 90.55 | 72.76 | 80.68 | 16.13 | +| ThresholdDetector | 0.00 | 0.00 | 0.00 | 18.95 | ## Citation ### BBC diff --git a/benchmarks/bbc_dataset.py b/benchmarks/bbc_dataset.py index d297a5a7..66a5a5b7 100644 --- a/benchmarks/bbc_dataset.py +++ b/benchmarks/bbc_dataset.py @@ -1,5 +1,6 @@ -import os import glob +import os + class BBCDataset: """ @@ -8,14 +9,19 @@ class BBCDataset: The dataset consists of 11 videos (BBC/videos/bbc_01.mp4 to BBC/videos/bbc_11.mp4). The annotated scenes are provided in corresponding files (BBC/fixed/[i]-scenes.txt). """ + def __init__(self, dataset_dir: str): - self._video_files = [file for file in sorted(glob.glob(os.path.join(dataset_dir, 'videos', '*.mp4')))] - self._scene_files = [file for file in sorted(glob.glob(os.path.join(dataset_dir, 'fixed', '*-scenes.txt')))] - assert (len(self._video_files) == len(self._scene_files)) + self._video_files = [ + file for file in sorted(glob.glob(os.path.join(dataset_dir, "videos", "*.mp4"))) + ] + self._scene_files = [ + file for file in sorted(glob.glob(os.path.join(dataset_dir, "fixed", "*.txt"))) + ] + assert len(self._video_files) == len(self._scene_files) for video_file, scene_file in zip(self._video_files, self._scene_files): - video_id = os.path.basename(video_file).replace('bbc_', '').split('.')[0] - scene_id = os.path.basename(scene_file).split('-')[0] - assert (video_id == scene_id) + video_id = os.path.basename(video_file).replace("bbc_", "").split(".")[0] + scene_id = os.path.basename(scene_file).split("_")[0] + assert video_id == scene_id def __getitem__(self, index): video_file = self._video_files[index] diff --git a/benchmarks/benchmark.py b/benchmarks/benchmark.py index 5f658bbe..bd0bc09e 100644 --- a/benchmarks/benchmark.py +++ b/benchmarks/benchmark.py @@ -1,51 +1,82 @@ -import time import argparse +import time + from bbc_dataset import BBCDataset from evaluator import Evaluator - from tqdm import tqdm -from scenedetect import detect -from scenedetect import AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ThresholdDetector -def _load_detector(detector_name: str): +from scenedetect import ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, + ThresholdDetector, + detect, +) + + +def make_detector(detector_name: str): detector_map = { - 'detect-adaptive': AdaptiveDetector(), - 'detect-content': ContentDetector(), - 'detect-hash': HashDetector(), - 'detect-hist': HistogramDetector(), - 'detect-threshold': ThresholdDetector(), + "detect-adaptive": AdaptiveDetector(), + "detect-content": ContentDetector(), + "detect-hash": HashDetector(), + "detect-hist": HistogramDetector(), + "detect-threshold": ThresholdDetector(), } return detector_map[detector_name] -def _detect_scenes(detector, dataset): + +def _detect_scenes(detector_type: str, dataset): pred_scenes = {} for video_file, scene_file in tqdm(dataset): start = time.time() + detector = make_detector(detector_type) pred_scene_list = detect(video_file, detector) elapsed = time.time() - start - - pred_scenes[scene_file] = { - 'video_file': video_file, - 'elapsed': elapsed, - 'pred_scenes': [scene[1].frame_num for scene in pred_scene_list] + scenes = { + scene_file: { + "video_file": video_file, + "elapsed": elapsed, + "pred_scenes": [scene[1].frame_num for scene in pred_scene_list], + } } + result = Evaluator().evaluate_performance(scenes) + print(f"{video_file} results:") + print( + "Recall: {:.2f}, Precision: {:.2f}, F1: {:.2f} Elapsed time: {:.2f}\n".format( + result["recall"], result["precision"], result["f1"], result["elapsed"] + ) + ) + pred_scenes.update(scenes) return pred_scenes -def main(args): - dataset = BBCDataset('BBC') - detector = _load_detector(args.detector) - pred_scenes = _detect_scenes(detector, dataset) - evaluator = Evaluator() - result = evaluator.evaluate_performance(pred_scenes) - print('Detector: {} Recall: {:.2f}, Precision: {:.2f}, F1: {:.2f} Elapsed time: {:.2f}' - .format(args.detector, result['recall'], result['precision'], result['f1'], result['elapsed'])) +def main(args): + pred_scenes = _detect_scenes(detector_type=args.detector, dataset=BBCDataset("BBC")) + result = Evaluator().evaluate_performance(pred_scenes) + print("Overall Results:") + print( + "Detector: {} Recall: {:.2f}, Precision: {:.2f}, F1: {:.2f} Elapsed time: {:.2f}".format( + args.detector, result["recall"], result["precision"], result["f1"], result["elapsed"] + ) + ) -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Benchmarking PySceneDetect performance.') - parser.add_argument('--detector', type=str, choices=['detect-adaptive', 'detect-content', 'detect-hash', 'detect-hist', 'detect-threshold'], - default='detect-content', help='Detector name. Implemented detectors are listed: https://www.scenedetect.com/docs/latest/cli.html') +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Benchmarking PySceneDetect performance.") + parser.add_argument( + "--detector", + type=str, + choices=[ + "detect-adaptive", + "detect-content", + "detect-hash", + "detect-hist", + "detect-threshold", + ], + default="detect-content", + help="Detector name. Implemented detectors are listed: https://www.scenedetect.com/docs/latest/cli.html", + ) args = parser.parse_args() - main(args) \ No newline at end of file + main(args) diff --git a/benchmarks/evaluator.py b/benchmarks/evaluator.py index 6a8190da..d38c8cef 100644 --- a/benchmarks/evaluator.py +++ b/benchmarks/evaluator.py @@ -1,12 +1,13 @@ from statistics import mean + class Evaluator: def __init__(self): pass - + def _load_scenes(self, scene_filename): with open(scene_filename) as f: - gt_scene_list = [x.strip().split('\t')[1] for x in f.readlines()] + gt_scene_list = [x.strip().split("\t")[1] for x in f.readlines()] gt_scene_list = [int(x) + 1 for x in gt_scene_list] return gt_scene_list @@ -14,22 +15,24 @@ def evaluate_performance(self, pred_scenes): total_correct = 0 total_pred = 0 total_gt = 0 + assert pred_scenes for scene_file, pred in pred_scenes.items(): gt_scene_list = self._load_scenes(scene_file) - pred_list = pred['pred_scenes'] + pred_list = pred["pred_scenes"] total_correct += len(set(pred_list) & set(gt_scene_list)) total_pred += len(pred_list) total_gt += len(gt_scene_list) + assert total_pred, pred_scenes recall = total_correct / total_gt precision = total_correct / total_pred f1 = 2 * recall * precision / (recall + precision) if (recall + precision) != 0 else 0 - avg_elapsed = mean([x['elapsed'] for x in pred_scenes.values()]) + avg_elapsed = mean([x["elapsed"] for x in pred_scenes.values()]) result = { - 'recall': recall * 100, - 'precision': precision * 100, - 'f1': f1 * 100, - 'elapsed': avg_elapsed + "recall": recall * 100, + "precision": precision * 100, + "f1": f1 * 100, + "elapsed": avg_elapsed, } - return result \ No newline at end of file + return result From 6f12c13171d7236210df3b9fc1c6d41167348bc9 Mon Sep 17 00:00:00 2001 From: awkrail Date: Tue, 18 Feb 2025 14:15:45 +0900 Subject: [PATCH 191/407] add benchmark link to readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 40819620..11845cac 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ def split_video_into_scenes(video_path, threshold=27.0): See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for more examples. +**Benchmark**: + +We evaluate the performance of different detectors in terms of accuracy and processing speed. See the [benchmark report](benchmarks/README.md) for details. + ## Reference - [Documentation](https://www.scenedetect.com/docs/) (covers application and Python API) From f85e7cdf01059039d775d6b7c90f0f0a7d1d367b Mon Sep 17 00:00:00 2001 From: welix Date: Fri, 21 Feb 2025 09:47:29 +0900 Subject: [PATCH 192/407] [benchmark] evaluate the detectors on the AutoShot dataset (#486) benchmark detectors on the AutoShot dataset Co-authored-by: Brandon Castellano --- benchmarks/README.md | 34 +++++++++++++++++++++++++++++++--- benchmarks/autoshot_dataset.py | 30 ++++++++++++++++++++++++++++++ benchmarks/bbc_dataset.py | 2 +- benchmarks/benchmark.py | 26 +++++++++++++++++++++++--- benchmarks/evaluator.py | 3 +-- 5 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 benchmarks/autoshot_dataset.py diff --git a/benchmarks/README.md b/benchmarks/README.md index 19a16190..2ad81c08 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -16,7 +16,14 @@ unzip BBC/videos.zip -d BBC rm -rf BBC/videos.zip ``` -### Evaluation +### AutoShot +Download `AutoShot_test.tar.gz` from [Google drive](https://drive.google.com/file/d/17diRkLlNUUjHDooXdqFUTXYje2-x4Yt6/view?usp=sharing). +``` +tar -zxvf AutoShot.tar.gz +rm AutoShot.tar.gz +``` + +## Evaluation To evaluate PySceneDetect on a dataset, run the following command: ``` python benchmark.py -d --detector @@ -28,7 +35,8 @@ python evaluate.py -d BBC --detector detect-content ### Result The performance is computed as recall, precision, f1, and elapsed time. -The following results indicate that ContentDetector achieves the highest performance on the BBC dataset. + +#### BBC | Detector | Recall | Precision | F1 | Elapsed time (second) | |:-----------------:|:------:|:---------:|:-----:|:---------------------:| @@ -38,6 +46,16 @@ The following results indicate that ContentDetector achieves the highest perform | HistogramDetector | 90.55 | 72.76 | 80.68 | 16.13 | | ThresholdDetector | 0.00 | 0.00 | 0.00 | 18.95 | +#### AutoShot + +| Detector | Recall | Precision | F1 | Elapsed time (second) | +|:-----------------:|:------:|:---------:|:-----:|:---------------------:| +| AdaptiveDetector | 70.77 | 77.65 | 74.05 | 1.23 | +| ContentDetector | 63.67 | 76.40 | 69.46 | 1.21 | +| HashDetector | 56.66 | 76.35 | 65.05 | 1.16 | +| HistogramDetector | 63.36 | 53.34 | 57.92 | 1.23 | +| ThresholdDetector | 0.75 | 38.64 | 1.47 | 1.24 | + ## Citation ### BBC ``` @@ -47,4 +65,14 @@ The following results indicate that ContentDetector achieves the highest perform booktitle = {Proceedings of the 23rd ACM International Conference on Multimedia}, year = {2015}, } -``` \ No newline at end of file +``` + +### AutoShot +``` +@InProceedings{autoshot_dataset, + author = {Wentao Zhu and Yufang Huang and Xiufeng Xie and Wenxian Liu and Jincan Deng and Debing Zhang and Zhangyang Wang and Ji Liu}, + title = {AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary Detection}, + booktitle = {Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR) Workshops}, + year = {2023}, +} +``` diff --git a/benchmarks/autoshot_dataset.py b/benchmarks/autoshot_dataset.py new file mode 100644 index 00000000..41f86a17 --- /dev/null +++ b/benchmarks/autoshot_dataset.py @@ -0,0 +1,30 @@ +import glob +import os + +class AutoShotDataset: + """ + The AutoShot Dataset (test splits) proposed by Zhu et al. in AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary Detection + Link: https://openaccess.thecvf.com/content/CVPR2023W/NAS/html/Zhu_AutoShot_A_Short_Video_Dataset_and_State-of-the-Art_Shot_Boundary_Detection_CVPRW_2023_paper.html + The original test set consists of 200 videos, but 36 videos are missing (AutoShot/videos/.mp4). + The annotated scenes are provided in corresponding files (AutoShot/annotations/.txt) + """ + + def __init__(self, dataset_dir: str): + self._video_files = [ + file for file in sorted(glob.glob(os.path.join(dataset_dir, "videos", "*.mp4"))) + ] + self._scene_files = [ + file for file in sorted(glob.glob(os.path.join(dataset_dir, "annotations", "*.txt"))) + ] + for video_file, scene_file in zip(self._video_files, self._scene_files): + video_id = os.path.basename(video_file).split(".")[0] + scene_id = os.path.basename(scene_file).split(".")[0] + assert video_id == scene_id + + def __getitem__(self, index): + video_file = self._video_files[index] + scene_file = self._scene_files[index] + return video_file, scene_file + + def __len__(self): + return len(self._video_files) diff --git a/benchmarks/bbc_dataset.py b/benchmarks/bbc_dataset.py index 66a5a5b7..1bb7693e 100644 --- a/benchmarks/bbc_dataset.py +++ b/benchmarks/bbc_dataset.py @@ -20,7 +20,7 @@ def __init__(self, dataset_dir: str): assert len(self._video_files) == len(self._scene_files) for video_file, scene_file in zip(self._video_files, self._scene_files): video_id = os.path.basename(video_file).replace("bbc_", "").split(".")[0] - scene_id = os.path.basename(scene_file).split("_")[0] + scene_id = os.path.basename(scene_file).split("-")[0] assert video_id == scene_id def __getitem__(self, index): diff --git a/benchmarks/benchmark.py b/benchmarks/benchmark.py index bd0bc09e..c5c51376 100644 --- a/benchmarks/benchmark.py +++ b/benchmarks/benchmark.py @@ -2,6 +2,8 @@ import time from bbc_dataset import BBCDataset +from autoshot_dataset import AutoShotDataset + from evaluator import Evaluator from tqdm import tqdm @@ -15,7 +17,7 @@ ) -def make_detector(detector_name: str): +def _make_detector(detector_name: str): detector_map = { "detect-adaptive": AdaptiveDetector(), "detect-content": ContentDetector(), @@ -26,11 +28,19 @@ def make_detector(detector_name: str): return detector_map[detector_name] +def _make_dataset(dataset_name: str): + dataset_map = { + "BBC": BBCDataset("BBC"), + "AutoShot": AutoShotDataset("AutoShot"), + } + return dataset_map[dataset_name] + + def _detect_scenes(detector_type: str, dataset): pred_scenes = {} for video_file, scene_file in tqdm(dataset): start = time.time() - detector = make_detector(detector_type) + detector = _make_detector(detector_type) pred_scene_list = detect(video_file, detector) elapsed = time.time() - start scenes = { @@ -53,7 +63,7 @@ def _detect_scenes(detector_type: str, dataset): def main(args): - pred_scenes = _detect_scenes(detector_type=args.detector, dataset=BBCDataset("BBC")) + pred_scenes = _detect_scenes(detector_type=args.detector, dataset=_make_dataset(args.dataset)) result = Evaluator().evaluate_performance(pred_scenes) print("Overall Results:") print( @@ -65,6 +75,16 @@ def main(args): if __name__ == "__main__": parser = argparse.ArgumentParser(description="Benchmarking PySceneDetect performance.") + parser.add_argument( + "--dataset", + type=str, + choices=[ + "BBC", + "AutoShot", + ], + default="BBC", + help="Dataset name. Supported datasets are BBC and AutoShot.", + ) parser.add_argument( "--detector", type=str, diff --git a/benchmarks/evaluator.py b/benchmarks/evaluator.py index d38c8cef..ee8801ac 100644 --- a/benchmarks/evaluator.py +++ b/benchmarks/evaluator.py @@ -24,9 +24,8 @@ def evaluate_performance(self, pred_scenes): total_pred += len(pred_list) total_gt += len(gt_scene_list) - assert total_pred, pred_scenes recall = total_correct / total_gt - precision = total_correct / total_pred + precision = total_correct / total_pred if total_pred != 0 else 0 f1 = 2 * recall * precision / (recall + precision) if (recall + precision) != 0 else 0 avg_elapsed = mean([x["elapsed"] for x in pred_scenes.values()]) result = { From f196a3138e02150a855fa8175e97382027ab4cc3 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Thu, 20 Feb 2025 21:39:52 -0500 Subject: [PATCH 193/407] Benchmark run as module (#489) [benchmarks] Add ability to run benchmarks from root of repo This allows using the local `scenedetect` module rather than requiring it being installed. This is required to run the benchmarks with local changes during development. Update instructions to run benchmarks accordingly. Re-run all benchmarks on BBC dataset as they were originally calculated with the wrong ground truth. --- .../BBC => benchmark/AutoShot}/.gitkeep | 0 benchmark/BBC/.gitkeep | 0 {benchmarks => benchmark}/README.md | 14 ++--- .../benchmark.py => benchmark/__main__.py | 63 +++++++++---------- {benchmarks => benchmark}/autoshot_dataset.py | 1 + {benchmarks => benchmark}/bbc_dataset.py | 0 {benchmarks => benchmark}/evaluator.py | 0 7 files changed, 39 insertions(+), 39 deletions(-) rename {benchmarks/BBC => benchmark/AutoShot}/.gitkeep (100%) create mode 100644 benchmark/BBC/.gitkeep rename {benchmarks => benchmark}/README.md (84%) rename benchmarks/benchmark.py => benchmark/__main__.py (56%) rename {benchmarks => benchmark}/autoshot_dataset.py (99%) rename {benchmarks => benchmark}/bbc_dataset.py (100%) rename {benchmarks => benchmark}/evaluator.py (100%) diff --git a/benchmarks/BBC/.gitkeep b/benchmark/AutoShot/.gitkeep similarity index 100% rename from benchmarks/BBC/.gitkeep rename to benchmark/AutoShot/.gitkeep diff --git a/benchmark/BBC/.gitkeep b/benchmark/BBC/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/benchmarks/README.md b/benchmark/README.md similarity index 84% rename from benchmarks/README.md rename to benchmark/README.md index 2ad81c08..47777f29 100644 --- a/benchmarks/README.md +++ b/benchmark/README.md @@ -24,13 +24,13 @@ rm AutoShot.tar.gz ``` ## Evaluation -To evaluate PySceneDetect on a dataset, run the following command: +To evaluate PySceneDetect on a dataset, run the following command from the root of the repo: ``` -python benchmark.py -d --detector +python -m benchmark -d --detector ``` For example, to evaluate ContentDetector on the BBC dataset: ``` -python evaluate.py -d BBC --detector detect-content +python -m benchmark -d BBC --detector detect-content ``` ### Result @@ -40,10 +40,10 @@ The performance is computed as recall, precision, f1, and elapsed time. | Detector | Recall | Precision | F1 | Elapsed time (second) | |:-----------------:|:------:|:---------:|:-----:|:---------------------:| -| AdaptiveDetector | 87.52 | 97.21 | 92.11 | 27.84 | -| ContentDetector | 85.23 | 89.53 | 87.33 | 26.46 | -| HashDetector | 92.96 | 76.27 | 83.79 | 16.26 | -| HistogramDetector | 90.55 | 72.76 | 80.68 | 16.13 | +| AdaptiveDetector | 87.12 | 96.55 | 91.59 | 27.84 | +| ContentDetector | 84.70 | 88.77 | 86.69 | 28.20 | +| HashDetector | 92.30 | 75.56 | 83.10 | 16.00 | +| HistogramDetector | 89.84 | 72.03 | 79.96 | 15.13 | | ThresholdDetector | 0.00 | 0.00 | 0.00 | 18.95 | #### AutoShot diff --git a/benchmarks/benchmark.py b/benchmark/__main__.py similarity index 56% rename from benchmarks/benchmark.py rename to benchmark/__main__.py index c5c51376..b1a90d67 100644 --- a/benchmarks/benchmark.py +++ b/benchmark/__main__.py @@ -1,12 +1,12 @@ import argparse import time +import os -from bbc_dataset import BBCDataset -from autoshot_dataset import AutoShotDataset - -from evaluator import Evaluator from tqdm import tqdm +from benchmark.autoshot_dataset import AutoShotDataset +from benchmark.bbc_dataset import BBCDataset +from benchmark.evaluator import Evaluator from scenedetect import ( AdaptiveDetector, ContentDetector, @@ -18,22 +18,27 @@ def _make_detector(detector_name: str): - detector_map = { - "detect-adaptive": AdaptiveDetector(), - "detect-content": ContentDetector(), - "detect-hash": HashDetector(), - "detect-hist": HistogramDetector(), - "detect-threshold": ThresholdDetector(), - } - return detector_map[detector_name] + if detector_name == "detect-adaptive": + return AdaptiveDetector() + if detector_name == "detect-content": + return ContentDetector() + if detector_name == "detect-hash": + return HashDetector() + if detector_name == "detect-hist": + return HistogramDetector() + if detector_name == "detect-threshold": + return ThresholdDetector() + raise RuntimeError(f"Unknown detector: {detector_name}") + +_DATASETS = { + "BBC": BBCDataset("benchmark/BBC"), + "AutoShot": AutoShotDataset("benchmark/AutoShot"), +} -def _make_dataset(dataset_name: str): - dataset_map = { - "BBC": BBCDataset("BBC"), - "AutoShot": AutoShotDataset("AutoShot"), - } - return dataset_map[dataset_name] +_RESULT_PRINT_FORMAT = ( + "Recall: {recall:.2f}, Precision: {precision:.2f}, F1: {f1:.2f} Elapsed time: {elapsed:.2f}\n" +) def _detect_scenes(detector_type: str, dataset): @@ -43,34 +48,28 @@ def _detect_scenes(detector_type: str, dataset): detector = _make_detector(detector_type) pred_scene_list = detect(video_file, detector) elapsed = time.time() - start + filename = os.path.basename(video_file) scenes = { scene_file: { - "video_file": video_file, + "video_file": filename, "elapsed": elapsed, "pred_scenes": [scene[1].frame_num for scene in pred_scene_list], } } result = Evaluator().evaluate_performance(scenes) - print(f"{video_file} results:") - print( - "Recall: {:.2f}, Precision: {:.2f}, F1: {:.2f} Elapsed time: {:.2f}\n".format( - result["recall"], result["precision"], result["f1"], result["elapsed"] - ) - ) + print(f"\n{filename} results:") + print(_RESULT_PRINT_FORMAT.format(**result) + "\n") pred_scenes.update(scenes) return pred_scenes def main(args): - pred_scenes = _detect_scenes(detector_type=args.detector, dataset=_make_dataset(args.dataset)) + print(f"Evaluating {args.detector} on dataset {args.dataset}...\n") + pred_scenes = _detect_scenes(detector_type=args.detector, dataset=_DATASETS[args.dataset]) result = Evaluator().evaluate_performance(pred_scenes) - print("Overall Results:") - print( - "Detector: {} Recall: {:.2f}, Precision: {:.2f}, F1: {:.2f} Elapsed time: {:.2f}".format( - args.detector, result["recall"], result["precision"], result["f1"], result["elapsed"] - ) - ) + print(f"\nOverall Results for {args.detector} on dataset {args.dataset}:") + print(_RESULT_PRINT_FORMAT.format(**result)) if __name__ == "__main__": diff --git a/benchmarks/autoshot_dataset.py b/benchmark/autoshot_dataset.py similarity index 99% rename from benchmarks/autoshot_dataset.py rename to benchmark/autoshot_dataset.py index 41f86a17..5312704e 100644 --- a/benchmarks/autoshot_dataset.py +++ b/benchmark/autoshot_dataset.py @@ -1,6 +1,7 @@ import glob import os + class AutoShotDataset: """ The AutoShot Dataset (test splits) proposed by Zhu et al. in AutoShot: A Short Video Dataset and State-of-the-Art Shot Boundary Detection diff --git a/benchmarks/bbc_dataset.py b/benchmark/bbc_dataset.py similarity index 100% rename from benchmarks/bbc_dataset.py rename to benchmark/bbc_dataset.py diff --git a/benchmarks/evaluator.py b/benchmark/evaluator.py similarity index 100% rename from benchmarks/evaluator.py rename to benchmark/evaluator.py From 100db93dfd4c4fadb7f0f691013a7552f55aa2ad Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 23 Feb 2025 11:40:08 -0500 Subject: [PATCH 194/407] [benchmark] Allow running all benchmarks (#491) Add --all flag to run all detector/dataset combinations. Add and require --detailed flag to output information for each video. Add extra separators in detailed mode to help more easily see the overall result versus each individual video. --- benchmark/README.md | 9 +++- benchmark/__main__.py | 102 ++++++++++++++++++++++++++++++------------ 2 files changed, 81 insertions(+), 30 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 47777f29..bd7b1680 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -26,12 +26,17 @@ rm AutoShot.tar.gz ## Evaluation To evaluate PySceneDetect on a dataset, run the following command from the root of the repo: ``` -python -m benchmark -d --detector +python -m benchmark --dataset --detector ``` For example, to evaluate ContentDetector on the BBC dataset: ``` -python -m benchmark -d BBC --detector detect-content +python -m benchmark --dataset BBC --detector detect-content ``` +To run all detectors on all datasets: +``` +python -m benchmark --all +``` +The `--all` flag can also be combined with `--dataset` or `--detector`. ### Result The performance is computed as recall, precision, f1, and elapsed time. diff --git a/benchmark/__main__.py b/benchmark/__main__.py index b1a90d67..431dc1b3 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -1,6 +1,7 @@ import argparse import time import os +import typing as ty from tqdm import tqdm @@ -16,19 +17,13 @@ detect, ) - -def _make_detector(detector_name: str): - if detector_name == "detect-adaptive": - return AdaptiveDetector() - if detector_name == "detect-content": - return ContentDetector() - if detector_name == "detect-hash": - return HashDetector() - if detector_name == "detect-hist": - return HistogramDetector() - if detector_name == "detect-threshold": - return ThresholdDetector() - raise RuntimeError(f"Unknown detector: {detector_name}") +_DETECTORS = { + "detect-adaptive": AdaptiveDetector, + "detect-content": ContentDetector, + "detect-hash": HashDetector, + "detect-hist": HistogramDetector, + "detect-threshold": ThresholdDetector, +} _DATASETS = { @@ -36,17 +31,19 @@ def _make_detector(detector_name: str): "AutoShot": AutoShotDataset("benchmark/AutoShot"), } +_DEFAULT_DETECTOR = "detect-content" +_DEFAULT_DATASET = "BBC" + _RESULT_PRINT_FORMAT = ( "Recall: {recall:.2f}, Precision: {precision:.2f}, F1: {f1:.2f} Elapsed time: {elapsed:.2f}\n" ) -def _detect_scenes(detector_type: str, dataset): +def _detect_scenes(detector: str, dataset: str, detailed: bool): pred_scenes = {} - for video_file, scene_file in tqdm(dataset): + for video_file, scene_file in tqdm(_DATASETS[dataset]): start = time.time() - detector = _make_detector(detector_type) - pred_scene_list = detect(video_file, detector) + pred_scene_list = detect(video_file, _DETECTORS[detector]()) elapsed = time.time() - start filename = os.path.basename(video_file) scenes = { @@ -57,22 +54,28 @@ def _detect_scenes(detector_type: str, dataset): } } result = Evaluator().evaluate_performance(scenes) - print(f"\n{filename} results:") - print(_RESULT_PRINT_FORMAT.format(**result) + "\n") + if detailed: + print(f"\n{filename} results:") + print(_RESULT_PRINT_FORMAT.format(**result) + "\n") pred_scenes.update(scenes) return pred_scenes -def main(args): - print(f"Evaluating {args.detector} on dataset {args.dataset}...\n") - pred_scenes = _detect_scenes(detector_type=args.detector, dataset=_DATASETS[args.dataset]) +def run_benchmark(detector: str, dataset: str, detailed: bool): + print(f"Evaluating {detector} on dataset {dataset}...\n") + pred_scenes = _detect_scenes(detector=detector, dataset=dataset, detailed=detailed) result = Evaluator().evaluate_performance(pred_scenes) - print(f"\nOverall Results for {args.detector} on dataset {args.dataset}:") + # Print extra separators in detailed output to identify overall results vs individual videos. + if detailed: + print("------------------------------------------------------------") + print(f"\nOverall Results for {detector} on dataset {dataset}:") print(_RESULT_PRINT_FORMAT.format(**result)) + if detailed: + print("------------------------------------------------------------") -if __name__ == "__main__": +def create_parser(): parser = argparse.ArgumentParser(description="Benchmarking PySceneDetect performance.") parser.add_argument( "--dataset", @@ -81,7 +84,6 @@ def main(args): "BBC", "AutoShot", ], - default="BBC", help="Dataset name. Supported datasets are BBC and AutoShot.", ) parser.add_argument( @@ -94,8 +96,52 @@ def main(args): "detect-hist", "detect-threshold", ], - default="detect-content", - help="Detector name. Implemented detectors are listed: https://www.scenedetect.com/docs/latest/cli.html", + help="Detector name. Implemented detectors are listed: " + "https://www.scenedetect.com/docs/latest/cli.html", + ) + parser.add_argument( + "--detailed", + action="store_const", + const=True, + help="Print results for each video, in addition to overall summary.", + ) + parser.add_argument( + "--all", + action="store_const", + const=True, + help="Benchmark all detectors on all datasets. If --detector or --dataset are specified, " + "will only run with those.", ) + return parser + + +def run_all_benchmarks(detector: ty.Optional[str], dataset: ty.Optional[str], detailed: bool): + detectors = {detector: _DETECTORS[detector]} if detector else _DETECTORS + datasets = {dataset: _DATASETS[dataset]} if dataset else _DATASETS + print( + "Running benchmarks for:\n" + f" - Detectors: {', '.join(detectors.keys())}\n" + f" - Datasets: {', '.join(datasets.keys())}\n" + ) + for detector in detectors: + for dataset in datasets: + run_benchmark(detector=detector, dataset=dataset, detailed=detailed) + + +def main(): + parser = create_parser() args = parser.parse_args() - main(args) + if args.all: + run_all_benchmarks( + detector=args.detector, dataset=args.dataset, detailed=bool(args.detailed) + ) + else: + run_benchmark( + detector=args.detector if args.detector else _DEFAULT_DETECTOR, + dataset=args.dataset if args.dataset else _DEFAULT_DATASET, + detailed=bool(args.detailed), + ) + + +if __name__ == "__main__": + main() From e1a3d93b4ea272c201b52c45e3077a6ebaddf06b Mon Sep 17 00:00:00 2001 From: awkrail Date: Tue, 25 Feb 2025 10:32:57 +0900 Subject: [PATCH 195/407] fix broken link in README.md --- README.md | 2 +- benchmark/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 11845cac..2ca8f8d2 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ See [the documentation](https://www.scenedetect.com/docs/latest/api.html) for mo **Benchmark**: -We evaluate the performance of different detectors in terms of accuracy and processing speed. See the [benchmark report](benchmarks/README.md) for details. +We evaluate the performance of different detectors in terms of accuracy and processing speed. See the [benchmark report](benchmark/README.md) for details. ## Reference diff --git a/benchmark/README.md b/benchmark/README.md index bd7b1680..52012e58 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,6 +1,6 @@ # Benchmarking PySceneDetect This repository benchmarks the performance of PySceneDetect in terms of both latency and accuracy. -We evaluate it using the standard dataset for video shot detection: [BBC](https://zenodo.org/records/14865504). +We evaluate it using the standard dataset for video shot detection: [BBC](https://zenodo.org/records/14865504) and [AutoShot](https://drive.google.com/file/d/17diRkLlNUUjHDooXdqFUTXYje2-x4Yt6/view?usp=sharing). ## Dataset Download ### BBC From b2383b5ac0e761a95ee69484a2ddfe6c0dc651f9 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 16:08:13 -0500 Subject: [PATCH 196/407] [cli] Replace export-html with save-html --- benchmark/__main__.py | 2 +- docs/cli.rst | 10 +- scenedetect.cfg | 2 +- scenedetect/_cli/__init__.py | 53 +++++--- scenedetect/_cli/commands.py | 6 +- scenedetect/_cli/config.py | 255 +++++++++++++++++++++-------------- scenedetect/_cli/context.py | 2 +- tests/test_cli.py | 14 +- website/pages/changelog.md | 1 + 9 files changed, 203 insertions(+), 142 deletions(-) diff --git a/benchmark/__main__.py b/benchmark/__main__.py index 431dc1b3..cb92c3ed 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -1,6 +1,6 @@ import argparse -import time import os +import time import typing as ty from tqdm import tqdm diff --git a/docs/cli.rst b/docs/cli.rst index f29d9cce..07c2f94d 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -432,17 +432,17 @@ Commands ************************************************************************ -.. _command-export-html: +.. _command-save-html: -.. program:: scenedetect export-html +.. program:: scenedetect save-html -``export-html`` +``save-html`` ======================================================================== -Export scene list to HTML file. +Save scene list to HTML file. -To customize image generation, specify the :ref:`save-images ` command before :ref:`export-html `. This command always uses the result of the preceeding :ref:`save-images ` command, or runs it with the default config values unless ``--no-images`` is set. +To customize image generation, specify the :ref:`save-images ` command before :ref:`save-html `. This command always uses the result of the preceeding :ref:`save-images ` command, or runs it with the default config values unless ``--no-images`` is set. Options diff --git a/scenedetect.cfg b/scenedetect.cfg index 321eb4ff..f3ddae57 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -244,7 +244,7 @@ #threading = yes -[export-html] +[save-html] # Filename format of created HTML file. Can use $VIDEO_NAME in the name. #filename = $VIDEO_NAME-Scenes.html diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 6ed9593b..86fd03b2 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -23,6 +23,7 @@ import os import os.path import typing as ty +from copy import deepcopy import click @@ -350,6 +351,13 @@ def scenedetect( ) +def add_hidden_alias(command: click.Command, alias: str): + """Adds a copy of `command` that can be invoked under the name `alias`.""" + hidden_command = deepcopy(command) + hidden_command.hidden = True + scenedetect.add_command(hidden_command, alias) + + @click.command("help", cls=Command) @click.argument( "command_name", @@ -359,6 +367,7 @@ def scenedetect( @click.pass_context def help_command(ctx: click.Context, command_name: str): """Print full help reference.""" + # TODO: Other commands still seem to run if this is specified. assert isinstance(ctx.parent.command, click.MultiCommand) parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) @@ -973,13 +982,13 @@ def load_scenes_command( ) -EXPORT_HTML_HELP = """Export scene list to HTML file. +SAVE_HTML_HELP = """Save scene list to HTML file. -To customize image generation, specify the `save-images` command before `export-html`. This command always uses the result of the preceeding `save-images` command, or runs it with the default config values unless `--no-images` is set. +To customize image generation, specify the `save-images` command before `save-html`. This command always uses the result of the preceeding `save-images` command, or runs it with the default config values unless `--no-images` is set. """ -@click.command("export-html", cls=Command, help=EXPORT_HTML_HELP) +@click.command("save-html", cls=Command, help=SAVE_HTML_HELP) @click.option( "--filename", "-f", @@ -987,7 +996,7 @@ def load_scenes_command( default="$VIDEO_NAME-Scenes.html", type=click.STRING, help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s" - % (USER_CONFIG.get_help_string("export-html", "filename")), + % (USER_CONFIG.get_help_string("save-html", "filename")), ) @click.option( "--no-images", @@ -995,7 +1004,7 @@ def load_scenes_command( is_flag=True, flag_value=True, help="Do not include images with the result.%s" - % (USER_CONFIG.get_help_string("export-html", "no-images")), + % (USER_CONFIG.get_help_string("save-html", "no-images")), ) @click.option( "--image-width", @@ -1003,7 +1012,7 @@ def load_scenes_command( metavar="pixels", type=click.INT, help="Width in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("export-html", "image-width", show_default=False)), + % (USER_CONFIG.get_help_string("save-html", "image-width", show_default=False)), ) @click.option( "--image-height", @@ -1011,7 +1020,7 @@ def load_scenes_command( metavar="pixels", type=click.INT, help="Height in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("export-html", "image-height", show_default=False)), + % (USER_CONFIG.get_help_string("save-html", "image-height", show_default=False)), ) @click.option( "--show", @@ -1020,10 +1029,10 @@ def load_scenes_command( flag_value=True, default=None, help="Automatically open resulting HTML when processing is complete.%s" - % (USER_CONFIG.get_help_string("export-html", "show")), + % (USER_CONFIG.get_help_string("save-html", "show")), ) @click.pass_context -def export_html_command( +def save_html_command( ctx: click.Context, filename: ty.Optional[ty.AnyStr], no_images: bool, @@ -1031,23 +1040,22 @@ def export_html_command( image_height: ty.Optional[int], show: bool, ): - # TODO: Rename this command to save-html to align with other export commands. This will require - # that we allow `export-html` as an alias on the CLI and via the config file for a few versions - # as to not break existing workflows. + if ctx.command.name == "save-html": + logger.warning("WARNING: export-html is deprecated, use save-html instead.") ctx = ctx.obj assert isinstance(ctx, CliContext) - include_images = not ctx.config.get_value("export-html", "no-images", no_images) + include_images = not ctx.config.get_value("save-html", "no-images", no_images) # Make sure a save-images command is in the pipeline for us to use the results from. if include_images and not ctx.save_images: save_images_command.callback() - export_html_args = { - "html_name_format": ctx.config.get_value("export-html", "filename", filename), - "image_width": ctx.config.get_value("export-html", "image-width", image_width), - "image_height": ctx.config.get_value("export-html", "image-height", image_height), + save_html_args = { + "html_name_format": ctx.config.get_value("save-html", "filename", filename), + "image_width": ctx.config.get_value("save-html", "image-width", image_width), + "image_height": ctx.config.get_value("save-html", "image-height", image_height), "include_images": include_images, - "show": ctx.config.get_value("export-html", "show", show), + "show": ctx.config.get_value("save-html", "show", show), } - ctx.add_command(cli_commands.export_html, export_html_args) + ctx.add_command(cli_commands.save_html, save_html_args) LIST_SCENES_HELP = """Create scene list CSV file (will be named $VIDEO_NAME-Scenes.csv by default). @@ -1509,7 +1517,7 @@ def save_images_command( } ctx.add_command(cli_commands.save_images, save_images_args) - # Record that we added a save-images command to the pipeline so we can allow export-html + # Record that we added a save-images command to the pipeline so we can allow save-html # to run afterwards (it is dependent on the output). ctx.save_images = True @@ -1585,8 +1593,11 @@ def save_qp_command( scenedetect.add_command(detect_threshold_command) # Output -scenedetect.add_command(export_html_command) +scenedetect.add_command(save_html_command) scenedetect.add_command(save_qp_command) scenedetect.add_command(list_scenes_command) scenedetect.add_command(save_images_command) scenedetect.add_command(split_video_command) + +# Deprecated Commands (Hidden From Help Output) +add_hidden_alias(save_html_command, "export-html") # Deprecated in v0.6.6, replaced with save-html diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 0ab707a1..dfac593c 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -35,7 +35,7 @@ logger = logging.getLogger("pyscenedetect") -def export_html( +def save_html( context: CliContext, scenes: SceneList, cuts: CutList, @@ -45,7 +45,7 @@ def export_html( include_images: bool, show: bool, ): - """Handles the `export-html` command.""" + """Handles the `save-html` command.""" (image_filenames, output_dir) = ( context.save_images_result if context.save_images_result is not None @@ -198,7 +198,7 @@ def save_images( interpolation=interpolation, threading=threading, ) - # Save the result for use by `export-html` if required. + # Save the result for use by `save-html` if required. context.save_images_result = (images, output_dir) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 496a40fb..c08acc16 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -17,10 +17,11 @@ import logging import os import os.path +import typing as ty from abc import ABC, abstractmethod -from configparser import ConfigParser, ParsingError +from configparser import ConfigParser +from configparser import Error as ConfigParserError from enum import Enum -from typing import Any, AnyStr, Dict, List, Optional, Tuple, Union from platformdirs import user_config_dir @@ -32,6 +33,8 @@ PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +LogMessage = ty.Tuple[int, str] + class OptionParseFailure(Exception): """Raised when a value provided in a user config file fails validation.""" @@ -46,7 +49,7 @@ class ValidatedValue(ABC): @property @abstractmethod - def value(self) -> Any: + def value(self) -> ty.Any: """Get the value after validation.""" ... @@ -72,13 +75,13 @@ class TimecodeValue(ValidatedValue): Stores value in original representation.""" - def __init__(self, value: Union[int, float, str]): + def __init__(self, value: ty.Union[int, float, str]): # Ensure value is a valid timecode. FrameTimecode(timecode=value, fps=100.0) self._value = value @property - def value(self) -> Union[int, float, str]: + def value(self) -> ty.Union[int, float, str]: return self._value @staticmethod @@ -96,9 +99,9 @@ class RangeValue(ValidatedValue): def __init__( self, - value: Union[int, float], - min_val: Union[int, float], - max_val: Union[int, float], + value: ty.Union[int, float], + min_val: ty.Union[int, float], + max_val: ty.Union[int, float], ): if value < min_val or value > max_val: # min and max are inclusive. @@ -108,16 +111,16 @@ def __init__( self._max_val = max_val @property - def value(self) -> Union[int, float]: + def value(self) -> ty.Union[int, float]: return self._value @property - def min_val(self) -> Union[int, float]: + def min_val(self) -> ty.Union[int, float]: """Minimum value of the range.""" return self._min_val @property - def max_val(self) -> Union[int, float]: + def max_val(self) -> ty.Union[int, float]: """Maximum value of the range.""" return self._max_val @@ -141,7 +144,7 @@ class CropValue(ValidatedValue): _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" - def __init__(self, value: Optional[Union[str, Tuple[int, int, int, int]]] = None): + def __init__(self, value: ty.Optional[ty.Union[str, ty.Tuple[int, int, int, int]]] = None): if isinstance(value, CropValue) or value is None: self._crop = value else: @@ -162,7 +165,7 @@ def __init__(self, value: Optional[Union[str, Tuple[int, int, int, int]]] = None self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) @property - def value(self) -> Tuple[int, int, int, int]: + def value(self) -> ty.Tuple[int, int, int, int]: return self._crop def __str__(self) -> str: @@ -182,7 +185,7 @@ class ScoreWeightsValue(ValidatedValue): _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" - def __init__(self, value: Union[str, ContentDetector.Components]): + def __init__(self, value: ty.Union[str, ContentDetector.Components]): if isinstance(value, ContentDetector.Components): self._value = value else: @@ -302,14 +305,14 @@ def format(self, timecode: FrameTimecode) -> str: raise RuntimeError("Unhandled format specifier.") -ConfigValue = Union[bool, int, float, str] -ConfigDict = Dict[str, Dict[str, ConfigValue]] +ConfigValue = ty.Union[bool, int, float, str] +ConfigDict = ty.Dict[str, ty.Dict[str, ConfigValue]] -_CONFIG_FILE_NAME: AnyStr = "scenedetect.cfg" -_CONFIG_FILE_DIR: AnyStr = user_config_dir("PySceneDetect", False) +_CONFIG_FILE_NAME: ty.AnyStr = "scenedetect.cfg" +_CONFIG_FILE_DIR: ty.AnyStr = user_config_dir("PySceneDetect", False) _PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format -CONFIG_FILE_PATH: AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) +CONFIG_FILE_PATH: ty.AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 @@ -360,13 +363,6 @@ def format(self, timecode: FrameTimecode) -> str: "load-scenes": { "start-col-name": "Start Frame", }, - "export-html": { - "filename": "$VIDEO_NAME-Scenes.html", - "image-height": 0, - "image-width": 0, - "no-images": False, - "show": False, - }, "list-scenes": { "cut-format": TimecodeFormat.TIMECODE, "col-separator": EscapedChar(","), @@ -392,6 +388,13 @@ def format(self, timecode: FrameTimecode) -> str: "output": None, "verbosity": "info", }, + "save-html": { + "filename": "$VIDEO_NAME-Scenes.html", + "image-height": 0, + "image-width": 0, + "no-images": False, + "show": False, + }, "save-images": { "compression": RangeValue(3, min_val=0, max_val=9), "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", @@ -427,7 +430,7 @@ def format(self, timecode: FrameTimecode) -> str: The types of these values are used when decoding the configuration file. Valid choices for certain string options are stored in `CHOICE_MAP`.""" -CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { +CHOICE_MAP: ty.Dict[str, ty.Dict[str, ty.List[str]]] = { "backend-pyav": { "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], }, @@ -471,131 +474,184 @@ def format(self, timecode: FrameTimecode) -> str: of a set to preserve order when generating error contexts. Values are case-insensitive, and must be in lowercase in this map.""" -# TODO: This isn't ideal for enums since this could be derived from the type directly, but it works. - - -def _validate_structure(config: ConfigParser) -> List[str]: - """Validates the layout of the section/option mapping. - - Returns: - List of any parsing errors in human-readable form. - """ - errors: List[str] = [] - for section in config.sections(): - if section not in CONFIG_MAP.keys(): - errors.append("Unsupported config section: [%s]" % (section)) +DEPRECATED_COMMANDS: ty.Dict[str, str] = {"export-html": "save-html"} +"""Deprecated config file sections that have a 1:1 mapping to a new replacement.""" + + +def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessage]]: + """Validates the layout of the section/option mapping. Returns a bool indicating if validation + was successful, and a list of log messages for the init log.""" + logs: ty.List[LogMessage] = [] + success = True + all_sections = set(parser.sections()) + for section in all_sections: + section_name = section + if section in DEPRECATED_COMMANDS: + section = DEPRECATED_COMMANDS[section] + logs.append( + ( + logging.WARNING, + f"WARNING: [{section_name}] is deprecated and will be removed!" + f"Use [{section}] instead.", + ) + ) + # The parser already handled duplicate sections, but it doesn't know about deprecated + # aliases. If there's a conflict, make sure we error out instead of warning. + if section in all_sections: + success = False + logs.append( + ( + logging.ERROR, + f"[{section_name}] conflicts with [{section}], only specify one.", + ) + ) + continue + elif section not in CONFIG_MAP.keys(): + success = False + logs.append((logging.ERROR, f"Unsupported config section: [{section_name}]")) continue - for option_name, _ in config.items(section): + for option_name, _ in parser.items(section_name): if option_name not in CONFIG_MAP[section].keys(): - errors.append("Unsupported config option in [%s]: %s" % (section, option_name)) - return errors - - -def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: - """Process the given configuration into a key-value mapping. - - Returns: - Configuration mapping and list of any processing errors in human readable form. - """ - out_map: ConfigDict = {} - errors: List[str] = [] + success = False + logs.append( + ( + logging.ERROR, + f"Unsupported config option in [{section_name}]: [{option_name}]", + ) + ) + return (success, logs) + + +def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty.List[LogMessage]]: + """Process the given configuration into a key-value mapping. Returns a tuple of the config + dict itself (or None on failure), and a list of log messages during parsing.""" + (success, logs) = _validate_structure(parser) + if not success: + return (None, logs) + config: ConfigDict = {} + success = True + # Re-map deprecated config sections to their replacements. Structure validation above should + # ensure no conflicts between the two. + for deprecated_command in DEPRECATED_COMMANDS: + if deprecated_command in parser: + replacement = DEPRECATED_COMMANDS[deprecated_command] + parser[replacement] = parser[deprecated_command] + del parser[deprecated_command] for command in CONFIG_MAP: - out_map[command] = {} + config[command] = {} for option in CONFIG_MAP[command]: - if command in config and option in config[command]: + if command in parser and option in parser[command]: try: value_type = None if isinstance(CONFIG_MAP[command][option], bool): value_type = "yes/no value" - out_map[command][option] = config.getboolean(command, option) + config[command][option] = parser.getboolean(command, option) continue elif isinstance(CONFIG_MAP[command][option], int): value_type = "integer" - out_map[command][option] = config.getint(command, option) + config[command][option] = parser.getint(command, option) continue elif isinstance(CONFIG_MAP[command][option], float): value_type = "number" - out_map[command][option] = config.getfloat(command, option) + config[command][option] = parser.getfloat(command, option) continue elif isinstance(CONFIG_MAP[command][option], Enum): config_value = ( - config.get(command, option).replace("\n", " ").strip().upper() + parser.get(command, option).replace("\n", " ").strip().upper() ) try: parsed = CONFIG_MAP[command][option].__class__[config_value] - out_map[command][option] = parsed + config[command][option] = parsed except TypeError: - errors.append( - "Invalid value for [%s] option %s': %s. Must be one of: %s." - % ( - command, - option, - config.get(command, option), - ", ".join( - str(choice) for choice in CHOICE_MAP[command][option] + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [%s] option %s': %s. Must be one of: %s." + % ( + command, + option, + parser.get(command, option), + ", ".join( + str(choice) for choice in CHOICE_MAP[command][option] + ), ), ) ) continue except ValueError as _: - errors.append( - "Invalid value for [%s] option '%s': %s is not a valid %s." - % (command, option, config.get(command, option), value_type) + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [%s] option '%s': %s is not a valid %s." + % (command, option, parser.get(command, option), value_type), + ) ) continue # Handle custom validation types. - config_value = config.get(command, option) + config_value = parser.get(command, option) default = CONFIG_MAP[command][option] option_type = type(default) if issubclass(option_type, ValidatedValue): try: - out_map[command][option] = option_type.from_config( + config[command][option] = option_type.from_config( config_value=config_value, default=default ) except OptionParseFailure as ex: - errors.append( - "Invalid value for [%s] option '%s': %s\nError: %s" - % (command, option, config_value, ex.error) + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [%s] option '%s': %s\nError: %s" + % (command, option, config_value, ex.error), + ) ) continue # If we didn't process the value as a given type, handle it as a string. We also # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: - config_value = config.get(command, option).replace("\n", " ").strip() + config_value = parser.get(command, option).replace("\n", " ").strip() if command in CHOICE_MAP and option in CHOICE_MAP[command]: if config_value.lower() not in CHOICE_MAP[command][option]: - errors.append( - "Invalid value for [%s] option '%s': %s. Must be one of: %s." - % ( - command, - option, - config.get(command, option), - ", ".join(choice for choice in CHOICE_MAP[command][option]), + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [%s] option '%s': %s. Must be one of: %s." + % ( + command, + option, + parser.get(command, option), + ", ".join(choice for choice in CHOICE_MAP[command][option]), + ), ) ) continue - out_map[command][option] = config_value + config[command][option] = config_value continue - return (out_map, errors) + if not success: + return (None, logs) + return (config, logs) class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" - def __init__(self, init_log: Tuple[int, str], reason: Optional[Exception] = None): + def __init__(self, init_log: ty.Tuple[int, str], reason: ty.Optional[Exception] = None): super().__init__() self.init_log = init_log self.reason = reason class ConfigRegistry: - def __init__(self, path: Optional[str] = None, throw_exception: bool = True): + def __init__(self, path: ty.Optional[str] = None, throw_exception: bool = True): self._config: ConfigDict = {} # Options set in the loaded config file. - self._init_log: List[Tuple[int, str]] = [] + self._init_log: ty.List[ty.Tuple[int, str]] = [] self._initialized = False try: @@ -653,23 +709,18 @@ def _load_from_disk(self, path=None): with open(path) as config_file: config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) - except ParsingError as ex: - if __debug__: - raise - raise ConfigLoadFailure(self._init_log, reason=ex) from None - except OSError as ex: + except (ConfigParserError, OSError) as ex: if __debug__: raise raise ConfigLoadFailure(self._init_log, reason=ex) from None # At this point the config file syntax is correct, but we need to still validate # the parsed options (i.e. that the options have valid values). - errors = _validate_structure(config) - if not errors: - self._config, errors = _parse_config(config) - if errors: - for log_str in errors: - self._init_log.append((logging.ERROR, log_str)) + (config, logs) = _parse_config(config) + for verbosity, message in logs: + self._init_log.append((verbosity, message)) + if config is None: raise ConfigLoadFailure(self._init_log) + self._config = config def is_default(self, command: str, option: str) -> bool: """True if specified config option is unset (i.e. the default), False otherwise.""" @@ -679,7 +730,7 @@ def get_value( self, command: str, option: str, - override: Optional[ConfigValue] = None, + override: ty.Optional[ConfigValue] = None, ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] @@ -694,7 +745,7 @@ def get_value( return value def get_help_string( - self, command: str, option: str, show_default: Optional[bool] = None + self, command: str, option: str, show_default: ty.Optional[bool] = None ) -> str: """Get a string to specify for the help text indicating the current command option value, if set, or the default. diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index c9798803..26cc04ef 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -85,7 +85,7 @@ def __init__(self): self.scene_manager: SceneManager = None self.stats_manager: StatsManager = None self.save_images: bool = False # True if the save-images command was specified - self.save_images_result: ty.Any = (None, None) # Result of save-images used by export-html + self.save_images_result: ty.Any = (None, None) # Result of save-images used by save-html # Input: self.video_stream: VideoStream = None diff --git a/tests/test_cli.py b/tests/test_cli.py index 50b51b4c..7ab343ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -539,17 +539,15 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): assert image.shape == (1280, 544, 3) -def test_cli_export_html(tmp_path: Path): - """Test `export-html` command.""" +def test_cli_save_html(tmp_path: Path): + """Test `save-html` command.""" base_command = "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}" + assert invoke_scenedetect(base_command, COMMAND="save-html", output_dir=tmp_path) == 0 assert ( - invoke_scenedetect(base_command, COMMAND="save-images export-html", output_dir=tmp_path) - == 0 - ) - assert ( - invoke_scenedetect(base_command, COMMAND="export-html --no-images", output_dir=tmp_path) - == 0 + invoke_scenedetect(base_command, COMMAND="save-html --no-images", output_dir=tmp_path) == 0 ) + # Ensure we can still call the now deprecated export-html command. + assert invoke_scenedetect(base_command, COMMAND="save-html", output_dir=tmp_path) == 0 # TODO: Check for existence of HTML & image files. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 19083ca9..a3bdbdc2 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -624,6 +624,7 @@ Development ## PySceneDetect 0.6.6 (In Development) + - [general] The `export-html` command is now deprecated, use `save-html` instead - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module From a878e222da81141d915dac6e9bfbdfa9a3b55d8b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 16:56:11 -0500 Subject: [PATCH 197/407] [docs] Fix incorrect short-form specification (#493) --- docs/cli.rst | 20 ++++++++++---------- scenedetect/_cli/__init__.py | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 07c2f94d..f1aba666 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -67,7 +67,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. TIMECODE can be specified as number of frames (:option:`-m=10 <-m>`), time in seconds (:option:`-m=2.5 <-m>`), or timecode (:option:`-m=00:02:53.633 <-m>`). + Minimum length of any scene. TIMECODE can be specified as number of frames (:option:`-m 10 <-m>`), time in seconds (:option:`-m 2.5 <-m>`), or timecode (:option:`-m 00:02:53.633 <-m>`). Default: ``0.6s`` @@ -91,11 +91,11 @@ Options .. option:: -d N, --downscale N - Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set :option:`-d=1 <-d>` to disable downscaling. + Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set :option:`-d 1 <-d>` to disable downscaling. .. option:: -fs N, --frame-skip N - Skip N frames during processing. Reduces processing speed at expense of accuracy. :option:`-fs=1 <-fs>` skips every other frame processing 50% of the video, :option:`-fs=2 <-fs>` processes 33% of the video frames, :option:`-fs=3 <-fs>` processes 25%, etc... + Skip N frames during processing. Reduces processing speed at expense of accuracy. :option:`-fs 1 <-fs>` skips every other frame processing 50% of the video, :option:`-fs 2 <-fs>` processes 33% of the video frames, :option:`-fs 3 <-fs>` processes 25%, etc... Default: ``0`` @@ -208,7 +208,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m 100 <-m>`), in seconds with `s` suffix (:option:`-m 3.5s <-m>`), or timecode (:option:`-m 00:01:52.778 <-m>`). .. _command-detect-content: @@ -263,7 +263,7 @@ Options .. option:: -l, --luma-only - Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w="0 0 1 0". + Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0. .. option:: -k N, --kernel-size N @@ -424,7 +424,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m=100 <-m>`), in seconds with `s` suffix (:option:`-m=3.5s <-m>`), or timecode (:option:`-m=00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m 100 <-m>`), in seconds with `s` suffix (:option:`-m 3.5s <-m>`), or timecode (:option:`-m 00:01:52.778 <-m>`). ************************************************************************ @@ -506,7 +506,7 @@ Options .. option:: -f NAME, --filename NAME - Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. :option:`-f=\$VIDEO_NAME-Scenes.csv <-f>`). + Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. :option:`-f \$VIDEO_NAME-Scenes.csv <-f>`). Default: ``$VIDEO_NAME-Scenes.csv`` @@ -590,13 +590,13 @@ Options .. option:: -f NAME, --filename NAME - Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. :option:`-f=\$SCENE_NUMBER-Image-\$IMAGE_NUMBER <-f>`) or single quotes. + Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. :option:`-f \$SCENE_NUMBER-Image-\$IMAGE_NUMBER <-f>`) or single quotes. Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER`` .. option:: -n N, --num-images N - Number of images to generate per scene. Will always include start/end frame, unless :option:`-n=1 <-n>`, in which case the image will be the frame at the mid-point of the scene. + Number of images to generate per scene. Will always include start/end frame, unless :option:`-n 1 <-n>`, in which case the image will be the frame at the mid-point of the scene. Default: ``3`` @@ -713,7 +713,7 @@ Options .. option:: -f NAME, --filename NAME - File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. :option:`-f=\$VIDEO_NAME-Scene-\$SCENE_NUMBER <-f>`). + File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. :option:`-f \$VIDEO_NAME-Scene-\$SCENE_NUMBER <-f>`). Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER`` diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 86fd03b2..ffe534a2 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -229,7 +229,7 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m=10), time in seconds (-m=2.5), or timecode (-m=00:02:53.633).%s" + help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633).%s" % USER_CONFIG.get_help_string("global", "min-scene-len"), ) @click.option( @@ -271,7 +271,7 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="N", type=click.INT, default=None, - help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d=1 to disable downscaling.%s" + help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling.%s" % (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), ) @click.option( @@ -280,7 +280,7 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="N", type=click.INT, default=None, - help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs=1 skips every other frame processing 50%% of the video, -fs=2 processes 33%% of the video frames, -fs=3 processes 25%%, etc... %s" + help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs 1 skips every other frame processing 50%% of the video, -fs 2 processes 33%% of the video frames, -fs 3 processes 25%%, etc... %s" % USER_CONFIG.get_help_string("global", "frame-skip"), ) @click.option( @@ -524,7 +524,7 @@ def time_command( "-l", is_flag=True, flag_value=True, - help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w="0 0 1 0".%s' + help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0.%s" % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( @@ -665,7 +665,7 @@ def detect_content_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778).%s" % ( "" if USER_CONFIG.is_default("detect-adaptive", "min-scene-len") @@ -750,7 +750,7 @@ def detect_adaptive_command( metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s" + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778).%s" % ( "" if USER_CONFIG.is_default("detect-threshold", "min-scene-len") @@ -1087,7 +1087,7 @@ def save_html_command( metavar="NAME", default="$VIDEO_NAME-Scenes.csv", type=click.STRING, - help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f=\\$VIDEO_NAME-Scenes.csv).%s" + help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f \\$VIDEO_NAME-Scenes.csv).%s" % (USER_CONFIG.get_help_string("list-scenes", "filename")), ) @click.option( @@ -1179,7 +1179,7 @@ def list_scenes_command( metavar="NAME", default=None, type=click.STRING, - help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f=\\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" + help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f \\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" % (USER_CONFIG.get_help_string("split-video", "filename")), ) @click.option( @@ -1356,7 +1356,7 @@ def split_video_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f=\\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" + help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f \\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" % (USER_CONFIG.get_help_string("save-images", "filename")), ) @click.option( @@ -1365,7 +1365,7 @@ def split_video_command( metavar="N", default=None, type=click.INT, - help="Number of images to generate per scene. Will always include start/end frame, unless -n=1, in which case the image will be the frame at the mid-point of the scene.%s" + help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene.%s" % (USER_CONFIG.get_help_string("save-images", "num-images")), ) @click.option( From 51e09d774c40ed2979e52484a6e30e8aa1efc9c0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 17:08:55 -0500 Subject: [PATCH 198/407] [docs] Update changelog for #493 --- website/pages/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a3bdbdc2..1217c23c 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -625,6 +625,7 @@ Development ## PySceneDetect 0.6.6 (In Development) - [general] The `export-html` command is now deprecated, use `save-html` instead + - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module From 3d6aedd226891a66dd976f09648c32b469e1377b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 18:16:51 -0500 Subject: [PATCH 199/407] [cli] Make argument names consistent with CLI options in command handlers --- scenedetect/_cli/__init__.py | 23 ++++++++--------- scenedetect/_cli/commands.py | 45 +++++++++++++++++----------------- scenedetect/_cli/context.py | 12 ++++----- scenedetect/_cli/controller.py | 2 +- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ffe534a2..49fb452f 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1049,7 +1049,7 @@ def save_html_command( if include_images and not ctx.save_images: save_images_command.callback() save_html_args = { - "html_name_format": ctx.config.get_value("save-html", "filename", filename), + "filename": ctx.config.get_value("save-html", "filename", filename), "image_width": ctx.config.get_value("save-html", "image-width", image_width), "image_height": ctx.config.get_value("save-html", "image-height", image_height), "include_images": include_images, @@ -1128,18 +1128,15 @@ def list_scenes_command( ctx = ctx.obj assert isinstance(ctx, CliContext) - create_file = not ctx.config.get_value("list-scenes", "no-output-file", no_output_file) - output_dir = ctx.config.get_value("list-scenes", "output", output) - name_format = ctx.config.get_value("list-scenes", "filename", filename) list_scenes_args = { "col_separator": ctx.config.get_value("list-scenes", "col-separator"), "cut_format": ctx.config.get_value("list-scenes", "cut-format"), "display_scenes": ctx.config.get_value("list-scenes", "display-scenes"), "display_cuts": ctx.config.get_value("list-scenes", "display-cuts"), - "scene_list_output": create_file, - "scene_list_name_format": name_format, + "no_output_file": ctx.config.get_value("list-scenes", "no-output-file", no_output_file), + "filename": ctx.config.get_value("list-scenes", "filename", filename), "skip_cuts": ctx.config.get_value("list-scenes", "skip-cuts", skip_cuts), - "output_dir": output_dir, + "output": ctx.config.get_value("list-scenes", "output", output), "quiet": ctx.config.get_value("list-scenes", "quiet", quiet) or ctx.quiet_mode, "row_separator": ctx.config.get_value("list-scenes", "row-separator"), } @@ -1322,7 +1319,7 @@ def split_video_command( split_video_args = { "name_format": ctx.config.get_value("split-video", "filename", filename), "use_mkvmerge": mkvmerge, - "output_dir": ctx.config.get_value("split-video", "output", output), + "output": ctx.config.get_value("split-video", "output", output), "show_output": not ctx.config.get_value("split-video", "quiet", quiet), "ffmpeg_args": args, } @@ -1506,10 +1503,10 @@ def save_images_command( "frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin), "height": height, "image_extension": image_extension, - "image_name_template": ctx.config.get_value("save-images", "filename", filename), + "filename": ctx.config.get_value("save-images", "filename", filename), "interpolation": scale_method, "num_images": ctx.config.get_value("save-images", "num-images", num_images), - "output_dir": output, + "output": output, "scale": scale, "show_progress": not ctx.quiet_mode, "threading": ctx.config.get_value("save-images", "threading"), @@ -1565,9 +1562,9 @@ def save_qp_command( assert isinstance(ctx, CliContext) save_qp_args = { - "filename_format": ctx.config.get_value("save-qp", "filename", filename), - "output_dir": ctx.config.get_value("save-qp", "output", output), - "shift_start": not ctx.config.get_value("save-qp", "disable-shift", disable_shift), + "filename": ctx.config.get_value("save-qp", "filename", filename), + "output": ctx.config.get_value("save-qp", "output", output), + "disable_shift": ctx.config.get_value("save-qp", "disable-shift", disable_shift), } ctx.add_command(cli_commands.save_qp, save_qp_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index dfac593c..210b6dd3 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -46,16 +46,16 @@ def save_html( show: bool, ): """Handles the `save-html` command.""" - (image_filenames, output_dir) = ( + (image_filenames, output) = ( context.save_images_result if context.save_images_result is not None - else (None, context.output_dir) + else (None, context.output) ) html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) if not html_filename.lower().endswith(".html"): html_filename += ".html" - html_path = get_and_create_path(html_filename, output_dir) + html_path = get_and_create_path(html_filename, output) write_scene_list_html( output_html_filename=html_path, scene_list=scenes, @@ -72,17 +72,18 @@ def save_qp( context: CliContext, scenes: SceneList, cuts: CutList, - output_dir: str, - filename_format: str, - shift_start: bool, + output: str, + filename: str, + disable_shift: bool, ): """Handler for the `save-qp` command.""" del scenes # We only use cuts for this handler. qp_path = get_and_create_path( - Template(filename_format).safe_substitute(VIDEO_NAME=context.video_stream.name), - output_dir, + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + output, ) start_frame = context.start_time.frame_num if context.start_time else 0 + shift_start = not disable_shift offset = start_frame if shift_start else 0 with open(qp_path, "wt") as qp_file: qp_file.write(f"{0 if shift_start else start_frame} I -1\n") @@ -95,9 +96,9 @@ def list_scenes( context: CliContext, scenes: SceneList, cuts: CutList, - scene_list_output: bool, - scene_list_name_format: str, - output_dir: str, + no_output_file: bool, + filename: str, + output: str, skip_cuts: bool, quiet: bool, display_scenes: bool, @@ -108,15 +109,15 @@ def list_scenes( ): """Handles the `list-scenes` command.""" # Write scene list CSV to if required. - if scene_list_output: - scene_list_filename = Template(scene_list_name_format).safe_substitute( + if not no_output_file: + scene_list_filename = Template(filename).safe_substitute( VIDEO_NAME=context.video_stream.name ) if not scene_list_filename.lower().endswith(".csv"): scene_list_filename += ".csv" scene_list_path = get_and_create_path( scene_list_filename, - output_dir, + output, ) logger.info("Writing scene list to CSV file:\n %s", scene_list_path) with open(scene_list_path, "w") as scene_list_file: @@ -170,8 +171,8 @@ def save_images( frame_margin: int, image_extension: str, encoder_param: int, - image_name_template: str, - output_dir: ty.Optional[str], + filename: str, + output: ty.Optional[str], show_progress: bool, scale: int, height: int, @@ -189,8 +190,8 @@ def save_images( frame_margin=frame_margin, image_extension=image_extension, encoder_param=encoder_param, - image_name_template=image_name_template, - output_dir=output_dir, + image_name_template=filename, + output_dir=output, show_progress=show_progress, scale=scale, height=height, @@ -199,7 +200,7 @@ def save_images( threading=threading, ) # Save the result for use by `save-html` if required. - context.save_images_result = (images, output_dir) + context.save_images_result = (images, output) def split_video( @@ -208,7 +209,7 @@ def split_video( cuts: CutList, name_format: str, use_mkvmerge: bool, - output_dir: str, + output: str, show_output: bool, ffmpeg_args: str, ): @@ -231,7 +232,7 @@ def split_video( split_video_mkvmerge( input_video_path=context.video_stream.path, scene_list=scenes, - output_dir=output_dir, + output_dir=output, output_file_template=name_format, show_output=show_output, ) @@ -239,7 +240,7 @@ def split_video( split_video_ffmpeg( input_video_path=context.video_stream.path, scene_list=scenes, - output_dir=output_dir, + output_dir=output, output_file_template=name_format, arg_override=ffmpeg_args, show_progress=not context.quiet_mode, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 26cc04ef..e48401d5 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -101,7 +101,7 @@ def __init__(self): self.merge_last_scene: bool = None self.min_scene_len: FrameTimecode = None self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None - self.output_dir: str = None + self.output: str = None self.stats_file_path: str = None # Output Commands (e.g. split-video, save-images): @@ -111,8 +111,8 @@ def __init__(self): def add_command(self, command: ty.Callable, command_args: ty.Dict[str, ty.Any]): """Add `command` to the processing pipeline. Will be called after processing the input.""" - if "output_dir" in command_args and command_args["output_dir"] is None: - command_args["output_dir"] = self.output_dir + if "output" in command_args and command_args["output"] is None: + command_args["output"] = self.output logger.debug("Adding command: %s(%s)", command.__name__, command_args) self.commands.append((command, command_args)) @@ -238,9 +238,9 @@ def handle_options( # Load the input video to obtain a time base for parsing timecodes. self._open_video_stream(input_path, framerate, backend) - self.output_dir = self.config.get_value("global", "output", output) - if self.output_dir: - logger.debug("Output directory set:\n %s", self.output_dir) + self.output = self.config.get_value("global", "output", output) + if self.output: + logger.debug("Output directory set:\n %s", self.output) self.min_scene_len = self.parse_timecode( min_scene_len diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index d9947d92..ea426102 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -154,7 +154,7 @@ def _save_stats(context: CliContext) -> None: if not context.stats_file_path: return if context.stats_manager.is_save_required(): - path = get_and_create_path(context.stats_file_path, context.output_dir) + path = get_and_create_path(context.stats_file_path, context.output) logger.info("Saving frame metrics to stats file: %s", path) with open(path, mode="w") as file: context.stats_manager.save_to_csv(csv_file=file) From a841943a5d595a75dcba6f494759d245330589d3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 18:50:22 -0500 Subject: [PATCH 200/407] [cli] Add new `save-xml` command Supports Final Cut Pro X and 7 formats, but there are still *many* TODOs to address. The output has not yet been validated and may not be usable yet. --- scenedetect.cfg | 15 +++ scenedetect/_cli/__init__.py | 57 ++++++++++- scenedetect/_cli/commands.py | 177 +++++++++++++++++++++++++++++++++++ scenedetect/_cli/config.py | 19 ++++ website/pages/changelog.md | 5 + 5 files changed, 271 insertions(+), 2 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index f3ddae57..912dab2e 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -314,6 +314,21 @@ #disable-shift = no + +[save-xml] + +# Filename format of XML file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.xml + +# Format of the XML file. Must be one of: +# - fcpx: Final Cut Pro X (default) +# - fcp: Final Cut Pro 7 +#format = fcpx + +# Folder to output XML file to. Overrides [global] output option. +#output = /usr/tmp/images + + # # BACKEND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 49fb452f..7cae8c01 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -35,6 +35,7 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, + XmlFormat, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS @@ -1569,6 +1570,57 @@ def save_qp_command( ctx.add_command(cli_commands.save_qp, save_qp_args) +SAVE_XML_HELP = """Save cuts in XML format.""" + + +@click.command("save-xml", cls=Command, help=SAVE_XML_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-xml", "filename")), +) +@click.option( + "--format", + metavar="TYPE", + type=click.Choice(CHOICE_MAP["save-xml"]["format"], False), + default=None, + help="Format to export. TYPE must be one of: %s.%s" + % ( + ", ".join(CHOICE_MAP["save-xml"]["format"]), + USER_CONFIG.get_help_string("save-xml", "format"), + ), +) +@click.option( + "--output", + "-o", + metavar="DIR", + type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), + help="Output directory to save XML file to. Overrides global option -o/--output.%s" + % (USER_CONFIG.get_help_string("save-xml", "output", show_default=False)), +) +@click.pass_context +def save_xml_command( + ctx: click.Context, + filename: ty.Optional[ty.AnyStr], + format: ty.Optional[ty.AnyStr], + output: ty.Optional[ty.AnyStr], +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + # TODO: Change config parser so get_value returns enums directly. + format = XmlFormat[ctx.config.get_value("save-xml", "format", format).upper()] + save_xml_args = { + "filename": ctx.config.get_value("save-xml", "filename", filename), + "format": format, + "output": ctx.config.get_value("save-xml", "output", output), + } + ctx.add_command(cli_commands.save_xml, save_xml_args) + + # ---------------------------------------------------------------------- # CLI Sub-Command Registration # ---------------------------------------------------------------------- @@ -1590,10 +1642,11 @@ def save_qp_command( scenedetect.add_command(detect_threshold_command) # Output -scenedetect.add_command(save_html_command) -scenedetect.add_command(save_qp_command) scenedetect.add_command(list_scenes_command) +scenedetect.add_command(save_html_command) scenedetect.add_command(save_images_command) +scenedetect.add_command(save_qp_command) +scenedetect.add_command(save_xml_command) scenedetect.add_command(split_video_command) # Deprecated Commands (Hidden From Help Output) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 210b6dd3..f4e7e25d 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -18,8 +18,13 @@ import logging import typing as ty import webbrowser +from datetime import datetime +from pathlib import Path from string import Template +from xml.dom import minidom +from xml.etree import ElementTree +from scenedetect._cli.config import XmlFormat from scenedetect._cli.context import CliContext from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import ( @@ -248,3 +253,175 @@ def split_video( ) if scenes: logger.info("Video splitting completed, scenes written to disk.") + + +def _save_xml_fcpx( + context: CliContext, + scenes: SceneList, + filename: str, + output: str, +): + """Saves scenes in Final Cut Pro X XML format.""" + ASSET_ID = "asset1" + FORMAT_ID = "format1" + # TODO: Need to handle other video formats! + VIDEO_FORMAT_TODO_HANDLE_OTHERS = "FFVideoFormat1080p24" + + root = ElementTree.Element("fcpxml", version="1.9") + resources = ElementTree.SubElement(root, "resources") + ElementTree.SubElement(resources, "format", id="format1", name=VIDEO_FORMAT_TODO_HANDLE_OTHERS) + + video_name = context.video_stream.name + + # TODO: We should calculate duration from the scene list. + duration = context.video_stream.duration + duration = str(duration.get_seconds()) + "s" # TODO: Is float okay here? + # TODO: This should be an absolute path, but the path types between VideoStream impls aren't + # consistent. Need to make a breaking change to the API so that they return pathlib.Path types. + path = context.video_stream.path + ElementTree.SubElement( + resources, + "asset", + id=ASSET_ID, + name=video_name, + src=path, + duration=duration, + hasVideo="1", + hasAudio="1", # TODO: Handle case of no audio. + format=FORMAT_ID, + ) + + library = ElementTree.SubElement(root, "library") + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + event = ElementTree.SubElement(library, "event", name=f"Shot Detection {now}") + project = ElementTree.SubElement( + event, "project", name=video_name + ) # TODO: Allow customizing project name. + sequence = ElementTree.SubElement(project, "sequence", format=FORMAT_ID, duration=duration) + spine = ElementTree.SubElement(sequence, "spine") + + for i, (start, end) in enumerate(scenes): + start_seconds = start.get_seconds() + duration_seconds = (end - start).get_seconds() + clip = ElementTree.SubElement( + spine, + "clip", + name=f"Shot {i + 1}", + duration=f"{duration_seconds:.3f}s", + start=f"{start_seconds:.3f}s", + offset=f"{start_seconds:.3f}s", + ) + ElementTree.SubElement( + clip, + "asset-clip", + ref=ASSET_ID, + duration=f"{duration_seconds:.3f}s", + start=f"{start_seconds:.3f}s", + offset="0s", + name=f"Shot {i + 1}", + ) + + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( + indent=" " + ) + xml_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + output, + ) + logger.info(f"Writing scenes in FCPX format to {xml_path}") + with open(xml_path, "w") as f: + f.write(pretty_xml) + + +def _save_xml_fcp( + context: CliContext, + scenes: SceneList, + filename: str, + output: str, +): + """Saves scenes in Final Cut Pro 7 XML format.""" + root = ElementTree.Element("xmeml", version="5") + project = ElementTree.SubElement(root, "project") + ElementTree.SubElement(project, "name").text = context.video_stream.name + sequence = ElementTree.SubElement(project, "sequence") + ElementTree.SubElement(sequence, "name").text = context.video_stream.name + + # TODO: We should calculate duration from the scene list. + duration = context.video_stream.duration + duration = str(duration.get_seconds()) # TODO: Is float okay here? + ElementTree.SubElement(sequence, "duration").text = duration + + rate = ElementTree.SubElement(sequence, "rate") + ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) + ElementTree.SubElement(rate, "ntsc").text = "FALSE" + + timecode = ElementTree.SubElement(sequence, "timecode") + tc_rate = ElementTree.SubElement(timecode, "rate") + ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate) + ElementTree.SubElement(tc_rate, "ntsc").text = "FALSE" + ElementTree.SubElement(timecode, "frame").text = "0" + ElementTree.SubElement(timecode, "displayformat").text = "NDF" + + media = ElementTree.SubElement(sequence, "media") + video = ElementTree.SubElement(media, "video") + format = ElementTree.SubElement(video, "format") + ElementTree.SubElement(format, "samplecharacteristics") + track = ElementTree.SubElement(video, "track") + + # Add clips for each shot boundary + for i, (start, end) in enumerate(scenes): + clip = ElementTree.SubElement(track, "clipitem") + ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" + ElementTree.SubElement(clip, "enabled").text = "TRUE" + ElementTree.SubElement(clip, "rate").append( + ElementTree.fromstring(f"{context.video_stream.frame_rate}") + ) + # TODO: Are these supposed to be frame numbers or another format? + ElementTree.SubElement(clip, "start").text = str(start.get_frames()) + ElementTree.SubElement(clip, "end").text = str(end.get_frames()) + ElementTree.SubElement(clip, "in").text = str(start.get_frames()) + ElementTree.SubElement(clip, "out").text = str(end.get_frames()) + + file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}") + ElementTree.SubElement(file_ref, "name").text = context.video_stream.name + # TODO: Turn this into absolute path, see TODO in the FCPX function for details. + path = context.video_stream.path + ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}" + + media_ref = ElementTree.SubElement(file_ref, "media") + video_ref = ElementTree.SubElement(media_ref, "video") + ElementTree.SubElement(video_ref, "samplecharacteristics") + link = ElementTree.SubElement(clip, "link") + ElementTree.SubElement(link, "linkclipref").text = f"file{i + 1}" + ElementTree.SubElement(link, "mediatype").text = "video" + + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( + indent=" " + ) + xml_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + output, + ) + logger.info(f"Writing scenes in FCP format to {xml_path}") + with open(xml_path, "w") as f: + f.write(pretty_xml) + + +def save_xml( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + format: XmlFormat, + output: str, +): + """Handles the `save-xml` command.""" + # We only use scene information. + del cuts + + if format == XmlFormat.FCPX: + _save_xml_fcpx(context, scenes, filename, output) + elif format == XmlFormat.FCP: + _save_xml_fcp(context, scenes, filename, output) + else: + logger.error(f"Unknown format: {format}") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index c08acc16..a4ca2824 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -305,6 +305,15 @@ def format(self, timecode: FrameTimecode) -> str: raise RuntimeError("Unhandled format specifier.") +class XmlFormat(Enum): + """Format to use with the `save-xml` command.""" + + FCPX = 0 + """Final Cut Pro X XML Format""" + FCP = 1 + """Final Cut Pro 7 XML Format""" + + ConfigValue = ty.Union[bool, int, float, str] ConfigDict = ty.Dict[str, ty.Dict[str, ConfigValue]] @@ -414,6 +423,11 @@ def format(self, timecode: FrameTimecode) -> str: "filename": "$VIDEO_NAME.qp", "output": None, }, + "save-xml": { + "format": XmlFormat.FCPX, + "filename": "$VIDEO_NAME.xml", + "output": None, + }, "split-video": { "args": DEFAULT_FFMPEG_ARGS, "copy": False, @@ -430,6 +444,8 @@ def format(self, timecode: FrameTimecode) -> str: The types of these values are used when decoding the configuration file. Valid choices for certain string options are stored in `CHOICE_MAP`.""" +# TODO: Use the fact that all enums derive from the Enum class to avoid duplicating their values +# here in the choice map. CHOICE_MAP: ty.Dict[str, ty.Dict[str, ty.List[str]]] = { "backend-pyav": { "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], @@ -456,6 +472,9 @@ def format(self, timecode: FrameTimecode) -> str: "format": ["jpeg", "png", "webp"], "scale-method": [value.name.lower() for value in Interpolation], }, + "save-xml": { + "format": [value.name.lower() for value in XmlFormat], + }, "split-video": { "preset": [ "ultrafast", diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1217c23c..b321b543 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -624,6 +624,11 @@ Development ## PySceneDetect 0.6.6 (In Development) +### Work In Progress +- [feature] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) + +### Complete + - [general] The `export-html` command is now deprecated, use `save-html` instead - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) From 47bfecd247da3ef67d6a7d9f97cd023c51605df4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 19:10:05 -0500 Subject: [PATCH 201/407] [cli] Add new `save-edl` command (#495) Output is still not verified but this is a good starting point. --- docs/cli.rst | 130 ++++++++++++++++++++++++++++++++--- scenedetect.cfg | 16 ++++- scenedetect/_cli/__init__.py | 57 +++++++++++++++ scenedetect/_cli/commands.py | 60 ++++++++++++++++ scenedetect/_cli/config.py | 6 ++ website/pages/changelog.md | 2 + 6 files changed, 260 insertions(+), 11 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index f1aba666..49206210 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -67,7 +67,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. TIMECODE can be specified as number of frames (:option:`-m 10 <-m>`), time in seconds (:option:`-m 2.5 <-m>`), or timecode (:option:`-m 00:02:53.633 <-m>`). + Minimum length of any scene. TIMECODE can be specified as number of frames (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633). Default: ``0.6s`` @@ -81,7 +81,7 @@ Options .. option:: -b BACKEND, --backend BACKEND - Backend to use for video input. Backend options can be set using a config file (:option:`-c/--config <-c>`). [available: opencv, pyav, moviepy] + Backend to use for video input. Backend options can be set using a config file (:option:`-c/--config <-c>`). [available: opencv, pyav] Default: ``opencv`` @@ -91,11 +91,11 @@ Options .. option:: -d N, --downscale N - Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set :option:`-d 1 <-d>` to disable downscaling. + Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling. .. option:: -fs N, --frame-skip N - Skip N frames during processing. Reduces processing speed at expense of accuracy. :option:`-fs 1 <-fs>` skips every other frame processing 50% of the video, :option:`-fs 2 <-fs>` processes 33% of the video frames, :option:`-fs 3 <-fs>` processes 25%, etc... + Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs 1 skips every other frame processing 50% of the video, -fs 2 processes 33% of the video frames, -fs 3 processes 25%, etc... Default: ``0`` @@ -208,7 +208,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m 100 <-m>`), in seconds with `s` suffix (:option:`-m 3.5s <-m>`), or timecode (:option:`-m 00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). .. _command-detect-content: @@ -424,7 +424,7 @@ Options .. option:: -m TIMECODE, --min-scene-len TIMECODE - Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (:option:`-m 100 <-m>`), in seconds with `s` suffix (:option:`-m 3.5s <-m>`), or timecode (:option:`-m 00:01:52.778 <-m>`). + Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). ************************************************************************ @@ -506,7 +506,7 @@ Options .. option:: -f NAME, --filename NAME - Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. :option:`-f \$VIDEO_NAME-Scenes.csv <-f>`). + Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f \$VIDEO_NAME-Scenes.csv). Default: ``$VIDEO_NAME-Scenes.csv`` @@ -558,6 +558,84 @@ Options Default: ``"Start Frame"`` +.. _command-save-edl: + +.. program:: scenedetect save-edl + + +``save-edl`` +======================================================================== + +Save cuts in EDL format (CMX 3600). + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.edl`` + +.. option:: -t NAME, --title NAME + + Title format to use. + + Default: ``$VIDEO_NAME`` + +.. option:: -r REEL, --reel REEL + + Reel name to use. + + Default: ``AX`` + +.. option:: -o DIR, --output DIR + + Output directory to save EDL file to. Overrides global option :option:`-o/--output `. + + +.. _command-save-html: + +.. program:: scenedetect save-html + + +``save-html`` +======================================================================== + +Save scene list to HTML file. + +To customize image generation, specify the :ref:`save-images ` command before :ref:`save-html `. This command always uses the result of the preceeding :ref:`save-images ` command, or runs it with the default config values unless ``--no-images`` is set. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes. + + Default: ``$VIDEO_NAME-Scenes.html`` + +.. option:: -n, --no-images + + Do not include images with the result. + +.. option:: -w pixels, --image-width pixels + + Width in pixels of the images in the resulting HTML table. + +.. option:: -h pixels, --image-height pixels + + Height in pixels of the images in the resulting HTML table. + +.. option:: -s, --show + + Automatically open resulting HTML when processing is complete. + + .. _command-save-images: .. program:: scenedetect save-images @@ -590,13 +668,13 @@ Options .. option:: -f NAME, --filename NAME - Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. :option:`-f \$SCENE_NUMBER-Image-\$IMAGE_NUMBER <-f>`) or single quotes. + Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f \$SCENE_NUMBER-Image-\$IMAGE_NUMBER) or single quotes. Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER`` .. option:: -n N, --num-images N - Number of images to generate per scene. Will always include start/end frame, unless :option:`-n 1 <-n>`, in which case the image will be the frame at the mid-point of the scene. + Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene. Default: ``3`` @@ -675,6 +753,38 @@ Options Disable shifting frame numbers by start time. +.. _command-save-xml: + +.. program:: scenedetect save-xml + + +``save-xml`` +======================================================================== + +Save cuts in XML format. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.xml`` + +.. option:: --format TYPE + + Format to export. TYPE must be one of: fcpx, fcp. + + Default: ``XmlFormat.FCPX`` + +.. option:: -o DIR, --output DIR + + Output directory to save XML file to. Overrides global option :option:`-o/--output `. + + .. _command-split-video: .. program:: scenedetect split-video @@ -713,7 +823,7 @@ Options .. option:: -f NAME, --filename NAME - File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. :option:`-f \$VIDEO_NAME-Scene-\$SCENE_NUMBER <-f>`). + File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f \$VIDEO_NAME-Scene-\$SCENE_NUMBER). Default: ``$VIDEO_NAME-Scene-$SCENE_NUMBER`` diff --git a/scenedetect.cfg b/scenedetect.cfg index 912dab2e..f6f939d0 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -302,6 +302,21 @@ #start-col-name = Start Frame +[save-edl] + +# Filename format of EDL file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.edl + +# Folder to output EDL file to. Overrides [global] output option. +#output = /usr/tmp/images + +# Reel/tape name to use. +#reel = AX + +# Title to use for the EDL file. Can use $VIDEO_NAME macro. +#title = $VIDEO_NAME + + [save-qp] # Filename format of QP file. Can use $VIDEO_NAME macro. @@ -314,7 +329,6 @@ #disable-shift = no - [save-xml] # Filename format of XML file. Can use $VIDEO_NAME macro. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 7cae8c01..3a3157b9 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1520,6 +1520,62 @@ def save_images_command( ctx.save_images = True +SAVE_EDL_HELP = """Save cuts in EDL format (CMX 3600).""" + + +@click.command("save-edl", cls=Command, help=SAVE_EDL_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-edl", "filename")), +) +@click.option( + "--title", + "-t", + metavar="NAME", + default=None, + type=click.STRING, + help="Title format to use.%s" % (USER_CONFIG.get_help_string("save-edl", "title")), +) +@click.option( + "--reel", + "-r", + metavar="REEL", + default=None, + type=click.STRING, + help="Reel name to use.%s" % (USER_CONFIG.get_help_string("save-edl", "reel")), +) +@click.option( + "--output", + "-o", + metavar="DIR", + type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), + help="Output directory to save EDL file to. Overrides global option -o/--output.%s" + % (USER_CONFIG.get_help_string("save-edl", "output", show_default=False)), +) +@click.pass_context +def save_edl_command( + ctx: click.Context, + filename: ty.Optional[ty.AnyStr], + title: ty.Optional[ty.AnyStr], + reel: ty.Optional[ty.AnyStr], + output: ty.Optional[ty.AnyStr], +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + save_edl_args = { + "filename": ctx.config.get_value("save-edl", "filename", filename), + "title": ctx.config.get_value("save-edl", "title", title), + "reel": ctx.config.get_value("save-edl", "reel", reel), + "output": ctx.config.get_value("save-edl", "output", output), + } + ctx.add_command(cli_commands.save_edl, save_edl_args) + + SAVE_QP_HELP = """Save cuts as keyframes (I-frames) for video encoding. The resulting QP file can be used with the `--qpfile` argument in x264/x265. @@ -1643,6 +1699,7 @@ def save_xml_command( # Output scenedetect.add_command(list_scenes_command) +scenedetect.add_command(save_edl_command) scenedetect.add_command(save_html_command) scenedetect.add_command(save_images_command) scenedetect.add_command(save_qp_command) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index f4e7e25d..861b1703 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -26,6 +26,7 @@ from scenedetect._cli.config import XmlFormat from scenedetect._cli.context import CliContext +from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import ( CutList, @@ -255,6 +256,65 @@ def split_video( logger.info("Video splitting completed, scenes written to disk.") +def save_edl( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + output: str, + title: str, + reel: str, +): + """Handles the `save-edl` command. Outputs in CMX 3600 format.""" + # We only use scene information. + del cuts + + # Converts FrameTimecode to HH:MM:SS:FF + # TODO: This should be part of the FrameTimecode object itself. + def get_edl_timecode(timecode: FrameTimecode): + total_seconds = timecode.get_seconds() + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + frames_part = int((total_seconds * timecode.get_framerate()) % timecode.get_framerate()) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" + + edl_content = [] + + title = Template(title).safe_substitute(VIDEO_NAME=context.video_stream.name) + edl_content.append(f"TITLE: {title}") + edl_content.append("FCM: NON-DROP FRAME") + edl_content.append("") + + # Add each shot as an edit entry + for i, (start, end) in enumerate(scenes): + # TODO: Handle start time shift. + in_tc = get_edl_timecode(start) + out_tc = get_edl_timecode(end) + + # TODO: How should the source/rec timestamps be aligned? One example I found showed: + # + # 001 AX V C 00:00:00:00 00:00:10:00 00:00:00:00 00:00:10:00 + # 002 AX V C 00:00:10:01 00:00:20:00 00:00:10:00 00:00:20:00 + # 003 AX V C 00:00:20:01 00:00:30:00 00:00:20:00 00:00:30:00 + # 004 AX V C 00:00:30:01 00:00:40:00 00:00:30:00 00:00:40:00 + # ^ + # |- Shifted by 1 frame here + + # Format the edit entry according to CMX 3600 format + event_line = f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}" + edl_content.append(event_line) + + edl_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + output, + ) + logger.info(f"Writing scenes in EDL format to {edl_path}") + with open(edl_path, "w") as f: + f.write("\n".join(edl_content)) + f.write("\n") + + def _save_xml_fcpx( context: CliContext, scenes: SceneList, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index a4ca2824..b986ced5 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -397,6 +397,12 @@ class XmlFormat(Enum): "output": None, "verbosity": "info", }, + "save-edl": { + "filename": "$VIDEO_NAME.edl", + "output": None, + "reel": "AX", + "title": "$VIDEO_NAME", + }, "save-html": { "filename": "$VIDEO_NAME-Scenes.html", "image-height": 0, diff --git a/website/pages/changelog.md b/website/pages/changelog.md index b321b543..361ad1a5 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -625,7 +625,9 @@ Development ## PySceneDetect 0.6.6 (In Development) ### Work In Progress + - [feature] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) ### Complete From bf55e7189db5e1aaa4c7b3f9e90274938c249b39 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 19:13:01 -0500 Subject: [PATCH 202/407] [cli] Fix wrong arg name passed to export-html command. --- scenedetect/_cli/__init__.py | 5 +++-- scenedetect/_cli/commands.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 3a3157b9..7a6469a8 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1045,15 +1045,16 @@ def save_html_command( logger.warning("WARNING: export-html is deprecated, use save-html instead.") ctx = ctx.obj assert isinstance(ctx, CliContext) + # Make sure a save-images command is in the pipeline for us to use the results from if we need + # to include images. include_images = not ctx.config.get_value("save-html", "no-images", no_images) - # Make sure a save-images command is in the pipeline for us to use the results from. if include_images and not ctx.save_images: save_images_command.callback() save_html_args = { "filename": ctx.config.get_value("save-html", "filename", filename), "image_width": ctx.config.get_value("save-html", "image-width", image_width), "image_height": ctx.config.get_value("save-html", "image-height", image_height), - "include_images": include_images, + "no_images": ctx.config.get_value("save-html", "no-images", no_images), "show": ctx.config.get_value("save-html", "show", show), } ctx.add_command(cli_commands.save_html, save_html_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 861b1703..83abe99d 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -47,8 +47,8 @@ def save_html( cuts: CutList, image_width: int, image_height: int, - html_name_format: str, - include_images: bool, + filename: str, + no_images: bool, show: bool, ): """Handles the `save-html` command.""" @@ -58,7 +58,7 @@ def save_html( else (None, context.output) ) - html_filename = Template(html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name) + html_filename = Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name) if not html_filename.lower().endswith(".html"): html_filename += ".html" html_path = get_and_create_path(html_filename, output) @@ -66,7 +66,7 @@ def save_html( output_html_filename=html_path, scene_list=scenes, cut_list=cuts, - image_filenames=image_filenames if include_images else None, + image_filenames=None if no_images else image_filenames, image_width=image_width, image_height=image_height, ) From 0e7aef5f37668187bc47aa9545c534dd57c6c1ca Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 21:09:54 -0500 Subject: [PATCH 203/407] [cli] Make config parser return Enums for overrides --- scenedetect/_cli/__init__.py | 5 +---- scenedetect/_cli/config.py | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 7a6469a8..997a6ffa 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -35,7 +35,6 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, - XmlFormat, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS @@ -1668,11 +1667,9 @@ def save_xml_command( ctx = ctx.obj assert isinstance(ctx, CliContext) - # TODO: Change config parser so get_value returns enums directly. - format = XmlFormat[ctx.config.get_value("save-xml", "format", format).upper()] save_xml_args = { "filename": ctx.config.get_value("save-xml", "filename", filename), - "format": format, + "format": ctx.config.get_value("save-xml", "format", format), "output": ctx.config.get_value("save-xml", "output", output), } ctx.add_command(cli_commands.save_xml, save_xml_args) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index b986ced5..5ee47f8d 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -450,8 +450,6 @@ class XmlFormat(Enum): The types of these values are used when decoding the configuration file. Valid choices for certain string options are stored in `CHOICE_MAP`.""" -# TODO: Use the fact that all enums derive from the Enum class to avoid duplicating their values -# here in the choice map. CHOICE_MAP: ty.Dict[str, ty.Dict[str, ty.List[str]]] = { "backend-pyav": { "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], @@ -765,8 +763,10 @@ def get_value( value = self._config[command][option] else: value = CONFIG_MAP[command][option] - if issubclass(type(value), ValidatedValue): + if isinstance(value, ValidatedValue): return value.value + if isinstance(CONFIG_MAP[command][option], Enum) and isinstance(override, str): + return CONFIG_MAP[command][option].__class__[value.upper().strip()] return value def get_help_string( From c1eb247c6c608bc6f50b5a880d1c41d4b91ecff5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 2 Mar 2025 21:24:56 -0500 Subject: [PATCH 204/407] [save-xml] Use pathlib.Path. --- scenedetect/_cli/commands.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 83abe99d..e2b60125 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -336,15 +336,13 @@ def _save_xml_fcpx( # TODO: We should calculate duration from the scene list. duration = context.video_stream.duration duration = str(duration.get_seconds()) + "s" # TODO: Is float okay here? - # TODO: This should be an absolute path, but the path types between VideoStream impls aren't - # consistent. Need to make a breaking change to the API so that they return pathlib.Path types. - path = context.video_stream.path + path = Path(context.video_stream.path).absolute() ElementTree.SubElement( resources, "asset", id=ASSET_ID, name=video_name, - src=path, + src=str(path), duration=duration, hasVideo="1", hasAudio="1", # TODO: Handle case of no audio. @@ -444,8 +442,8 @@ def _save_xml_fcp( file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}") ElementTree.SubElement(file_ref, "name").text = context.video_stream.name - # TODO: Turn this into absolute path, see TODO in the FCPX function for details. - path = context.video_stream.path + path = Path(context.video_stream.path).absolute() + # TODO: Can we just use path.as_uri() here? ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}" media_ref = ElementTree.SubElement(file_ref, "media") From 8e05684949e43463e99d1b8940d84530bd0d79a3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 3 Mar 2025 21:55:07 -0500 Subject: [PATCH 205/407] [cli] Finalize `save-edl` command Fix end timecode alignment. Tested with DaVinci Resolve and added unit tests. Fixes #495 --- scenedetect.cfg | 2 +- scenedetect/_cli/commands.py | 27 +++++++++------------- tests/test_cli.py | 44 +++++++++++++++++++++++++++++++++++- website/pages/changelog.md | 2 +- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index f6f939d0..27fb7244 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -313,7 +313,7 @@ # Reel/tape name to use. #reel = AX -# Title to use for the EDL file. Can use $VIDEO_NAME macro. +# Title to use for the EDL information. Can use $VIDEO_NAME macro. #title = $VIDEO_NAME diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index e2b60125..1e60df59 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -24,6 +24,7 @@ from xml.dom import minidom from xml.etree import ElementTree +import scenedetect from scenedetect._cli.config import XmlFormat from scenedetect._cli.context import CliContext from scenedetect.frame_timecode import FrameTimecode @@ -288,19 +289,8 @@ def get_edl_timecode(timecode: FrameTimecode): # Add each shot as an edit entry for i, (start, end) in enumerate(scenes): - # TODO: Handle start time shift. in_tc = get_edl_timecode(start) - out_tc = get_edl_timecode(end) - - # TODO: How should the source/rec timestamps be aligned? One example I found showed: - # - # 001 AX V C 00:00:00:00 00:00:10:00 00:00:00:00 00:00:10:00 - # 002 AX V C 00:00:10:01 00:00:20:00 00:00:10:00 00:00:20:00 - # 003 AX V C 00:00:20:01 00:00:30:00 00:00:20:00 00:00:30:00 - # 004 AX V C 00:00:30:01 00:00:40:00 00:00:30:00 00:00:40:00 - # ^ - # |- Shifted by 1 frame here - + out_tc = get_edl_timecode(end - 1) # Correct for presentation time # Format the edit entry according to CMX 3600 format event_line = f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}" edl_content.append(event_line) @@ -311,6 +301,7 @@ def get_edl_timecode(timecode: FrameTimecode): ) logger.info(f"Writing scenes in EDL format to {edl_path}") with open(edl_path, "w") as f: + f.write(f"* CREATED WITH PYSCENEDETECT {scenedetect.__version__}\n") f.write("\n".join(edl_content)) f.write("\n") @@ -398,16 +389,15 @@ def _save_xml_fcp( output: str, ): """Saves scenes in Final Cut Pro 7 XML format.""" + assert scenes root = ElementTree.Element("xmeml", version="5") project = ElementTree.SubElement(root, "project") ElementTree.SubElement(project, "name").text = context.video_stream.name sequence = ElementTree.SubElement(project, "sequence") ElementTree.SubElement(sequence, "name").text = context.video_stream.name - # TODO: We should calculate duration from the scene list. - duration = context.video_stream.duration - duration = str(duration.get_seconds()) # TODO: Is float okay here? - ElementTree.SubElement(sequence, "duration").text = duration + duration = scenes[-1][1] - scenes[0][0] + ElementTree.SubElement(sequence, "duration").text = f"{duration.get_frames()}" rate = ElementTree.SubElement(sequence, "rate") ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) @@ -444,6 +434,8 @@ def _save_xml_fcp( ElementTree.SubElement(file_ref, "name").text = context.video_stream.name path = Path(context.video_stream.path).absolute() # TODO: Can we just use path.as_uri() here? + # On Windows this should be: file://localhost/C:/Users/... according to the samples provided + # from https://github.com/Breakthrough/PySceneDetect/issues/156#issuecomment-1076213412. ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}" media_ref = ElementTree.SubElement(file_ref, "media") @@ -477,6 +469,9 @@ def save_xml( # We only use scene information. del cuts + if not scenes: + return + if format == XmlFormat.FCPX: _save_xml_fcpx(context, scenes, filename, output) elif format == XmlFormat.FCP: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ab343ab..d35082bc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,6 @@ # included LICENSE file, or visit one of the above pages for details. # -import glob import os import subprocess import typing as ty @@ -20,6 +19,7 @@ import numpy as np import pytest +import scenedetect from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available # These tests validate that the CLI itself functions correctly, mainly based on the return @@ -739,3 +739,45 @@ def test_cli_load_scenes_round_trip(): assert ground_truth.split(SPLIT_POINT)[1] == loaded_first_pass.split(SPLIT_POINT)[1] with open("testout.csv") as first, open("testout2.csv") as second: assert first.readlines() == second.readlines() + + +def test_cli_save_edl(tmp_path: Path): + """Test `save-edl` command.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} save-edl", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.edl") + assert os.path.exists(output_path) + EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} +TITLE: {DEFAULT_VIDEO_NAME} +FCM: NON-DROP FRAME + +001 AX V C 00:00:02:00 00:00:03:17 00:00:02:00 00:00:03:17 +002 AX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 +""" + assert output_path.read_text() == EXPECTED_EDL_OUTPUT + + +def test_cli_save_edl_with_params(tmp_path: Path): + """Test `save-edl` command but override the other options.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} save-edl -t title -r BX -f file_no_ext", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath("file_no_ext") + assert os.path.exists(output_path) + EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} +TITLE: title +FCM: NON-DROP FRAME + +001 BX V C 00:00:02:00 00:00:03:17 00:00:02:00 00:00:03:17 +002 BX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 +""" + assert output_path.read_text() == EXPECTED_EDL_OUTPUT diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 361ad1a5..a082b99b 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -627,10 +627,10 @@ Development ### Work In Progress - [feature] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) -- [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) ### Complete + - [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) - [general] The `export-html` command is now deprecated, use `save-html` instead - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) From 47589648106a6b29b32a0a5ea64c621bbb9511fa Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 4 Mar 2025 22:54:50 -0500 Subject: [PATCH 206/407] [cli] Add new `save-otio` command Verified works with DaVinci Resolve. #497 --- docs/cli.rst | 36 +++++- scenedetect.cfg | 14 ++- scenedetect/_cli/__init__.py | 51 ++++++++- scenedetect/_cli/commands.py | 103 ++++++++++++++++- scenedetect/_cli/config.py | 5 + tests/test_cli.py | 209 +++++++++++++++++++++++++++++++++++ 6 files changed, 413 insertions(+), 5 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 49206210..e8c44890 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -644,7 +644,7 @@ Options ``save-images`` ======================================================================== -Extract images from each detected scene. +Save images from each detected scene. Examples @@ -721,6 +721,40 @@ Options Width (pixels) of images. +.. _command-save-otio: + +.. program:: scenedetect save-otio + + +``save-otio`` +======================================================================== + +Save cuts as an OTIO timeline. + +Uses the Timeline.1 schema. OTIO (OpenTimelineIO) timelines can be imported by many video editors. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.otio`` + +.. option:: -n NAME, --name NAME + + Name of timeline to use. + + Default: ``"$VIDEO_NAME (PySceneDetect)"`` + +.. option:: -o DIR, --output DIR + + Output directory to save OTIO file to. Overrides global option :option:`-o/--output `. + + .. _command-save-qp: .. program:: scenedetect save-qp diff --git a/scenedetect.cfg b/scenedetect.cfg index 27fb7244..985982ed 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -314,7 +314,19 @@ #reel = AX # Title to use for the EDL information. Can use $VIDEO_NAME macro. -#title = $VIDEO_NAME +#title = $VIDEO_NAME (PySceneDetect) + + +[save-otio] + +# Filename format of OTIO file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.otio + +# Folder to output OTIO file to. Overrides [global] output option. +#output = /usr/tmp/images + +# Name to use for the OTIO timeline. Can use $VIDEO_NAME macro. +#title = $VIDEO_NAME (PySceneDetect) [save-qp] diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 997a6ffa..8b0fc9f7 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1327,7 +1327,7 @@ def split_video_command( ctx.add_command(cli_commands.split_video, split_video_args) -SAVE_IMAGES_HELP = """Extract images from each detected scene. +SAVE_IMAGES_HELP = """Save images from each detected scene. Examples: @@ -1675,6 +1675,54 @@ def save_xml_command( ctx.add_command(cli_commands.save_xml, save_xml_args) +SAVE_OTIO_HELP = """Save cuts as an OTIO timeline. + +Uses the Timeline.1 schema. OTIO (OpenTimelineIO) timelines can be imported by many video editors.""" + + +@click.command("save-otio", cls=Command, help=SAVE_OTIO_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-otio", "filename")), +) +@click.option( + "--name", + "-n", + metavar="NAME", + default=None, + type=click.STRING, + help="Name of timeline to use.%s" % (USER_CONFIG.get_help_string("save-otio", "name")), +) +@click.option( + "--output", + "-o", + metavar="DIR", + type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), + help="Output directory to save OTIO file to. Overrides global option -o/--output.%s" + % (USER_CONFIG.get_help_string("save-otio", "output", show_default=False)), +) +@click.pass_context +def save_otio_command( + ctx: click.Context, + filename: ty.Optional[ty.AnyStr], + name: ty.Optional[ty.AnyStr], + output: ty.Optional[ty.AnyStr], +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + save_otio_args = { + "filename": ctx.config.get_value("save-otio", "filename", filename), + "name": ctx.config.get_value("save-otio", "name", name), + "output": ctx.config.get_value("save-otio", "output", output), + } + ctx.add_command(cli_commands.save_otio, save_otio_args) + + # ---------------------------------------------------------------------- # CLI Sub-Command Registration # ---------------------------------------------------------------------- @@ -1702,6 +1750,7 @@ def save_xml_command( scenedetect.add_command(save_images_command) scenedetect.add_command(save_qp_command) scenedetect.add_command(save_xml_command) +scenedetect.add_command(save_otio_command) scenedetect.add_command(split_video_command) # Deprecated Commands (Hidden From Help Output) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 1e60df59..e2893bab 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -15,7 +15,9 @@ current command-line context, as well as the processing result (scenes and cuts). """ +import json import logging +import os.path import typing as ty import webbrowser from datetime import datetime @@ -401,12 +403,12 @@ def _save_xml_fcp( rate = ElementTree.SubElement(sequence, "rate") ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) - ElementTree.SubElement(rate, "ntsc").text = "FALSE" + ElementTree.SubElement(rate, "ntsc").text = "False" timecode = ElementTree.SubElement(sequence, "timecode") tc_rate = ElementTree.SubElement(timecode, "rate") ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate) - ElementTree.SubElement(tc_rate, "ntsc").text = "FALSE" + ElementTree.SubElement(tc_rate, "ntsc").text = "False" ElementTree.SubElement(timecode, "frame").text = "0" ElementTree.SubElement(timecode, "displayformat").text = "NDF" @@ -478,3 +480,100 @@ def save_xml( _save_xml_fcp(context, scenes, filename, output) else: logger.error(f"Unknown format: {format}") + + +def save_otio( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + output: str, + name: str, +): + """Saves scenes in OTIO format.""" + + del cuts # We only use scene information + + video_name = context.video_stream.name + video_path = os.path.abspath(context.video_stream.path) + video_base_name = os.path.basename(context.video_stream.path) + frame_rate = context.video_stream.frame_rate + + # List of track mapping to resource type. + # TODO(#497): Allow exporting without an audio track. + track_list = {"Video 1": "Video", "Audio 1": "Audio"} + + otio = { + "OTIO_SCHEMA": "Timeline.1", + "name": Template(name).safe_substitute(VIDEO_NAME=video_name), + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": frame_rate, + "value": 0.0, + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": True, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": track_name, + "enabled": True, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": video_base_name, + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": frame_rate, + "value": float((end - start).get_frames()), + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": frame_rate, + "value": float(start.get_frames()), + }, + }, + "enabled": True, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": video_base_name, + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": frame_rate, + "value": 1980.0, + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": frame_rate, + "value": 0.0, + }, + }, + "available_image_bounds": None, + "target_url": video_path, + } + }, + "active_media_reference_key": "DEFAULT_MEDIA", + } + for (start, end) in scenes + ], + "kind": track_type, + } + for (track_name, track_type) in track_list.items() + ], + }, + } + + otio_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + output, + ) + logger.info(f"Writing scenes in OTIO format to {otio_path}") + with open(otio_path, "w") as f: + json.dump(otio, f, indent=4) + f.write("\n") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 5ee47f8d..9a8704be 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -424,6 +424,11 @@ class XmlFormat(Enum): "threading": True, "width": 0, }, + "save-otio": { + "filename": "$VIDEO_NAME.otio", + "name": "$VIDEO_NAME (PySceneDetect)", + "output": None, + }, "save-qp": { "disable-shift": False, "filename": "$VIDEO_NAME.qp", diff --git a/tests/test_cli.py b/tests/test_cli.py index d35082bc..5556ae5b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ import subprocess import typing as ty from pathlib import Path +from string import Template import cv2 import numpy as np @@ -781,3 +782,211 @@ def test_cli_save_edl_with_params(tmp_path: Path): 002 BX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 """ assert output_path.read_text() == EXPECTED_EDL_OUTPUT + + +def test_cli_save_otio(tmp_path: Path): + """Test `save-otio` command.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} save-otio", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") + assert os.path.exists(output_path) + EXPECTED_OTIO_OUTPUT = """{ + "OTIO_SCHEMA": "Timeline.1", + "name": "goldeneye (PySceneDetect)", + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": "Video 1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 42.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 48.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 54.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 90.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "name": "Audio 1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 42.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 48.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 54.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 90.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Audio" + } + ] + } +} +""" + assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace( + "{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\") + ) From 2b7ebbd69ef3de0f40add85fb38ea40fbdebb87e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 18:07:44 -0400 Subject: [PATCH 207/407] [save-otio] Finalize OTIO support --- docs/cli.rst | 10 ++- scenedetect.cfg | 17 +---- scenedetect/_cli/__init__.py | 24 ++++++- scenedetect/_cli/commands.py | 5 +- scenedetect/_cli/config.py | 1 + tests/test_cli.py | 120 +++++++++++++++++++++++++++++++++++ website/pages/changelog.md | 28 +++++--- 7 files changed, 177 insertions(+), 28 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index e8c44890..a7553400 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -754,6 +754,14 @@ Options Output directory to save OTIO file to. Overrides global option :option:`-o/--output `. +.. option:: --audio + + Include audio track (default). + +.. option:: --no-audio + + Exclude audio track. + .. _command-save-qp: @@ -795,7 +803,7 @@ Options ``save-xml`` ======================================================================== -Save cuts in XML format. +[IN DEVELOPMENT] Save cuts in XML format. Options diff --git a/scenedetect.cfg b/scenedetect.cfg index 985982ed..fd6241cd 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -328,6 +328,9 @@ # Name to use for the OTIO timeline. Can use $VIDEO_NAME macro. #title = $VIDEO_NAME (PySceneDetect) +# Include audio track (yes/no). +#audio = yes + [save-qp] @@ -341,20 +344,6 @@ #disable-shift = no -[save-xml] - -# Filename format of XML file. Can use $VIDEO_NAME macro. -#filename = $VIDEO_NAME.xml - -# Format of the XML file. Must be one of: -# - fcpx: Final Cut Pro X (default) -# - fcp: Final Cut Pro 7 -#format = fcpx - -# Folder to output XML file to. Overrides [global] output option. -#output = /usr/tmp/images - - # # BACKEND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 8b0fc9f7..8d658f51 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1626,10 +1626,10 @@ def save_qp_command( ctx.add_command(cli_commands.save_qp, save_qp_args) -SAVE_XML_HELP = """Save cuts in XML format.""" +SAVE_XML_HELP = """[IN DEVELOPMENT] Save cuts in XML format.""" -@click.command("save-xml", cls=Command, help=SAVE_XML_HELP) +@click.command("save-xml", cls=Command, help=SAVE_XML_HELP, hidden=True) @click.option( "--filename", "-f", @@ -1705,20 +1705,40 @@ def save_xml_command( help="Output directory to save OTIO file to. Overrides global option -o/--output.%s" % (USER_CONFIG.get_help_string("save-otio", "output", show_default=False)), ) +@click.option( + "--audio", + is_flag=True, + flag_value=True, + help="Include audio track (default).", +) +@click.option( + "--no-audio", + is_flag=True, + flag_value=True, + help="Exclude audio track.", +) @click.pass_context def save_otio_command( ctx: click.Context, filename: ty.Optional[ty.AnyStr], name: ty.Optional[ty.AnyStr], output: ty.Optional[ty.AnyStr], + audio: bool, + no_audio: bool, ): ctx = ctx.obj assert isinstance(ctx, CliContext) + if audio and no_audio: + raise click.BadArgumentUsage("Only one of --audio or --no-audio can be specified.") + save_otio_args = { "filename": ctx.config.get_value("save-otio", "filename", filename), "name": ctx.config.get_value("save-otio", "name", name), "output": ctx.config.get_value("save-otio", "output", output), + "audio": ctx.config.get_value( + "save-otio", "audio", True if audio else False if no_audio else None + ), } ctx.add_command(cli_commands.save_otio, save_otio_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index e2893bab..3d747fa0 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -489,6 +489,7 @@ def save_otio( filename: str, output: str, name: str, + audio: bool, ): """Saves scenes in OTIO format.""" @@ -501,7 +502,9 @@ def save_otio( # List of track mapping to resource type. # TODO(#497): Allow exporting without an audio track. - track_list = {"Video 1": "Video", "Audio 1": "Audio"} + track_list = {"Video 1": "Video"} + if audio: + track_list["Audio 1"] = "Audio" otio = { "OTIO_SCHEMA": "Timeline.1", diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 9a8704be..75faeb71 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -425,6 +425,7 @@ class XmlFormat(Enum): "width": 0, }, "save-otio": { + "audio": True, "filename": "$VIDEO_NAME.otio", "name": "$VIDEO_NAME (PySceneDetect)", "output": None, diff --git a/tests/test_cli.py b/tests/test_cli.py index 5556ae5b..7d7280ad 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -990,3 +990,123 @@ def test_cli_save_otio(tmp_path: Path): assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace( "{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\") ) + + +def test_cli_save_otio_no_audio(tmp_path: Path): + """Test `save-otio` command without audio.""" + assert ( + invoke_scenedetect( + "-i {VIDEO} time {TIME} {DETECTOR} save-otio --no-audio", + output_dir=tmp_path, + ) + == 0 + ) + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") + assert os.path.exists(output_path) + EXPECTED_OTIO_OUTPUT = """{ + "OTIO_SCHEMA": "Timeline.1", + "name": "goldeneye (PySceneDetect)", + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": "Video 1", + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 42.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 48.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "name": "goldeneye.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 54.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 90.0 + } + }, + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": "goldeneye.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 1980.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976023976023978, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "{ABSOLUTE_PATH}" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + } + ] + } +} +""" + assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace( + "{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\") + ) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a082b99b..f9f0cf7d 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,23 @@ Releases ## PySceneDetect 0.6 +### PySceneDetect 0.6.6 (March 9, 2025) + +#### Release Notes + +PySceneDetect v0.6.6 introduces new output formats, which improve compatibility with popular video editors (e.g. DaVinci Resolve). Also included are several important bugfixes. + +#### Changelog + + - [feature] New `save-otio` command supports saving scenes in OTIO format [#497](https://github.com/Breakthrough/PySceneDetect/issues/497) + - [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) + - [general] The `export-html` command is now deprecated, use `save-html` instead + - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) + - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) + - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option + - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module + + ### PySceneDetect 0.6.5 (November 24, 2024) #### Release Notes @@ -622,17 +639,8 @@ Both the Windows installer and portable distributions now include signed executa Development ========================================================== -## PySceneDetect 0.6.6 (In Development) +## PySceneDetect 0.7 (In Development) ### Work In Progress - [feature] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - -### Complete - - - [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) - - [general] The `export-html` command is now deprecated, use `save-html` instead - - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) - - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) - - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option - - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module From 3e08d536dea73a9a59e3e0fadb9f542927675323 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 18:09:55 -0400 Subject: [PATCH 208/407] [dist] Prepare for v0.6.6 release. --- README.md | 2 +- scenedetect/__init__.py | 2 +- website/pages/download.md | 8 ++++---- website/pages/index.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2ca8f8d2..012c1225 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.5 (November 24, 2024) +### Latest Release: v0.6.6 (March 9, 2025) **Website**: [scenedetect.com](https://www.scenedetect.com) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index daf9baf1..71a63dfe 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.5.2" +__version__ = "0.6.6-dev0" init_logger() logger = getLogger("pyscenedetect") diff --git a/website/pages/download.md b/website/pages/download.md index c3d01ee6..1f1d1941 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi ## Windows Build (64-bit Only)  
    -

    Latest Release: v0.6.5

    -

      Release Date:  November 24, 2024

    -  Installer  (recommended)      -  Portable .zip      +

    Latest Release: v0.6.6

    +

      Release Date:  March 9, 2025

    +  Installer  (recommended)      +  Portable .zip        Getting Started
    diff --git a/website/pages/index.md b/website/pages/index.md index 839356d3..05d1866b 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
    -

      Latest Release: v0.6.5 (November 24, 2024)

    +

      Latest Release: v0.6.6 (March 9, 2025)

      Download        Changelog        Documentation        Getting Started
    See the changelog for the latest release notes and known issues. From b2fb5aa44f51a2f306a0eef205100c747d02c098 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 18:44:26 -0400 Subject: [PATCH 209/407] [dist] Finalize v0.6.6 release --- .github/workflows/generate-docs.yml | 2 +- appveyor.yml | 2 +- dist/installer/PySceneDetect.aip | 13 ++++++------- scenedetect/__init__.py | 2 +- website/pages/changelog.md | 4 ++-- website/pages/docs.md | 1 + 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 247912c6..e64076b1 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -16,7 +16,7 @@ jobs: env: # TODO: Figure out a better way to handle figuring out what version /latest should be, # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.5' + scenedetect_docs_latest: '0.6.6' scenedetect_docs_dest: '' steps: diff --git a/appveyor.yml b/appveyor.yml index dfbd15ca..4029ec9a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -72,7 +72,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.3\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.5\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index f8a7a16a..44e635d8 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -1,5 +1,5 @@ - + @@ -23,10 +23,10 @@ - + - + @@ -121,7 +121,7 @@ - + @@ -253,7 +253,7 @@ - + @@ -619,7 +619,6 @@ - @@ -1602,7 +1601,7 @@ - + diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 71a63dfe..cd35626f 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.6-dev0" +__version__ = "0.6.6" init_logger() logger = getLogger("pyscenedetect") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index f9f0cf7d..80106921 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -8,17 +8,17 @@ Releases #### Release Notes -PySceneDetect v0.6.6 introduces new output formats, which improve compatibility with popular video editors (e.g. DaVinci Resolve). Also included are several important bugfixes. +PySceneDetect v0.6.6 introduces new output formats, which improve compatibility with popular video editors (e.g. DaVinci Resolve). #### Changelog - [feature] New `save-otio` command supports saving scenes in OTIO format [#497](https://github.com/Breakthrough/PySceneDetect/issues/497) - [feature] New `save-edl` command supports saving scenes in EDL format CMX 3600 [#495](https://github.com/Breakthrough/PySceneDetect/issues/495) - - [general] The `export-html` command is now deprecated, use `save-html` instead - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module + - [general] The `export-html` command is now deprecated, use `save-html` instead ### PySceneDetect 0.6.5 (November 24, 2024) diff --git a/website/pages/docs.md b/website/pages/docs.md index 69e2fbab..96555cdb 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,7 @@ ## Stable * [latest](latest/) + * [v0.6.6](0.6.6/) * [v0.6.5](0.6.5/) * [v0.6.4](0.6.4/) * [v0.6.3](0.6.3/) From 16ef4c48fd31c4b80bb5ee23c8aaccbe972b5605 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 19:44:11 -0400 Subject: [PATCH 210/407] [dist] Update Windows distribution dependencies --- dist/installer/PySceneDetect.aip | 719 ++++++++++++++++--------------- dist/requirements_windows.txt | 12 +- website/pages/changelog.md | 10 +- 3 files changed, 398 insertions(+), 343 deletions(-) diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 44e635d8..1fea79b0 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -76,18 +76,21 @@ + + + @@ -103,6 +106,7 @@ + @@ -126,13 +130,13 @@ - + - + - - + + @@ -140,58 +144,58 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + @@ -240,180 +244,186 @@ - + - + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - + + + + + + + - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + @@ -426,55 +436,45 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -489,6 +489,7 @@ + @@ -497,12 +498,12 @@ - + @@ -525,39 +526,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -570,7 +574,7 @@ - + @@ -594,8 +598,16 @@ - - + + + + + + + + + + @@ -608,17 +620,18 @@ - + - - - + + + - + + @@ -626,13 +639,13 @@ - - - - - - - + + + + + + + @@ -711,6 +724,8 @@ + + @@ -1569,6 +1584,22 @@ + + + + + + + + + + + + + + + + @@ -1586,6 +1617,16 @@ + + + + + + + + + + @@ -1785,39 +1826,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1825,7 +1869,7 @@ - + @@ -1836,12 +1880,11 @@ - - + + + - - @@ -1888,7 +1931,11 @@ + + + + diff --git a/dist/requirements_windows.txt b/dist/requirements_windows.txt index 0d14a4aa..ead7d6b3 100644 --- a/dist/requirements_windows.txt +++ b/dist/requirements_windows.txt @@ -1,10 +1,10 @@ # PySceneDetect Requirements for Windows Build -av==13.1.0 -click==8.1.7 -opencv-python-headless==4.10.0.84 -imageio-ffmpeg==0.5.1 -moviepy==2.1.1 -numpy==2.1.3 +av==14.2.0 +click==8.1.8 +opencv-python-headless==4.11.0.86 +imageio-ffmpeg==0.6.0 +moviepy==2.1.2 +numpy==2.2.3 platformdirs==4.3.6 tqdm==4.67.1 diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 80106921..9fa7fa44 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -17,8 +17,16 @@ PySceneDetect v0.6.6 introduces new output formats, which improve compatibility - [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493) - [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473) - [bugfix] Fix incorrect default filename template for `split-video` command with `-m`/`--mkvmerge` option - - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` function in `scenedetect.video_splitter` module + - [bugfix] Fix inconsistent filenames when using `split_video_mkvmerge()` + - [bugfix] Ensure auto-rotation is always enabled for `VideoStreamCv2` as workaround for (opencv#26795)[https://github.com/opencv/opencv/issues/26795] - [general] The `export-html` command is now deprecated, use `save-html` instead + - [general] Updates to Windows distributions: + - av 13.1.0 -> 14.2.0 + - click 8.1.7 -> 8.1.8 + - imageio-ffmpeg 0.5.1 -> 0.6.0 + - moviepy 2.1.1 -> 2.1.2 + - numpy 2.1.3 -> 2.2.3 + - opencv-python 4.10.0.84 -> 4.11.0.86 ### PySceneDetect 0.6.5 (November 24, 2024) From 7fa9a258285193e286d1dcbe18eeb8ef6d5a35b2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 20:57:25 -0400 Subject: [PATCH 211/407] [docs] Remove hidden in-development command from docs --- docs/cli.rst | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index a7553400..52f48a24 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -800,38 +800,6 @@ Options .. program:: scenedetect save-xml -``save-xml`` -======================================================================== - -[IN DEVELOPMENT] Save cuts in XML format. - - -Options ------------------------------------------------------------------------- - - -.. option:: -f NAME, --filename NAME - - Filename format to use. - - Default: ``$VIDEO_NAME.xml`` - -.. option:: --format TYPE - - Format to export. TYPE must be one of: fcpx, fcp. - - Default: ``XmlFormat.FCPX`` - -.. option:: -o DIR, --output DIR - - Output directory to save XML file to. Overrides global option :option:`-o/--output `. - - -.. _command-split-video: - -.. program:: scenedetect split-video - - ``split-video`` ======================================================================== From bdd422255fe71863bbb2d899ec35fc085fdde7f1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 21:19:13 -0400 Subject: [PATCH 212/407] [docs] Fix listing hidden items --- docs/cli.rst | 34 +++++++++++++++++++++++++++++++++- docs/generate_cli_docs.py | 1 + 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 52f48a24..e469e25f 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -81,7 +81,7 @@ Options .. option:: -b BACKEND, --backend BACKEND - Backend to use for video input. Backend options can be set using a config file (:option:`-c/--config <-c>`). [available: opencv, pyav] + Backend to use for video input. Backend options can be set using a config file (:option:`-c/--config <-c>`). [available: opencv, pyav, moviepy] Default: ``opencv`` @@ -800,6 +800,38 @@ Options .. program:: scenedetect save-xml +``save-xml`` +======================================================================== + +[IN DEVELOPMENT] Save cuts in XML format. + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.xml`` + +.. option:: --format TYPE + + Format to export. TYPE must be one of: fcpx, fcp. + + Default: ``XmlFormat.FCPX`` + +.. option:: -o DIR, --output DIR + + Output directory to save XML file to. Overrides global option :option:`-o/--output `. + + +.. _command-split-video: + +.. program:: scenedetect split-video + + ``split-video`` ======================================================================== diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index 77cb31e7..e4092047 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -252,6 +252,7 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) + commands = list(filter(lambda command: not ctx.command.hidden, commands)) # ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ generate_title("``scenedetect`` 🎬 Command", level=0), From 7dee4b97faa78a5899af4c702a5506e3cc916f79 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 9 Mar 2025 21:39:47 -0400 Subject: [PATCH 213/407] [docs] Update download URL --- website/pages/changelog.md | 1 + website/pages/download.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 9fa7fa44..0801a045 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -27,6 +27,7 @@ PySceneDetect v0.6.6 introduces new output formats, which improve compatibility - moviepy 2.1.1 -> 2.1.2 - numpy 2.1.3 -> 2.2.3 - opencv-python 4.10.0.84 -> 4.11.0.86 + - [general] Windows download URLs for standalone ZIP distribution no longer have `portable` suffix ### PySceneDetect 0.6.5 (November 24, 2024) diff --git a/website/pages/download.md b/website/pages/download.md index 1f1d1941..4d3b6d62 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -23,7 +23,7 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi

    Latest Release: v0.6.6

      Release Date:  March 9, 2025

      Installer  (recommended)      -  Portable .zip      +  Portable .zip        Getting Started
    From 485e561abf80938c81ac0af6264194369d5c28ca Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 15 Mar 2025 21:43:32 -0400 Subject: [PATCH 214/407] [project] Use `import typing as ty` everywhere --- scenedetect/backends/__init__.py | 6 +- scenedetect/backends/moviepy.py | 20 ++++--- scenedetect/backends/opencv.py | 34 +++++------ scenedetect/backends/pyav.py | 20 +++---- scenedetect/detectors/adaptive_detector.py | 16 +++--- scenedetect/detectors/content_detector.py | 20 +++---- scenedetect/detectors/hash_detector.py | 2 +- scenedetect/detectors/histogram_detector.py | 6 +- scenedetect/frame_timecode.py | 28 +++++----- scenedetect/platform.py | 22 ++++---- scenedetect/scene_detector.py | 2 +- scenedetect/stats_manager.py | 29 +++++----- scenedetect/video_manager.py | 62 ++++++++++----------- scenedetect/video_stream.py | 14 ++--- tests/conftest.py | 4 +- tests/test_scene_manager.py | 7 +-- tests/test_video_stream.py | 30 +++++----- 17 files changed, 160 insertions(+), 162 deletions(-) diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index a8bd763a..498d17e7 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -83,7 +83,7 @@ # TODO: Future VideoStream implementations under consideration: # - Nvidia VPF: https://developer.nvidia.com/blog/vpf-hardware-accelerated-video-processing-framework-in-python/ -from typing import Dict, Type +import typing as ty # OpenCV must be available at minimum. from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 @@ -100,7 +100,7 @@ # TODO: Lazy-loading backends would improve startup performance. However, this requires removing # some of the re-exported types above from the public API. -AVAILABLE_BACKENDS: Dict[str, Type] = { +AVAILABLE_BACKENDS: ty.Dict[str, ty.Type] = { backend.BACKEND_NAME: backend for backend in filter( None, @@ -114,5 +114,5 @@ """All available backends that :func:`scenedetect.open_video` can consider for the `backend` parameter. These backends must support construction with the following signature: - BackendType(path: str, framerate: Optional[float]) + BackendType(path: str, framerate: ty.Optional[float]) """ diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index c3fb0935..b3bf2b18 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -16,8 +16,8 @@ image sequences or AviSynth scripts are supported as inputs. """ +import typing as ty from logging import getLogger -from typing import AnyStr, Optional, Tuple, Union import cv2 import numpy as np @@ -34,7 +34,9 @@ class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: bool = False): + def __init__( + self, path: ty.AnyStr, framerate: ty.Optional[float] = None, print_infos: bool = False + ): """Open a video or device. Arguments: @@ -64,8 +66,8 @@ def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame # will always be the current frame, and self._reader.lastread will be the next. - self._last_frame: Union[bool, np.ndarray] = False - self._last_frame_rgb: Optional[np.ndarray] = None + self._last_frame: ty.Union[bool, np.ndarray] = False + self._last_frame_rgb: ty.Optional[np.ndarray] = None # Older versions don't track the video position when calling read_frame so we need # to keep track of the current frame number. self._frame_number = 0 @@ -86,7 +88,7 @@ def frame_rate(self) -> float: return self._reader.fps @property - def path(self) -> Union[bytes, str]: + def path(self) -> ty.Union[bytes, str]: """Video path.""" return self._path @@ -101,12 +103,12 @@ def is_seekable(self) -> bool: return True @property - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> ty.Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return tuple(self._reader.infos["video_size"]) @property - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" assert isinstance(self._reader.infos["duration"], float) return self.base_timecode + self._reader.infos["duration"] @@ -155,7 +157,7 @@ def frame_number(self) -> int: This method will always return 0 if no frames have been `read`.""" return self._frame_number - def seek(self, target: Union[FrameTimecode, float, int]): + def seek(self, target: ty.Union[FrameTimecode, float, int]): """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -207,7 +209,7 @@ def reset(self, print_infos=False): self._eof = False self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index c9d0d389..e5b255b9 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -19,8 +19,8 @@ import math import os.path +import typing as ty from logging import getLogger -from typing import AnyStr, Optional, Tuple, Union import cv2 import numpy as np @@ -58,10 +58,10 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: AnyStr = None, - framerate: Optional[float] = None, + path: ty.AnyStr = None, + framerate: ty.Optional[float] = None, max_decode_attempts: int = 5, - path_or_device: Union[bytes, str, int] = None, + path_or_device: ty.Union[bytes, str, int] = None, ): """Open a video file, image sequence, or network stream. @@ -98,10 +98,10 @@ def __init__( self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: - self._cap: Optional[cv2.VideoCapture] = ( + self._cap: ty.Optional[cv2.VideoCapture] = ( None # Reference to underlying cv2.VideoCapture object. ) - self._frame_rate: Optional[float] = None + self._frame_rate: ty.Optional[float] = None # VideoCapture state self._has_grabbed = False @@ -140,7 +140,7 @@ def frame_rate(self) -> float: return self._frame_rate @property - def path(self) -> Union[bytes, str]: + def path(self) -> ty.Union[bytes, str]: """Video or device path.""" if self._is_device: assert isinstance(self._path_or_device, (int)) @@ -168,7 +168,7 @@ def is_seekable(self) -> bool: return not self._is_device @property - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> ty.Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -176,7 +176,7 @@ def frame_size(self) -> Tuple[int, int]: ) @property - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None @@ -218,7 +218,7 @@ def frame_number(self) -> int: This method will always return 0 if no frames have been `read`.""" return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) - def seek(self, target: Union[FrameTimecode, float, int]): + def seek(self, target: ty.Union[FrameTimecode, float, int]): """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -264,7 +264,7 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. @@ -308,7 +308,7 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, b # Private Methods # - def _open_capture(self, framerate: Optional[float] = None): + def _open_capture(self, framerate: ty.Optional[float] = None): """Opens capture referenced by this object and resets internal state.""" if self._is_device and self._path_or_device < 0: raise ValueError("Invalid/negative device ID specified.") @@ -364,7 +364,7 @@ class VideoCaptureAdapter(VideoStream): def __init__( self, cap: cv2.VideoCapture, - framerate: Optional[float] = None, + framerate: ty.Optional[float] = None, max_read_attempts: int = 5, ): """Create from an existing OpenCV VideoCapture object. Used for webcams, live streams, @@ -448,7 +448,7 @@ def is_seekable(self) -> bool: return False @property - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> ty.Tuple[int, int]: """Reported size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -456,7 +456,7 @@ def frame_size(self) -> Tuple[int, int]: ) @property - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: @@ -500,7 +500,7 @@ def frame_number(self) -> int: This method will always return 0 if no frames have been `read`.""" return self._num_frames - def seek(self, target: Union[FrameTimecode, float, int]): + def seek(self, target: ty.Union[FrameTimecode, float, int]): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") @@ -508,7 +508,7 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 9a558e7c..319559d5 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -11,8 +11,8 @@ # """:class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" +import typing as ty from logging import getLogger -from typing import AnyStr, BinaryIO, Optional, Tuple, Union import av import numpy as np @@ -35,10 +35,10 @@ class VideoStreamAv(VideoStream): # calculates the end time. def __init__( self, - path_or_io: Union[AnyStr, BinaryIO], - framerate: Optional[float] = None, - name: Optional[str] = None, - threading_mode: Optional[str] = None, + path_or_io: ty.Union[ty.AnyStr, ty.BinaryIO], + framerate: ty.Optional[float] = None, + name: ty.Optional[str] = None, + threading_mode: ty.Optional[str] = None, suppress_output: bool = False, ): """Open a video by path. @@ -147,12 +147,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> Union[bytes, str]: + def path(self) -> ty.Union[bytes, str]: """Video path.""" return self._path @property - def name(self) -> Union[bytes, str]: + def name(self) -> ty.Union[bytes, str]: """Name of the video, without extension.""" return self._name @@ -162,7 +162,7 @@ def is_seekable(self) -> bool: return self._io.seekable() @property - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> ty.Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return (self._codec_context.width, self._codec_context.height) @@ -219,7 +219,7 @@ def aspect_ratio(self) -> float: frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio - def seek(self, target: Union[FrameTimecode, float, int]) -> None: + def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -263,7 +263,7 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 5e638a63..5a073fb9 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -16,8 +16,8 @@ This detector is available from the command-line as the `detect-adaptive` command. """ +import typing as ty from logging import getLogger -from typing import List, Optional import numpy as np @@ -42,9 +42,9 @@ def __init__( min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: Optional[int] = None, + kernel_size: ty.Optional[int] = None, video_manager=None, - min_delta_hsv: Optional[float] = None, + min_delta_hsv: ty.Optional[float] = None, ): """ Arguments: @@ -98,7 +98,7 @@ def __init__( self._first_frame_num = None # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. - self._last_cut: Optional[int] = None + self._last_cut: ty.Optional[int] = None self._buffer = [] @@ -107,7 +107,7 @@ def event_buffer_length(self) -> int: """Number of frames any detected cuts will be behind the current frame due to buffering.""" return self.window_width - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" return super().get_metrics() + [self._adaptive_ratio_key] @@ -115,7 +115,7 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]: + def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -124,7 +124,7 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ @@ -168,7 +168,7 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List return [target_frame] return [] - def get_content_val(self, frame_num: int) -> Optional[float]: + def get_content_val(self, frame_num: int) -> ty.Optional[float]: """Returns the average content change for a frame.""" # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. logger.error( diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 1269727c..b1a274c2 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -16,8 +16,8 @@ """ import math +import typing as ty from dataclasses import dataclass -from typing import List, NamedTuple, Optional import cv2 import numpy @@ -54,7 +54,7 @@ class ContentDetector(SceneDetector): # TODO: Come up with some good weights for a new default if there is one that can pass # a wider variety of test cases. - class Components(NamedTuple): + class Components(ty.NamedTuple): """Components that make up a frame's score, and their default values.""" delta_hue: float = 1.0 @@ -97,7 +97,7 @@ class _FrameData: """Frame saturation map [2D 8-bit].""" lum: numpy.ndarray """Frame luma/brightness map [2D 8-bit].""" - edges: Optional[numpy.ndarray] + edges: ty.Optional[numpy.ndarray] """Frame edge map [2D 8-bit, edges are 255, non edges 0]. Affected by `kernel_size`.""" def __init__( @@ -106,7 +106,7 @@ def __init__( min_scene_len: int = 15, weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: Optional[int] = None, + kernel_size: ty.Optional[int] = None, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): """ @@ -126,17 +126,17 @@ def __init__( super().__init__() self._threshold: float = threshold self._min_scene_len: int = min_scene_len - self._last_above_threshold: Optional[int] = None - self._last_frame: Optional[ContentDetector._FrameData] = None + self._last_above_threshold: ty.Optional[int] = None + self._last_frame: ty.Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: self._weights = ContentDetector.LUMA_ONLY_WEIGHTS - self._kernel: Optional[numpy.ndarray] = None + self._kernel: ty.Optional[numpy.ndarray] = None if kernel_size is not None: if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) - self._frame_score: Optional[float] = None + self._frame_score: ty.Optional[float] = None self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): @@ -187,7 +187,7 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) return frame_score - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -196,7 +196,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ self._frame_score = self._calculate_frame_score(frame_num, frame_img) diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 2ca37afa..38e458d5 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -98,7 +98,7 @@ def process_frame(self, frame_num, frame_img): (inhereted from the base SceneDetector class) returns True. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 15f63834..14f0dfb1 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -15,7 +15,7 @@ This detector is available from the command-line as the `detect-hist` command. """ -from typing import List +import typing as ty import cv2 import numpy @@ -51,7 +51,7 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int self._last_scene_cut = None self._metric_key = f"hist_diff [bins={self._bins}]" - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Computes the histogram of the luma channel of the frame image and compares it with the histogram of the luma channel of the previous frame. If the difference between the histograms exceeds the threshold, a scene cut is detected. @@ -163,5 +163,5 @@ def calculate_histogram( def is_processing_required(self, frame_num: int) -> bool: return True - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: return [self._metric_key] diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index d942bbc0..24d5f895 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -67,7 +67,7 @@ """ import math -from typing import Union +import typing as ty MAX_FPS_DELTA: float = 1.0 / 100000 """Maximum amount two framerates can differ by for equality testing.""" @@ -89,8 +89,8 @@ class FrameTimecode: def __init__( self, - timecode: Union[int, float, str, "FrameTimecode"] = None, - fps: Union[int, float, str, "FrameTimecode"] = None, + timecode: ty.Union[int, float, str, "FrameTimecode"] = None, + fps: ty.Union[int, float, str, "FrameTimecode"] = None, ): """ Arguments: @@ -237,7 +237,7 @@ def _seconds_to_frames(self, seconds: float) -> int: """ return round(seconds * self.framerate) - def _parse_timecode_number(self, timecode: Union[int, float]) -> int: + def _parse_timecode_number(self, timecode: ty.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) @@ -311,7 +311,7 @@ def _parse_timecode_string(self, input: str) -> int: raise ValueError("Timecode seconds value must be positive.") return self._seconds_to_frames(as_float) - def __iadd__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): self.frame_num += other elif isinstance(other, FrameTimecode): @@ -330,12 +330,12 @@ def __iadd__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTime self.frame_num = 0 return self - def __add__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def __add__(self, other: ty.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": + def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): self.frame_num -= other elif isinstance(other, FrameTimecode): @@ -356,12 +356,12 @@ def __isub__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTime self.frame_num = 0 return self - def __sub__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def __sub__(self, other: ty.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": + def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): return self.frame_num == other elif isinstance(other, float): @@ -382,10 +382,10 @@ def __eq__(self, other: Union[int, float, str, "FrameTimecode"]) -> "FrameTimeco "Unsupported type for performing == with FrameTimecode: %s" % type(other) ) - def __ne__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return not self == other - def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num < other elif isinstance(other, float): @@ -404,7 +404,7 @@ def __lt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: "Unsupported type for performing < with FrameTimecode: %s" % type(other) ) - def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num <= other elif isinstance(other, float): @@ -423,7 +423,7 @@ def __le__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: "Unsupported type for performing <= with FrameTimecode: %s" % type(other) ) - def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num > other elif isinstance(other, float): @@ -442,7 +442,7 @@ def __gt__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: "Unsupported type for performing > with FrameTimecode: %s" % type(other) ) - def __ge__(self, other: Union[int, float, str, "FrameTimecode"]) -> bool: + def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self.frame_num >= other elif isinstance(other, float): diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 9e12dbc2..b832e250 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -24,7 +24,7 @@ import string import subprocess import sys -from typing import AnyStr, Dict, List, Optional, Union +import typing as ty import cv2 @@ -76,7 +76,7 @@ def __exit__(self, type, value, traceback): # TODO: Move this into scene_manager. -def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: +def get_cv2_imwrite_params() -> ty.Dict[str, ty.Union[int, None]]: """Get OpenCV imwrite Params: Returns a dict of supported image formats and their associated quality/compression parameter index, or None if that format is not supported. @@ -88,7 +88,7 @@ def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]: current system library (e.g. {'jpg': None}). """ - def _get_cv2_param(param_name: str) -> Union[int, None]: + def _get_cv2_param(param_name: str) -> ty.Union[int, None]: if param_name.startswith("CV_"): param_name = param_name[3:] try: @@ -108,7 +108,7 @@ def _get_cv2_param(param_name: str) -> Union[int, None]: ## -def get_file_name(file_path: AnyStr, include_extension=True) -> AnyStr: +def get_file_name(file_path: ty.AnyStr, include_extension=True) -> ty.AnyStr: """Return the file name that `file_path` refers to, optionally removing the extension. If `include_extension` is False, the result will always be a str. @@ -123,7 +123,9 @@ def get_file_name(file_path: AnyStr, include_extension=True) -> AnyStr: return file_name -def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = None) -> AnyStr: +def get_and_create_path( + file_path: ty.AnyStr, output_directory: ty.Optional[ty.AnyStr] = None +) -> ty.AnyStr: """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories along the way. @@ -157,7 +159,7 @@ def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = def init_logger( - log_level: int = logging.INFO, show_stdout: bool = False, log_file: Optional[str] = None + log_level: int = logging.INFO, show_stdout: bool = False, log_file: ty.Optional[str] = None ): """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'. By default the logger has no handlers to suppress output. All existing log handlers are replaced @@ -202,7 +204,7 @@ class CommandTooLong(Exception): """Raised if the length of a command line argument exceeds the limit allowed on Windows.""" -def invoke_command(args: List[str]) -> int: +def invoke_command(args: ty.List[str]) -> int: """Same as calling Python's subprocess.call() method, but explicitly raises a different exception when the command length is too long. @@ -231,7 +233,7 @@ def invoke_command(args: List[str]) -> int: raise -def get_ffmpeg_path() -> Optional[str]: +def get_ffmpeg_path() -> ty.Optional[str]: """Get path to ffmpeg if available on the current system. First looks at PATH, then checks if one is available from the `imageio_ffmpeg` package. Returns None if ffmpeg couldn't be found. """ @@ -261,7 +263,7 @@ def get_ffmpeg_path() -> Optional[str]: return None -def get_ffmpeg_version() -> Optional[str]: +def get_ffmpeg_version() -> ty.Optional[str]: """Get ffmpeg version identifier, or None if ffmpeg is not found. Uses `get_ffmpeg_path()`.""" ffmpeg_path = get_ffmpeg_path() if ffmpeg_path is None: @@ -275,7 +277,7 @@ def get_ffmpeg_version() -> Optional[str]: return output.splitlines()[0] -def get_mkvmerge_version() -> Optional[str]: +def get_mkvmerge_version() -> ty.Optional[str]: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" tool_name = "mkvmerge" try: diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 014bf31a..8f20e682 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -106,7 +106,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. Returns: - List[int]: List of frames where scene cuts have been detected. There may be 0 + ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. Returns: diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 320f726e..9c305603 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -27,9 +27,6 @@ from logging import getLogger from pathlib import Path -# TODO: Replace below imports with `ty.` prefix. -from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union - from scenedetect.frame_timecode import FrameTimecode logger = getLogger("pyscenedetect") @@ -106,10 +103,10 @@ def __init__(self, base_timecode: FrameTimecode = None): """ # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) # of each frame metric key and the value it represents (usually float). - self._frame_metrics: Dict[FrameTimecode, Dict[str, float]] = dict() - self._metric_keys: Set[str] = set() + self._frame_metrics: ty.Dict[FrameTimecode, ty.Dict[str, float]] = dict() + self._metric_keys: ty.Set[str] = set() self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: Optional[FrameTimecode] = ( + self._base_timecode: ty.Optional[FrameTimecode] = ( base_timecode # Used for timing calculations. ) @@ -117,14 +114,14 @@ def __init__(self, base_timecode: FrameTimecode = None): def metric_keys(self) -> ty.Iterable[str]: return self._metric_keys - def register_metrics(self, metric_keys: Iterable[str]) -> None: + def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: """Register a list of metric keys that will be used by the detector.""" self._metric_keys = self._metric_keys.union(set(metric_keys)) # TODO(v1.0): Change frame_number to a FrameTimecode now that it is just a hash and will # be required for VFR support. This API is also really difficult to use, this type should just # function like a dictionary. - def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any]: + def get_metrics(self, frame_number: int, metric_keys: ty.Iterable[str]) -> ty.List[ty.Any]: """Return the requested statistics/metrics for a given frame. Arguments: @@ -138,7 +135,7 @@ def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any """ return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] - def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None: + def set_metrics(self, frame_number: int, metric_kv_dict: ty.Dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: @@ -149,7 +146,7 @@ def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None for metric_key in metric_kv_dict: self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) - def metrics_exist(self, frame_number: int, metric_keys: Iterable[str]) -> bool: + def metrics_exist(self, frame_number: int, metric_keys: ty.Iterable[str]) -> bool: """Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: @@ -168,8 +165,8 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: Union[str, bytes, Path, TextIO], - base_timecode: Optional[FrameTimecode] = None, + csv_file: ty.Union[str, bytes, Path, ty.TextIO], + base_timecode: ty.Optional[FrameTimecode] = None, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. @@ -210,7 +207,7 @@ def save_to_csv( ) @staticmethod - def valid_header(row: List[str]) -> bool: + def valid_header(row: ty.List[str]) -> bool: """Check that the given CSV row is a valid header for a statsfile. Arguments: @@ -227,7 +224,7 @@ def valid_header(row: List[str]) -> bool: # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. - def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: + def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optional[int]: """[DEPRECATED] DO NOT USE Load all metrics stored in a CSV file into the StatsManager instance. Will be removed in a @@ -306,12 +303,12 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]: # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]: + def _get_metric(self, frame_number: int, metric_key: str) -> ty.Optional[ty.Any]: if self._metric_exists(frame_number, metric_key): return self._frame_metrics[frame_number][metric_key] return None - def _set_metric(self, frame_number: int, metric_key: str, metric_value: Any) -> None: + def _set_metric(self, frame_number: int, metric_key: str, metric_value: ty.Any) -> None: self._metrics_updated = True if frame_number not in self._frame_metrics: self._frame_metrics[frame_number] = dict() diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index ab09c8a5..171b0369 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -20,8 +20,8 @@ import math import os +import typing as ty from logging import getLogger -from typing import Iterable, List, Optional, Tuple, Union import cv2 import numpy as np @@ -43,7 +43,7 @@ class VideoParameterMismatch(Exception): def __init__( self, file_list=None, message="OpenCV VideoCapture object parameters do not match." ): - # type: (Iterable[Tuple[int, float, float, str, str]], str) -> None + # type: (ty.Iterable[ty.Tuple[int, float, float, str, str]], str) -> None # Pass message string to base Exception class. super(VideoParameterMismatch, self).__init__(message) # list of (param_mismatch_type: int, parameter value, expected value, @@ -67,7 +67,7 @@ class InvalidDownscaleFactor(ValueError): ## -def get_video_name(video_file: str) -> Tuple[str, str]: +def get_video_name(video_file: str) -> ty.Tuple[str, str]: """Get the video file/device name. Returns: @@ -78,7 +78,7 @@ def get_video_name(video_file: str) -> Tuple[str, str]: return (os.path.split(video_file)[1], video_file) -def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int: +def get_num_frames(cap_list: ty.Iterable[cv2.VideoCapture]) -> int: """Get Number of Frames: Returns total number of frames in the cap_list. Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. @@ -87,10 +87,10 @@ def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int: def open_captures( - video_files: Iterable[str], - framerate: Optional[float] = None, + video_files: ty.Iterable[str], + framerate: ty.Optional[float] = None, validate_parameters: bool = True, -) -> Tuple[List[cv2.VideoCapture], float, Tuple[int, int]]: +) -> ty.Tuple[ty.List[cv2.VideoCapture], float, ty.Tuple[int, int]]: """Open Captures - helper function to open all capture objects, set the framerate, and ensure that all open captures have been opened and the framerates match on a list of video file paths, or a list containing a single device ID. @@ -188,10 +188,10 @@ def open_captures( def validate_capture_framerate( - video_names: Iterable[Tuple[str, str]], - cap_framerates: List[float], - framerate: Optional[float] = None, -) -> Tuple[float, bool]: + video_names: ty.Iterable[ty.Tuple[str, str]], + cap_framerates: ty.List[float], + framerate: ty.Optional[float] = None, +) -> ty.Tuple[float, bool]: """Ensure the passed capture framerates are valid and equal. Raises: @@ -222,10 +222,10 @@ def validate_capture_framerate( def validate_capture_parameters( - video_names: List[Tuple[str, str]], - cap_frame_sizes: List[Tuple[int, int]], + video_names: ty.List[ty.Tuple[str, str]], + cap_frame_sizes: ty.List[ty.Tuple[int, int]], check_framerate: bool = False, - cap_framerates: Optional[List[float]] = None, + cap_framerates: ty.Optional[ty.List[float]] = None, ) -> None: """Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) framerates are equal. Raises VideoParameterMismatch if there is a mismatch. @@ -285,8 +285,8 @@ class VideoManager(VideoStream): def __init__( self, - video_files: List[str], - framerate: Optional[float] = None, + video_files: ty.List[str], + framerate: ty.Optional[float] = None, logger=None, ): """[DEPRECATED] DO NOT USE. @@ -358,11 +358,11 @@ def get_num_videos(self) -> int: """ return len(self._cap_list) - def get_video_paths(self) -> List[str]: + def get_video_paths(self) -> ty.List[str]: """Get list of strings containing paths to the open video(s). Returns: - List[str]: List of paths to the video files opened by the VideoManager. + ty.List[str]: List of paths to the video files opened by the VideoManager. """ return list(self._video_file_paths) @@ -421,7 +421,7 @@ def get_current_timecode(self) -> FrameTimecode: """ return self._curr_time - def get_framesize(self) -> Tuple[int, int]: + def get_framesize(self) -> ty.Tuple[int, int]: """Get frame size of the video(s) open in the VideoManager's capture objects. Returns: @@ -429,7 +429,7 @@ def get_framesize(self) -> Tuple[int, int]: """ return self._cap_framesize - def get_framesize_effective(self) -> Tuple[int, int]: + def get_framesize_effective(self) -> ty.Tuple[int, int]: """Get Frame Size - returns the frame size of the video(s) open in the VideoManager's capture objects. @@ -440,9 +440,9 @@ def get_framesize_effective(self) -> Tuple[int, int]: def set_duration( self, - duration: Optional[FrameTimecode] = None, - start_time: Optional[FrameTimecode] = None, - end_time: Optional[FrameTimecode] = None, + duration: ty.Optional[FrameTimecode] = None, + start_time: ty.Optional[FrameTimecode] = None, + end_time: ty.Optional[FrameTimecode] = None, ) -> None: """Set Duration - sets the duration/length of the video(s) to decode, as well as the start/end times. Must be called before :meth:`start()` is called, otherwise @@ -508,7 +508,7 @@ def get_duration(self) -> FrameTimecode: is calculated as the start timecode + total duration. Returns: - Tuple[FrameTimecode, FrameTimecode, FrameTimecode]: The current video(s) + ty.Tuple[FrameTimecode, FrameTimecode, FrameTimecode]: The current video(s) total duration, start timecode, and end timecode. """ end_time = self._end_time @@ -616,7 +616,7 @@ def reset(self) -> None: ) self._curr_cap, self._curr_cap_idx = None, None - def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, int]: + def get(self, capture_prop: int, index: ty.Optional[int] = None) -> ty.Union[float, int]: """Get (cv2.VideoCapture method) - obtains capture properties from the current VideoCapture object in use. Index represents the same index as the original video_files list passed to the constructor. Getting/setting the position (POS) @@ -668,7 +668,7 @@ def grab(self) -> bool: self._correct_frame_length() return grabbed - def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: + def retrieve(self) -> ty.Tuple[bool, ty.Optional[np.ndarray]]: """Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. Frame returned corresponds to last call to :meth:`grab()`. @@ -691,7 +691,7 @@ def retrieve(self) -> Tuple[bool, Optional[np.ndarray]]: self._last_frame = None return (retrieved, self._last_frame) - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Return next frame (or current if advance = False), or False if end of video. Arguments: @@ -743,7 +743,7 @@ def aspect_ratio(self) -> float: return self._aspect_ratio @property - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" return self.get_duration()[0] @@ -785,7 +785,7 @@ def frame_rate(self) -> float: return self._cap_framerate @property - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> ty.Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -798,14 +798,14 @@ def is_seekable(self) -> bool: return True @property - def path(self) -> Union[bytes, str]: + def path(self) -> ty.Union[bytes, str]: """Video or device path.""" if self._is_device: return "Device %d" % self._path return self._path @property - def name(self) -> Union[bytes, str]: + def name(self) -> ty.Union[bytes, str]: """Name of the video, without extension, or device.""" if self._is_device: return self.path diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 2537174b..b522c61f 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -31,8 +31,8 @@ tested by adding it to the test suite in `tests/test_video_stream.py`. """ +import typing as ty from abc import ABC, abstractmethod -from typing import Optional, Tuple, Union import numpy as np @@ -108,13 +108,13 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> Union[bytes, str]: + def path(self) -> ty.Union[bytes, str]: """Video or device path.""" ... @property @abstractmethod - def name(self) -> Union[bytes, str]: + def name(self) -> ty.Union[bytes, str]: """Name of the video, without extension, or device.""" ... @@ -132,13 +132,13 @@ def frame_rate(self) -> float: @property @abstractmethod - def duration(self) -> Optional[FrameTimecode]: + def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" ... @property @abstractmethod - def frame_size(self) -> Tuple[int, int]: + def frame_size(self) -> ty.Tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" ... @@ -177,7 +177,7 @@ def frame_number(self) -> int: # @abstractmethod - def read(self, decode: bool = True, advance: bool = True) -> Union[np.ndarray, bool]: + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -196,7 +196,7 @@ def reset(self) -> None: ... @abstractmethod - def seek(self, target: Union[FrameTimecode, float, int]) -> None: + def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). diff --git a/tests/conftest.py b/tests/conftest.py index 6034456c..6ee5ff0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ import logging import os -from typing import AnyStr +import typing as ty import pytest @@ -37,7 +37,7 @@ # -def check_exists(path: AnyStr) -> AnyStr: +def check_exists(path: ty.AnyStr) -> ty.AnyStr: """Returns the absolute path to a (relative) path of a file that should exist within the tests/ directory. diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index b59ae75e..d67604db 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -15,11 +15,8 @@ which applies SceneDetector algorithms on VideoStream backends. """ -import glob -import os -import os.path +import typing as ty from pathlib import Path -from typing import List import pytest @@ -199,7 +196,7 @@ class FakeCallback: """Fake callback used for testing. Tracks the frame numbers the callback was invoked with.""" def __init__(self): - self.scene_list: List[int] = [] + self.scene_list: ty.List[int] = [] def get_callback_lambda(self): """For testing using a lambda..""" diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 894d60f6..0ab03430 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -17,8 +17,8 @@ """ import os.path +import typing as ty from dataclasses import dataclass -from typing import List, Type import numpy import pytest @@ -92,7 +92,7 @@ class VideoParameters: # TODO: Save two "golden" frames from each video on a shot boundary, and use that to validate # that seeking works correctly for all backends (as well as that no frames are dropped). -def get_test_video_params() -> List[VideoParameters]: +def get_test_video_params() -> ty.List[VideoParameters]: """Fixture for parameters of all videos.""" return [ VideoParameters( @@ -145,7 +145,7 @@ def get_test_video_params() -> List[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_properties(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) @@ -158,7 +158,7 @@ def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameter test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" stream = vs_type(test_video.path) frame = stream.read() @@ -166,7 +166,7 @@ def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_advance(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read_no_advance(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `advance` set to False.""" stream = vs_type(test_video.path) frame = stream.read().copy() @@ -175,7 +175,7 @@ def test_read_no_advance(self, vs_type: Type[VideoStream], test_video: VideoPara assert stream.frame_number == 1 assert calculate_frame_delta(frame, frame_copy) == pytest.approx(0.0) - def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read_no_decode(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True @@ -183,7 +183,7 @@ def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParam stream.read(decode=False, advance=False) assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_time_invariants(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -206,7 +206,7 @@ def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoPara 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_reset(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" stream = vs_type(test_video.path) # Decode some frames, then reset the VideoStream and validate the time invariants. @@ -218,7 +218,7 @@ def test_reset(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate `seek()` functionality with different offset types.""" stream = vs_type(test_video.path) @@ -264,7 +264,7 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters): assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_start(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" stream = vs_type(test_video.path) # Here we check similar invariants to test_time_invariants, but using seek(). @@ -299,7 +299,7 @@ def test_seek_start(self, vs_type: Type[VideoStream], test_video: VideoParameter assert stream.frame_number == 2 stream = vs_type(test_video.path) - def test_read_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_read_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Ensure calling `read()` handles the end of the video correctly.""" stream = vs_type(test_video.path) # To make the test faster, we seek to the second last frame. @@ -312,7 +312,7 @@ def test_read_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters) else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate calling `seek()` to offset past end of video.""" if vs_type == VideoManager: pytest.skip(reason="VideoManager does not have compliant end-of-video seek behaviour.") @@ -334,7 +334,7 @@ def test_seek_past_eof(self, vs_type: Type[VideoStream], test_video: VideoParame else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParameters): + def test_seek_invalid(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -350,13 +350,13 @@ def test_seek_invalid(self, vs_type: Type[VideoStream], test_video: VideoParamet # -def test_invalid_path(vs_type: Type[VideoStream]): +def test_invalid_path(vs_type: ty.Type[VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): _ = vs_type("this_path_should_not_exist.mp4") -def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str): +def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoManager: pytest.skip(reason="VideoManager does not support handling corrupt videos.") From 4b9edcc653f153161131e87cbaa7c81d6e6e5582 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 16 Mar 2025 18:33:46 -0400 Subject: [PATCH 215/407] [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. --- scenedetect/__init__.py | 2 +- scenedetect/backends/opencv.py | 34 ++++++++++++++++++++++- scenedetect/backends/pyav.py | 16 ++++++++++- scenedetect/timecode.py | 49 ++++++++++++++++++++++++++++++++++ scenedetect/video_stream.py | 38 +++++++++++++++++++++++--- tests/conftest.py | 6 +++++ 6 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 scenedetect/timecode.py diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index cd35626f..7e1e7b3f 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.6" +__version__ = "0.7-dev0" init_logger() logger = getLogger("pyscenedetect") diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index e5b255b9..3d1da77d 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -20,6 +20,7 @@ import math import os.path import typing as ty +from fractions import Fraction from logging import getLogger import cv2 @@ -27,7 +28,14 @@ from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.video_stream import FrameRateUnavailable, SeekError, VideoOpenFailure, VideoStream +from scenedetect.timecode import Timecode +from scenedetect.video_stream import ( + FrameRateUnavailable, + SeekError, + VideoFrame, + VideoOpenFailure, + VideoStream, +) logger = getLogger("pyscenedetect") @@ -264,6 +272,30 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) + def __next__(self): + # NOTE: POS_FRAMES starts from 0 before any frames are read. + read, image = self._cap.read() + if not read: + raise StopIteration() + # We can only query CAP_PROP_PTS if this uses the ffmpeg backend, however it doesn't seem + # to work correctly. Quite frequently consecutive frames return the same PTS. We might need + # to just abandon using PTS with OpenCV and rely on milliseconds. This will still result + # in occasional off-by-one errors for VFR videos, but better than the status quo. + # + # We should also add a config option so users can specify if OpenCV should use fixed or + # variable timing (i.e. if we should use CAP_PROP_POS_MSEC or CAP_PROP_POS_FRAMES for + # timestamp calculation). + USE_PTS = False + if USE_PTS: + pts = self._cap.get(cv2.CAP_PROP_PTS) + time_base = Fraction.from_float(self._cap.get(cv2.CAP_PROP_FPS)) + time_base = Fraction(numerator=time_base.denominator, denominator=time_base.numerator) + else: + pts = self._cap.get(cv2.CAP_PROP_POS_MSEC) + time_base = Fraction(1, 1000) + timecode = Timecode(pts=round(pts), time_base=time_base) + return VideoFrame(image=image, timecode=timecode) + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 319559d5..d63fa0c4 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -19,7 +19,8 @@ from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream +from scenedetect.timecode import Timecode +from scenedetect.video_stream import FrameRateUnavailable, VideoFrame, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") @@ -263,6 +264,19 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex + def __next__(self) -> VideoFrame: + # TODO: On the VFR test video, we seem to only decode 1979 frames instead of 1980. See what + # the issue could be. + try: + frame = next(self._container.decode(video=0)) + except av.error.EOFError as ex: + if not self._handle_eof(): + raise StopIteration() from ex + return next(self) # *NOTE*: self._handle_eof must ensure we won't recurse again. + image = frame.to_ndarray(format="bgr24") + timecode = Timecode(pts=frame.pts, time_base=frame.time_base) + return VideoFrame(image=image, timecode=timecode) + def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. diff --git a/scenedetect/timecode.py b/scenedetect/timecode.py new file mode 100644 index 00000000..50f30e79 --- /dev/null +++ b/scenedetect/timecode.py @@ -0,0 +1,49 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""``scenedetect.timecode`` Module + +This module contains types and functions for handling video timecodes, including parsing user input +and timecode format conversion. +""" + +from dataclasses import dataclass +from fractions import Fraction + + +# TODO(@Breakthrough): Add conversion from Timecode -> FrameTimecode for backwards compatibility. +# TODO(@Breakthrough): How should we deal with frame numbers? We might need to detect if a video is +# VFR or not, and if so, either omit them or always start them from 0 regardless of the start seek. +# With PyAV we can probably assume the video is VFR if the guessed rate of the stream differs +# from the average rate. +# +# Each backend has slight nuances we have to take into account: +# - PyAV: Does not include a position in frames, we can probably estimate it. Need to also compare +# with how OpenCV handles this. It also seems to fail to decode the last frame. This library +# provides the most accurate timing information however. +# - OpenCV: Lacks any kind of timebase, only provides position in milliseconds and as frames. +# This is probably sufficient, since we could just use 1ms as a timebase. +# - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is +# probably not feasible, so we should make sure the docs warn users about this. +# +# +@dataclass +class Timecode: + """Timing information associated with a given frame.""" + + pts: int + """Presentation timestamp of the frame in units of `time_base`.""" + time_base: Fraction + """The base unit in which `pts` is measured.""" + + @property + def seconds(self) -> float: + return float(self.time_base * self.pts) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index b522c61f..fe39987c 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -33,14 +33,12 @@ import typing as ty from abc import ABC, abstractmethod +from dataclasses import dataclass import numpy as np from scenedetect.frame_timecode import FrameTimecode - -## -## VideoStream Exceptions -## +from scenedetect.timecode import Timecode class SeekError(Exception): @@ -79,6 +77,14 @@ def __init__(self): ## +@dataclass +class VideoFrame: + """Data returned when reading/decoding a frame from a video.""" + + image: np.ndarray + timecode: Timecode + + class VideoStream(ABC): """Interface which all video backends must implement.""" @@ -175,7 +181,31 @@ def frame_number(self) -> int: # # Abstract Methods # + def __iter__(self) -> ty.Iterable[VideoFrame]: + return self + + def __next__(self) -> VideoFrame: + """Read and decode the next frame from the current seek position. + + Raises: + StopIteration: The next frame could not be decoded (i.e. the stream ended). + """ + # TODO(v0.7): Make this an abstract method when it is implemented for all backends. + raise NotImplementedError() + + def skip(self) -> ty.Optional[Timecode]: + """Advance the stream to the next frame without decoding the frame data. *May* be faster in + cases where the image data for a given frame isn't required. + + Returns: + The `Timecode` of the frame that was skipped, or None if it could not be decoded (i.e. + the stream ended). + """ + # TODO(v0.7): Make this an abstract method when it is implemented for all backends. + raise NotImplementedError() + # TODO(v0.7): Mark this as deprecated in lieu of `__next__` and `skip`. See if there is a way + # we can change this to no longer be an abstract method, but to instead use the above methods. @abstractmethod def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. diff --git a/tests/conftest.py b/tests/conftest.py index 6ee5ff0f..26e0258f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,6 +109,12 @@ def test_movie_clip() -> str: return check_exists("tests/resources/goldeneye.mp4") +@pytest.fixture +def test_vfr_video() -> str: + """Movie clip containing fast cut, but encoded as variable framerate.""" + return check_exists("tests/resources/goldeneye-vfr.mp4") + + @pytest.fixture def corrupt_video_file() -> str: """Video containing a corrupted frame causing a decode failure.""" From 08e5ba07f12d0a8bd99fafc992bb1d421768eeb2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 16 Mar 2025 19:58:40 -0400 Subject: [PATCH 216/407] [project] Create new common module Remove timecode module and move contents into common. --- scenedetect/backends/opencv.py | 2 +- scenedetect/backends/pyav.py | 2 +- scenedetect/{timecode.py => common.py} | 33 ++++++++++++++++++++------ scenedetect/scene_manager.py | 12 +--------- scenedetect/video_splitter.py | 4 +--- scenedetect/video_stream.py | 2 +- 6 files changed, 31 insertions(+), 24 deletions(-) rename scenedetect/{timecode.py => common.py} (66%) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 3d1da77d..df17fb10 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -26,9 +26,9 @@ import cv2 import numpy as np +from scenedetect.common import Timecode from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.timecode import Timecode from scenedetect.video_stream import ( FrameRateUnavailable, SeekError, diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index d63fa0c4..f5f9cd46 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -17,9 +17,9 @@ import av import numpy as np +from scenedetect.common import Timecode from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.timecode import Timecode from scenedetect.video_stream import FrameRateUnavailable, VideoFrame, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") diff --git a/scenedetect/timecode.py b/scenedetect/common.py similarity index 66% rename from scenedetect/timecode.py rename to scenedetect/common.py index 50f30e79..715162f9 100644 --- a/scenedetect/timecode.py +++ b/scenedetect/common.py @@ -9,17 +9,38 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""``scenedetect.timecode`` Module +"""``scenedetect.common`` Module -This module contains types and functions for handling video timecodes, including parsing user input -and timecode format conversion. -""" +This module contains common types and functions used throughout PySceneDetect.""" +import typing as ty from dataclasses import dataclass from fractions import Fraction +# TODO(v0.7): We should move frame_timecode into this file. +from scenedetect.frame_timecode import FrameTimecode + +## +## Type Aliases +## + +SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] +"""Type hint for a list of scenes in the form (start time, end time).""" + +CutList = ty.List[FrameTimecode] +"""Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" + +CropRegion = ty.Tuple[int, int, int, int] +"""Type hint for rectangle of the form X0 Y0 X1 Y1 for cropping frames. Coordinates are relative +to source frame without downscaling. +""" + +TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] +"""Named type for pairs of timecodes, which typically represents the start/end of a scene.""" + -# TODO(@Breakthrough): Add conversion from Timecode -> FrameTimecode for backwards compatibility. +# TODO(@Breakthrough): Figure out interop with FrameTimecode. We can probably just store this inside +# of FrameTimecode. # TODO(@Breakthrough): How should we deal with frame numbers? We might need to detect if a video is # VFR or not, and if so, either omit them or always start them from 0 regardless of the start seek. # With PyAV we can probably assume the video is VFR if the guessed rate of the stream differs @@ -33,8 +54,6 @@ # This is probably sufficient, since we could just use 1ms as a timebase. # - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is # probably not feasible, so we should make sure the docs warn users about this. -# -# @dataclass class Timecode: """Timing information associated with a given frame.""" diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 9af8a661..66216aa5 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -100,6 +100,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SimpleTableImage, SimpleTableRow, ) +from scenedetect.common import CropRegion, CutList, SceneList from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.scene_detector import SceneDetector, SparseSceneDetector @@ -108,17 +109,6 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): logger = logging.getLogger("pyscenedetect") -SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] -"""Type hint for a list of scenes in the form (start time, end time).""" - -CutList = ty.List[FrameTimecode] -"""Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" - -CropRegion = ty.Tuple[int, int, int, int] -"""Type hint for rectangle of the form X0 Y0 X1 Y1 for cropping frames. Coordinates are relative -to source frame without downscaling. -""" - # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current # value doesn't seem to have caused any issues at least. diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 819d678e..807bbdfc 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -40,14 +40,12 @@ from dataclasses import dataclass from pathlib import Path +from scenedetect.common import TimecodePair from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import CommandTooLong, Template, get_ffmpeg_path, invoke_command, tqdm logger = logging.getLogger("pyscenedetect") -TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] -"""Named type for pairs of timecodes, which typically represents the start/end of a scene.""" - COMMAND_TOO_LONG_STRING = """ Cannot split video due to too many scenes (resulting command is too large to process). To work around this issue, you can diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index fe39987c..bf2db10e 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -37,8 +37,8 @@ import numpy as np +from scenedetect.common import Timecode from scenedetect.frame_timecode import FrameTimecode -from scenedetect.timecode import Timecode class SeekError(Exception): From f808d29b898072da8bd1ebe159e3895c5bd08319 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 16 Mar 2025 21:53:49 -0400 Subject: [PATCH 217/407] [common] Move FrameTimecode into scenedetect.common --- docs/api.rst | 9 +- docs/api/{frame_timecode.rst => common.rst} | 6 +- docs/api/migration_guide.rst | 2 +- docs/index.rst | 2 +- scenedetect/common.py | 476 +++++++++++++++++++- scenedetect/frame_timecode.py | 470 +------------------ website/pages/changelog.md | 9 +- 7 files changed, 487 insertions(+), 487 deletions(-) rename docs/api/{frame_timecode.rst => common.rst} (61%) diff --git a/docs/api.rst b/docs/api.rst index 5278aad0..79096e76 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -29,10 +29,7 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.video_splitter ✂️ `: Contains :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` to split a video based on the detected scenes. - * :ref:`scenedetect.frame_timecode ⏱️ `: Contains - :class:`FrameTimecode ` - class for storing, converting, and performing arithmetic on timecodes - with frame-accurate precision. + * :ref:`scenedetect.common ⏱️ `: Contains common types such as :class:`FrameTimecode ` used for timecode handing. * :ref:`scenedetect.scene_detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. @@ -68,7 +65,7 @@ PySceneDetect makes it very easy to find scene transitions in a video with the : for (scene_start, scene_end) in scenes: print(f'{scene_start}-{scene_end}') -``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. +``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. Here, we use :mod:`ContentDetector ` to detect fast cuts. There are :ref:`many detector types ` which can be used to find fast cuts and fades in/out. PySceneDetect can also export scene data in various formats, and can :ref:`split the input video ` automatically if `ffmpeg` is available: @@ -103,7 +100,7 @@ Module Reference api/scene_manager api/video_splitter api/stats_manager - api/frame_timecode + api/common api/scene_detector api/video_stream api/platform diff --git a/docs/api/frame_timecode.rst b/docs/api/common.rst similarity index 61% rename from docs/api/frame_timecode.rst rename to docs/api/common.rst index b51dc388..ca3a1ede 100644 --- a/docs/api/frame_timecode.rst +++ b/docs/api/common.rst @@ -1,9 +1,9 @@ -.. _scenedetect-frame_timecode: +.. _scenedetect-common: --------------------------------------------------------------- -FrameTimecode +Common --------------------------------------------------------------- -.. automodule:: scenedetect.frame_timecode +.. automodule:: scenedetect.common :members: diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index 11da142a..d41afbec 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -127,7 +127,7 @@ The `calculate_frame_score` method of :class:`ContentDetector `. +In `scenedetect.frame_timecode` the constants `MINIMUM_FRAMES_PER_SECOND_FLOAT` and `MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT` have been replaced with :data:`MAX_FPS_DELTA `. `get_aspect_ratio` Function diff --git a/docs/index.rst b/docs/index.rst index 1aca59b1..d0c11060 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,9 +48,9 @@ Table of Contents api/detectors api/backends api/scene_manager + api/common api/video_splitter api/stats_manager - api/frame_timecode api/scene_detector api/video_stream api/platform diff --git a/scenedetect/common.py b/scenedetect/common.py index 715162f9..e36b3b50 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -11,23 +11,75 @@ # """``scenedetect.common`` Module -This module contains common types and functions used throughout PySceneDetect.""" +This module contains common types and functions used throughout PySceneDetect. +This includes :class:`FrameTimecode` 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 :class:`FrameTimecode constructor `. + +=============================================================== +Usage Examples +=============================================================== + +A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames, +`float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate: + +.. code:: python + + frames = FrameTimecode(timecode = 29, fps = 29.97) + seconds_float = FrameTimecode(timecode = 10.0, fps = 10.0) + timecode_str = FrameTimecode(timecode = "00:00:10.000", fps = 10.0) + + +Arithmetic/comparison operations with :class:`FrameTimecode` objects is also possible, and the +other operand can also be of the above types: + +.. code:: python + + x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0) + # Can add int (frames), float (seconds), or str (timecode). + print(x + 10) + print(x + 10.0) + print(x + "00:10:00") + # Same for all comparison operators. + print((x + 10.0) == "00:01:10.000") + + +:class:`FrameTimecode` objects can be added and subtracted, however the current implementation +disallows negative values, and will clamp negative results to 0. + +.. warning:: + + Be careful when subtracting :class:`FrameTimecode` objects or adding negative + amounts of frames/seconds. In the example below, ``c`` will be at frame 0 since + ``b > a``, but ``d`` will be at frame 5: + + .. code:: python + + a = FrameTimecode(5, 10.0) + b = FrameTimecode(10, 10.0) + c = a - b # b > a, so c == 0 + d = b - a + assert(c == 0) + assert(d == 5) +""" + +import math import typing as ty from dataclasses import dataclass from fractions import Fraction -# TODO(v0.7): We should move frame_timecode into this file. -from scenedetect.frame_timecode import FrameTimecode - ## ## Type Aliases ## -SceneList = ty.List[ty.Tuple[FrameTimecode, FrameTimecode]] +SceneList = ty.List[ty.Tuple["FrameTimecode", "FrameTimecode"]] """Type hint for a list of scenes in the form (start time, end time).""" -CutList = ty.List[FrameTimecode] +CutList = ty.List["FrameTimecode"] """Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" CropRegion = ty.Tuple[int, int, int, int] @@ -35,9 +87,16 @@ to source frame without downscaling. """ -TimecodePair = ty.Tuple[FrameTimecode, FrameTimecode] +TimecodePair = ty.Tuple["FrameTimecode", "FrameTimecode"] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" +MAX_FPS_DELTA: float = 1.0 / 100000 +"""Maximum amount two framerates can differ by for equality testing.""" + +_SECONDS_PER_MINUTE = 60.0 +_SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE +_MINUTES_PER_HOUR = 60.0 + # TODO(@Breakthrough): Figure out interop with FrameTimecode. We can probably just store this inside # of FrameTimecode. @@ -66,3 +125,406 @@ class Timecode: @property def seconds(self) -> float: return float(self.time_base * self.pts) + + +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 "SSSS.nnnn" (`"45.678"`) + 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) + """ + + def __init__( + self, + timecode: ty.Union[int, float, str, "FrameTimecode"] = None, + fps: ty.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 :meth:`get_seconds` method). + + If using to compare a :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 :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 :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() + hrs = int(secs / _SECONDS_PER_HOUR) + secs -= hrs * _SECONDS_PER_HOUR + mins = int(secs / _SECONDS_PER_MINUTE) + secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) + if use_rounding: + secs = round(secs, precision) + secs = min(_SECONDS_PER_MINUTE, secs) + # Guard against emitting timecodes with 60 seconds after rounding/floating point errors. + if int(secs) == _SECONDS_PER_MINUTE: + secs = 0.0 + mins += 1 + if mins >= _MINUTES_PER_HOUR: + mins = 0 + hrs += 1 + # We have to extend the precision by 1 here, since `format` will round up. + msec = format(secs, ".%df" % (precision + 1)) if precision else "" + # Need to include decimal place in `msec_str`. + msec_str = msec[-(2 + precision) : -1] + secs_str = f"{int(secs):02d}{msec_str}" + # Return hours, minutes, and seconds as a formatted timecode string. + return "%02d:%02d:%s" % (hrs, mins, secs_str) + + # 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: ty.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, input: 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.0' are all possible valid values, all representing + a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). + + Raises: + ValueError: Value could not be parsed correctly. + """ + assert self.framerate is not None + input = input.strip() + # Exact number of frames N + if input.isdigit(): + timecode = int(input) + if timecode < 0: + raise ValueError("Timecode frame number must be positive.") + return timecode + # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' + elif input.find(":") >= 0: + values = input.split(":") + if len(values) not in (2, 3): + raise ValueError("Invalid timecode (too many separators).") + # Case of 'HH:MM:SS[.nnn]' + if len(values) == 3: + hrs, mins = int(values[0]), int(values[1]) + secs = float(values[2]) if "." in values[2] else int(values[2]) + # Case of 'MM:SS[.nnn]' + elif len(values) == 2: + hrs = 0 + mins = int(values[0]) + secs = float(values[1]) if "." in values[1] else int(values[1]) + 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 * 60) + (mins * 60) + return self._seconds_to_frames(secs) + # Try to parse the number as seconds in the format 1234.5 or 1234s + if input.endswith("s"): + input = input[:-1] + if not input.replace(".", "").isdigit(): + raise ValueError("All characters in timecode seconds string must be digits.") + as_float = float(input) + if as_float < 0.0: + raise ValueError("Timecode seconds value must be positive.") + return self._seconds_to_frames(as_float) + + def __iadd__(self, other: ty.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: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + to_return = FrameTimecode(timecode=self) + to_return += other + return to_return + + def __isub__(self, other: ty.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: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + to_return = FrameTimecode(timecode=self) + to_return -= other + return to_return + + def __eq__(self, other: ty.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: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return not self == other + + def __lt__(self, other: ty.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: ty.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: ty.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: ty.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 diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 24d5f895..52dddc44 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -9,472 +9,8 @@ # 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 +"""For backwards compatibility only, will be removed in a future release.""" -This module implements :class:`FrameTimecode` 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. +# TODO(v0.7): Include a warning if this module is imported. -See the following examples, or the :class:`FrameTimecode constructor `. - -=============================================================== -Usage Examples -=============================================================== - -A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames, -`float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate: - -.. code:: python - - frames = FrameTimecode(timecode = 29, fps = 29.97) - seconds_float = FrameTimecode(timecode = 10.0, fps = 10.0) - timecode_str = FrameTimecode(timecode = "00:00:10.000", fps = 10.0) - - -Arithmetic/comparison operations with :class:`FrameTimecode` objects is also possible, and the -other operand can also be of the above types: - -.. code:: python - - x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0) - # Can add int (frames), float (seconds), or str (timecode). - print(x + 10) - print(x + 10.0) - print(x + "00:10:00") - # Same for all comparison operators. - print((x + 10.0) == "00:01:10.000") - - -:class:`FrameTimecode` objects can be added and subtracted, however the current implementation -disallows negative values, and will clamp negative results to 0. - -.. warning:: - - Be careful when subtracting :class:`FrameTimecode` objects or adding negative - amounts of frames/seconds. In the example below, ``c`` will be at frame 0 since - ``b > a``, but ``d`` will be at frame 5: - - .. code:: python - - a = FrameTimecode(5, 10.0) - b = FrameTimecode(10, 10.0) - c = a - b # b > a, so c == 0 - d = b - a - assert(c == 0) - assert(d == 5) - -""" - -import math -import typing as ty - -MAX_FPS_DELTA: float = 1.0 / 100000 -"""Maximum amount two framerates can differ by for equality testing.""" - -_SECONDS_PER_MINUTE = 60.0 -_SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE -_MINUTES_PER_HOUR = 60.0 - - -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 "SSSS.nnnn" (`"45.678"`) - 3. Exact number of frames as `int`, or `str` in form NNNNN (`456` or `"456"`) - """ - - def __init__( - self, - timecode: ty.Union[int, float, str, "FrameTimecode"] = None, - fps: ty.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 :meth:`get_seconds` method). - - If using to compare a :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 :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 :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() - hrs = int(secs / _SECONDS_PER_HOUR) - secs -= hrs * _SECONDS_PER_HOUR - mins = int(secs / _SECONDS_PER_MINUTE) - secs = max(0.0, secs - (mins * _SECONDS_PER_MINUTE)) - if use_rounding: - secs = round(secs, precision) - secs = min(_SECONDS_PER_MINUTE, secs) - # Guard against emitting timecodes with 60 seconds after rounding/floating point errors. - if int(secs) == _SECONDS_PER_MINUTE: - secs = 0.0 - mins += 1 - if mins >= _MINUTES_PER_HOUR: - mins = 0 - hrs += 1 - # We have to extend the precision by 1 here, since `format` will round up. - msec = format(secs, ".%df" % (precision + 1)) if precision else "" - # Need to include decimal place in `msec_str`. - msec_str = msec[-(2 + precision) : -1] - secs_str = f"{int(secs):02d}{msec_str}" - # Return hours, minutes, and seconds as a formatted timecode string. - return "%02d:%02d:%s" % (hrs, mins, secs_str) - - # 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: ty.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, input: 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.0' are all possible valid values, all representing - a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). - - Raises: - ValueError: Value could not be parsed correctly. - """ - assert self.framerate is not None - input = input.strip() - # Exact number of frames N - if input.isdigit(): - timecode = int(input) - if timecode < 0: - raise ValueError("Timecode frame number must be positive.") - return timecode - # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' - elif input.find(":") >= 0: - values = input.split(":") - if len(values) not in (2, 3): - raise ValueError("Invalid timecode (too many separators).") - # Case of 'HH:MM:SS[.nnn]' - if len(values) == 3: - hrs, mins = int(values[0]), int(values[1]) - secs = float(values[2]) if "." in values[2] else int(values[2]) - # Case of 'MM:SS[.nnn]' - elif len(values) == 2: - hrs = 0 - mins = int(values[0]) - secs = float(values[1]) if "." in values[1] else int(values[1]) - 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 * 60) + (mins * 60) - return self._seconds_to_frames(secs) - # Try to parse the number as seconds in the format 1234.5 or 1234s - if input.endswith("s"): - input = input[:-1] - if not input.replace(".", "").isdigit(): - raise ValueError("All characters in timecode seconds string must be digits.") - as_float = float(input) - if as_float < 0.0: - raise ValueError("Timecode seconds value must be positive.") - return self._seconds_to_frames(as_float) - - def __iadd__(self, other: ty.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: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - to_return = FrameTimecode(timecode=self) - to_return += other - return to_return - - def __isub__(self, other: ty.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: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - to_return = FrameTimecode(timecode=self) - to_return -= other - return to_return - - def __eq__(self, other: ty.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: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - return not self == other - - def __lt__(self, other: ty.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: ty.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: ty.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: ty.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 +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0801a045..e61e7c83 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -650,6 +650,11 @@ Development ## PySceneDetect 0.7 (In Development) -### Work In Progress +### CLI Changes -- [feature] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] WORK IN PROGRESS: New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) + + +### API Changes + +- [deprecation] The `scenedetect.frame_timecode` module is deprecated, import `FrameTimecode` from `scenedetect` (or `scenedetect.common`) instead From c7c82a12dadcdad45b3b9628f39bebfc2bb2e0ac Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 16 Mar 2025 21:57:50 -0400 Subject: [PATCH 218/407] [docs] Cleanup v0.5 migration guide --- docs/api.rst | 8 --- docs/api/migration_guide.rst | 136 ----------------------------------- 2 files changed, 144 deletions(-) delete mode 100644 docs/api/migration_guide.rst diff --git a/docs/api.rst b/docs/api.rst index 79096e76..6579c654 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -104,7 +104,6 @@ Module Reference api/scene_detector api/video_stream api/platform - api/migration_guide ======================================================================= @@ -112,10 +111,3 @@ Logging ======================================================================= PySceneDetect outputs messages to a logger named ``pyscenedetect`` which does not have any default handlers. You can use :func:`scenedetect.init_logger ` with ``show_stdout=True`` or specify a log file (verbosity can also be specified) to attach some common handlers, or use ``logging.getLogger("pyscenedetect")`` and attach log handlers manually. - - -======================================================================= -Migrating From 0.5 -======================================================================= - -PySceneDetect 0.6 introduces several breaking changes which are incompatible with 0.5. See :ref:`Migration Guide ` for details on how to update your application. In addition, demonstrations of common use cases can be found in the `tests/test_api.py `_ file. diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst deleted file mode 100644 index d41afbec..00000000 --- a/docs/api/migration_guide.rst +++ /dev/null @@ -1,136 +0,0 @@ - -.. _scenedetect-migration_guide: - ---------------------------------------------------------------- -Migration Guide ---------------------------------------------------------------- - -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. - -PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. - -This page covers commonly used APIs which require updates to work with v0.6. Note that this page is not an exhaustive set of changes. For a complete list of breaking API changes, see `the changelog `_. - -In some places, a backwards compatibility layer has been added to avoid breaking most applications upon release. This should not be relied upon, and will be removed in the future. You can call ``scenedetect.platform.init_logger(show_stdout=True)`` or attach a custom log handler to the ``'pyscenedetect'`` logger to help find these cases. - - -=============================================================== -`VideoManager` Class -=============================================================== - -`VideoManager` has been deprecated and replaced with :mod:`scenedetect.backends`. For most applications, the :func:`open_video ` function should be used instead: - -.. code:: python - - from scenedetect import open_video - video = open_video(video.mp4') - -The resulting object can then be passed to a :class:`SceneManager ` when calling :meth:`detect_scenes `, or any other function/method that used to take a `VideoManager`, e.g.: - -.. code:: python - - from scenedetect import open_video, SceneManager, ContentDetector - video = open_video('video.mp4') - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector(threshold=threshold)) - scene_manager.detect_scenes(video) - print(scene_manager.get_scene_list()) - -See :mod:`scenedetect.backends` for examples of how to create specific backends. Where previously a list of paths was accepted, now only a single string should be provided. - - -Seeking and Start/End Times -=============================================================== - -Instead of setting the start time via the `VideoManager`, now :meth:`seek ` to the starting time on the :class:`VideoStream ` object. - -Instead of setting the duration or end time via the `VideoManager`, now set the `duration` or `end_time` parameters when calling :meth:`detect_scenes `. - -.. code:: python - - from scenedetect import open_video, SceneManager, ContentDetector - video = open_video('video.mp4') - # Can be seconds (float), frame # (int), or FrameTimecode - start_time, end_time = 2.5, 5.0 - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector(threshold=threshold)) - video.seek(start_time) - # Note there is also a `duration` parameter that can also be set. - # If neither `duration` nor `end_time` is provided, the video will - # be processed from its current position until the end. - scene_manager.detect_scenes(video, end_time=end_time) - print(scene_manager.get_scene_list()) - - -=============================================================== -`SceneManager` Class -=============================================================== - -The first argument of the :meth:`detect_scenes ` method has been renamed to `video` and should now be a :class:`VideoStream ` object (see above). - - -=============================================================== -`save_images` Function -=============================================================== - -The second argument of :func:`save_images ` in :mod:`scenedetect.scene_manager` has been renamed from `video_manager` to `video`. - -The `downscale_factor` parameter has been removed from :func:`save_images ` (use the `scale` parameter instead). To achieve the same result as the previous version, set `scale` to `1.0 / downscale_factor`. - - -=============================================================== -`split_video_*` Functions -=============================================================== - -The the :mod:`scenedetect.video_splitter` functions :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` now only accept a single path as the input (first) argument. - -The `suppress_output` and `hide_progress` arguments to the :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` have been removed, and two new options have been added: - - * `suppress_output` is now `show_output`, default is `False` - * `hide_progress` is now `show_progress`, default is `False` - -This makes the API consistent with that of :class:`SceneManager `. - - -=============================================================== -`StatsManager` Class -=============================================================== - -The :func:`save_to_csv ` and :func:`load_from_csv ` methods now accept either a `path` or an open `file` handle. - -The `base_timecode` argument has been removed from :func:`save_to_csv `. It is no longer required. - - -=============================================================== -`AdaptiveDetector` Class -=============================================================== - -The `video_manager` parameter has been removed and is no longer required when constructing an :class:`AdaptiveDetector ` object. - - -=============================================================== -Other -=============================================================== - -`ThresholdDetector` Class -=============================================================== - -The `block_size` argument has been removed from the :class:`ThresholdDetector ` constructor. It is no longer required. - - -`ContentDetector` Class -=============================================================== - -The `calculate_frame_score` method of :class:`ContentDetector ` has been renamed to :meth:`_calculate_frame_score `. Use new global function :func:`calculate_frame_score ` to achieve the same result. - - -`MINIMUM_FRAMES_PER_SECOND_*` Constants -=============================================================== - -In `scenedetect.frame_timecode` the constants `MINIMUM_FRAMES_PER_SECOND_FLOAT` and `MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT` have been replaced with :data:`MAX_FPS_DELTA `. - - -`get_aspect_ratio` Function -=============================================================== - - The `get_aspect_ratio` function has been removed from `scenedetect.platform`. Use the :attr:`aspect_ratio ` property from the :class:`VideoStream ` object instead. From b7e99de59aaa82f7404a8942a638d26b090bf0e7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 16 Mar 2025 22:25:34 -0400 Subject: [PATCH 219/407] [common] Allow FrameTimecode to be created from Timecode This allows us to keep the existing VideoStream interface but still have backends provide precise timing information. For now this is gated under a hard-coded feature flag as it still needs to be integrated with the SceneDetector interface. --- docs/api.rst | 2 +- scenedetect/backends/opencv.py | 42 +++---- scenedetect/backends/pyav.py | 22 ++-- scenedetect/common.py | 196 +++++++++++++++++---------------- scenedetect/scene_detector.py | 7 -- scenedetect/scene_manager.py | 8 +- scenedetect/video_stream.py | 32 ------ website/pages/changelog.md | 10 +- 8 files changed, 139 insertions(+), 180 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 6579c654..88ff2a0f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -46,7 +46,7 @@ Most types/functions are also available directly from the `scenedetect` package .. code:: python - scenedetect<0.7 + scenedetect<0.8 .. _scenedetect-quickstart: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index df17fb10..be0d2b7a 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -32,7 +32,6 @@ from scenedetect.video_stream import ( FrameRateUnavailable, SeekError, - VideoFrame, VideoOpenFailure, VideoStream, ) @@ -47,6 +46,8 @@ " ! ", # gstreamer pipe ) +_USE_PTS_IN_DEVELOPMENT = False + def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float: """Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels).""" @@ -195,6 +196,16 @@ def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" return _get_aspect_ratio(self._cap) + @property + def timecode(self) -> Timecode: + """Current position within stream as a Timecode. This is not frame accurate.""" + # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we + # use `CAP_PROP_POS_MSEC` instead, with a time base of 1/1000. Unfortunately this means that + # rounding errors will affect frame accuracy with this backend. + pts = self._cap.get(cv2.CAP_PROP_POS_MSEC) + time_base = Fraction(1, 1000) + return Timecode(pts=round(pts), time_base=time_base) + @property def position(self) -> FrameTimecode: """Current position within stream as FrameTimecode. @@ -204,6 +215,8 @@ def position(self) -> FrameTimecode: This method will always return 0 (e.g. be equal to `base_timecode`) if no frames have been `read`.""" + if _USE_PTS_IN_DEVELOPMENT: + return FrameTimecode(timecode=self.timecode, fps=self.frame_rate) if self.frame_number < 1: return self.base_timecode return self.base_timecode + (self.frame_number - 1) @@ -272,30 +285,6 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) - def __next__(self): - # NOTE: POS_FRAMES starts from 0 before any frames are read. - read, image = self._cap.read() - if not read: - raise StopIteration() - # We can only query CAP_PROP_PTS if this uses the ffmpeg backend, however it doesn't seem - # to work correctly. Quite frequently consecutive frames return the same PTS. We might need - # to just abandon using PTS with OpenCV and rely on milliseconds. This will still result - # in occasional off-by-one errors for VFR videos, but better than the status quo. - # - # We should also add a config option so users can specify if OpenCV should use fixed or - # variable timing (i.e. if we should use CAP_PROP_POS_MSEC or CAP_PROP_POS_FRAMES for - # timestamp calculation). - USE_PTS = False - if USE_PTS: - pts = self._cap.get(cv2.CAP_PROP_PTS) - time_base = Fraction.from_float(self._cap.get(cv2.CAP_PROP_FPS)) - time_base = Fraction(numerator=time_base.denominator, denominator=time_base.numerator) - else: - pts = self._cap.get(cv2.CAP_PROP_POS_MSEC) - time_base = Fraction(1, 1000) - timecode = Timecode(pts=round(pts), time_base=time_base) - return VideoFrame(image=image, timecode=timecode) - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends, or the maximum number of decode attempts has passed. @@ -490,6 +479,8 @@ def frame_size(self) -> ty.Tuple[int, int]: @property def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" + # TODO(v0.7): This will be incorrect for VFR. See if there is another property we can use + # to estimate the video length correctly. frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: return self.base_timecode + frame_count @@ -508,6 +499,7 @@ def position(self) -> FrameTimecode: This method will always return 0 (e.g. be equal to `base_timecode`) if no frames have been `read`.""" + if self.frame_number < 1: return self.base_timecode return self.base_timecode + (self.frame_number - 1) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index f5f9cd46..5ed8b6e3 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -20,12 +20,14 @@ from scenedetect.common import Timecode from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import get_file_name -from scenedetect.video_stream import FrameRateUnavailable, VideoFrame, VideoOpenFailure, VideoStream +from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] +_USE_PTS_IN_DEVELOPMENT = False + class VideoStreamAv(VideoStream): """PyAV `av.InputContainer` backend.""" @@ -80,7 +82,7 @@ def __init__( self._name = "" if name is None else name self._path = "" - self._frame = None + self._frame: ty.Optional[av.VideoFrame] = None self._reopened = True if threading_mode: @@ -183,6 +185,9 @@ def position(self) -> FrameTimecode: This can be interpreted as presentation time stamp, thus frame 1 corresponds to the presentation time 0. Returns 0 even if `frame_number` is 1.""" + if _USE_PTS_IN_DEVELOPMENT: + timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) if self._frame is None: return self.base_timecode return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate) @@ -264,19 +269,6 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def __next__(self) -> VideoFrame: - # TODO: On the VFR test video, we seem to only decode 1979 frames instead of 1980. See what - # the issue could be. - try: - frame = next(self._container.decode(video=0)) - except av.error.EOFError as ex: - if not self._handle_eof(): - raise StopIteration() from ex - return next(self) # *NOTE*: self._handle_eof must ensure we won't recurse again. - image = frame.to_ndarray(format="bgr24") - timecode = Timecode(pts=frame.pts, time_base=frame.time_base) - return VideoFrame(image=image, timecode=timecode) - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. diff --git a/scenedetect/common.py b/scenedetect/common.py index e36b3b50..f6ae0055 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -98,12 +98,7 @@ _MINUTES_PER_HOUR = 60.0 -# TODO(@Breakthrough): Figure out interop with FrameTimecode. We can probably just store this inside -# of FrameTimecode. -# TODO(@Breakthrough): How should we deal with frame numbers? We might need to detect if a video is -# VFR or not, and if so, either omit them or always start them from 0 regardless of the start seek. -# With PyAV we can probably assume the video is VFR if the guessed rate of the stream differs -# from the average rate. +# TODO(@Breakthrough): How should we deal with frame numbers when we have a `Timecode`? # # Each backend has slight nuances we have to take into account: # - PyAV: Does not include a position in frames, we can probably estimate it. Need to also compare @@ -113,6 +108,14 @@ # This is probably sufficient, since we could just use 1ms as a timebase. # - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is # probably not feasible, so we should make sure the docs warn users about this. +# +# In the meantime, having backends provide accurate timing information is controlled by a hard-coded +# constant `_USE_PTS_IN_DEVELOPMENT` in each backend implementation that supports it. It still does +# not work correctly however, as we have to modify detectors themselves to work with FrameTimecode +# objects instead of integer frame numbers like they do now. +# +# We might be able to avoid changing the detector interface if we just have them work directly with +# PTS and convert them back to FrameTimecodes with the same time base. @dataclass class Timecode: """Timing information associated with a given frame.""" @@ -139,13 +142,13 @@ class FrameTimecode: def __init__( self, - timecode: ty.Union[int, float, str, "FrameTimecode"] = None, + timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None, fps: ty.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'`). + timecode: A frame number (`int`), number of seconds (`float`), timecode string in + the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`, or a `Timecode`. 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. @@ -155,38 +158,52 @@ def __init__( # 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 + self._framerate = fps + self._frame_num = None + self._timecode: ty.Optional[Timecode] = None - # Copy constructor. Only the timecode argument is used in this case. + # Copy constructor. 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) - + self._framerate = timecode._framerate if fps is None else fps + self._frame_num = timecode._frame_num + return + + # Timecode. + if isinstance(timecode, Timecode): + self._timecode = timecode + return + + # Ensure args 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) + # TODO(v0.7): This will be incorrect for VFR videos. Need to represent this format + # differently so we can support start/end times and min_scene_len correctly. + self._frame_num = self._parse_timecode_string(timecode) else: - self.frame_num = self._parse_timecode_number(timecode) + self._frame_num = self._parse_timecode_number(timecode) + + @property + def frame_num(self) -> ty.Optional[int]: + return self._frame_num - # TODO(v1.0): Add a `frame` property to replace the existing one and deprecate this getter. + @property + def framerate(self) -> ty.Optional[int]: + return self._framerate + + # TODO(v0.7): Mark this as deprecated (use frame_num instead). 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 @@ -201,7 +218,7 @@ def get_frames(self) -> int: """ return self.frame_num - # TODO(v1.0): Add a `framerate` property to replace the existing one and deprecate this getter. + # TODO(v0.7): Mark this as deprecated (use framerate instead). def get_framerate(self) -> float: """Get Framerate: Returns the framerate used by the FrameTimecode object. @@ -210,6 +227,7 @@ def get_framerate(self) -> float: """ return self.framerate + # TODO(v0.7): Figure out how to deal with VFR here. def equal_framerate(self, fps) -> bool: """Equal Framerate: Determines if the passed framerate is equal to that of this object. @@ -220,9 +238,10 @@ def equal_framerate(self, fps) -> bool: bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. """ + # TODO(v0.7): Support this comparison in the case FPS is not set but a timecode is. return math.fabs(self.framerate - fps) < MAX_FPS_DELTA - # TODO(v1.0): Add a `seconds` property to replace this and deprecate the existing one. + # TODO(v0.7): 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. @@ -232,9 +251,11 @@ def get_seconds(self) -> float: Returns: float: The current time/position in seconds. """ - return float(self.frame_num) / self.framerate + if self._timecode: + return self._timecode.seconds + # Assume constant framerate if we don't have timing information. + 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]. @@ -270,22 +291,12 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: # Return hours, minutes, and seconds as a formatted timecode string. return "%02d:%02d:%s" % (hrs, mins, secs_str) - # 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). + """Convert `seconds` to the nearest number of frames using the current framerate. - Returns: - Integer number of frames the passed number of seconds represents using - the current FrameTimecode's framerate property. + *NOTE*: This will not be correct for variable framerate videos. """ - return round(seconds * self.framerate) + return round(seconds * self._framerate) def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: """Parse a timecode number, storing it as the exact number of frames. @@ -305,11 +316,6 @@ def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: 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.") @@ -325,7 +331,7 @@ def _parse_timecode_string(self, input: str) -> int: Raises: ValueError: Value could not be parsed correctly. """ - assert self.framerate is not None + assert self._framerate is not None input = input.strip() # Exact number of frames N if input.isdigit(): @@ -363,21 +369,21 @@ def _parse_timecode_string(self, input: str) -> int: def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): - self.frame_num += other + self._frame_num += other elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - self.frame_num += other.frame_num + 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) + self._frame_num += self._seconds_to_frames(other) elif isinstance(other, str): - self.frame_num += self._parse_timecode_string(other) + 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 + if self._frame_num < 0: # Required to allow adding negative seconds/frames. + self._frame_num = 0 return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -387,23 +393,23 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): - self.frame_num -= other + self._frame_num -= other elif isinstance(other, FrameTimecode): - if self.equal_framerate(other.framerate): - self.frame_num -= other.frame_num + 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) + self._frame_num -= self._seconds_to_frames(other) elif isinstance(other, str): - self.frame_num -= self._parse_timecode_string(other) + 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 + if self._frame_num < 0: + self._frame_num = 0 return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -413,14 +419,14 @@ def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": if isinstance(other, int): - return self.frame_num == other + 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) + 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 + 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." @@ -437,14 +443,14 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): - return self.frame_num < other + 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) + 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 + 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." @@ -456,14 +462,14 @@ def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): - return self.frame_num <= other + 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) + 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 + 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." @@ -475,14 +481,14 @@ def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): - return self.frame_num > other + 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) + 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 + 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." @@ -494,14 +500,14 @@ def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): - return self.frame_num >= other + 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) + 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 + 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." @@ -515,7 +521,7 @@ def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: # need to use relevant property instead. def __int__(self) -> int: - return self.frame_num + return self._frame_num def __float__(self) -> float: return self.get_seconds() @@ -524,7 +530,7 @@ 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) + return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self._frame_num, self._framerate) def __hash__(self) -> int: - return self.frame_num + return self._frame_num diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 8f20e682..23966ed6 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -32,13 +32,6 @@ from scenedetect.stats_manager import StatsManager -# TODO(v0.7): Add a new base class called just "Detector" to eventually replace SceneDetector. -# -# class Detector: -# def process(buffer: ty.List[ty.Tuple[numpy.ndarray, FrameTimecode]]): -# # Return EventType.CUT, FADE_IN, FADE_OUT, etc... -# pass -# class SceneDetector: """Base class to inherit from when implementing a scene detection algorithm. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 66216aa5..8706a378 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -658,7 +658,7 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config parameters.""" - framerate = scene_list[0][0].framerate + framerate = scene_list[0][0]._framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. return [ ( @@ -821,7 +821,7 @@ def save_images( image_num_format = "%0" image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - framerate = scene_list[0][0].framerate + framerate = scene_list[0][0]._framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. timecode_list = [ @@ -1352,7 +1352,7 @@ def detect_scenes( raise self._exception_info[1].with_traceback(self._exception_info[2]) self._last_pos = video.position - self._post_process(video.position.frame_num) + self._post_process(video.position._frame_num) return video.frame_number - start_frame_num def _decode_thread( @@ -1371,7 +1371,7 @@ def _decode_thread( # (all of which should be modified under the GIL). # TODO(v1.0): This optimization should be removed as it is an uncommon use case and # greatly increases the complexity of detection algorithms using it. - if self._is_processing_required(video.position.frame_num): + if self._is_processing_required(video.position._frame_num): frame_im = video.read() if frame_im is False: break diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index bf2db10e..2bce6e7b 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -77,14 +77,6 @@ def __init__(self): ## -@dataclass -class VideoFrame: - """Data returned when reading/decoding a frame from a video.""" - - image: np.ndarray - timecode: Timecode - - class VideoStream(ABC): """Interface which all video backends must implement.""" @@ -181,31 +173,7 @@ def frame_number(self) -> int: # # Abstract Methods # - def __iter__(self) -> ty.Iterable[VideoFrame]: - return self - - def __next__(self) -> VideoFrame: - """Read and decode the next frame from the current seek position. - - Raises: - StopIteration: The next frame could not be decoded (i.e. the stream ended). - """ - # TODO(v0.7): Make this an abstract method when it is implemented for all backends. - raise NotImplementedError() - - def skip(self) -> ty.Optional[Timecode]: - """Advance the stream to the next frame without decoding the frame data. *May* be faster in - cases where the image data for a given frame isn't required. - - Returns: - The `Timecode` of the frame that was skipped, or None if it could not be decoded (i.e. - the stream ended). - """ - # TODO(v0.7): Make this an abstract method when it is implemented for all backends. - raise NotImplementedError() - # TODO(v0.7): Mark this as deprecated in lieu of `__next__` and `skip`. See if there is a way - # we can change this to no longer be an abstract method, but to instead use the above methods. @abstractmethod def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e61e7c83..a3cc8cb5 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -657,4 +657,12 @@ Development ### API Changes -- [deprecation] The `scenedetect.frame_timecode` module is deprecated, import `FrameTimecode` from `scenedetect` (or `scenedetect.common`) instead +#### Breaking + +- `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them +- Remove `FrameTimecode.previous_frame()` method + +#### Deprecation + +- `scenedetect.frame_timecode` module is now deprecated, use `scenedetect.common` (or `scenedetect`) instead + From d0da65282d5f4789703a185b886db1a99aa1f167 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 21:11:13 -0400 Subject: [PATCH 220/407] [detector] Remove `is_processing_required` --- scenedetect/detectors/content_detector.py | 3 - scenedetect/detectors/hash_detector.py | 19 +---- scenedetect/detectors/histogram_detector.py | 3 - scenedetect/scene_detector.py | 23 ------ scenedetect/scene_manager.py | 92 +++++++++------------ website/pages/changelog.md | 6 +- 6 files changed, 46 insertions(+), 100 deletions(-) diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index b1a274c2..5852a050 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -142,9 +142,6 @@ def __init__( def get_metrics(self): return ContentDetector.METRIC_KEYS - def is_processing_required(self, frame_num): - return True - def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> float: """Calculate score representing relative amount of motion in `frame_img` compared to the last time the function was called (returns 0.0 on the first call).""" diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 38e458d5..f03b1281 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -82,25 +82,10 @@ def __init__( def get_metrics(self): return [self._metric_key] - def is_processing_required(self, frame_num): - return True - - def process_frame(self, frame_num, frame_img): + def process_frame(self, frame_num: int, frame_img: numpy.ndarray): """Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference - frame to frame. - - Arguments: - frame_num (int): Frame number of frame that is being passed. - - frame_img (Optional[int]): Decoded frame image (numpy.ndarray) to perform scene - detection on. Can be None *only* if the self.is_processing_required() method - (inhereted from the base SceneDetector class) returns True. - - Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. - """ + frame to frame.""" cut_list = [] diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 14f0dfb1..fc776c23 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -160,8 +160,5 @@ def calculate_histogram( return hist - def is_processing_required(self, frame_num: int) -> bool: - return True - def get_metrics(self) -> ty.List[str]: return [self._metric_key] diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 23966ed6..0e7c61a7 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -50,29 +50,6 @@ class SceneDetector: """Optional :class:`StatsManager ` to use for caching frame metrics to and from.""" - # TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain. - def is_processing_required(self, frame_num: int) -> bool: - """[DEPRECATED] DO NOT USE - - Test if all calculations for a given frame are already done. - - Returns: - False if the SceneDetector has assigned _metric_keys, and the - stats_manager property is set to a valid StatsManager object containing - the required frame metrics/calculations for the given frame - thus, not - needing the frame to perform scene detection. - - True otherwise (i.e. the frame_img passed to process_frame is required - to be passed to process_frame for the given frame_num). - - :meta private: - """ - metric_keys = self.get_metrics() - return not metric_keys or not ( - self.stats_manager is not None - and self.stats_manager.metrics_exist(frame_num, metric_keys) - ) - def stats_manager_required(self) -> bool: """Stats Manager Required: Prototype indicating if detector requires stats. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 8706a378..4d5c3719 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -1369,53 +1369,47 @@ def _decode_thread( # We don't do any kind of locking here since the worst-case of this being wrong # is that we do some extra work, and this function should never mutate any data # (all of which should be modified under the GIL). - # TODO(v1.0): This optimization should be removed as it is an uncommon use case and - # greatly increases the complexity of detection algorithms using it. - if self._is_processing_required(video.position._frame_num): - frame_im = video.read() - if frame_im is False: - break - # Verify the decoded frame size against the video container's reported - # resolution, and also verify that consecutive frames have the correct size. - decoded_size = (frame_im.shape[1], frame_im.shape[0]) - if self._frame_size is None: - self._frame_size = decoded_size - if video.frame_size != decoded_size: - logger.warn( - f"WARNING: Decoded frame size ({decoded_size}) does not match " - f" video resolution {video.frame_size}, possible corrupt input." - ) - elif self._frame_size != decoded_size: - self._frame_size_errors += 1 - if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: - logger.error( - f"ERROR: Frame at {str(video.position)} has incorrect size and " - f"cannot be processed: decoded size = {decoded_size}, " - f"expected = {self._frame_size}. Video may be corrupt." - ) - if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: - logger.warn( - "WARNING: Too many errors emitted, skipping future messages." - ) - # Skip processing frames that have an incorrect size. - continue - - if self._crop: - (x0, y0, x1, y1) = self._crop - frame_im = frame_im[y0:y1, x0:x1] - - if downscale_factor > 1.0: - frame_im = cv2.resize( - frame_im, - ( - max(1, round(frame_im.shape[1] / downscale_factor)), - max(1, round(frame_im.shape[0] / downscale_factor)), - ), - interpolation=self._interpolation.value, + frame_im = video.read() + if frame_im is False: + break + # Verify the decoded frame size against the video container's reported + # resolution, and also verify that consecutive frames have the correct size. + decoded_size = (frame_im.shape[1], frame_im.shape[0]) + if self._frame_size is None: + self._frame_size = decoded_size + if video.frame_size != decoded_size: + logger.warn( + f"WARNING: Decoded frame size ({decoded_size}) does not match " + f" video resolution {video.frame_size}, possible corrupt input." ) - else: - if video.read(decode=False) is False: - break + elif self._frame_size != decoded_size: + self._frame_size_errors += 1 + if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: + logger.error( + f"ERROR: Frame at {str(video.position)} has incorrect size and " + f"cannot be processed: decoded size = {decoded_size}, " + f"expected = {self._frame_size}. Video may be corrupt." + ) + if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: + logger.warn( + "WARNING: Too many errors emitted, skipping future messages." + ) + # Skip processing frames that have an incorrect size. + continue + + if self._crop: + (x0, y0, x1, y1) = self._crop + frame_im = frame_im[y0:y1, x0:x1] + + if downscale_factor > 1.0: + frame_im = cv2.resize( + frame_im, + ( + max(1, round(frame_im.shape[1] / downscale_factor)), + max(1, round(frame_im.shape[0] / downscale_factor)), + ), + interpolation=self._interpolation.value, + ) # Set the start position now that we decoded at least the first frame. if self._start_pos is None: @@ -1505,9 +1499,3 @@ def get_event_list(self, base_timecode: ty.Optional[FrameTimecode] = None) -> Sc # TODO(v0.7): Use the warnings module to turn this into a warning. logger.error("`get_event_list()` is deprecated and will be removed in a future release.") return self._get_event_list() - - def _is_processing_required(self, frame_num: int) -> bool: - """True if frame metrics not in StatsManager, False otherwise.""" - if self.stats_manager is None: - return True - return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index a3cc8cb5..8e7c858a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -659,8 +659,10 @@ Development #### Breaking -- `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them -- Remove `FrameTimecode.previous_frame()` method + - `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them + - Remove `FrameTimecode.previous_frame()` method + - Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation + - `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called #### Deprecation From 2f602b55f572d5073c25309f75e4e6856133140c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 21:20:25 -0400 Subject: [PATCH 221/407] [detector] Rename `scene_detector` -> `detector` --- docs/api.rst | 4 ++-- docs/api/{scene_detector.rst => detector.rst} | 4 ++-- docs/index.rst | 2 +- scenedetect/{scene_detector.py => detector.py} | 2 +- scenedetect/detectors/__init__.py | 2 +- scenedetect/scene_manager.py | 4 +--- scenedetect/stats_manager.py | 2 +- website/pages/api.md | 6 +++--- website/pages/changelog.md | 7 ++++++- 9 files changed, 18 insertions(+), 15 deletions(-) rename docs/api/{scene_detector.rst => detector.rst} (63%) rename scenedetect/{scene_detector.py => detector.py} (99%) diff --git a/docs/api.rst b/docs/api.rst index 88ff2a0f..ace61eb2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -31,7 +31,7 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.common ⏱️ `: Contains common types such as :class:`FrameTimecode ` used for timecode handing. - * :ref:`scenedetect.scene_detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. + * :ref:`scenedetect.detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. * :ref:`scenedetect.stats_manager 🧮 `: Contains :class:`StatsManager ` class for caching frame metrics and loading/saving them to disk in CSV format for analysis. @@ -101,7 +101,7 @@ Module Reference api/video_splitter api/stats_manager api/common - api/scene_detector + api/detector api/video_stream api/platform diff --git a/docs/api/scene_detector.rst b/docs/api/detector.rst similarity index 63% rename from docs/api/scene_detector.rst rename to docs/api/detector.rst index 98492a51..31e5920e 100644 --- a/docs/api/scene_detector.rst +++ b/docs/api/detector.rst @@ -1,9 +1,9 @@ -.. _scenedetect-scene_detector: +.. _scenedetect-detector: ------------------------------------------------- SceneDetector ------------------------------------------------- -.. automodule:: scenedetect.scene_detector +.. automodule:: scenedetect.detector :members: diff --git a/docs/index.rst b/docs/index.rst index d0c11060..65cfc0a8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,7 +51,7 @@ Table of Contents api/common api/video_splitter api/stats_manager - api/scene_detector + api/detector api/video_stream api/platform diff --git a/scenedetect/scene_detector.py b/scenedetect/detector.py similarity index 99% rename from scenedetect/scene_detector.py rename to scenedetect/detector.py index 0e7c61a7..743efdef 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/detector.py @@ -9,7 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""``scenedetect.scene_detector`` Module +"""``scenedetect.detector`` Module This module contains the :class:`SceneDetector` interface, from which all scene detectors in :mod:`scenedetect.detectors` module are derived from. diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index a87a5689..70eb5eb7 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -30,7 +30,7 @@ Uses perceptual hashing to calculate similarity between adjacent frames. Detection algorithms are created by implementing the -:class:`SceneDetector ` interface. Detectors are +:class:`SceneDetector ` interface. Detectors are typically attached to a :class:`SceneManager ` when processing videos, however they can also be used to process frames directly. """ diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 4d5c3719..74e057ae 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -1391,9 +1391,7 @@ def _decode_thread( f"expected = {self._frame_size}. Video may be corrupt." ) if self._frame_size_errors == MAX_FRAME_SIZE_ERRORS: - logger.warn( - "WARNING: Too many errors emitted, skipping future messages." - ) + logger.warn("WARNING: Too many errors emitted, skipping future messages.") # Skip processing frames that have an incorrect size. continue diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 9c305603..d00191d1 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -12,7 +12,7 @@ """``scenedetect.stats_manager`` Module This module contains the :class:`StatsManager` class, which provides a key-value store for each -:class:`SceneDetector ` to write the metrics calculated +:class:`SceneDetector ` to write the metrics calculated for each frame. The :class:`StatsManager` must be registered to a :class:`SceneManager ` upon construction. diff --git a/website/pages/api.md b/website/pages/api.md index 3b09d398..740c05a8 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -36,12 +36,12 @@ The perceptual hash detector (`detect-hash`) calculates a hash for a frame and c # Creating New Detection Algorithms -All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). +All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). Creating a new scene detection method can be as simple as implementing the `process_frame` function, and optionally `post_process`: ```python -from scenedetect.scene_detector import SceneDetector +from scenedetect.detector import SceneDetector class CustomDetector(SceneDetector): """CustomDetector class to implement a scene detection algorithm.""" @@ -62,7 +62,7 @@ class CustomDetector(SceneDetector): `process_frame` is called on every frame in the input video, which will be called after the final frame of the video is passed to `process_frame`. This may be useful for multi-pass algorithms, or detectors which are waiting on some condition but still wish to output an event on the final frame. -For example, a detector may output at most 1 cuts for every call to `process_frame`, it may output the entire scene list in `post_process`, or a combination of both. Note that the latter will not work in cases where a live video stream or camera input device is being used. See the [API documentation for the `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html#scenedetect.scene_detector.SceneDetector) for details. Alternatively, you can call `help(SceneDetector)` from a Python REPL. For examples of actual detection algorithm implementations, see the source files in the `scenedetect/detectors/` directory (e.g. `threshold_detector.py`, `content_detector.py`). +For example, a detector may output at most 1 cuts for every call to `process_frame`, it may output the entire scene list in `post_process`, or a combination of both. Note that the latter will not work in cases where a live video stream or camera input device is being used. See the [API documentation for the `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/detector.html#scenedetect.scene_detector.SceneDetector) for details. Alternatively, you can call `help(SceneDetector)` from a Python REPL. For examples of actual detection algorithm implementations, see the source files in the `scenedetect/detectors/` directory (e.g. `threshold_detector.py`, `content_detector.py`). Processing is done by calling the `process_frame(...)` function for all frames in the video, followed by `post_process(...)` (optional) after the final frame. Scene cuts are detected and added to the passed list object in both cases. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 8e7c858a..20dee426 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -659,6 +659,11 @@ Development #### Breaking + - Refactoring to make code less verbose: + - `scenedetect.scene_detector` is now `scenedetect.detector` + - `scenedetect.frame_timecode` is now `scenedetect.common` + + - `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them - Remove `FrameTimecode.previous_frame()` method - Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation @@ -666,5 +671,5 @@ Development #### Deprecation -- `scenedetect.frame_timecode` module is now deprecated, use `scenedetect.common` (or `scenedetect`) instead +- `scenedetect.scene_detector` module is now deprecated, use `scenedetect.detector` (or `scenedetect`) instead From 44ce9fa6b11de63c39162f9d059b1d56fe5ce303 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 21:22:56 -0400 Subject: [PATCH 222/407] [detector] Add backwards compatibility module helper. --- scenedetect/scene_detector.py | 16 ++++++++++++++++ website/pages/changelog.md | 3 --- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 scenedetect/scene_detector.py diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py new file mode 100644 index 00000000..ef8fc569 --- /dev/null +++ b/scenedetect/scene_detector.py @@ -0,0 +1,16 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""For backwards compatibility only, will be removed in a future release.""" + +# TODO(v0.7): Include a warning if this module is imported. + +from scenedetect.detector import * # noqa: F403 diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 20dee426..6e69ca41 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -658,12 +658,9 @@ Development ### API Changes #### Breaking - - Refactoring to make code less verbose: - `scenedetect.scene_detector` is now `scenedetect.detector` - `scenedetect.frame_timecode` is now `scenedetect.common` - - - `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them - Remove `FrameTimecode.previous_frame()` method - Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation From ec123530cd078d7e5fd6c344a4db01154dd01ca7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 21:30:31 -0400 Subject: [PATCH 223/407] [api] Remove deprecated `video_manager` module --- scenedetect/__init__.py | 3 +- scenedetect/_cli/config.py | 2 +- scenedetect/_cli/context.py | 2 +- scenedetect/detectors/adaptive_detector.py | 6 - scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/hash_detector.py | 2 +- scenedetect/detectors/histogram_detector.py | 2 +- scenedetect/detectors/threshold_detector.py | 2 +- scenedetect/scene_manager.py | 8 +- scenedetect/video_manager.py | 812 -------------------- tests/conftest.py | 3 +- tests/test_backwards_compat.py | 89 --- tests/test_video_stream.py | 6 - website/pages/changelog.md | 2 + 14 files changed, 11 insertions(+), 930 deletions(-) delete mode 100644 scenedetect/video_manager.py delete mode 100644 tests/test_backwards_compat.py diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 7e1e7b3f..d7520af1 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -35,7 +35,7 @@ from scenedetect.frame_timecode import FrameTimecode from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge -from scenedetect.scene_detector import SceneDetector +from scenedetect.detector import SceneDetector from scenedetect.detectors import ( ContentDetector, AdaptiveDetector, @@ -52,7 +52,6 @@ ) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt from scenedetect.scene_manager import SceneManager, save_images, SceneList, CutList, Interpolation -from scenedetect.video_manager import VideoManager # [DEPRECATED] DO NOT USE. # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 75faeb71..b7e5a3f1 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -25,9 +25,9 @@ from platformdirs import user_config_dir +from scenedetect.detector import FlashFilter from scenedetect.detectors import ContentDetector from scenedetect.frame_timecode import FrameTimecode -from scenedetect.scene_detector import FlashFilter from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index e48401d5..c46b7fad 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -24,6 +24,7 @@ ConfigRegistry, CropValue, ) +from scenedetect.detector import FlashFilter, SceneDetector from scenedetect.detectors import ( AdaptiveDetector, ContentDetector, @@ -33,7 +34,6 @@ ) from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import init_logger -from scenedetect.scene_detector import FlashFilter, SceneDetector from scenedetect.scene_manager import Interpolation, SceneManager from scenedetect.stats_manager import StatsManager from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 5a073fb9..5b30a6d0 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -43,7 +43,6 @@ def __init__( weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: ty.Optional[int] = None, - video_manager=None, min_delta_hsv: ty.Optional[float] = None, ): """ @@ -65,13 +64,8 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel to use for post edge detection filtering. If None, automatically set based on video resolution. - video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead. """ - # TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will - # be removed in v0.8. - if video_manager is not None: - logger.error("video_manager is deprecated, use video instead.") if min_delta_hsv is not None: logger.error("min_delta_hsv is deprecated, use min_content_val instead.") min_content_val = min_delta_hsv diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 5852a050..e37b1380 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -22,7 +22,7 @@ import cv2 import numpy -from scenedetect.scene_detector import FlashFilter, SceneDetector +from scenedetect.detector import FlashFilter, SceneDetector def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index f03b1281..ad7f5310 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -38,7 +38,7 @@ import numpy # PySceneDetect Library Imports -from scenedetect.scene_detector import SceneDetector +from scenedetect.detector import SceneDetector class HashDetector(SceneDetector): diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index fc776c23..7ee209c5 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -21,7 +21,7 @@ import numpy # PySceneDetect Library Imports -from scenedetect.scene_detector import SceneDetector +from scenedetect.detector import SceneDetector class HistogramDetector(SceneDetector): diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 4121d9c8..c823e338 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -21,7 +21,7 @@ import numpy -from scenedetect.scene_detector import SceneDetector +from scenedetect.detector import SceneDetector logger = getLogger("pyscenedetect") diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 74e057ae..99547696 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -101,9 +101,9 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SimpleTableRow, ) from scenedetect.common import CropRegion, CutList, SceneList +from scenedetect.detector import SceneDetector, SparseSceneDetector from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm -from scenedetect.scene_detector import SceneDetector, SparseSceneDetector from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream @@ -722,7 +722,6 @@ def save_images( width: ty.Optional[int] = None, interpolation: Interpolation = Interpolation.CUBIC, threading: bool = True, - video_manager=None, ) -> ty.Dict[int, ty.List[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -761,7 +760,6 @@ def save_images( while preserving the aspect ratio. interpolation: Type of interpolation to use when resizing images. threading: Offload image encoding and disk IO to background threads to improve performance. - video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only. Returns: Dictionary of the format { scene_num : [image_paths] }, where scene_num is the @@ -772,10 +770,6 @@ def save_images( ValueError: Raised if any arguments are invalid or out of range (e.g. if num_images is negative). """ - # TODO(v0.7): Add DeprecationWarning that `video_manager` will be removed in v0.8. - if video_manager is not None: - logger.error("`video_manager` argument is deprecated, use `video` instead.") - video = video_manager if not scene_list: return {} diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py deleted file mode 100644 index 171b0369..00000000 --- a/scenedetect/video_manager.py +++ /dev/null @@ -1,812 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""``scenedetect.video_manager`` Module - -[DEPRECATED] DO NOT USE. Use `open_video` from `scenedetect.backends` or create a -VideoStreamCv2 object (`scenedetect.backends.opencv`) instead. - -This module exists for *some* backwards compatibility with v0.5, and will be removed -in a future release. -""" - -import math -import os -import typing as ty -from logging import getLogger - -import cv2 -import numpy as np - -from scenedetect.backends.opencv import _get_aspect_ratio -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode -from scenedetect.platform import get_file_name -from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream - -## -## VideoManager Exceptions -## - - -class VideoParameterMismatch(Exception): - """VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some - of the video parameters (frame height, frame width, and framerate/FPS) do not match.""" - - def __init__( - self, file_list=None, message="OpenCV VideoCapture object parameters do not match." - ): - # type: (ty.Iterable[ty.Tuple[int, float, float, str, str]], str) -> None - # Pass message string to base Exception class. - super(VideoParameterMismatch, self).__init__(message) - # list of (param_mismatch_type: int, parameter value, expected value, - # filename: str, filepath: str) - # where param_mismatch_type is an OpenCV CAP_PROP (e.g. CAP_PROP_FPS). - self.file_list = file_list - - -class VideoDecodingInProgress(RuntimeError): - """VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that - must be called *before* start() has been called.""" - - -class InvalidDownscaleFactor(ValueError): - """InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, - i.e. the supplied downscale factor was not a positive integer greater than zero.""" - - -## -## VideoManager Helper Functions -## - - -def get_video_name(video_file: str) -> ty.Tuple[str, str]: - """Get the video file/device name. - - Returns: - Tuple of the form [name, video_file]. - """ - if isinstance(video_file, int): - return ("Device %d" % video_file, video_file) - return (os.path.split(video_file)[1], video_file) - - -def get_num_frames(cap_list: ty.Iterable[cv2.VideoCapture]) -> int: - """Get Number of Frames: Returns total number of frames in the cap_list. - - Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. - """ - return sum([math.trunc(cap.get(cv2.CAP_PROP_FRAME_COUNT)) for cap in cap_list]) - - -def open_captures( - video_files: ty.Iterable[str], - framerate: ty.Optional[float] = None, - validate_parameters: bool = True, -) -> ty.Tuple[ty.List[cv2.VideoCapture], float, ty.Tuple[int, int]]: - """Open Captures - helper function to open all capture objects, set the framerate, - and ensure that all open captures have been opened and the framerates match on a list - of video file paths, or a list containing a single device ID. - - Arguments: - video_files: List of one or more paths (str), or a list - of a single integer device ID, to open as an OpenCV VideoCapture object. - A ValueError will be raised if the list does not conform to the above. - framerate: Framerate to assume when opening the video_files. - If not set, the first open video is used for deducing the framerate of - all videos in the sequence. - validate_parameters (bool, optional): If true, will ensure that the frame sizes - (width, height) and frame rate (FPS) of all passed videos is the same. - A VideoParameterMismatch is raised if the framerates do not match. - - Returns: - A tuple of form (cap_list, framerate, framesize) where cap_list is a list of open - OpenCV VideoCapture objects in the same order as the video_files list, framerate - is a float of the video(s) framerate(s), and framesize is a tuple of (width, height) - where width and height are integers representing the frame size in pixels. - - Raises: - ValueError: No video file(s) specified, or invalid/multiple device IDs specified. - TypeError: `framerate` must be type `float`. - IOError: Video file(s) not found. - FrameRateUnavailable: Video framerate could not be obtained and `framerate` - was not set manually. - VideoParameterMismatch: All videos in `video_files` do not have equal parameters. - Set `validate_parameters=False` to skip this check. - VideoOpenFailure: Video(s) could not be opened. - """ - is_device = False - if not video_files: - raise ValueError("Expected at least 1 video file or device ID.") - if isinstance(video_files[0], int): - if len(video_files) > 1: - raise ValueError("If device ID is specified, no video sources may be appended.") - elif video_files[0] < 0: - raise ValueError("Invalid/negative device ID specified.") - is_device = True - elif not all([isinstance(video_file, (str, bytes)) for video_file in video_files]): - print(video_files) - raise ValueError("Unexpected element type in video_files list (expected str(s)/int).") - elif framerate is not None and not isinstance(framerate, float): - raise TypeError("Expected type float for parameter framerate.") - # Check if files exist if passed video file is not an image sequence - # (checked with presence of % in filename) or not a URL (://). - if not is_device and any( - [ - not os.path.exists(video_file) - for video_file in video_files - if not ("%" in video_file or "://" in video_file) - ] - ): - raise OSError("Video file(s) not found.") - cap_list = [] - - try: - cap_list = [cv2.VideoCapture(video_file) for video_file in video_files] - video_names = [get_video_name(video_file) for video_file in video_files] - closed_caps = [video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened()] - if closed_caps: - raise VideoOpenFailure(str(closed_caps)) - - cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list] - cap_framerate, check_framerate = validate_capture_framerate( - video_names, cap_framerates, framerate - ) - # Store frame sizes as integers (VideoCapture.get() returns float). - cap_frame_sizes = [ - ( - math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) - for cap in cap_list - ] - cap_frame_size = cap_frame_sizes[0] - - # If we need to validate the parameters, we check that the FPS and width/height - # of all open captures is identical (or almost identical in the case of FPS). - if validate_parameters: - validate_capture_parameters( - video_names=video_names, - cap_frame_sizes=cap_frame_sizes, - check_framerate=check_framerate, - cap_framerates=cap_framerates, - ) - - except: - for cap in cap_list: - cap.release() - raise - - return (cap_list, cap_framerate, cap_frame_size) - - -def validate_capture_framerate( - video_names: ty.Iterable[ty.Tuple[str, str]], - cap_framerates: ty.List[float], - framerate: ty.Optional[float] = None, -) -> ty.Tuple[float, bool]: - """Ensure the passed capture framerates are valid and equal. - - Raises: - ValueError: Invalid framerate (must be positive non-zero value). - TypeError: Framerate must be of type float. - FrameRateUnavailable: Framerate for video could not be obtained, - and `framerate` was not set. - """ - check_framerate = True - cap_framerate = cap_framerates[0] - if framerate is not None: - if isinstance(framerate, float): - if framerate < MAX_FPS_DELTA: - raise ValueError("Invalid framerate (must be a positive non-zero value).") - cap_framerate = framerate - check_framerate = False - else: - raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__) - else: - unavailable_framerates = [ - (video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if fps < MAX_FPS_DELTA - ] - if unavailable_framerates: - raise FrameRateUnavailable() - return (cap_framerate, check_framerate) - - -def validate_capture_parameters( - video_names: ty.List[ty.Tuple[str, str]], - cap_frame_sizes: ty.List[ty.Tuple[int, int]], - check_framerate: bool = False, - cap_framerates: ty.Optional[ty.List[float]] = None, -) -> None: - """Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) - framerates are equal. Raises VideoParameterMismatch if there is a mismatch. - - Raises: - VideoParameterMismatch - """ - bad_params = [] - max_framerate_delta = MAX_FPS_DELTA - # Check heights/widths match. - bad_params += [ - ( - cv2.CAP_PROP_FRAME_WIDTH, - frame_size[0], - cap_frame_sizes[0][0], - video_names[i][0], - video_names[i][1], - ) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0 - ] - bad_params += [ - ( - cv2.CAP_PROP_FRAME_HEIGHT, - frame_size[1], - cap_frame_sizes[0][1], - video_names[i][0], - video_names[i][1], - ) - for i, frame_size in enumerate(cap_frame_sizes) - if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0 - ] - # Check framerates if required. - if check_framerate: - bad_params += [ - (cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], video_names[i][1]) - for i, fps in enumerate(cap_framerates) - if math.fabs(fps - cap_framerates[0]) > max_framerate_delta - ] - - if bad_params: - raise VideoParameterMismatch(bad_params) - - -## -## VideoManager Class Implementation -## - - -class VideoManager(VideoStream): - """[DEPRECATED] DO NOT USE. - - Provides a cv2.VideoCapture-like interface to a set of one or more video files, - or a single device ID. Supports seeking and setting end time/duration.""" - - BACKEND_NAME = "video_manager_do_not_use" - - def __init__( - self, - video_files: ty.List[str], - framerate: ty.Optional[float] = None, - logger=None, - ): - """[DEPRECATED] DO NOT USE. - - Arguments: - video_files (list of str(s)/int): A list of one or more paths (str), or a list - of a single integer device ID, to open as an OpenCV VideoCapture object. - framerate (float, optional): Framerate to assume when storing FrameTimecodes. - If not set (i.e. is None), it will be deduced from the first open capture - in video_files, else raises a FrameRateUnavailable exception. - - Raises: - ValueError: No video file(s) specified, or invalid/multiple device IDs specified. - TypeError: `framerate` must be type `float`. - IOError: Video file(s) not found. - FrameRateUnavailable: Video framerate could not be obtained and `framerate` - was not set manually. - VideoParameterMismatch: All videos in `video_files` do not have equal parameters. - Set `validate_parameters=False` to skip this check. - VideoOpenFailure: Video(s) could not be opened. - """ - # TODO(v0.7): Add DeprecationWarning that this class will be removed in v0.8: 'VideoManager - # will be removed in PySceneDetect v0.8. Use VideoStreamCv2 or VideoCaptureAdapter instead.' - if logger is None: - logger = getLogger("pyscenedetect") - logger.error("VideoManager is deprecated and will be removed.") - if not video_files: - raise ValueError("At least one string/integer must be passed in the video_files list.") - # Need to support video_files as a single str too for compatibility. - if isinstance(video_files, str): - video_files = [video_files] - # These VideoCaptures are only open in this process. - self._is_device = isinstance(video_files[0], int) - self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=video_files, framerate=framerate - ) - self._path = video_files[0] if not self._is_device else video_files - self._end_of_video = False - self._start_time = self.get_base_timecode() - self._end_time = None - self._curr_time = self.get_base_timecode() - self._last_frame = None - self._curr_cap, self._curr_cap_idx = None, None - self._video_file_paths = video_files - self._logger = logger - if self._logger is not None: - self._logger.info( - "Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d", - len(self._cap_list), - "s" if len(self._cap_list) > 1 else "", - self.get_framerate(), - *self.get_framesize(), - ) - self._started = False - self._frame_length = self.get_base_timecode() + get_num_frames(self._cap_list) - self._first_cap_len = self.get_base_timecode() + get_num_frames([self._cap_list[0]]) - self._aspect_ratio = _get_aspect_ratio(self._cap_list[0]) - - def set_downscale_factor(self, downscale_factor=None): - """No-op. Set downscale_factor in `SceneManager` instead.""" - _ = downscale_factor - - def get_num_videos(self) -> int: - """Get the length of the internal capture list, - representing the number of videos the VideoManager was constructed with. - - Returns: - int: Number of videos, equal to length of capture list. - """ - return len(self._cap_list) - - def get_video_paths(self) -> ty.List[str]: - """Get list of strings containing paths to the open video(s). - - Returns: - ty.List[str]: List of paths to the video files opened by the VideoManager. - """ - return list(self._video_file_paths) - - def get_video_name(self) -> str: - """Get name of the video based on the first video path. - - Returns: - The base name of the video file, without extension. - """ - video_paths = self.get_video_paths() - if not video_paths: - return "" - video_name = os.path.basename(video_paths[0]) - if video_name.rfind(".") >= 0: - video_name = video_name[: video_name.rfind(".")] - return video_name - - def get_framerate(self) -> float: - """Get the framerate the VideoManager is assuming for all - open VideoCaptures. Obtained from either the capture itself, or the passed - framerate parameter when the VideoManager object was constructed. - - Returns: - Framerate, in frames/sec. - """ - return self._cap_framerate - - def get_base_timecode(self) -> FrameTimecode: - """Get a FrameTimecode object at frame 0 / time 00:00:00. - - The timecode returned by this method can be used to perform arithmetic (e.g. - addition), passing the resulting values back to the VideoManager (e.g. for the - :meth:`set_duration()` method), as the framerate of the returned FrameTimecode - object matches that of the VideoManager. - - As such, this method is equivalent to creating a FrameTimecode at frame 0 with - the VideoManager framerate, for example, given a VideoManager called obj, - the following expression will evaluate as True: - - obj.get_base_timecode() == FrameTimecode(0, obj.get_framerate()) - - Furthermore, the base timecode object returned by a particular VideoManager - should not be passed to another one, unless you first verify that their - framerates are the same. - - Returns: - FrameTimecode at frame 0/time 00:00:00 with the video(s) framerate. - """ - return FrameTimecode(timecode=0, fps=self._cap_framerate) - - def get_current_timecode(self) -> FrameTimecode: - """Get Current Timecode - returns a FrameTimecode object at current VideoManager position. - - Returns: - Timecode at the current VideoManager position. - """ - return self._curr_time - - def get_framesize(self) -> ty.Tuple[int, int]: - """Get frame size of the video(s) open in the VideoManager's capture objects. - - Returns: - Video frame size, in pixels, in the form (width, height). - """ - return self._cap_framesize - - def get_framesize_effective(self) -> ty.Tuple[int, int]: - """Get Frame Size - returns the frame size of the video(s) open in the - VideoManager's capture objects. - - Returns: - Video frame size, in pixels, in the form (width, height). - """ - return self._cap_framesize - - def set_duration( - self, - duration: ty.Optional[FrameTimecode] = None, - start_time: ty.Optional[FrameTimecode] = None, - end_time: ty.Optional[FrameTimecode] = None, - ) -> None: - """Set Duration - sets the duration/length of the video(s) to decode, as well as - the start/end times. Must be called before :meth:`start()` is called, otherwise - a VideoDecodingInProgress exception will be thrown. May be called after - :meth:`reset()` as well. - - Arguments: - duration (Optional[FrameTimecode]): The (maximum) duration in time to - decode from the opened video(s). Mutually exclusive with end_time - (i.e. if duration is set, end_time must be None). - start_time (Optional[FrameTimecode]): The time/first frame at which to - start decoding frames from. If set, the input video(s) will be - seeked to when start() is called, at which point the frame at - start_time can be obtained by calling retrieve(). - end_time (Optional[FrameTimecode]): The time at which to stop decoding - frames from the opened video(s). Mutually exclusive with duration - (i.e. if end_time is set, duration must be None). - - Raises: - VideoDecodingInProgress: Must call before start(). - """ - if self._started: - raise VideoDecodingInProgress() - - # Ensure any passed timecodes have the proper framerate. - if ( - (duration is not None and not duration.equal_framerate(self._cap_framerate)) - or (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) - or (end_time is not None and not end_time.equal_framerate(self._cap_framerate)) - ): - raise ValueError("FrameTimecode framerate does not match.") - - if duration is not None and end_time is not None: - raise TypeError("Only one of duration and end_time may be specified, not both.") - - if start_time is not None: - self._start_time = start_time - - if end_time is not None: - if end_time < self._start_time: - raise ValueError("end_time is before start_time in time.") - self._end_time = end_time - elif duration is not None: - self._end_time = self._start_time + duration - - if self._end_time is not None: - self._frame_length = min(self._frame_length, self._end_time + 1) - self._frame_length -= self._start_time - - if self._logger is not None: - self._logger.info( - "Duration set, start: %s, duration: %s, end: %s.", - start_time.get_timecode() if start_time is not None else start_time, - duration.get_timecode() if duration is not None else duration, - end_time.get_timecode() if end_time is not None else end_time, - ) - - def get_duration(self) -> FrameTimecode: - """Get Duration - gets the duration/length of the video(s) to decode, - as well as the start/end times. - - If the end time was not set by :meth:`set_duration()`, the end timecode - is calculated as the start timecode + total duration. - - Returns: - ty.Tuple[FrameTimecode, FrameTimecode, FrameTimecode]: The current video(s) - total duration, start timecode, and end timecode. - """ - end_time = self._end_time - if end_time is None: - end_time = self.get_base_timecode() + self._frame_length - return (self._frame_length, self._start_time, end_time) - - def start(self) -> None: - """Start - starts video decoding and seeks to start time. Raises - exception VideoDecodingInProgress if the method is called after the - decoder process has already been started. - - Raises: - VideoDecodingInProgress: Must call :meth:`stop()` before this - method if :meth:`start()` has already been called after - initial construction. - """ - if self._started: - raise VideoDecodingInProgress() - - self._started = True - self._get_next_cap() - if self._start_time != 0: - self.seek(self._start_time) - - # This overrides the seek method from the VideoStream interface, but the name was changed - # from `timecode` to `target`. For compatibility, we allow calling seek with the form - # seek(0), seek(timecode=0), and seek(target=0). Specifying both arguments is an error. - def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> bool: - """Seek forwards to the passed timecode. - - Only supports seeking forwards (i.e. timecode must be greater than the - current position). Can only be used after the :meth:`start()` - method has been called. - - Arguments: - timecode: Time in video to seek forwards to. Only one of timecode or target can be set. - target: Same as timecode. Only one of timecode or target can be set. - - Returns: - bool: True if seeking succeeded, False if no more frames / end of video. - - Raises: - ValueError: Either none or both `timecode` and `target` were set. - """ - if timecode is None and target is None: - raise ValueError("`target` must be set.") - if timecode is not None and target is not None: - raise ValueError("Only one of `timecode` or `target` can be set.") - if target is not None: - timecode = target - assert timecode is not None - if timecode < 0: - raise ValueError("Target seek position cannot be negative!") - - if not self._started: - self.start() - - timecode = self.base_timecode + timecode - if self._end_time is not None and timecode > self._end_time: - timecode = self._end_time - - # TODO: Seeking only works for the first (or current) video in the VideoManager. - # Warn the user there are multiple videos in the VideoManager, and the requested - # seek time exceeds the length of the first video. - if len(self._cap_list) > 1 and timecode > self._first_cap_len: - # TODO: This should throw an exception instead of potentially failing silently - # if no logger was provided. - if self._logger is not None: - self._logger.error("Seeking past the first input video is not currently supported.") - self._logger.warning("Seeking to end of first input.") - timecode = self._first_cap_len - if self._curr_cap is not None and self._end_of_video is not True: - self._curr_cap.set(cv2.CAP_PROP_POS_FRAMES, timecode.get_frames() - 1) - self._curr_time = timecode - 1 - - while self._curr_time < timecode: - if not self.grab(): - return False - return True - - def release(self) -> None: - """Release (cv2.VideoCapture method), releases all open capture(s).""" - for cap in self._cap_list: - cap.release() - self._cap_list = [] - self._started = False - - def reset(self) -> None: - """Reset - Reopens captures passed to the constructor of the VideoManager. - - Can only be called after the :meth:`release()` method has been called. - - Raises: - VideoDecodingInProgress: Must call :meth:`release()` before this method. - """ - if self._started: - self.release() - - self._started = False - self._end_of_video = False - self._curr_time = self.get_base_timecode() - self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( - video_files=self._video_file_paths, framerate=self._curr_time.get_framerate() - ) - self._curr_cap, self._curr_cap_idx = None, None - - def get(self, capture_prop: int, index: ty.Optional[int] = None) -> ty.Union[float, int]: - """Get (cv2.VideoCapture method) - obtains capture properties from the current - VideoCapture object in use. Index represents the same index as the original - video_files list passed to the constructor. Getting/setting the position (POS) - properties has no effect; seeking is implemented using VideoDecoder methods. - - Note that getting the property CAP_PROP_FRAME_COUNT will return the integer sum of - the frame count for all VideoCapture objects if index is not specified (or is None), - otherwise the frame count for the given VideoCapture index is returned instead. - - Arguments: - capture_prop: OpenCV VideoCapture property to get (i.e. CAP_PROP_FPS). - index (int, optional): Index in file_list of capture to get property from (default - is zero). Index is not checked and will raise exception if out of bounds. - - Returns: - float: Return value from calling get(property) on the VideoCapture object. - """ - if capture_prop == cv2.CAP_PROP_FRAME_COUNT and index is None: - return self._frame_length.get_frames() - elif capture_prop == cv2.CAP_PROP_POS_FRAMES: - return self._curr_time - elif capture_prop == cv2.CAP_PROP_FPS: - return self._cap_framerate - elif index is None: - index = 0 - return self._cap_list[index].get(capture_prop) - - def grab(self) -> bool: - """Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. - - Returns: - bool: True if a frame was grabbed, False otherwise. - """ - if not self._started: - self.start() - - grabbed = False - if self._curr_cap is not None and not self._end_of_video: - while not grabbed: - grabbed = self._curr_cap.grab() - if not grabbed and not self._get_next_cap(): - break - if self._end_time is not None and self._curr_time > self._end_time: - grabbed = False - self._last_frame = None - if grabbed: - self._curr_time += 1 - else: - self._correct_frame_length() - return grabbed - - def retrieve(self) -> ty.Tuple[bool, ty.Optional[np.ndarray]]: - """Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. - - Frame returned corresponds to last call to :meth:`grab()`. - - Returns: - Tuple of (True, frame_image) if a frame was grabbed during the last call to grab(), - and where frame_image is a numpy np.ndarray of the decoded frame. Otherwise (False, None). - """ - if not self._started: - self.start() - - retrieved = False - if self._curr_cap is not None and not self._end_of_video: - while not retrieved: - retrieved, self._last_frame = self._curr_cap.retrieve() - if not retrieved and not self._get_next_cap(): - break - if self._end_time is not None and self._curr_time > self._end_time: - retrieved = False - self._last_frame = None - return (retrieved, self._last_frame) - - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: - """Return next frame (or current if advance = False), or False if end of video. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will remain on the current frame. - - Returns: - If decode = True, returns either the decoded frame, or False if end of video. - If decode = False, a boolean indicating if the next frame was advanced to or not is - returned. - """ - if not self._started: - self.start() - has_grabbed = False - if advance: - has_grabbed = self.grab() - if decode: - retrieved, frame = self.retrieve() - return frame if retrieved else False - return has_grabbed - - def _get_next_cap(self) -> bool: - self._curr_cap = None - if self._curr_cap_idx is None: - self._curr_cap_idx = 0 - self._curr_cap = self._cap_list[0] - return True - else: - if not (self._curr_cap_idx + 1) < len(self._cap_list): - self._end_of_video = True - return False - self._curr_cap_idx += 1 - self._curr_cap = self._cap_list[self._curr_cap_idx] - return True - - def _correct_frame_length(self) -> None: - """Checks if the current frame position exceeds that originally calculated, - and adjusts the internally calculated frame length accordingly. Called after - exhausting all input frames from the video source(s). - """ - self._end_time = self._curr_time - self._frame_length = self._curr_time - self._start_time - - # VideoStream Interface (Some Covered Above) - - @property - def aspect_ratio(self) -> float: - """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - return self._aspect_ratio - - @property - def duration(self) -> ty.Optional[FrameTimecode]: - """Duration of the stream as a FrameTimecode, or None if non terminating.""" - return self.get_duration()[0] - - @property - def position(self) -> FrameTimecode: - """Current position within stream as FrameTimecode. - - This can be interpreted as presentation time stamp of the last frame which was - decoded by calling `read` with advance=True. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" - frames = self._curr_time.get_frames() - if frames < 1: - return self.base_timecode - return self.base_timecode + (frames - 1) - - @property - def position_ms(self) -> float: - """Current position within stream as a float of the presentation time in milliseconds. - The first frame has a time of 0.0 ms. - - This method will always return 0.0 if no frames have been `read`.""" - return self.position.get_seconds() * 1000.0 - - @property - def frame_number(self) -> int: - """Current position within stream in frames as an int. - - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" - return self._curr_time.get_frames() - - @property - def frame_rate(self) -> float: - """Framerate in frames/sec.""" - return self._cap_framerate - - @property - def frame_size(self) -> ty.Tuple[int, int]: - """Size of each video frame in pixels as a tuple of (width, height).""" - return ( - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)), - math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) - - @property - def is_seekable(self) -> bool: - """Just returns True.""" - return True - - @property - def path(self) -> ty.Union[bytes, str]: - """Video or device path.""" - if self._is_device: - return "Device %d" % self._path - return self._path - - @property - def name(self) -> ty.Union[bytes, str]: - """Name of the video, without extension, or device.""" - if self._is_device: - return self.path - return get_file_name(self.path, include_extension=False) diff --git a/tests/conftest.py b/tests/conftest.py index 26e0258f..25bf517e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,8 +86,7 @@ def pytest_assertrepr_compare(op, left, right): @pytest.fixture(autouse=True) def no_logs_gte_error(caplog): """Ensure no log messages with error severity or higher were reported during test execution.""" - # TODO: Remove exclusion for VideoManager module when removed from codebase. - EXCLUDED_MODULES = {"video_manager"} + EXCLUDED_MODULES = set() yield errors = [ record diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py deleted file mode 100644 index 7e9036e3..00000000 --- a/tests/test_backwards_compat.py +++ /dev/null @@ -1,89 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""Test for compatibility with v0.5 API. - -Do not use this file as examples or in production code - see `test_api.py` instead. - -The whole API is not compatible, but the compatibility layer makes the high level examples -work without modification. -""" - -import logging -import os - -from scenedetect import ContentDetector, SceneManager, StatsManager, VideoManager -from scenedetect.platform import init_logger - - -def validate_backwards_compatibility(test_video_file: str, stats_file_path: str): - """Validate backwards compatibility wrapper for VideoManager. - - This is equivalent to the tests/api_test.py file from v0.5 with additional assertions. - Do not following this test for writing applications - see test_api.py for examples - using the current API. This test is equivalent to `test_api_stats_manager`. - """ - # Suppress errors generated by using deprecated classes/arguments below. - init_logger(log_level=logging.CRITICAL) - video_manager = VideoManager([test_video_file]) - stats_file_path = test_video_file + ".csv" - stats_manager = StatsManager() - scene_manager = SceneManager(stats_manager) - scene_manager.add_detector(ContentDetector()) - base_timecode = video_manager.get_base_timecode() - scene_list = [] - try: - start_time = base_timecode + 4.0 - end_time = base_timecode + 8.0 - - if os.path.exists(stats_file_path): - with open(stats_file_path) as stats_file: - stats_manager.load_from_csv(stats_file) - # ContentDetector requires at least 1 frame before it can calculate any metrics. - assert stats_manager.metrics_exist( - start_time.get_frames() + 1, [ContentDetector.FRAME_SCORE_KEY] - ) - # Correct end frame # for presentation duration. - assert stats_manager.metrics_exist( - end_time.get_frames() - 1, [ContentDetector.FRAME_SCORE_KEY] - ) - - video_manager.set_duration(start_time=start_time, end_time=end_time) - video_manager.set_downscale_factor() - video_manager.start() - assert video_manager.get_current_timecode().get_frames() == start_time.get_frames() - - scene_manager.detect_scenes(frame_source=video_manager) - scene_list = scene_manager.get_scene_list() - - # Correct end frame # for presentation duration. - assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1 - - if stats_manager.is_save_required(): - with open(stats_file_path, "w") as stats_file: - stats_manager.save_to_csv(stats_file, base_timecode=base_timecode) - finally: - video_manager.release() - return scene_list - - -def test_backwards_compatibility_with_stats(test_video_file: str): - """Runs equivalent code to `tests/api_test.py` from v0.5 twice to also - exercise loading a statsfile from disk.""" - stats_file_path = test_video_file + ".csv" - if os.path.exists(stats_file_path): - os.remove(stats_file_path) - scenes = validate_backwards_compatibility(test_video_file, stats_file_path) - assert scenes - assert os.path.exists(stats_file_path) - # Make sure run with statsfile matches previous results. - assert validate_backwards_compatibility(test_video_file, stats_file_path) == scenes - os.remove(stats_file_path) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 0ab03430..bce5bdfe 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -25,7 +25,6 @@ from scenedetect.backends import VideoStreamAv, VideoStreamMoviePy from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.video_manager import VideoManager from scenedetect.video_stream import SeekError, VideoStream # Accuracy a framerate is checked to for testing purposes. @@ -132,7 +131,6 @@ def get_test_video_params() -> ty.List[VideoParameters]: VideoStreamCv2, VideoStreamAv, VideoStreamMoviePy, - VideoManager, ], ) ), @@ -314,8 +312,6 @@ def test_read_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParamete def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate calling `seek()` to offset past end of video.""" - if vs_type == VideoManager: - pytest.skip(reason="VideoManager does not have compliant end-of-video seek behaviour.") stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit # frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF, @@ -358,8 +354,6 @@ def test_invalid_path(vs_type: ty.Type[VideoStream]): def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" - if vs_type == VideoManager: - pytest.skip(reason="VideoManager does not support handling corrupt videos.") if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. # See https://github.com/Zulko/moviepy/pull/2253 for a PR that attempts to more gracefully diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6e69ca41..d7eb4871 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -665,6 +665,8 @@ Development - Remove `FrameTimecode.previous_frame()` method - Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation - `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called + - Remove `scenedetect.video_manager` module ([use `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead) + * Deprecated `video_manager` parameter has been removed from many functions and constructors, use `video` parameter instead when required #### Deprecation From 4bbe0ae8bc995a656395701d369eac3154fee438 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 22:00:01 -0400 Subject: [PATCH 224/407] [docs] Fix incorrect header --- docs/api/detector.rst | 2 +- docs/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/detector.rst b/docs/api/detector.rst index 31e5920e..8a53d6f4 100644 --- a/docs/api/detector.rst +++ b/docs/api/detector.rst @@ -2,7 +2,7 @@ .. _scenedetect-detector: ------------------------------------------------- -SceneDetector +Detector ------------------------------------------------- .. automodule:: scenedetect.detector diff --git a/docs/index.rst b/docs/index.rst index 65cfc0a8..7decf081 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,9 +46,9 @@ Table of Contents api api/detectors - api/backends api/scene_manager api/common + api/backends api/video_splitter api/stats_manager api/detector From a4d04395ec87f9880f6fd36251668f34ef8b1570 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 22:13:14 -0400 Subject: [PATCH 225/407] [docs] Give better names to documentation sections in TOC --- docs/api.rst | 4 ++-- docs/api/backends.rst | 2 +- docs/api/common.rst | 2 +- docs/api/detector.rst | 2 +- docs/api/scene_manager.rst | 2 +- docs/api/stats_manager.rst | 2 +- docs/api/video_stream.rst | 2 +- scenedetect/common.py | 6 ------ 8 files changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index ace61eb2..5de74eef 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -96,11 +96,11 @@ Module Reference :name: fullapitoc api/detectors - api/backends api/scene_manager + api/common api/video_splitter + api/backends api/stats_manager - api/common api/detector api/video_stream api/platform diff --git a/docs/api/backends.rst b/docs/api/backends.rst index efa6b860..fe035c07 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -2,7 +2,7 @@ .. _scenedetect-backends: ---------------------------------------- -Backends +Input Backends ---------------------------------------- .. automodule:: scenedetect.backends diff --git a/docs/api/common.rst b/docs/api/common.rst index ca3a1ede..1daee5e7 100644 --- a/docs/api/common.rst +++ b/docs/api/common.rst @@ -2,7 +2,7 @@ .. _scenedetect-common: --------------------------------------------------------------- -Common +Common Types --------------------------------------------------------------- .. automodule:: scenedetect.common diff --git a/docs/api/detector.rst b/docs/api/detector.rst index 8a53d6f4..2263cc7f 100644 --- a/docs/api/detector.rst +++ b/docs/api/detector.rst @@ -2,7 +2,7 @@ .. _scenedetect-detector: ------------------------------------------------- -Detector +Detector Interface ------------------------------------------------- .. automodule:: scenedetect.detector diff --git a/docs/api/scene_manager.rst b/docs/api/scene_manager.rst index 7dfb0b50..fa47f743 100644 --- a/docs/api/scene_manager.rst +++ b/docs/api/scene_manager.rst @@ -2,7 +2,7 @@ .. _scenedetect-scene_manager: ----------------------------------------------------------------------- -SceneManager +Scene Manager ----------------------------------------------------------------------- .. automodule:: scenedetect.scene_manager diff --git a/docs/api/stats_manager.rst b/docs/api/stats_manager.rst index 0abc5d89..2ce4c806 100644 --- a/docs/api/stats_manager.rst +++ b/docs/api/stats_manager.rst @@ -2,7 +2,7 @@ .. _scenedetect-stats_manager: ----------------------------------------------------------------------- -StatsManager +Stats Manager ----------------------------------------------------------------------- .. automodule:: scenedetect.stats_manager diff --git a/docs/api/video_stream.rst b/docs/api/video_stream.rst index aa31f50a..c6d8563e 100644 --- a/docs/api/video_stream.rst +++ b/docs/api/video_stream.rst @@ -2,7 +2,7 @@ .. _scenedetect-video_stream: --------------------------------------------------------------- -VideoStream +Stream Interface --------------------------------------------------------------- .. automodule:: scenedetect.video_stream diff --git a/scenedetect/common.py b/scenedetect/common.py index f6ae0055..65b833e8 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -18,12 +18,6 @@ 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 :class:`FrameTimecode constructor `. - -=============================================================== -Usage Examples -=============================================================== - A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames, `float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate: From 4d8b0bc2f5fff0ab722c7df8e0f253e4fb85060d Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 17 Mar 2025 22:20:46 -0400 Subject: [PATCH 226/407] [api] Remove `SparseSceneDetector` and `SceneManager.get_event_list()` --- scenedetect/detector.py | 39 ------------------------- scenedetect/scene_manager.py | 56 +++--------------------------------- website/pages/changelog.md | 23 ++++++++------- 3 files changed, 17 insertions(+), 101 deletions(-) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 743efdef..3443308c 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -102,45 +102,6 @@ def event_buffer_length(self) -> int: return 0 -# TODO(v0.7): Remove this early, no point in keeping it around. -class SparseSceneDetector(SceneDetector): - """Base class to inherit from when implementing a sparse scene detection algorithm. - - This class will be removed in v1.0 and should not be used. - - Unlike dense detectors, sparse detectors detect "events" and return a *pair* of frames, - as opposed to just a single cut. - - An example of a SparseSceneDetector is the MotionDetector. - - :meta private: - """ - - def process_frame( - self, frame_num: int, frame_img: numpy.ndarray - ) -> ty.List[ty.Tuple[int, int]]: - """Process Frame: Computes/stores metrics and detects any scene changes. - - Prototype method, no actual detection. - - Returns: - List of frame pairs representing individual scenes - to be added to the output scene list directly. - """ - return [] - - def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: - """Post Process: Performs any processing after the last frame has been read. - - Prototype method, no actual detection. - - Returns: - List of frame pairs representing individual scenes - to be added to the output scene list directly. - """ - return [] - - class FlashFilter: """Filters fast-cuts to enforce minimum scene length.""" diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 99547696..edf4f85a 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -101,7 +101,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SimpleTableRow, ) from scenedetect.common import CropRegion, CutList, SceneList -from scenedetect.detector import SceneDetector, SparseSceneDetector +from scenedetect.detector import SceneDetector from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.stats_manager import StatsManager @@ -947,9 +947,7 @@ def __init__( accessed via the `stats_manager` property of the resulting object to save to disk. """ self._cutting_list = [] - self._event_list = [] self._detector_list: ty.List[SceneDetector] = [] - self._sparse_detector_list = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the # `stats_manager` argument to to `store_stats: bool=False`, or lazy-init one. @@ -1063,19 +1061,14 @@ def add_detector(self, detector: SceneDetector) -> None: detector (SceneDetector): Scene detector to add to the SceneManager. """ if self._stats_manager is None and detector.stats_manager_required(): - # Make sure the lists are empty so that the detectors don't get - # out of sync (require an explicit statsmanager instead) - assert not self._detector_list and not self._sparse_detector_list + assert not self._detector_list self._stats_manager = StatsManager() detector.stats_manager = self._stats_manager if self._stats_manager is not None: self._stats_manager.register_metrics(detector.get_metrics()) - if not issubclass(type(detector), SparseSceneDetector): - self._detector_list.append(detector) - else: - self._sparse_detector_list.append(detector) + self._detector_list.append(detector) self._frame_buffer_size = max(detector.event_buffer_length, self._frame_buffer_size) @@ -1092,7 +1085,6 @@ def clear(self) -> None: cached frame metrics that were computed and saved in the previous call to detect_scenes. """ self._cutting_list.clear() - self._event_list.clear() self._last_pos = None self._start_pos = None self._frame_size = None @@ -1101,7 +1093,6 @@ def clear(self) -> None: def clear_detectors(self) -> None: """Remove all scene detectors added to the SceneManager via add_detector().""" self._detector_list.clear() - self._sparse_detector_list.clear() def get_scene_list( self, base_timecode: ty.Optional[FrameTimecode] = None, start_in_scene: bool = False @@ -1134,7 +1125,7 @@ def get_scene_list( # unless start_in_scene is True. if not cut_list and not start_in_scene: scene_list = [] - return sorted(self._get_event_list() + scene_list) + return sorted(scene_list) def _get_cutting_list(self) -> ty.List[int]: """Return a sorted list of unique frame numbers of any detected scene cuts.""" @@ -1144,15 +1135,6 @@ def _get_cutting_list(self) -> ty.List[int]: # Ensure all cuts are unique by using a set to remove all duplicates. return [self._base_timecode + cut for cut in sorted(set(self._cutting_list))] - def _get_event_list(self) -> SceneList: - if not self._event_list: - return [] - assert self._base_timecode is not None - return [ - (self._base_timecode + start, self._base_timecode + end) - for start, end in self._event_list - ] - def _process_frame( self, frame_num: int, @@ -1177,13 +1159,6 @@ def _process_frame( for cut_frame_num in cuts: buffer_index = cut_frame_num - (frame_num + 1) callback(self._frame_buffer[buffer_index], cut_frame_num) - for detector in self._sparse_detector_list: - events = detector.process_frame(frame_num, frame_im) - self._event_list += events - if callback: - for event_start, _ in events: - buffer_index = event_start - (frame_num + 1) - callback(self._frame_buffer[buffer_index], event_start) return new_cuts def _post_process(self, frame_num: int) -> None: @@ -1452,8 +1427,6 @@ def get_cut_list( the scene list, noting that each scene is contiguous starting from the first frame and ending at the last frame detected. - If only sparse detectors are used (e.g. MotionDetector), this will always be empty. - Arguments: base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. show_warning: If set to False, suppresses the error from being warned. In v0.7, @@ -1470,24 +1443,3 @@ def get_cut_list( if show_warning: logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") return self._get_cutting_list() - - def get_event_list(self, base_timecode: ty.Optional[FrameTimecode] = None) -> SceneList: - """[DEPRECATED] DO NOT USE. - - Get a list of start/end timecodes of sparse detection events. - - Unlike get_scene_list, the event list returns a list of FrameTimecodes representing - the point in the input video where a new scene was detected only by sparse detectors, - otherwise it is the same. - - Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. - - Returns: - List of pairs of FrameTimecode objects denoting the detected scenes. - - :meta private: - """ - # TODO(v0.7): Use the warnings module to turn this into a warning. - logger.error("`get_event_list()` is deprecated and will be removed in a future release.") - return self._get_event_list() diff --git a/website/pages/changelog.md b/website/pages/changelog.md index d7eb4871..15f8cae6 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -658,17 +658,20 @@ Development ### API Changes #### Breaking - - Refactoring to make code less verbose: - - `scenedetect.scene_detector` is now `scenedetect.detector` - - `scenedetect.frame_timecode` is now `scenedetect.common` - - `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them - - Remove `FrameTimecode.previous_frame()` method - - Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation - - `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called - - Remove `scenedetect.video_manager` module ([use `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead) + + * Remove deprecated `scenedetect.video_manager` module ([use `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead) * Deprecated `video_manager` parameter has been removed from many functions and constructors, use `video` parameter instead when required + * Refactoring to make code less verbose: + * `scenedetect.scene_detector` is now `scenedetect.detector` + * `scenedetect.frame_timecode` is now `scenedetect.common` + * `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them + * Remove `FrameTimecode.previous_frame()` method + * Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation + * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called + * Remove deprecated `SparseSceneDetector` interface + * Remove deprecated `SceneManager.get_event_list()` method #### Deprecation -- `scenedetect.scene_detector` module is now deprecated, use `scenedetect.detector` (or `scenedetect`) instead - + * `scenedetect.scene_detector` module is now deprecated, import from `scenedetect` or `scenedetect.detector` instead + * `scenedetect.frame_timecode` module is now deprecated, import from `scenedetect` or `scenedetect.common` instead From f8b9c5a85a6ca70acf65b5b42b276e876ec05954 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 18 Mar 2025 23:08:07 -0400 Subject: [PATCH 227/407] [backends] Land initial VFR support for PyAV This required extensive API changes to the SceneDetector interface and SceneManager. --- scenedetect/_cli/commands.py | 2 +- scenedetect/_cli/config.py | 2 +- scenedetect/_cli/context.py | 2 +- scenedetect/_cli/controller.py | 2 +- scenedetect/backends/moviepy.py | 2 +- scenedetect/backends/opencv.py | 5 +-- scenedetect/backends/pyav.py | 10 +++--- scenedetect/common.py | 3 ++ scenedetect/detector.py | 20 +++++++---- scenedetect/detectors/adaptive_detector.py | 13 ++++--- scenedetect/detectors/content_detector.py | 15 ++++---- scenedetect/detectors/threshold_detector.py | 24 +++++++------ scenedetect/scene_manager.py | 38 +++++++++++++-------- scenedetect/stats_manager.py | 2 +- scenedetect/video_splitter.py | 3 +- scenedetect/video_stream.py | 3 +- tests/test_frame_timecode.py | 2 +- tests/test_scene_manager.py | 2 +- tests/test_stats_manager.py | 2 +- website/pages/changelog.md | 1 + 20 files changed, 89 insertions(+), 64 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 3d747fa0..bb512e99 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -29,7 +29,7 @@ import scenedetect from scenedetect._cli.config import XmlFormat from scenedetect._cli.context import CliContext -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import ( CutList, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index b7e5a3f1..c55927bc 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -25,9 +25,9 @@ from platformdirs import user_config_dir +from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter from scenedetect.detectors import ContentDetector -from scenedetect.frame_timecode import FrameTimecode from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index c46b7fad..32f5df24 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -24,6 +24,7 @@ ConfigRegistry, CropValue, ) +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode from scenedetect.detector import FlashFilter, SceneDetector from scenedetect.detectors import ( AdaptiveDetector, @@ -32,7 +33,6 @@ HistogramDetector, ThresholdDetector, ) -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode from scenedetect.platform import init_logger from scenedetect.scene_manager import Interpolation, SceneManager from scenedetect.stats_manager import StatsManager diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index ea426102..af730b5b 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -20,7 +20,7 @@ from scenedetect._cli.context import CliContext from scenedetect.backends import VideoStreamCv2, VideoStreamMoviePy -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import CutList, SceneList, get_scenes_from_cuts from scenedetect.video_stream import SeekError diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index b3bf2b18..ed2418ac 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -24,7 +24,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index be0d2b7a..23f82364 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -26,8 +26,7 @@ import cv2 import numpy as np -from scenedetect.common import Timecode -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, _USE_PTS_IN_DEVELOPMENT from scenedetect.platform import get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, @@ -46,8 +45,6 @@ " ! ", # gstreamer pipe ) -_USE_PTS_IN_DEVELOPMENT = False - def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float: """Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels).""" diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 5ed8b6e3..c2637284 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -17,8 +17,7 @@ import av import numpy as np -from scenedetect.common import Timecode -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, _USE_PTS_IN_DEVELOPMENT from scenedetect.platform import get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream @@ -26,8 +25,6 @@ VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] -_USE_PTS_IN_DEVELOPMENT = False - class VideoStreamAv(VideoStream): """PyAV `av.InputContainer` backend.""" @@ -205,7 +202,12 @@ def frame_number(self) -> int: """Current position within stream as the frame number. Will return 0 until the first frame is `read`.""" + if self._frame: + if _USE_PTS_IN_DEVELOPMENT: + return FrameTimecode( + round(self._frame.time * self.frame_rate), self.frame_rate + ).frame_num return self.position.frame_num + 1 return 0 diff --git a/scenedetect/common.py b/scenedetect/common.py index 65b833e8..2c6957bf 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -66,6 +66,9 @@ from dataclasses import dataclass from fractions import Fraction + +_USE_PTS_IN_DEVELOPMENT = False + ## ## Type Aliases ## diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 3443308c..d07d75fe 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -29,6 +29,7 @@ import numpy +from scenedetect.common import FrameTimecode, _USE_PTS_IN_DEVELOPMENT from scenedetect.stats_manager import StatsManager @@ -46,6 +47,7 @@ class SceneDetector: # TODO(v0.7): Make this a proper abstract base class. + # TODO(v0.7): This should be a property. stats_manager: ty.Optional[StatsManager] = None """Optional :class:`StatsManager ` to use for caching frame metrics to and from.""" @@ -67,7 +69,9 @@ def get_metrics(self) -> ty.List[str]: """ return [] - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> ty.List[FrameTimecode]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -84,7 +88,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int """ return [] - def post_process(self, frame_num: int) -> ty.List[int]: + def post_process(self, timecode: int) -> ty.List[FrameTimecode]: """Post Process: Performs any processing after the last frame has been read. Prototype method, no actual detection. @@ -130,15 +134,17 @@ def __init__(self, mode: Mode, length: int): def max_behind(self) -> int: return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length - def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: if not self._filter_length > 0: - return [frame_num] if above_threshold else [] + return [timecode] if above_threshold else [] + if _USE_PTS_IN_DEVELOPMENT: + raise NotImplementedError("TODO: Change filter to use units of time instead of frames.") if self._last_above is None: - self._last_above = frame_num + self._last_above = timecode if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) + return self._filter_merge(frame_num=timecode, above_threshold=above_threshold) elif self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) + return self._filter_suppress(frame_num=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 5b30a6d0..67422255 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -21,6 +21,7 @@ import numpy as np +from scenedetect.common import FrameTimecode from scenedetect.detectors import ContentDetector logger = getLogger("pyscenedetect") @@ -109,7 +110,9 @@ def stats_manager_required(self) -> bool: """Not required for AdaptiveDetector.""" return False - def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> ty.List[int]: + def process_frame( + self, timecode: FrameTimecode, frame_img: ty.Optional[np.ndarray] + ) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -124,14 +127,14 @@ def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> t # TODO(#283): Merge this with ContentDetector and turn it on by default. - super().process_frame(frame_num=frame_num, frame_img=frame_img) + super().process_frame(timecode=timecode, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. if self._last_cut is None: - self._last_cut = frame_num + self._last_cut = timecode required_frames = 1 + (2 * self.window_width) - self._buffer.append((frame_num, self._frame_score)) + self._buffer.append((timecode, self._frame_score)) if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] @@ -156,7 +159,7 @@ def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> t threshold_met: bool = ( adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val ) - min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len + min_length_met: bool = (timecode - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: self._last_cut = target_frame return [target_frame] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index e37b1380..c21cdf4d 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -22,6 +22,7 @@ import cv2 import numpy +from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter, SceneDetector @@ -125,7 +126,6 @@ def __init__( """ super().__init__() self._threshold: float = threshold - self._min_scene_len: int = min_scene_len self._last_above_threshold: ty.Optional[int] = None self._last_frame: ty.Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights @@ -137,12 +137,13 @@ def __init__( raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: ty.Optional[float] = None + # TODO(v0.7): Handle timecodes in filter. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): return ContentDetector.METRIC_KEYS - def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> float: + def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> float: """Calculate score representing relative amount of motion in `frame_img` compared to the last time the function was called (returns 0.0 on the first call).""" # TODO: Add option to enable motion estimation before calculating score components. @@ -178,13 +179,15 @@ def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> fl if self.stats_manager is not None: metrics = {self.FRAME_SCORE_KEY: frame_score} metrics.update(score_components._asdict()) - self.stats_manager.set_metrics(frame_num, metrics) + self.stats_manager.set_metrics(timecode.frame_num, metrics) # Store all data required to calculate the next frame's score. self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) return frame_score - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> ty.List[FrameTimecode]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -196,12 +199,12 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - self._frame_score = self._calculate_frame_score(frame_num, frame_img) + self._frame_score = self._calculate_frame_score(timecode, frame_img) if self._frame_score is None: return [] above_threshold: bool = self._frame_score >= self._threshold - return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) + return self._flash_filter.filter(timecode=timecode, above_threshold=above_threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index c823e338..cd8c539c 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -21,6 +21,7 @@ import numpy +from scenedetect.common import FrameTimecode from scenedetect.detector import SceneDetector logger = getLogger("pyscenedetect") @@ -87,11 +88,12 @@ def __init__( "type": None, # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] + self._time_base = None def get_metrics(self) -> ty.List[str]: return self._metric_keys - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -103,6 +105,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ + # TODO(v0.7): We might need to consider PTS here instead. + frame_num = timecode.frame_num # Initialize last scene cut point at the beginning of the frames of interest. if self.last_scene_cut is None: @@ -113,7 +117,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int # then we trigger a new scene cut/break. # List of cuts to return. - cut_list = [] + cuts = [] # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on @@ -149,7 +153,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int f_split = int( (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2 ) - cut_list.append(f_split) + cuts.append(f_split) self.last_scene_cut = frame_num self.last_fade["type"] = "in" self.last_fade["frame"] = frame_num @@ -160,9 +164,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int else: self.last_fade["type"] = "in" self.processed_frame = True - return cut_list + return [FrameTimecode(cut, fps=timecode) for cut in cuts] - def post_process(self, frame_num: int): + def post_process(self, timecode: FrameTimecode): """Writes a final scene cut if the last detected fade was a fade-out. Only writes the scene cut if add_final_scene is true, and the last fade @@ -174,14 +178,14 @@ def post_process(self, frame_num: int): # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cut_times = [] + cuts = [] if ( self.last_fade["type"] == "out" and self.add_final_scene and ( - (self.last_scene_cut is None and frame_num >= self.min_scene_len) - or (frame_num - self.last_scene_cut) >= self.min_scene_len + (self.last_scene_cut is None and timecode >= self.min_scene_len) + or (timecode - self.last_scene_cut) >= self.min_scene_len ) ): - cut_times.append(self.last_fade["frame"]) - return cut_times + cuts.append(self.last_fade["frame"]) + return [FrameTimecode(cut, fps=timecode) for cut in cuts] diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index edf4f85a..1de716b5 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -100,9 +100,14 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SimpleTableImage, SimpleTableRow, ) -from scenedetect.common import CropRegion, CutList, SceneList +from scenedetect.common import ( + CropRegion, + CutList, + FrameTimecode, + SceneList, + _USE_PTS_IN_DEVELOPMENT, +) from scenedetect.detector import SceneDetector -from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream @@ -946,7 +951,7 @@ def __init__( stats_manager: :class:`StatsManager` to bind to this `SceneManager`. Can be accessed via the `stats_manager` property of the resulting object to save to disk. """ - self._cutting_list = [] + self._cutting_list: ty.List[FrameTimecode] = [] self._detector_list: ty.List[SceneDetector] = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the @@ -1127,17 +1132,16 @@ def get_scene_list( scene_list = [] return sorted(scene_list) - def _get_cutting_list(self) -> ty.List[int]: + def _get_cutting_list(self) -> ty.List[FrameTimecode]: """Return a sorted list of unique frame numbers of any detected scene cuts.""" if not self._cutting_list: return [] - assert self._base_timecode is not None # Ensure all cuts are unique by using a set to remove all duplicates. - return [self._base_timecode + cut for cut in sorted(set(self._cutting_list))] + return [cut for cut in sorted(set(self._cutting_list))] def _process_frame( self, - frame_num: int, + position: FrameTimecode, frame_im: np.ndarray, callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, ) -> bool: @@ -1152,19 +1156,22 @@ def _process_frame( # so index based on cut frame should be [event_frame - (frame_num + 1)] self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] for detector in self._detector_list: - cuts = detector.process_frame(frame_num, frame_im) + cuts = detector.process_frame(position, frame_im) self._cutting_list += cuts new_cuts = True if cuts else False + # TODO: Support callbacks with PTS. if callback: - for cut_frame_num in cuts: - buffer_index = cut_frame_num - (frame_num + 1) - callback(self._frame_buffer[buffer_index], cut_frame_num) + if _USE_PTS_IN_DEVELOPMENT: + raise NotImplementedError() + for cut in cuts: + buffer_index = cut.frame_num - (position.frame_num + 1) + callback(self._frame_buffer[buffer_index], cut.frame_num) return new_cuts - def _post_process(self, frame_num: int) -> None: + def _post_process(self, timecode: FrameTimecode) -> None: """Add remaining cuts to the cutting list, after processing the last frame.""" for detector in self._detector_list: - self._cutting_list += detector.post_process(frame_num) + self._cutting_list += detector.post_process(timecode) def stop(self) -> None: """Stop the current :meth:`detect_scenes` call, if any. Thread-safe.""" @@ -1298,7 +1305,7 @@ def detect_scenes( break if next_frame is not None: frame_im = next_frame - new_cuts = self._process_frame(position.frame_num, frame_im, callback) + new_cuts = self._process_frame(position, frame_im, callback) if progress_bar is not None: if new_cuts: progress_bar.set_description( @@ -1321,7 +1328,8 @@ def detect_scenes( raise self._exception_info[1].with_traceback(self._exception_info[2]) self._last_pos = video.position - self._post_process(video.position._frame_num) + self._post_process(video.position) + return video.frame_number - start_frame_num def _decode_thread( diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index d00191d1..45705a83 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -27,7 +27,7 @@ from logging import getLogger from pathlib import Path -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode logger = getLogger("pyscenedetect") diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 807bbdfc..1de861e4 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -40,8 +40,7 @@ from dataclasses import dataclass from pathlib import Path -from scenedetect.common import TimecodePair -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodePair from scenedetect.platform import CommandTooLong, Template, get_ffmpeg_path, invoke_command, tqdm logger = logging.getLogger("pyscenedetect") diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 2bce6e7b..ae197be1 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -37,8 +37,7 @@ import numpy as np -from scenedetect.common import Timecode -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode, Timecode class SeekError(Exception): diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 14c423e4..9f403345 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -24,7 +24,7 @@ import pytest # Standard Library Imports -from scenedetect.frame_timecode import MAX_FPS_DELTA, FrameTimecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode def test_framerate(): diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index d67604db..a5477078 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -21,8 +21,8 @@ import pytest from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.common import FrameTimecode from scenedetect.detectors import AdaptiveDetector, ContentDetector -from scenedetect.frame_timecode import FrameTimecode from scenedetect.scene_manager import SceneManager, save_images TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 6d47d748..0c32371e 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -34,8 +34,8 @@ import pytest from scenedetect.backends.opencv import VideoStreamCv2 +from scenedetect.common import FrameTimecode from scenedetect.detectors import ContentDetector -from scenedetect.frame_timecode import FrameTimecode from scenedetect.scene_manager import SceneManager from scenedetect.stats_manager import ( COLUMN_NAME_FRAME_NUMBER, diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 15f8cae6..41c44833 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -659,6 +659,7 @@ Development #### Breaking + * The `SceneDetector` interface now uses timecodes instead of frame numbers * Remove deprecated `scenedetect.video_manager` module ([use `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead) * Deprecated `video_manager` parameter has been removed from many functions and constructors, use `video` parameter instead when required * Refactoring to make code less verbose: From 0a2b4b87aad73f05cd4ee89464ffb6657c7a96d2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 18 Mar 2025 23:28:51 -0400 Subject: [PATCH 228/407] [lint] Fix lint errors --- scenedetect/backends/opencv.py | 2 +- scenedetect/backends/pyav.py | 2 +- scenedetect/common.py | 1 - scenedetect/detector.py | 2 +- scenedetect/scene_manager.py | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 23f82364..bf3a32e0 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -26,7 +26,7 @@ import cv2 import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, _USE_PTS_IN_DEVELOPMENT +from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode from scenedetect.platform import get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index c2637284..fddbf283 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -17,7 +17,7 @@ import av import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, _USE_PTS_IN_DEVELOPMENT +from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode from scenedetect.platform import get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream diff --git a/scenedetect/common.py b/scenedetect/common.py index 2c6957bf..2541f963 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -66,7 +66,6 @@ from dataclasses import dataclass from fractions import Fraction - _USE_PTS_IN_DEVELOPMENT = False ## diff --git a/scenedetect/detector.py b/scenedetect/detector.py index d07d75fe..4410e998 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -29,7 +29,7 @@ import numpy -from scenedetect.common import FrameTimecode, _USE_PTS_IN_DEVELOPMENT +from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode from scenedetect.stats_manager import StatsManager diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 1de716b5..0fa2474f 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -101,11 +101,11 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SimpleTableRow, ) from scenedetect.common import ( + _USE_PTS_IN_DEVELOPMENT, CropRegion, CutList, FrameTimecode, SceneList, - _USE_PTS_IN_DEVELOPMENT, ) from scenedetect.detector import SceneDetector from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm From 2430ba66fe0b83f45058b3305cff2c23d695bab9 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 21 Mar 2025 22:21:30 -0400 Subject: [PATCH 229/407] [detectors] Complete timecode migration Complete migrating all existing detectors and `StatsManager` to use timecodes instead of frame numbers. In some cases (e.g. `ThresholdDetector`), we still use frame numbers pending conversion of the algorithm. --- scenedetect/detectors/adaptive_detector.py | 59 +++++-------------- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/hash_detector.py | 62 ++++++++------------ scenedetect/detectors/histogram_detector.py | 18 +++--- scenedetect/detectors/threshold_detector.py | 11 ++-- scenedetect/stats_manager.py | 63 ++++++++------------- website/pages/api.md | 23 ++++---- website/pages/changelog.md | 5 ++ 8 files changed, 95 insertions(+), 148 deletions(-) diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 67422255..1d553ce1 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -81,7 +81,7 @@ def __init__( kernel_size=kernel_size, ) - # TODO: Turn these options into properties. + # TODO: Turn these public options into properties. self.min_scene_len = min_scene_len self.adaptive_threshold = adaptive_threshold self.min_content_val = min_content_val @@ -90,41 +90,21 @@ def __init__( self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( window_width=window_width, luma_only="" if not luma_only else "_lum" ) - self._first_frame_num = None - - # NOTE: This must be different than `self._last_scene_cut` which is used by the base class. - self._last_cut: ty.Optional[int] = None - - self._buffer = [] + self._buffer: ty.List[ty.Tuple[FrameTimecode, float]] = [] + # NOTE: The name of last cut is different from `self._last_scene_cut` from our base class, + # and serves a different purpose! + self._last_cut: ty.Optional[FrameTimecode] = None @property def event_buffer_length(self) -> int: - """Number of frames any detected cuts will be behind the current frame due to buffering.""" return self.window_width def get_metrics(self) -> ty.List[str]: - """Combines base ContentDetector metric keys with the AdaptiveDetector one.""" return super().get_metrics() + [self._adaptive_ratio_key] - def stats_manager_required(self) -> bool: - """Not required for AdaptiveDetector.""" - return False - def process_frame( - self, timecode: FrameTimecode, frame_img: ty.Optional[np.ndarray] - ) -> ty.List[int]: - """Process the next frame. `frame_num` is assumed to be sequential. - - Args: - frame_num (int): Frame number of frame that is being passed. Can start from any value - but must remain sequential. - frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. - - Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. - """ - + self, timecode: FrameTimecode, frame_img: np.ndarray + ) -> ty.List[FrameTimecode]: # TODO(#283): Merge this with ContentDetector and turn it on by default. super().process_frame(timecode=timecode, frame_img=frame_img) @@ -138,7 +118,7 @@ def process_frame( if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] - (target_frame, target_score) = self._buffer[self.window_width] + (target_timecode, target_score) = self._buffer[self.window_width] average_window_score = sum( score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width ) / (2.0 * self.window_width) @@ -152,7 +132,9 @@ def process_frame( # if we would have divided by zero, set adaptive_ratio to the max (255.0) adaptive_ratio = 255.0 if self.stats_manager is not None: - self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio}) + self.stats_manager.set_metrics( + target_timecode, {self._adaptive_ratio_key: adaptive_ratio} + ) # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut @@ -161,21 +143,6 @@ def process_frame( ) min_length_met: bool = (timecode - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: - self._last_cut = target_frame - return [target_frame] - return [] - - def get_content_val(self, frame_num: int) -> ty.Optional[float]: - """Returns the average content change for a frame.""" - # TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7. - logger.error( - "get_content_val is deprecated and will be removed. Lookup the value" - " using a StatsManager with ContentDetector.FRAME_SCORE_KEY." - ) - if self.stats_manager is not None: - return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0] - return 0.0 - - def post_process(self, _unused_frame_num: int): - """Not required for AdaptiveDetector.""" + self._last_cut = target_timecode + return [target_timecode] return [] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index c21cdf4d..1b66ff7b 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -179,7 +179,7 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr if self.stats_manager is not None: metrics = {self.FRAME_SCORE_KEY: frame_score} metrics.update(score_components._asdict()) - self.stats_manager.set_metrics(timecode.frame_num, metrics) + self.stats_manager.set_metrics(timecode, metrics) # Store all data required to calculate the next frame's score. self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges) diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index ad7f5310..484f49d5 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -1,43 +1,27 @@ # -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# [ Documentation: http://pyscenedetect.readthedocs.org/ ] +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # # Copyright (C) 2014-2022 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. # -# PySceneDetect is licensed under the BSD 3-Clause License; see the included -# LICENSE file, or visit one of the following pages for details: -# - https://github.com/Breakthrough/PySceneDetect/ -# - http://www.bcastell.com/projects/PySceneDetect/ -# -# This software uses Numpy, OpenCV, click, tqdm, simpletable, and pytest. -# See the included LICENSE files or one of the above URLs for more information. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -# -"""``scenedetect.detectors.hash_detector`` Module +""":py:class:`HashDetector` calculates a hash for each frame of a video using a perceptual +hashing algorithm. The differences (distance) in hash value between frames is calculated. +If this difference exceeds a set threshold, a scene cut is triggered. -This module implements the :py:class:`HashDetector`, which calculates a hash -value for each from of a video using a perceptual hashing algorithm. Then, the -differences in hash value between frames is calculated. If this difference -exceeds a set threshold, a scene cut is triggered. - -This detector is available from the command-line interface by using the -`detect-hash` command. +This detector is available from the command-line interface by using the `detect-hash` command. """ -# Third-Party Library Imports +import typing as ty + import cv2 import numpy -# PySceneDetect Library Imports +from scenedetect.common import FrameTimecode from scenedetect.detector import SceneDetector @@ -74,15 +58,17 @@ def __init__( self._size = size self._size_sq = float(size * size) self._factor = lowpass - self._last_frame = None - self._last_scene_cut = None + self._last_frame: numpy.ndarray = None + self._last_scene_cut: FrameTimecode = None self._last_hash = numpy.array([]) self._metric_key = f"hash_dist [size={self._size} lowpass={self._factor}]" def get_metrics(self): return [self._metric_key] - def process_frame(self, frame_num: int, frame_img: numpy.ndarray): + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> ty.List[FrameTimecode]: """Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference frame to frame.""" @@ -91,7 +77,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray): # Initialize last scene cut point at the beginning of the frames of interest. if self._last_scene_cut is None: - self._last_scene_cut = frame_num + self._last_scene_cut = timecode # We can only start detecting once we have a frame to compare with. if self._last_frame is not None: @@ -115,17 +101,17 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray): hash_dist_norm = hash_dist / self._size_sq if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_key: hash_dist_norm}) + self.stats_manager.set_metrics(timecode, {self._metric_key: hash_dist_norm}) self._last_hash = curr_hash # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). if hash_dist_norm >= self._threshold and ( - (frame_num - self._last_scene_cut) >= self._min_scene_len + (timecode - self._last_scene_cut) >= self._min_scene_len ): - cut_list.append(frame_num) - self._last_scene_cut = frame_num + cut_list.append(timecode) + self._last_scene_cut = timecode self._last_frame = frame_img.copy() diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 7ee209c5..812c5852 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -20,7 +20,7 @@ import cv2 import numpy -# PySceneDetect Library Imports +from scenedetect.common import FrameTimecode from scenedetect.detector import SceneDetector @@ -48,10 +48,10 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int self._bins = bins self._min_scene_len = min_scene_len self._last_hist = None - self._last_scene_cut = None + self._last_cut = None self._metric_key = f"hist_diff [bins={self._bins}]" - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]: """Computes the histogram of the luma channel of the frame image and compares it with the histogram of the luma channel of the previous frame. If the difference between the histograms exceeds the threshold, a scene cut is detected. @@ -77,8 +77,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int raise ValueError("Image must have three color channels for HistogramDetector") # Initialize last scene cut point at the beginning of the frames of interest. - if not self._last_scene_cut: - self._last_scene_cut = frame_num + if not self._last_cut: + self._last_cut = timecode hist = self.calculate_histogram(frame_img, bins=self._bins) @@ -98,14 +98,14 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation # less than 0.8 between histograms will be considered significant enough to denote a scene change. if hist_diff <= self._threshold and ( - (frame_num - self._last_scene_cut) >= self._min_scene_len + (timecode - self._last_cut) >= self._min_scene_len ): - cut_list.append(frame_num) - self._last_scene_cut = frame_num + cut_list.append(timecode) + self._last_cut = timecode # Save stats to a StatsManager if it is being used if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_key: hist_diff}) + self.stats_manager.set_metrics(timecode, {self._metric_key: hist_diff}) self._last_hist = hist diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index cd8c539c..f41dfe5e 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -93,7 +93,9 @@ def __init__( def get_metrics(self) -> ty.List[str]: return self._metric_keys - def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame( + self, timecode: FrameTimecode, frame_img: numpy.ndarray + ) -> ty.List[FrameTimecode]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -105,7 +107,8 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - # TODO(v0.7): We might need to consider PTS here instead. + # TODO(v0.7): We need to consider PTS here instead. The methods below using frame numbers + # won't work for variable framerates. frame_num = timecode.frame_num # Initialize last scene cut point at the beginning of the frames of interest. @@ -130,7 +133,7 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty else: frame_avg = numpy.mean(frame_img) if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg}) + self.stats_manager.set_metrics(timecode, {self._metric_keys[0]: frame_avg}) if self.processed_frame: if self.last_fade["type"] == "in" and ( @@ -166,7 +169,7 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty self.processed_frame = True return [FrameTimecode(cut, fps=timecode) for cut in cuts] - def post_process(self, timecode: FrameTimecode): + def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out. Only writes the scene cut if add_final_scene is true, and the last fade diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 45705a83..169a7930 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -118,41 +118,35 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: """Register a list of metric keys that will be used by the detector.""" self._metric_keys = self._metric_keys.union(set(metric_keys)) - # TODO(v1.0): Change frame_number to a FrameTimecode now that it is just a hash and will - # be required for VFR support. This API is also really difficult to use, this type should just - # function like a dictionary. - def get_metrics(self, frame_number: int, metric_keys: ty.Iterable[str]) -> ty.List[ty.Any]: - """Return the requested statistics/metrics for a given frame. - - Arguments: - frame_number (int): Frame number to retrieve metrics for. - metric_keys (List[str]): A list of metric keys to look up. + # TODO(v1.0): This interface is difficult to use, we should support the dictionary protocol. + def get_metrics( + self, timecode: FrameTimecode, metric_keys: ty.Iterable[str] + ) -> ty.List[ty.Any]: + """Return the requested statistics/metrics for a given timecode. Returns: - A list containing the requested frame metrics for the given frame number - in the same order as the input list of metric keys. If a metric could - not be found, None is returned for that particular metric. + A list containing the requested frame metrics for the given frame number, ordered as + they are in `metric_keys`. """ - return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] + return [self._get_metric(timecode, metric_key) for metric_key in metric_keys] - def set_metrics(self, frame_number: int, metric_kv_dict: ty.Dict[str, ty.Any]) -> None: + def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: ty.Dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: - frame_number: Frame number to retrieve metrics for. - metric_kv_dict: A dict mapping metric keys to the - respective integer/floating-point metric values to set. + timecode: Timecode to set metrics for. + metric_kv_dict: Key value mapping of metrics to their values for `timecode`. """ for metric_key in metric_kv_dict: - self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) + self._set_metric(timecode, metric_key, metric_kv_dict[metric_key]) - def metrics_exist(self, frame_number: int, metric_keys: ty.Iterable[str]) -> bool: + def metrics_exist(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> bool: """Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: bool: True if the given metric keys exist for the frame, False otherwise. """ - return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys]) + return all([self._metric_exists(timecode, metric_key) for metric_key in metric_keys]) def is_save_required(self) -> bool: """Is Save Required: Checks if the stats have been updated since loading. @@ -166,23 +160,17 @@ def is_save_required(self) -> bool: def save_to_csv( self, csv_file: ty.Union[str, bytes, Path, ty.TextIO], - base_timecode: ty.Optional[FrameTimecode] = None, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. Arguments: csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str. - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility. force_save: If True, writes metrics out even if an update is not required. Raises: OSError: If `path` cannot be opened or a write failure occurs. """ - # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. - if base_timecode is not None: - logger.error("base_timecode is deprecated and has no effect.") - if not (force_save or self.is_save_required()): logger.info("No metrics to write.") return @@ -200,9 +188,8 @@ def save_to_csv( frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: - frame_timecode = self._base_timecode + frame_key csv_writer.writerow( - [frame_timecode.get_frames() + 1, frame_timecode.get_timecode()] + [frame_key.get_frames() + 1, frame_key.get_timecode()] + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] ) @@ -303,18 +290,16 @@ def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optiona # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, frame_number: int, metric_key: str) -> ty.Optional[ty.Any]: - if self._metric_exists(frame_number, metric_key): - return self._frame_metrics[frame_number][metric_key] + def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Optional[ty.Any]: + if self._metric_exists(timecode, metric_key): + return self._frame_metrics[timecode][metric_key] return None - def _set_metric(self, frame_number: int, metric_key: str, metric_value: ty.Any) -> None: + def _set_metric(self, timecode: FrameTimecode, metric_key: str, metric_value: ty.Any) -> None: self._metrics_updated = True - if frame_number not in self._frame_metrics: - self._frame_metrics[frame_number] = dict() - self._frame_metrics[frame_number][metric_key] = metric_value + if timecode not in self._frame_metrics: + self._frame_metrics[timecode] = dict() + self._frame_metrics[timecode][metric_key] = metric_value - def _metric_exists(self, frame_number: int, metric_key: str) -> bool: - return ( - frame_number in self._frame_metrics and metric_key in self._frame_metrics[frame_number] - ) + def _metric_exists(self, timecode: FrameTimecode, metric_key: str) -> bool: + return timecode in self._frame_metrics and metric_key in self._frame_metrics[timecode] diff --git a/website/pages/api.md b/website/pages/api.md index 740c05a8..c6513693 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -41,23 +41,24 @@ All scene detection algorithms must inherit from [the base `SceneDetector` class Creating a new scene detection method can be as simple as implementing the `process_frame` function, and optionally `post_process`: ```python -from scenedetect.detector import SceneDetector +import typing as ty +import numpy as np +from scenedetect import FrameTimecode, SceneDetector class CustomDetector(SceneDetector): """CustomDetector class to implement a scene detection algorithm.""" - def __init__(self): - pass - def process_frame(self, frame_num, frame_img, frame_metrics, scene_list): - """Computes/stores metrics and detects any scene changes. - - Returns: - A list containing 1 or more the frame numbers of any detected scenes. - """ + def process_frame( + self, + timecode: FrameTimecode, + frame_im: np.ndarray, + ) -> ty.List[FrameTimecode]: + # Return a list of timecodes where we found cuts (either on this frame or previously). return [] - def post_process(self, scene_list): - pass + def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + # Called after the last frame has been read to handle pending events. + return [] ``` `process_frame` is called on every frame in the input video, which will be called after the final frame of the video is passed to `process_frame`. This may be useful for multi-pass algorithms, or detectors which are waiting on some condition but still wish to output an event on the final frame. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 41c44833..3755390b 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -659,6 +659,9 @@ Development #### Breaking + * Many types and interfaces now use timecodes instead of frame numbers, which is a breaking change for: + * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` + * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` * The `SceneDetector` interface now uses timecodes instead of frame numbers * Remove deprecated `scenedetect.video_manager` module ([use `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead) * Deprecated `video_manager` parameter has been removed from many functions and constructors, use `video` parameter instead when required @@ -671,6 +674,8 @@ Development * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called * Remove deprecated `SparseSceneDetector` interface * Remove deprecated `SceneManager.get_event_list()` method + * Remove deprecated `base_timecode` argument in `SceneManager.save_to_csv()` + * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) #### Deprecation From 55ea073edde217144fd08819aa8732a4385848c3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 21 Mar 2025 22:28:58 -0400 Subject: [PATCH 230/407] [scene_manager] Remove unused base_timecode `argument` --- scenedetect/scene_manager.py | 16 +--------------- website/pages/changelog.md | 11 +++++------ 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 0fa2474f..3a6cb46f 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -169,7 +169,6 @@ def get_scenes_from_cuts( cut_list: CutList, start_pos: ty.Union[int, FrameTimecode], end_pos: ty.Union[int, FrameTimecode], - base_timecode: ty.Optional[FrameTimecode] = None, ) -> SceneList: """Returns a list of tuples of start/end FrameTimecodes for each scene based on a list of detected scene cuts/breaks. @@ -181,20 +180,15 @@ def get_scenes_from_cuts( Arguments: cut_list: List of FrameTimecode objects where scene cuts/breaks occur. - base_timecode: The base_timecode of which all FrameTimecodes in the cut_list are based on. num_frames: The number of frames, or FrameTimecode representing duration, of the video that was processed (used to generate last scene's end time). start_frame: The start frame or FrameTimecode of the cut list. Used to generate the first scene's start time. - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. Returns: List of tuples in the form (start_time, end_time), where both start_time and end_time are FrameTimecode objects representing the exact time/frame where each scene occupies based on the input cut_list. """ - # TODO(v0.7): Use the warnings module to turn this into a warning. - if base_timecode is not None: - logger.error("`base_timecode` argument is deprecated has no effect.") # Scene list, where scenes are tuples of (Start FrameTimecode, End FrameTimecode). scene_list = [] @@ -1099,13 +1093,10 @@ def clear_detectors(self) -> None: """Remove all scene detectors added to the SceneManager via add_detector().""" self._detector_list.clear() - def get_scene_list( - self, base_timecode: ty.Optional[FrameTimecode] = None, start_in_scene: bool = False - ) -> SceneList: + def get_scene_list(self, start_in_scene: bool = False) -> SceneList: """Return a list of tuples of start/end FrameTimecodes for each detected scene. Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility. start_in_scene: Assume the video begins in a scene. This means that when detecting fast cuts with `ContentDetector`, if no cuts are found, the resulting scene list will contain a single scene spanning the entire video (instead of no scenes). @@ -1117,9 +1108,6 @@ def get_scene_list( end_time are FrameTimecode objects representing the exact time/frame where each detected scene in the video begins and ends. """ - # TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8. - if base_timecode is not None: - logger.error("`base_timecode` argument is deprecated and has no effect.") if self._base_timecode is None: return [] cut_list = self._get_cutting_list() @@ -1424,7 +1412,6 @@ def _decode_thread( def get_cut_list( self, - base_timecode: ty.Optional[FrameTimecode] = None, show_warning: bool = True, ) -> CutList: """[DEPRECATED] Return a list of FrameTimecodes of the detected scene changes/cuts. @@ -1436,7 +1423,6 @@ def get_cut_list( and ending at the last frame detected. Arguments: - base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility only. show_warning: If set to False, suppresses the error from being warned. In v0.7, this will have no effect and the error will become a Python warning. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 3755390b..40cbbb24 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -659,22 +659,21 @@ Development #### Breaking - * Many types and interfaces now use timecodes instead of frame numbers, which is a breaking change for: + * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` - * The `SceneDetector` interface now uses timecodes instead of frame numbers - * Remove deprecated `scenedetect.video_manager` module ([use `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead) - * Deprecated `video_manager` parameter has been removed from many functions and constructors, use `video` parameter instead when required - * Refactoring to make code less verbose: + * Reorganized submodules: * `scenedetect.scene_detector` is now `scenedetect.detector` * `scenedetect.frame_timecode` is now `scenedetect.common` + * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead + * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it + * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead * `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them * Remove `FrameTimecode.previous_frame()` method * Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called * Remove deprecated `SparseSceneDetector` interface * Remove deprecated `SceneManager.get_event_list()` method - * Remove deprecated `base_timecode` argument in `SceneManager.save_to_csv()` * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) #### Deprecation From 7a21089e284ac7de3a4d5ee5dfddb40a033056bf Mon Sep 17 00:00:00 2001 From: Walter Schwenger Date: Sat, 22 Mar 2025 17:40:28 -0400 Subject: [PATCH 231/407] Fixed download link (#499) --- website/pages/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/pages/cli.md b/website/pages/cli.md index 8816dc62..5876eeb0 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -29,7 +29,7 @@ As a concrete example to become familiar with PySceneDetect, let's use the follo [https://www.youtube.com/watch?v=OMgIPnCnlbQ](https://www.youtube.com/watch?v=OMgIPnCnlbQ) -You can [download the clip from here](https://github.com/Breakthrough/PySceneDetect/raw/resources/tests/resources/goldeneye/goldeneye.mp4) (right-click and save the video in your working directory as `goldeneye.mp4`). +You can [download the clip from here](https://github.com/Breakthrough/PySceneDetect/raw/refs/heads/resources/tests/resources/goldeneye.mp4) (right-click and save the video in your working directory as `goldeneye.mp4`). Let's split this scene into clips on each fast cut. This means we need to use content-aware detecton mode (`detect-content`) or adaptive mode (`detect-adaptive`). If the video instead contains fade-in/fade-out transitions you want to find, you can use `detect-threshold` instead. If no detector is specified, `detect-adaptive` will be used by default. From 33fb53537befe11d46b531ba2d2da185a58115d7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 22 Mar 2025 18:04:05 -0400 Subject: [PATCH 232/407] [video_stream] Remove the `advance` parameter It was always set to `True` and made it really confusing when implementing certain functionality. This also reduces the internal state that some implementations need to keep. --- scenedetect/backends/moviepy.py | 33 ++----- scenedetect/backends/opencv.py | 157 ++++++++------------------------ scenedetect/backends/pyav.py | 43 +++------ scenedetect/detector.py | 26 ++---- scenedetect/video_stream.py | 7 +- tests/test_video_stream.py | 14 +-- website/pages/changelog.md | 7 ++ 7 files changed, 78 insertions(+), 209 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index ed2418ac..fe2a5774 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -131,11 +131,9 @@ def aspect_ratio(self) -> float: def position(self) -> FrameTimecode: """Current position within stream as FrameTimecode. - This can be interpreted as presentation time stamp of the last frame which was - decoded by calling `read` with advance=True. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" + This can be interpreted as presentation time stamp of the last frame which was decoded by + calling `read`. This will always return 0 (e.g. be equal to `base_timecode`) if no frames + have been `read` yet.""" frame_number = max(self._frame_number - 1, 0) return FrameTimecode(frame_number, self.frame_rate) @@ -151,10 +149,8 @@ def position_ms(self) -> float: def frame_number(self) -> int: """Current position within stream in frames as an int. - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" + 0 indicates that no frames have been `read`, 1 indicates the first frame was just read. + """ return self._frame_number def seek(self, target: ty.Union[FrameTimecode, float, int]): @@ -209,24 +205,7 @@ def reset(self, print_infos=False): self._eof = False self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ - if not advance: - last_frame_valid = self._last_frame is not None and self._last_frame is not False - if not last_frame_valid: - return False - if self._last_frame_rgb is None: - self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) - return self._last_frame_rgb + def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: if not hasattr(self._reader, "lastread") or self._eof: return False has_last_read = hasattr(self._reader, "last_read") diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index bf3a32e0..ac03ae10 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -141,13 +141,11 @@ def capture(self) -> cv2.VideoCapture: @property def frame_rate(self) -> float: - """Framerate in frames/sec.""" assert self._frame_rate return self._frame_rate @property def path(self) -> ty.Union[bytes, str]: - """Video or device path.""" if self._is_device: assert isinstance(self._path_or_device, (int)) return "Device %d" % self._path_or_device @@ -156,7 +154,6 @@ def path(self) -> ty.Union[bytes, str]: @property def name(self) -> str: - """Name of the video, without extension, or device.""" if self._is_device: return self.path file_name: str = get_file_name(self.path, include_extension=False) @@ -205,13 +202,6 @@ def timecode(self) -> Timecode: @property def position(self) -> FrameTimecode: - """Current position within stream as FrameTimecode. - - This can be interpreted as presentation time stamp of the last frame which was - decoded by calling `read` with advance=True. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" if _USE_PTS_IN_DEVELOPMENT: return FrameTimecode(timecode=self.timecode, fps=self.frame_rate) if self.frame_number < 1: @@ -220,41 +210,13 @@ def position(self) -> FrameTimecode: @property def position_ms(self) -> float: - """Current position within stream as a float of the presentation time in milliseconds. - The first frame has a time of 0.0 ms. - - This method will always return 0.0 if no frames have been `read`.""" return self._cap.get(cv2.CAP_PROP_POS_MSEC) @property def frame_number(self) -> int: - """Current position within stream in frames as an int. - - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) def seek(self, target: ty.Union[FrameTimecode, float, int]): - """Seek to the given timecode. If given as a frame number, represents the current seek - pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). - - For 1-based indices (first frame is frame #1), the target frame number needs to be converted - to 0-based by subtracting one. For example, if we want to seek to the first frame, we call - seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed - by read(), at which point frame_number will be 5. - - Not supported if the VideoStream is a device/camera. Untested with web streams. - - Arguments: - target: Target position in video stream to seek to. - If float, interpreted as time in seconds. - If int, interpreted as frame number. - Raises: - SeekError: An error occurs while seeking, or seeking is not supported. - ValueError: `target` is not a valid value (i.e. it is negative). - """ if self._is_device: raise SeekError("Cannot seek if input is a device!") if target < 0: @@ -282,40 +244,27 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends, - or the maximum number of decode attempts has passed. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ + def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: if not self._cap.isOpened(): return False - # Grab the next frame if possible. - if advance: - has_grabbed = self._cap.grab() - # If we failed to grab the frame, retry a few times if required. - if not has_grabbed: - if self.duration > 0 and self.position < (self.duration - 1): - for _ in range(self._max_decode_attempts): - has_grabbed = self._cap.grab() - if has_grabbed: - break - # Report previous failure in debug mode. - if has_grabbed: - self._decode_failures += 1 - logger.debug("Frame failed to decode.") - if not self._warning_displayed and self._decode_failures > 1: - logger.warning("Failed to decode some frames, results may be inaccurate.") - # We didn't manage to grab a frame even after retrying, so just return. - if not has_grabbed: - return False - self._has_grabbed = True + has_grabbed = self._cap.grab() + # If we failed to grab the frame, retry a few times if required. + if not has_grabbed: + if self.duration > 0 and self.position < (self.duration - 1): + for _ in range(self._max_decode_attempts): + has_grabbed = self._cap.grab() + if has_grabbed: + break + # Report previous failure in debug mode. + if has_grabbed: + self._decode_failures += 1 + logger.debug("Frame failed to decode.") + if not self._warning_displayed and self._decode_failures > 1: + logger.warning("Failed to decode some frames, results may be inaccurate.") + # We didn't manage to grab a frame even after retrying, so just return. + if not has_grabbed: + return False + self._has_grabbed = True # Need to make sure we actually grabbed a frame before calling retrieve. if decode and self._has_grabbed: _, frame = self._cap.retrieve() @@ -490,35 +439,18 @@ def aspect_ratio(self) -> float: @property def position(self) -> FrameTimecode: - """Current position within stream as FrameTimecode. Use the :meth:`position_ms` - if an accurate duration of elapsed time is required, as `position` is currently - based off of the number of frames, and may not be accurate for devicesor live streams. - - This method will always return 0 (e.g. be equal to `base_timecode`) if no frames - have been `read`.""" - if self.frame_number < 1: return self.base_timecode return self.base_timecode + (self.frame_number - 1) @property def position_ms(self) -> float: - """Current position within stream as a float of the presentation time in milliseconds. - The first frame has a time of 0.0 ms. - - This method will always return 0.0 if no frames have been `read`.""" if self._num_frames == 0: return 0.0 return self._cap.get(cv2.CAP_PROP_POS_MSEC) - self._time_base @property def frame_number(self) -> int: - """Current position within stream in frames as an int. - - 1 indicates the first frame was just decoded by the last call to `read` with advance=True, - whereas 0 indicates that no frames have been `read`. - - This method will always return 0 if no frames have been `read`.""" return self._num_frames def seek(self, target: ty.Union[FrameTimecode, float, int]): @@ -529,41 +461,28 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends, - or the maximum number of decode attempts has passed. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ + def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: if not self._cap.isOpened(): return False - # Grab the next frame if possible. - if advance: - has_grabbed = self._cap.grab() - # If we failed to grab the frame, retry a few times if required. - if not has_grabbed: - for _ in range(self._max_read_attempts): - has_grabbed = self._cap.grab() - if has_grabbed: - break - # Report previous failure in debug mode. + has_grabbed = self._cap.grab() + # If we failed to grab the frame, retry a few times if required. + if not has_grabbed: + for _ in range(self._max_read_attempts): + has_grabbed = self._cap.grab() if has_grabbed: - self._decode_failures += 1 - logger.debug("Frame failed to decode.") - if not self._warning_displayed and self._decode_failures > 1: - logger.warning("Failed to decode some frames, results may be inaccurate.") - # We didn't manage to grab a frame even after retrying, so just return. - if not has_grabbed: - return False - if self._num_frames == 0: - self._time_base = self._cap.get(cv2.CAP_PROP_POS_MSEC) - self._num_frames += 1 + break + # Report previous failure in debug mode. + if has_grabbed: + self._decode_failures += 1 + logger.debug("Frame failed to decode.") + if not self._warning_displayed and self._decode_failures > 1: + logger.warning("Failed to decode some frames, results may be inaccurate.") + # We didn't manage to grab a frame even after retrying, so just return. + if not has_grabbed: + return False + if self._num_frames == 0: + self._time_base = self._cap.get(cv2.CAP_PROP_POS_MSEC) + self._num_frames += 1 # Need to make sure we actually grabbed a frame before calling retrieve. if decode and self._num_frames > 0: _, frame = self._cap.retrieve() diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index fddbf283..57ab1f66 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -257,9 +257,9 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: self._frame = None self._container.seek(target_pts, stream=self._video_stream) if not beginning: - self.read(decode=False, advance=True) + self.read(decode=False) while self.position < target: - if self.read(decode=False, advance=True) is False: + if self.read(decode=False) is False: break def reset(self): @@ -271,33 +271,18 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: - """Read and decode the next frame as a np.ndarray. Returns False when video ends. - - Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. - - Returns: - If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. - If decode = False, a bool indicating if advancing to the the next frame succeeded. - """ - has_advanced = False - if advance: - try: - last_frame = self._frame - self._frame = next(self._container.decode(video=0)) - except av.error.EOFError: - self._frame = last_frame - if self._handle_eof(): - return self.read(decode, advance=True) - return False - except StopIteration: - return False - has_advanced = True - if decode: - return self._frame.to_ndarray(format="bgr24") - return has_advanced + def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + try: + last_frame = self._frame + self._frame = next(self._container.decode(video=0)) + except av.error.EOFError: + self._frame = last_frame + if self._handle_eof(): + return self.read(decode) + return False + except StopIteration: + return False + return self._frame.to_ndarray(format="bgr24") if decode else True # # Private Methods/Properties diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 4410e998..1e0b2e7c 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -37,12 +37,6 @@ class SceneDetector: """Base class to inherit from when implementing a scene detection algorithm. This API is not yet stable and subject to change. - - This represents a "dense" scene detector, which returns a list of frames where - the next scene/shot begins in a video. - - Also see the implemented scene detectors in the scenedetect.detectors module - to get an idea of how a particular detector can be created. """ # TODO(v0.7): Make this a proper abstract base class. @@ -61,7 +55,7 @@ def stats_manager_required(self) -> bool: return False def get_metrics(self) -> ty.List[str]: - """Get Metrics: Get a list of all metric names/keys used by the detector. + """Returns a list of all metric names/keys used by this detector. Returns: List of strings of frame metric key names that will be used by @@ -75,26 +69,22 @@ def process_frame( """Process the next frame. `frame_num` is assumed to be sequential. Args: - frame_num (int): Frame number of frame that is being passed. Can start from any value - but must remain sequential. - frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. + timecode: Timecode corresponding to the frame being processed. + frame_img: Video frame as a 24-bit BGR image. Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. - - Returns: - List of frame numbers of cuts to be added to the cutting list. + List of timecodes where scene cuts have been detected, if any. """ return [] def post_process(self, timecode: int) -> ty.List[FrameTimecode]: - """Post Process: Performs any processing after the last frame has been read. + """Called after there are no more frames to process. - Prototype method, no actual detection. + Args: + timecode: The last position in the video which was read. Returns: - List of frame numbers of cuts to be added to the cutting list. + List of timecodes where scene cuts have been detected, if any. """ return [] diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index ae197be1..772a922a 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -174,12 +174,13 @@ def frame_number(self) -> int: # @abstractmethod - def read(self, decode: bool = True, advance: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: - decode: Decode and return the frame. - advance: Seek to the next frame. If False, will return the current (last) frame. + decode: Return the frame image itself. If False, a boolean indicating if the stream + was advanced to the next frame or not. This can improve performance by reducing + memory copying and colorspace conversions when a given frame's data is not required. Returns: If decode = True, the decoded frame (np.ndarray), or False (bool) if end of video. diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index bce5bdfe..0948c756 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -164,22 +164,11 @@ def test_read(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_advance(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): - """Validate invoking `read` with `advance` set to False.""" - stream = vs_type(test_video.path) - frame = stream.read().copy() - assert stream.frame_number == 1 - frame_copy = stream.read(advance=False) - assert stream.frame_number == 1 - assert calculate_frame_delta(frame, frame_copy) == pytest.approx(0.0) - def test_read_no_decode(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True assert stream.frame_number == 1 - stream.read(decode=False, advance=False) - assert stream.frame_number == 1 def test_time_invariants(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): """Validate the `frame_number`, `position`, and `position_ms` properties.""" @@ -322,8 +311,7 @@ def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoPar return # For those backends that do allow seek offsets past EOF, they should act as though we # seeked to the end of the video (i.e. shouldn't be able to decode any more frames). - assert stream.read(advance=True) is False - assert stream.read(advance=False) is not False + assert stream.read() is False # TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs. if vs_type == VideoStreamAv: assert stream.frame_number in (test_video.total_frames, test_video.total_frames - 1) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 40cbbb24..3f1ba8de 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -650,6 +650,12 @@ Development ## PySceneDetect 0.7 (In Development) +### Release Notes + +PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs, with the goal of simplifying usage and integration. + +Applications written for the 0.6 API may need to be modified to work with the new 0.7 API. These changes should be minimal in most cases, and backwards compatibility has been added where possible to reduce the scope of breaking changes. + ### CLI Changes - [feature] WORK IN PROGRESS: New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) @@ -675,6 +681,7 @@ Development * Remove deprecated `SparseSceneDetector` interface * Remove deprecated `SceneManager.get_event_list()` method * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) + * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) #### Deprecation From c6f73415e855cbd5e0b1853968f8fc2c27431d8a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 22 Mar 2025 18:09:37 -0400 Subject: [PATCH 233/407] [scenedetect] Update module imports --- scenedetect/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index d7520af1..268ca79a 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -32,7 +32,7 @@ # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. # Note that order of importants is important! from scenedetect.platform import init_logger # noqa: I001 -from scenedetect.frame_timecode import FrameTimecode +from scenedetect.common import FrameTimecode, SceneList, CutList, CropRegion, TimecodePair from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.detector import SceneDetector @@ -51,7 +51,7 @@ VideoCaptureAdapter, ) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt -from scenedetect.scene_manager import SceneManager, save_images, SceneList, CutList, Interpolation +from scenedetect.scene_manager import SceneManager, save_images, Interpolation # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). From 6cdd489a7040734c229991f19238de5b9c5473cc Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 23 Mar 2025 20:03:14 -0400 Subject: [PATCH 234/407] [docs] Make section headers more concise --- docs/api.rst | 2 +- docs/api/backends.rst | 2 +- docs/api/common.rst | 2 +- docs/api/detectors.rst | 2 +- docs/api/migration_guide.rst | 136 +++++++++++++++++++++++++++++++ scenedetect/backends/__init__.py | 6 +- 6 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 docs/api/migration_guide.rst diff --git a/docs/api.rst b/docs/api.rst index 5de74eef..7f11069f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -63,7 +63,7 @@ PySceneDetect makes it very easy to find scene transitions in a video with the : path = "video.mp4" scenes = detect(path, ContentDetector()) for (scene_start, scene_end) in scenes: - print(f'{scene_start}-{scene_end}') + print(f"{scene_start}-{scene_end}") ``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. diff --git a/docs/api/backends.rst b/docs/api/backends.rst index fe035c07..952c131b 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -2,7 +2,7 @@ .. _scenedetect-backends: ---------------------------------------- -Input Backends +Video Backends ---------------------------------------- .. automodule:: scenedetect.backends diff --git a/docs/api/common.rst b/docs/api/common.rst index 1daee5e7..ca3a1ede 100644 --- a/docs/api/common.rst +++ b/docs/api/common.rst @@ -2,7 +2,7 @@ .. _scenedetect-common: --------------------------------------------------------------- -Common Types +Common --------------------------------------------------------------- .. automodule:: scenedetect.common diff --git a/docs/api/detectors.rst b/docs/api/detectors.rst index 6ec7b85c..486f3f53 100644 --- a/docs/api/detectors.rst +++ b/docs/api/detectors.rst @@ -2,7 +2,7 @@ .. _scenedetect-detectors: ---------------------------------------- -Detection Algorithms +Detectors ---------------------------------------- .. automodule:: scenedetect.detectors diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst new file mode 100644 index 00000000..11da142a --- /dev/null +++ b/docs/api/migration_guide.rst @@ -0,0 +1,136 @@ + +.. _scenedetect-migration_guide: + +--------------------------------------------------------------- +Migration Guide +--------------------------------------------------------------- + +This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. + +PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. + +This page covers commonly used APIs which require updates to work with v0.6. Note that this page is not an exhaustive set of changes. For a complete list of breaking API changes, see `the changelog `_. + +In some places, a backwards compatibility layer has been added to avoid breaking most applications upon release. This should not be relied upon, and will be removed in the future. You can call ``scenedetect.platform.init_logger(show_stdout=True)`` or attach a custom log handler to the ``'pyscenedetect'`` logger to help find these cases. + + +=============================================================== +`VideoManager` Class +=============================================================== + +`VideoManager` has been deprecated and replaced with :mod:`scenedetect.backends`. For most applications, the :func:`open_video ` function should be used instead: + +.. code:: python + + from scenedetect import open_video + video = open_video(video.mp4') + +The resulting object can then be passed to a :class:`SceneManager ` when calling :meth:`detect_scenes `, or any other function/method that used to take a `VideoManager`, e.g.: + +.. code:: python + + from scenedetect import open_video, SceneManager, ContentDetector + video = open_video('video.mp4') + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector(threshold=threshold)) + scene_manager.detect_scenes(video) + print(scene_manager.get_scene_list()) + +See :mod:`scenedetect.backends` for examples of how to create specific backends. Where previously a list of paths was accepted, now only a single string should be provided. + + +Seeking and Start/End Times +=============================================================== + +Instead of setting the start time via the `VideoManager`, now :meth:`seek ` to the starting time on the :class:`VideoStream ` object. + +Instead of setting the duration or end time via the `VideoManager`, now set the `duration` or `end_time` parameters when calling :meth:`detect_scenes `. + +.. code:: python + + from scenedetect import open_video, SceneManager, ContentDetector + video = open_video('video.mp4') + # Can be seconds (float), frame # (int), or FrameTimecode + start_time, end_time = 2.5, 5.0 + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector(threshold=threshold)) + video.seek(start_time) + # Note there is also a `duration` parameter that can also be set. + # If neither `duration` nor `end_time` is provided, the video will + # be processed from its current position until the end. + scene_manager.detect_scenes(video, end_time=end_time) + print(scene_manager.get_scene_list()) + + +=============================================================== +`SceneManager` Class +=============================================================== + +The first argument of the :meth:`detect_scenes ` method has been renamed to `video` and should now be a :class:`VideoStream ` object (see above). + + +=============================================================== +`save_images` Function +=============================================================== + +The second argument of :func:`save_images ` in :mod:`scenedetect.scene_manager` has been renamed from `video_manager` to `video`. + +The `downscale_factor` parameter has been removed from :func:`save_images ` (use the `scale` parameter instead). To achieve the same result as the previous version, set `scale` to `1.0 / downscale_factor`. + + +=============================================================== +`split_video_*` Functions +=============================================================== + +The the :mod:`scenedetect.video_splitter` functions :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` now only accept a single path as the input (first) argument. + +The `suppress_output` and `hide_progress` arguments to the :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` have been removed, and two new options have been added: + + * `suppress_output` is now `show_output`, default is `False` + * `hide_progress` is now `show_progress`, default is `False` + +This makes the API consistent with that of :class:`SceneManager `. + + +=============================================================== +`StatsManager` Class +=============================================================== + +The :func:`save_to_csv ` and :func:`load_from_csv ` methods now accept either a `path` or an open `file` handle. + +The `base_timecode` argument has been removed from :func:`save_to_csv `. It is no longer required. + + +=============================================================== +`AdaptiveDetector` Class +=============================================================== + +The `video_manager` parameter has been removed and is no longer required when constructing an :class:`AdaptiveDetector ` object. + + +=============================================================== +Other +=============================================================== + +`ThresholdDetector` Class +=============================================================== + +The `block_size` argument has been removed from the :class:`ThresholdDetector ` constructor. It is no longer required. + + +`ContentDetector` Class +=============================================================== + +The `calculate_frame_score` method of :class:`ContentDetector ` has been renamed to :meth:`_calculate_frame_score `. Use new global function :func:`calculate_frame_score ` to achieve the same result. + + +`MINIMUM_FRAMES_PER_SECOND_*` Constants +=============================================================== + +In :mod:`scenedetect.frame_timecode` the constants `MINIMUM_FRAMES_PER_SECOND_FLOAT` and `MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT` have been replaced with :data:`MAX_FPS_DELTA `. + + +`get_aspect_ratio` Function +=============================================================== + + The `get_aspect_ratio` function has been removed from `scenedetect.platform`. Use the :attr:`aspect_ratio ` property from the :class:`VideoStream ` object instead. diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 498d17e7..6f5d9086 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -32,10 +32,10 @@ .. code:: python from scenedetect import open_video - video = open_video('video.mp4') + video = open_video("video.mp4") An optional backend from :data:`AVAILABLE_BACKENDS` can be passed to :func:`open_video` -(e.g. `backend='opencv'`). Additional keyword arguments passed to :func:`open_video` +(e.g. `backend="opencv"`). Additional keyword arguments passed to :func:`open_video` will be forwarded to the backend constructor. If the specified backend is unavailable, or loading the video fails, ``opencv`` will be tried as a fallback. @@ -45,7 +45,7 @@ # Manually importing and constructing a backend: from scenedetect.backends.opencv import VideoStreamCv2 - video = VideoStreamCv2('video.mp4') + video = VideoStreamCv2("video.mp4") In both examples above, the resulting ``video`` can be used with :meth:`SceneManager.detect_scenes() `. From a6bc5a7a8cbf1ef5d76966a1d394a8cbbdbeed6f Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 23 Mar 2025 21:14:37 -0400 Subject: [PATCH 235/407] [output] Move post processing into new `scenedetect.output` module --- docs/api.rst | 14 +- docs/api/migration_guide.rst | 136 ---- docs/api/output.rst | 19 + docs/api/video_splitter.rst | 10 - docs/index.rst | 2 +- scenedetect/__init__.py | 24 +- scenedetect/_cli/commands.py | 11 +- scenedetect/_cli/config.py | 2 +- scenedetect/_cli/context.py | 2 +- scenedetect/common.py | 18 + scenedetect/frame_timecode.py | 16 - scenedetect/output/__init__.py | 235 ++++++ scenedetect/output/image.py | 561 +++++++++++++ .../{video_splitter.py => output/video.py} | 25 +- scenedetect/scene_detector.py | 16 - scenedetect/scene_manager.py | 751 +----------------- tests/test_cli.py | 3 +- tests/test_output.py | 193 +++++ tests/test_scene_manager.py | 109 +-- tests/test_video_splitter.py | 80 -- website/pages/changelog.md | 17 +- 21 files changed, 1088 insertions(+), 1156 deletions(-) delete mode 100644 docs/api/migration_guide.rst create mode 100644 docs/api/output.rst delete mode 100644 docs/api/video_splitter.rst delete mode 100644 scenedetect/frame_timecode.py create mode 100644 scenedetect/output/__init__.py create mode 100644 scenedetect/output/image.py rename scenedetect/{video_splitter.py => output/video.py} (92%) delete mode 100644 scenedetect/scene_detector.py create mode 100644 tests/test_output.py delete mode 100644 tests/test_video_splitter.py diff --git a/docs/api.rst b/docs/api.rst index 7f11069f..7e6a1b2a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -7,7 +7,7 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect 🎬 `: Includes the :func:`scenedetect.detect ` function which takes a path and a :ref:`detector ` to find scene transitions (:ref:`example `), and :func:`scenedetect.open_video ` for video input - * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). This module also contains functionality to export information about scenes in various formats: :func:`save_images ` to save images for each scene, :func:`write_scene_list ` to save scene/cut info as CSV, and :func:`write_scene_list_html ` to export scenes in viewable HTML format. + * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). * :ref:`scenedetect.detectors 🕵️ `: Detection algorithms: @@ -27,7 +27,13 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * PyAV: :class:`VideoStreamAv ` * MoviePy: :class:`VideoStreamMoviePy ` - * :ref:`scenedetect.video_splitter ✂️ `: Contains :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` to split a video based on the detected scenes. + * :ref:`scenedetect.output ✂️ `: Output formats: + + * :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` split a video based on the detected scenes + + * :func:`save_images ` can save an arbitrary number of images from each scene + + * :func:`write_scene_list ` can be used to save scene/cut info as CSV, :func:`write_scene_list_html ` for HTML * :ref:`scenedetect.common ⏱️ `: Contains common types such as :class:`FrameTimecode ` used for timecode handing. @@ -67,7 +73,7 @@ PySceneDetect makes it very easy to find scene transitions in a video with the : ``scenes`` now contains a list of :class:`FrameTimecode ` pairs representing the start/end of each scene. Note that you can set ``show_progress=True`` when calling :func:`detect ` to display a progress bar with estimated time remaining. -Here, we use :mod:`ContentDetector ` to detect fast cuts. There are :ref:`many detector types ` which can be used to find fast cuts and fades in/out. PySceneDetect can also export scene data in various formats, and can :ref:`split the input video ` automatically if `ffmpeg` is available: +Here, we use :mod:`ContentDetector ` to detect fast cuts. There are :ref:`many detector types ` which can be used to find fast cuts and fades in/out. PySceneDetect can also export scene data in various formats, and can :ref:`split the input video ` automatically if `ffmpeg` is available: .. code:: python @@ -98,7 +104,7 @@ Module Reference api/detectors api/scene_manager api/common - api/video_splitter + api/output api/backends api/stats_manager api/detector diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst deleted file mode 100644 index 11da142a..00000000 --- a/docs/api/migration_guide.rst +++ /dev/null @@ -1,136 +0,0 @@ - -.. _scenedetect-migration_guide: - ---------------------------------------------------------------- -Migration Guide ---------------------------------------------------------------- - -This page details how to transition a program written using PySceneDetect 0.5 to the new 0.6 API. It is recommended to review the new :ref:`Example ` section first, as it covers the majority of use cases. Also see `tests/test_api.py `_ for a set of demonstrations covering many high level use cases. - -PySceneDetect v0.6 is a major step towards a more stable and simplified API. The biggest change to existing workflows is how video input is handled, and that Python 3.6 or above is now required. - -This page covers commonly used APIs which require updates to work with v0.6. Note that this page is not an exhaustive set of changes. For a complete list of breaking API changes, see `the changelog `_. - -In some places, a backwards compatibility layer has been added to avoid breaking most applications upon release. This should not be relied upon, and will be removed in the future. You can call ``scenedetect.platform.init_logger(show_stdout=True)`` or attach a custom log handler to the ``'pyscenedetect'`` logger to help find these cases. - - -=============================================================== -`VideoManager` Class -=============================================================== - -`VideoManager` has been deprecated and replaced with :mod:`scenedetect.backends`. For most applications, the :func:`open_video ` function should be used instead: - -.. code:: python - - from scenedetect import open_video - video = open_video(video.mp4') - -The resulting object can then be passed to a :class:`SceneManager ` when calling :meth:`detect_scenes `, or any other function/method that used to take a `VideoManager`, e.g.: - -.. code:: python - - from scenedetect import open_video, SceneManager, ContentDetector - video = open_video('video.mp4') - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector(threshold=threshold)) - scene_manager.detect_scenes(video) - print(scene_manager.get_scene_list()) - -See :mod:`scenedetect.backends` for examples of how to create specific backends. Where previously a list of paths was accepted, now only a single string should be provided. - - -Seeking and Start/End Times -=============================================================== - -Instead of setting the start time via the `VideoManager`, now :meth:`seek ` to the starting time on the :class:`VideoStream ` object. - -Instead of setting the duration or end time via the `VideoManager`, now set the `duration` or `end_time` parameters when calling :meth:`detect_scenes `. - -.. code:: python - - from scenedetect import open_video, SceneManager, ContentDetector - video = open_video('video.mp4') - # Can be seconds (float), frame # (int), or FrameTimecode - start_time, end_time = 2.5, 5.0 - scene_manager = SceneManager() - scene_manager.add_detector(ContentDetector(threshold=threshold)) - video.seek(start_time) - # Note there is also a `duration` parameter that can also be set. - # If neither `duration` nor `end_time` is provided, the video will - # be processed from its current position until the end. - scene_manager.detect_scenes(video, end_time=end_time) - print(scene_manager.get_scene_list()) - - -=============================================================== -`SceneManager` Class -=============================================================== - -The first argument of the :meth:`detect_scenes ` method has been renamed to `video` and should now be a :class:`VideoStream ` object (see above). - - -=============================================================== -`save_images` Function -=============================================================== - -The second argument of :func:`save_images ` in :mod:`scenedetect.scene_manager` has been renamed from `video_manager` to `video`. - -The `downscale_factor` parameter has been removed from :func:`save_images ` (use the `scale` parameter instead). To achieve the same result as the previous version, set `scale` to `1.0 / downscale_factor`. - - -=============================================================== -`split_video_*` Functions -=============================================================== - -The the :mod:`scenedetect.video_splitter` functions :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` now only accept a single path as the input (first) argument. - -The `suppress_output` and `hide_progress` arguments to the :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` have been removed, and two new options have been added: - - * `suppress_output` is now `show_output`, default is `False` - * `hide_progress` is now `show_progress`, default is `False` - -This makes the API consistent with that of :class:`SceneManager `. - - -=============================================================== -`StatsManager` Class -=============================================================== - -The :func:`save_to_csv ` and :func:`load_from_csv ` methods now accept either a `path` or an open `file` handle. - -The `base_timecode` argument has been removed from :func:`save_to_csv `. It is no longer required. - - -=============================================================== -`AdaptiveDetector` Class -=============================================================== - -The `video_manager` parameter has been removed and is no longer required when constructing an :class:`AdaptiveDetector ` object. - - -=============================================================== -Other -=============================================================== - -`ThresholdDetector` Class -=============================================================== - -The `block_size` argument has been removed from the :class:`ThresholdDetector ` constructor. It is no longer required. - - -`ContentDetector` Class -=============================================================== - -The `calculate_frame_score` method of :class:`ContentDetector ` has been renamed to :meth:`_calculate_frame_score `. Use new global function :func:`calculate_frame_score ` to achieve the same result. - - -`MINIMUM_FRAMES_PER_SECOND_*` Constants -=============================================================== - -In :mod:`scenedetect.frame_timecode` the constants `MINIMUM_FRAMES_PER_SECOND_FLOAT` and `MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT` have been replaced with :data:`MAX_FPS_DELTA `. - - -`get_aspect_ratio` Function -=============================================================== - - The `get_aspect_ratio` function has been removed from `scenedetect.platform`. Use the :attr:`aspect_ratio ` property from the :class:`VideoStream ` object instead. diff --git a/docs/api/output.rst b/docs/api/output.rst new file mode 100644 index 00000000..3143f455 --- /dev/null +++ b/docs/api/output.rst @@ -0,0 +1,19 @@ + + +.. _scenedetect-output: + +--------------------------------------------------------------- +Ouptut +--------------------------------------------------------------- + +.. automodule:: scenedetect.output + :members: + +.. autofunction:: scenedetect.output.image.save_images + +--------------------------------------------------------------- +Video +--------------------------------------------------------------- + +.. automodule:: scenedetect.output.video + :members: diff --git a/docs/api/video_splitter.rst b/docs/api/video_splitter.rst deleted file mode 100644 index a870f129..00000000 --- a/docs/api/video_splitter.rst +++ /dev/null @@ -1,10 +0,0 @@ - - -.. _scenedetect-video_splitter: - ---------------------------------------------------------------- -Video Splitting ---------------------------------------------------------------- - -.. automodule:: scenedetect.video_splitter - :members: diff --git a/docs/index.rst b/docs/index.rst index 7decf081..fc06e2f1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,7 +49,7 @@ Table of Contents api/scene_manager api/common api/backends - api/video_splitter + api/output api/stats_manager api/detector api/video_stream diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 268ca79a..59fd44d6 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -32,9 +32,27 @@ # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. # Note that order of importants is important! from scenedetect.platform import init_logger # noqa: I001 -from scenedetect.common import FrameTimecode, SceneList, CutList, CropRegion, TimecodePair +from scenedetect.common import ( + FrameTimecode, + SceneList, + CutList, + CropRegion, + TimecodePair, + Interpolation, +) from scenedetect.video_stream import VideoStream, VideoOpenFailure -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge +from scenedetect.output import ( + save_images, + split_video_ffmpeg, + split_video_mkvmerge, + is_ffmpeg_available, + is_mkvmerge_available, + write_scene_list, + write_scene_list_html, + PathFormatter, + VideoMetadata, + SceneMetadata, +) from scenedetect.detector import SceneDetector from scenedetect.detectors import ( ContentDetector, @@ -51,7 +69,7 @@ VideoCaptureAdapter, ) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt -from scenedetect.scene_manager import SceneManager, save_images, Interpolation +from scenedetect.scene_manager import SceneManager # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index bb512e99..a9e7320d 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -30,16 +30,19 @@ from scenedetect._cli.config import XmlFormat from scenedetect._cli.context import CliContext from scenedetect.common import FrameTimecode +from scenedetect.output import save_images as save_images_impl +from scenedetect.output import ( + split_video_ffmpeg, + split_video_mkvmerge, + write_scene_list, + write_scene_list_html, +) from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import ( CutList, Interpolation, SceneList, - write_scene_list, - write_scene_list_html, ) -from scenedetect.scene_manager import save_images as save_images_impl -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge logger = logging.getLogger("pyscenedetect") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index c55927bc..36dbc4c5 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -28,8 +28,8 @@ from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter from scenedetect.detectors import ContentDetector +from scenedetect.output.video import DEFAULT_FFMPEG_ARGS from scenedetect.scene_manager import Interpolation -from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 32f5df24..b94aa71b 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -33,10 +33,10 @@ HistogramDetector, ThresholdDetector, ) +from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available from scenedetect.platform import init_logger from scenedetect.scene_manager import Interpolation, SceneManager from scenedetect.stats_manager import StatsManager -from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = logging.getLogger("pyscenedetect") diff --git a/scenedetect/common.py b/scenedetect/common.py index 2541f963..77755754 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -64,8 +64,11 @@ import math import typing as ty from dataclasses import dataclass +from enum import Enum from fractions import Fraction +import cv2 + _USE_PTS_IN_DEVELOPMENT = False ## @@ -94,6 +97,21 @@ _MINUTES_PER_HOUR = 60.0 +class Interpolation(Enum): + """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" + + NEAREST = cv2.INTER_NEAREST + """Nearest neighbor interpolation.""" + LINEAR = cv2.INTER_LINEAR + """Bilinear interpolation.""" + CUBIC = cv2.INTER_CUBIC + """Bicubic interpolation.""" + AREA = cv2.INTER_AREA + """Pixel area relation resampling. Provides moire'-free downscaling.""" + LANCZOS4 = cv2.INTER_LANCZOS4 + """Lanczos interpolation over 8x8 neighborhood.""" + + # TODO(@Breakthrough): How should we deal with frame numbers when we have a `Timecode`? # # Each backend has slight nuances we have to take into account: diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py deleted file mode 100644 index 52dddc44..00000000 --- a/scenedetect/frame_timecode.py +++ /dev/null @@ -1,16 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""For backwards compatibility only, will be removed in a future release.""" - -# TODO(v0.7): Include a warning if this module is imported. - -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py new file mode 100644 index 00000000..5f6375f9 --- /dev/null +++ b/scenedetect/output/__init__.py @@ -0,0 +1,235 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# + +"""The ``scenedetect.output`` module contains functions which can be used to generate output +based on the output of scene detection. This includes saving images for each scene, exporting to +CSV/HTML, or splitting the input video into individual shots. +""" + +import csv +import logging +import typing as ty + +from scenedetect._thirdparty.simpletable import ( + HTMLPage, + SimpleTable, + SimpleTableCell, + SimpleTableImage, + SimpleTableRow, +) +from scenedetect.common import ( + CutList, + SceneList, +) + +# Commonly used classes/functions exported under the `scenedetect.output` namespace for brevity. +from scenedetect.output.image import save_images +from scenedetect.output.video import ( + PathFormatter, + SceneMetadata, + VideoMetadata, + default_formatter, + is_ffmpeg_available, + is_mkvmerge_available, + split_video_ffmpeg, + split_video_mkvmerge, +) + +logger = logging.getLogger("pyscenedetect") + + +def write_scene_list( + output_csv_file: ty.TextIO, + scene_list: SceneList, + include_cut_list: bool = True, + cut_list: ty.Optional[CutList] = None, + col_separator: str = ",", + row_separator: str = "\n", +): + """Writes the given list of scenes to an output file handle in CSV format. + + Arguments: + output_csv_file: Handle to open file in write mode. + scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. + include_cut_list: Bool indicating if the first row should include the timecodes where + each scene starts. Should be set to False if RFC 4180 compliant CSV output is required. + cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames + in the video that need to be split to generate individual scenes). If not specified, + the cut list is generated using the start times of each scene following the first one. + col_separator: Delimiter to use between values. Must be single character. + row_separator: Line terminator to use between rows. + + Raises: + TypeError: "delimiter" must be a 1-character string + """ + csv_writer = csv.writer(output_csv_file, delimiter=col_separator, lineterminator=row_separator) + # If required, output the cutting list as the first row (i.e. before the header row). + if include_cut_list: + csv_writer.writerow( + ["Timecode List:"] + cut_list + if cut_list + else [start.get_timecode() for start, _ in scene_list[1:]] + ) + csv_writer.writerow( + [ + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", + ] + ) + for i, (start, end) in enumerate(scene_list): + duration = end - start + csv_writer.writerow( + [ + "%d" % (i + 1), + "%d" % (start.get_frames() + 1), + start.get_timecode(), + "%.3f" % start.get_seconds(), + "%d" % end.get_frames(), + end.get_timecode(), + "%.3f" % end.get_seconds(), + "%d" % duration.get_frames(), + duration.get_timecode(), + "%.3f" % duration.get_seconds(), + ] + ) + + +def write_scene_list_html( + output_html_filename: str, + scene_list: SceneList, + cut_list: ty.Optional[CutList] = None, + css: str = None, + css_class: str = "mytable", + image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]] = None, + image_width: ty.Optional[int] = None, + image_height: ty.Optional[int] = None, +): + """Writes the given list of scenes to an output file handle in html format. + + Arguments: + output_html_filename: filename of output html file + scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. + cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames + in the video that need to be split to generate individual scenes). If not passed, + the start times of each scene (besides the 0th scene) is used instead. + css: String containing all the css information for the resulting html page. + css_class: String containing the named css class + image_filenames: dict where key i contains a list with n elements (filenames of + the n saved images from that scene) + image_width: Optional desired width of images in table in pixels + image_height: Optional desired height of images in table in pixels + """ + logger.info("Exporting scenes to html:\n %s:", output_html_filename) + if not css: + css = """ + table.mytable { + font-family: times; + font-size:12px; + color:#000000; + border-width: 1px; + border-color: #eeeeee; + border-collapse: collapse; + background-color: #ffffff; + width=100%; + max-width:550px; + table-layout:fixed; + } + table.mytable th { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + background-color: #e6eed6; + color:#000000; + } + table.mytable td { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + } + #code { + display:inline; + font-family: courier; + color: #3d9400; + } + #string { + display:inline; + font-weight: bold; + } + """ + + # Output Timecode list + timecode_table = SimpleTable( + [ + ["Timecode List:"] + + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) + ], + css_class=css_class, + ) + + # Output list of scenes + header_row = [ + "Scene Number", + "Start Frame", + "Start Timecode", + "Start Time (seconds)", + "End Frame", + "End Timecode", + "End Time (seconds)", + "Length (frames)", + "Length (timecode)", + "Length (seconds)", + ] + for i, (start, end) in enumerate(scene_list): + duration = end - start + + row = SimpleTableRow( + [ + "%d" % (i + 1), + "%d" % (start.get_frames() + 1), + start.get_timecode(), + "%.3f" % start.get_seconds(), + "%d" % end.get_frames(), + end.get_timecode(), + "%.3f" % end.get_seconds(), + "%d" % duration.get_frames(), + duration.get_timecode(), + "%.3f" % duration.get_seconds(), + ] + ) + + if image_filenames: + for image in image_filenames[i]: + row.add_cell( + SimpleTableCell(SimpleTableImage(image, width=image_width, height=image_height)) + ) + + if i == 0: + scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) + else: + scene_table.add_row(row=row) + + # Write html file + page = HTMLPage() + page.add_table(timecode_table) + page.add_table(scene_table) + page.css = css + page.save(output_html_filename) diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py new file mode 100644 index 00000000..5955f2fe --- /dev/null +++ b/scenedetect/output/image.py @@ -0,0 +1,561 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Implements :func:`save_images` functionality.""" + +import logging +import math +import queue +import sys +import threading +import typing as ty +from pathlib import Path +from string import Template + +import cv2 +import numpy as np + +from scenedetect.common import ( + FrameTimecode, + Interpolation, + SceneList, +) +from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm +from scenedetect.video_stream import VideoStream + +logger = logging.getLogger("pyscenedetect") + + +def _scale_image( + image: np.ndarray, + aspect_ratio: float, + height: ty.Optional[int], + width: ty.Optional[int], + scale: ty.Optional[float], + interpolation: Interpolation, +) -> np.ndarray: + # TODO: Combine this resize with the ones below. + if aspect_ratio is not None: + image = cv2.resize( + image, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value + ) + image_height = image.shape[0] + image_width = image.shape[1] + + # Figure out what kind of resizing needs to be done + if height or width: + if height and not width: + factor = height / float(image_height) + width = int(factor * image_width) + if width and not height: + factor = width / float(image_width) + height = int(factor * image_height) + assert height > 0 and width > 0 + image = cv2.resize(image, (width, height), interpolation=interpolation.value) + elif scale: + image = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) + return image + + +class _ImageExtractor: + def __init__( + self, + num_images: int = 3, + frame_margin: int = 1, + image_extension: str = "jpg", + imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, + image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + scale: ty.Optional[float] = None, + height: ty.Optional[int] = None, + width: ty.Optional[int] = None, + interpolation: Interpolation = Interpolation.CUBIC, + ): + """Multi-threaded implementation of save-images functionality. Uses background threads to + handle image encoding and saving images to disk to improve parallelism. + + This object is thread-safe. + + Arguments: + num_images: Number of images to generate for each scene. Minimum is 1. + frame_margin: Number of frames to pad each scene around the beginning + and end (e.g. moves the first/last image into the scene by N frames). + Can set to 0, but will result in some video files failing to extract + the very last frame. + image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). + encoder_param: Quality/compression efficiency, based on type of image: + 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. + 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. + image_name_template: Template to use for output filanames. Can use template variables + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. + *NOTE*: Should not include the image extension (set `image_extension` instead). + scale: Optional factor by which to rescale saved images. A scaling factor of 1 would + not result in rescaling. A value < 1 results in a smaller saved image, while a + value > 1 results in an image larger than the original. This value is ignored if + either the height or width values are specified. + height: Optional value for the height of the saved images. Specifying both the height + and width will resize images to an exact size, regardless of aspect ratio. + Specifying only height will rescale the image to that number of pixels in height + while preserving the aspect ratio. + width: Optional value for the width of the saved images. Specifying both the width + and height will resize images to an exact size, regardless of aspect ratio. + Specifying only width will rescale the image to that number of pixels wide + while preserving the aspect ratio. + interpolation: Type of interpolation to use when resizing images. + """ + self._num_images = num_images + self._frame_margin = frame_margin + self._image_extension = image_extension + self._image_name_template = image_name_template + self._scale = scale + self._height = height + self._width = width + self._interpolation = interpolation + self._imwrite_param = imwrite_param if imwrite_param else {} + + def run( + self, + video: VideoStream, + scene_list: SceneList, + output_dir: ty.Optional[str] = None, + show_progress=False, + ) -> ty.Dict[int, ty.List[str]]: + """Run image extraction on `video` using the current parameters. Thread-safe. + + Arguments: + video: The video to process. + scene_list: The scenes detected in the video. + output_dir: Directory to write files to. + show_progress: If `true` and tqdm is available, shows a progress bar. + """ + # Setup flags and init progress bar if available. + completed = True + logger.info( + f"Saving {self._num_images} images per scene [format={self._image_extension}] {output_dir if output_dir else ''} " + ) + progress_bar = None + if show_progress: + progress_bar = tqdm( + total=len(scene_list) * self._num_images, unit="images", dynamic_ncols=True + ) + + timecode_list = self.generate_timecode_list(scene_list) + image_filenames = {i: [] for i in range(len(timecode_list))} + + filename_template = Template(self._image_name_template) + logger.debug("Writing images with template %s", filename_template.template) + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d" + + def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode): + return "%s.%s" % ( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (scene_number + 1), + IMAGE_NUMBER=image_num_format % (image_number + 1), + FRAME_NUMBER=image_timecode.get_frames(), + TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), + self._image_extension, + ) + + MAX_QUEUED_ENCODE_FRAMES = 4 + MAX_QUEUED_SAVE_IMAGES = 4 + encode_queue = queue.Queue(MAX_QUEUED_ENCODE_FRAMES) + save_queue = queue.Queue(MAX_QUEUED_SAVE_IMAGES) + error_queue = queue.Queue(2) # Queue size must be the same as the # of worker threads! + + def check_error_queue(): + try: + return error_queue.get(block=False) + except queue.Empty: + pass + return None + + def launch_thread(callable, *args, **kwargs): + def capture_errors(callable, *args, **kwargs): + try: + return callable(*args, **kwargs) + # Errors we capture in `error_queue` will be re-raised by this thread. + except: # noqa: E722 + error_queue.put(sys.exc_info()) + return None + + thread = threading.Thread( + target=capture_errors, + args=( + callable, + *args, + ), + kwargs=kwargs, + daemon=True, + ) + thread.start() + return thread + + def checked_put(work_queue: queue.Queue, item: ty.Any): + error = None + while True: + try: + work_queue.put(item, timeout=0.1) + return + except queue.Full: + error = check_error_queue() + if error is not None: + break + continue + raise error[1].with_traceback(error[2]) + + encode_thread = launch_thread( + self.image_encode_thread, + video, + encode_queue, + save_queue, + ) + save_thread = launch_thread(self.image_save_thread, save_queue, progress_bar) + + for i, scene_timecodes in enumerate(timecode_list): + for j, timecode in enumerate(scene_timecodes): + video.seek(timecode) + frame_im = video.read() + if frame_im is not None and frame_im is not False: + file_path = format_filename(i, j, timecode) + image_filenames[i].append(file_path) + checked_put( + encode_queue, (frame_im, get_and_create_path(file_path, output_dir)) + ) + else: + completed = False + break + + checked_put(encode_queue, (None, None)) + encode_thread.join() + checked_put(save_queue, (None, None)) + save_thread.join() + + error = check_error_queue() + if error is not None: + raise error[1].with_traceback(error[2]) + + if progress_bar is not None: + progress_bar.close() + if not completed: + logger.error("Could not generate all output images.") + + return image_filenames + + def image_encode_thread( + self, + video: VideoStream, + encode_queue: queue.Queue, + save_queue: queue.Queue, + ): + aspect_ratio = video.aspect_ratio + if abs(aspect_ratio - 1.0) < 0.01: + aspect_ratio = None + # TODO: Validate that encoder_param is within the proper range. + # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. + while True: + frame_im, dest_path = encode_queue.get() + if frame_im is None: + return + frame_im = self.resize_image( + frame_im, + aspect_ratio, + ) + (is_ok, encoded) = cv2.imencode( + f".{self._image_extension}", frame_im, self._imwrite_param + ) + if not is_ok: + continue + save_queue.put((encoded, dest_path)) + + def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): + while True: + encoded, dest_path = save_queue.get() + if encoded is None: + return + if encoded is not False: + encoded.tofile(Path(dest_path)) + if progress_bar is not None: + progress_bar.update(1) + + def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: + """Generates a list of timecodes for each scene in `scene_list` based on the current config + parameters.""" + framerate = scene_list[0][0]._framerate + # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. + return [ + ( + FrameTimecode(int(f), fps=framerate) + for f in ( + # middle frames + a[len(a) // 2] + if (0 < j < self._num_images - 1) or self._num_images == 1 + # first frame + else min(a[0] + self._frame_margin, a[-1]) + if j == 0 + # last frame + else max(a[-1] - self._frame_margin, a[0]) + # for each evenly-split array of frames in the scene list + for j, a in enumerate(np.array_split(r, self._num_images)) + ) + ) + for r in ( + # pad ranges to number of images + r + if 1 + r[-1] - r[0] >= self._num_images + else list(r) + [r[-1]] * (self._num_images - len(r)) + # create range of frames in scene + for r in ( + range( + start.get_frames(), + start.get_frames() + + max( + 1, # guard against zero length scenes + end.get_frames() - start.get_frames(), + ), + ) + # for each scene in scene list + for start, end in scene_list + ) + ) + ] + + def resize_image( + self, + image: np.ndarray, + aspect_ratio: float, + ) -> np.ndarray: + return _scale_image( + image, aspect_ratio, self._height, self._width, self._scale, self._interpolation + ) + + +def save_images( + scene_list: SceneList, + video: VideoStream, + num_images: int = 3, + frame_margin: int = 1, + image_extension: str = "jpg", + encoder_param: int = 95, + image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", + output_dir: ty.Optional[str] = None, + show_progress: ty.Optional[bool] = False, + scale: ty.Optional[float] = None, + height: ty.Optional[int] = None, + width: ty.Optional[int] = None, + interpolation: Interpolation = Interpolation.CUBIC, + threading: bool = True, +) -> ty.Dict[int, ty.List[str]]: + """Save a set number of images from each scene, given a list of scenes + and the associated video/frame source. + + Arguments: + scene_list: A list of scenes (pairs of FrameTimecode objects) returned + from calling a SceneManager's detect_scenes() method. + video: A VideoStream object corresponding to the scene list. + Note that the video will be closed/re-opened and seeked through. + num_images: Number of images to generate for each scene. Minimum is 1. + frame_margin: Number of frames to pad each scene around the beginning + and end (e.g. moves the first/last image into the scene by N frames). + Can set to 0, but will result in some video files failing to extract + the very last frame. + image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). + encoder_param: Quality/compression efficiency, based on type of image: + 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. + 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. + image_name_template: Template to use for naming image files. Can use the template variables + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. + Should not include an extension. + output_dir: Directory to output the images into. If not set, the output + is created in the working directory. + show_progress: If True, shows a progress bar if tqdm is installed. + scale: Optional factor by which to rescale saved images. A scaling factor of 1 would + not result in rescaling. A value < 1 results in a smaller saved image, while a + value > 1 results in an image larger than the original. This value is ignored if + either the height or width values are specified. + height: Optional value for the height of the saved images. Specifying both the height + and width will resize images to an exact size, regardless of aspect ratio. + Specifying only height will rescale the image to that number of pixels in height + while preserving the aspect ratio. + width: Optional value for the width of the saved images. Specifying both the width + and height will resize images to an exact size, regardless of aspect ratio. + Specifying only width will rescale the image to that number of pixels wide + while preserving the aspect ratio. + interpolation: Type of interpolation to use when resizing images. + threading: Offload image encoding and disk IO to background threads to improve performance. + + Returns: + Dictionary of the format { scene_num : [image_paths] }, where scene_num is the + number of the scene in scene_list (starting from 1), and image_paths is a list of + the paths to the newly saved/created images. + + Raises: + ValueError: Raised if any arguments are invalid or out of range (e.g. + if num_images is negative). + """ + + if not scene_list: + return {} + if num_images <= 0 or frame_margin < 0: + raise ValueError() + + # TODO: Validate that encoder_param is within the proper range. + # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. + imwrite_param = ( + [get_cv2_imwrite_params()[image_extension], encoder_param] + if encoder_param is not None + else [] + ) + video.reset() + + if threading: + extractor = _ImageExtractor( + num_images, + frame_margin, + image_extension, + imwrite_param, + image_name_template, + scale, + height, + width, + interpolation, + ) + return extractor.run(video, scene_list, output_dir, show_progress) + + # Setup flags and init progress bar if available. + completed = True + logger.info( + f"Saving {num_images} images per scene [format={image_extension}] {output_dir if output_dir else ''} " + ) + progress_bar = None + if show_progress: + progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) + + filename_template = Template(image_name_template) + + scene_num_format = "%0" + scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" + image_num_format = "%0" + image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" + + framerate = scene_list[0][0]._framerate + + # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. + timecode_list = [ + [ + FrameTimecode(int(f), fps=framerate) + for f in [ + # middle frames + a[len(a) // 2] + if (0 < j < num_images - 1) or num_images == 1 + # first frame + else min(a[0] + frame_margin, a[-1]) + if j == 0 + # last frame + else max(a[-1] - frame_margin, a[0]) + # for each evenly-split array of frames in the scene list + for j, a in enumerate(np.array_split(r, num_images)) + ] + ] + for i, r in enumerate( + [ + # pad ranges to number of images + r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) + # create range of frames in scene + for r in ( + range( + start.get_frames(), + start.get_frames() + + max( + 1, # guard against zero length scenes + end.get_frames() - start.get_frames(), + ), + ) + # for each scene in scene list + for start, end in scene_list + ) + ] + ) + ] + + image_filenames = {i: [] for i in range(len(timecode_list))} + aspect_ratio = video.aspect_ratio + if abs(aspect_ratio - 1.0) < 0.01: + aspect_ratio = None + + logger.debug("Writing images with template %s", filename_template.template) + for i, scene_timecodes in enumerate(timecode_list): + for j, image_timecode in enumerate(scene_timecodes): + video.seek(image_timecode) + frame_im = video.read() + if frame_im is not None and frame_im is not False: + # TODO: Add extension to template. + # TODO: Allow NUM to be a valid suffix in addition to NUMBER. + file_path = "%s.%s" % ( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (i + 1), + IMAGE_NUMBER=image_num_format % (j + 1), + FRAME_NUMBER=image_timecode.get_frames(), + TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";"), + ), + image_extension, + ) + image_filenames[i].append(file_path) + # TODO: Combine this resize with the ones below. + if aspect_ratio is not None: + frame_im = cv2.resize( + frame_im, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value + ) + frame_height = frame_im.shape[0] + frame_width = frame_im.shape[1] + + # Figure out what kind of resizing needs to be done + if height or width: + if height and not width: + factor = height / float(frame_height) + width = int(factor * frame_width) + if width and not height: + factor = width / float(frame_width) + height = int(factor * frame_height) + assert height > 0 and width > 0 + frame_im = cv2.resize( + frame_im, (width, height), interpolation=interpolation.value + ) + elif scale: + frame_im = cv2.resize( + frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value + ) + path = Path(get_and_create_path(file_path, output_dir)) + (is_ok, encoded) = cv2.imencode(f".{image_extension}", frame_im, imwrite_param) + if is_ok: + encoded.tofile(path) + else: + logger.error(f"Failed to encode image for {file_path}") + # + else: + completed = False + break + if progress_bar is not None: + progress_bar.update(1) + + if progress_bar is not None: + progress_bar.close() + + if not completed: + logger.error("Could not generate all output images.") + + return image_filenames diff --git a/scenedetect/video_splitter.py b/scenedetect/output/video.py similarity index 92% rename from scenedetect/video_splitter.py rename to scenedetect/output/video.py index 1de861e4..020d95d2 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/output/video.py @@ -15,9 +15,7 @@ # Certain distributions of PySceneDetect may include the above software; # see the included LICENSE-FFMPEG and LICENSE-MKVMERGE files. # -"""``scenedetect.video_splitter`` Module - -The `scenedetect.video_splitter` module contains functions to split existing videos into clips +"""The ``scenedetect.output.video`` module contains functions to split existing videos into clips using ffmpeg or mkvmerge. These programs can be obtained from following URLs (note that mkvmerge is a part mkvtoolnix): @@ -159,8 +157,7 @@ def split_video_mkvmerge( show_output: bool = False, suppress_output=None, ) -> int: - """Calls the mkvmerge command on the input video, splitting it at the - passed timecodes, where each scene is written in sequence from 001. + """Split `input_video_path` using `mkvmerge` based on the scenes in `scene_list`. Arguments: input_video_path: Path to the video to be split. @@ -169,7 +166,7 @@ def split_video_mkvmerge( output_file_template: Template to use for generating output files. Note that mkvmerge always adds the suffix "-$SCENE_NUMBER" to the output paths. Only the $VIDEO_NAME variable is supported by this function. - video_name (str): Name of the video to be substituted in output_file_template for + video_name: Name of the video to be substituted in output_file_template for $VIDEO_NAME. If not specified, will be obtained from the filename. show_output: If False, adds the --quiet flag when invoking `mkvmerge`. suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. @@ -259,22 +256,20 @@ def split_video_ffmpeg( hide_progress=None, formatter: ty.Optional[PathFormatter] = None, ) -> int: - """Calls the ffmpeg command on the input video, generating a new video for - each scene based on the start/end timecodes. + """Split `input_video_path` using `ffmpeg` based on the scenes in `scene_list`. Arguments: input_video_path: Path to the video to be split. - scene_list (List[ty.Tuple[FrameTimecode, FrameTimecode]]): List of scenes - (pairs of FrameTimecodes) denoting the start/end frames of each scene. + scene_list: List of scenes (pairs of FrameTimecodes) denoting the start/end of each scene. output_dir: Directory to output videos. If not set, output will be in working directory. - output_file_template (str): Template to use for generating output filenames. + output_file_template: Template to use for generating output filenames. The following variables will be replaced in the template for each scene: $VIDEO_NAME, $SCENE_NUMBER, $START_TIME, $END_TIME, $START_FRAME, $END_FRAME - video_name (str): Name of the video to be substituted in output_file_template. If not + video_name: Name of the video to be substituted in output_file_template. If not passed will be calculated from input_video_path automatically. - arg_override (str): Allows overriding the arguments passed to ffmpeg for encoding. - show_progress (bool): If True, will show progress bar provided by tqdm (if installed). - show_output (bool): If True, will show output from ffmpeg for first split. + arg_override: Allows overriding the arguments passed to ffmpeg for encoding. + show_progress: If True, will show progress bar provided by tqdm (if installed). + show_output: If True, will show output from ffmpeg for first split. suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only. hide_progress: [DEPRECATED] DO NOT USE. For backwards compatibility only. formatter: Custom formatter callback. Overrides `output_file_template`. diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py deleted file mode 100644 index ef8fc569..00000000 --- a/scenedetect/scene_detector.py +++ /dev/null @@ -1,16 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2025 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""For backwards compatibility only, will be removed in a future release.""" - -# TODO(v0.7): Include a warning if this module is imported. - -from scenedetect.detector import * # noqa: F403 diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 3a6cb46f..11d4812e 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -16,9 +16,6 @@ (:mod:`VideoStream `). Video decoding is done in a separate thread to improve performance. -This module also contains other helper functions (e.g. :func:`save_images`) which can be used to -process the resulting scene list. - =============================================================== Usage =============================================================== @@ -79,36 +76,25 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): analysis of the video. """ -import csv import logging -import math import queue import sys import threading import typing as ty -from enum import Enum -from pathlib import Path -from string import Template import cv2 import numpy as np -from scenedetect._thirdparty.simpletable import ( - HTMLPage, - SimpleTable, - SimpleTableCell, - SimpleTableImage, - SimpleTableRow, -) from scenedetect.common import ( _USE_PTS_IN_DEVELOPMENT, CropRegion, CutList, FrameTimecode, + Interpolation, SceneList, ) from scenedetect.detector import SceneDetector -from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm +from scenedetect.platform import tqdm from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream @@ -130,21 +116,6 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): """Template to use for progress bar.""" -class Interpolation(Enum): - """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" - - NEAREST = cv2.INTER_NEAREST - """Nearest neighbor interpolation.""" - LINEAR = cv2.INTER_LINEAR - """Bilinear interpolation.""" - CUBIC = cv2.INTER_CUBIC - """Bicubic interpolation.""" - AREA = cv2.INTER_AREA - """Pixel area relation resampling. Provides moire'-free downscaling.""" - LANCZOS4 = cv2.INTER_LANCZOS4 - """Lanczos interpolation over 8x8 neighborhood.""" - - def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> float: """Get the optimal default downscale factor based on a video's resolution (currently only the width in pixels is considered). @@ -207,724 +178,6 @@ def get_scenes_from_cuts( return scene_list -# TODO(#463): Move post-processing functionality into separate submodule. - - -def write_scene_list( - output_csv_file: ty.TextIO, - scene_list: SceneList, - include_cut_list: bool = True, - cut_list: ty.Optional[CutList] = None, - col_separator: str = ",", - row_separator: str = "\n", -): - """Writes the given list of scenes to an output file handle in CSV format. - - Arguments: - output_csv_file: Handle to open file in write mode. - scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. - include_cut_list: Bool indicating if the first row should include the timecodes where - each scene starts. Should be set to False if RFC 4180 compliant CSV output is required. - cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames - in the video that need to be split to generate individual scenes). If not specified, - the cut list is generated using the start times of each scene following the first one. - col_separator: Delimiter to use between values. Must be single character. - row_separator: Line terminator to use between rows. - - Raises: - TypeError: "delimiter" must be a 1-character string - """ - csv_writer = csv.writer(output_csv_file, delimiter=col_separator, lineterminator=row_separator) - # If required, output the cutting list as the first row (i.e. before the header row). - if include_cut_list: - csv_writer.writerow( - ["Timecode List:"] + cut_list - if cut_list - else [start.get_timecode() for start, _ in scene_list[1:]] - ) - csv_writer.writerow( - [ - "Scene Number", - "Start Frame", - "Start Timecode", - "Start Time (seconds)", - "End Frame", - "End Timecode", - "End Time (seconds)", - "Length (frames)", - "Length (timecode)", - "Length (seconds)", - ] - ) - for i, (start, end) in enumerate(scene_list): - duration = end - start - csv_writer.writerow( - [ - "%d" % (i + 1), - "%d" % (start.get_frames() + 1), - start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), - end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), - duration.get_timecode(), - "%.3f" % duration.get_seconds(), - ] - ) - - -def write_scene_list_html( - output_html_filename: str, - scene_list: SceneList, - cut_list: ty.Optional[CutList] = None, - css: str = None, - css_class: str = "mytable", - image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]] = None, - image_width: ty.Optional[int] = None, - image_height: ty.Optional[int] = None, -): - """Writes the given list of scenes to an output file handle in html format. - - Arguments: - output_html_filename: filename of output html file - scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. - cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames - in the video that need to be split to generate individual scenes). If not passed, - the start times of each scene (besides the 0th scene) is used instead. - css: String containing all the css information for the resulting html page. - css_class: String containing the named css class - image_filenames: dict where key i contains a list with n elements (filenames of - the n saved images from that scene) - image_width: Optional desired width of images in table in pixels - image_height: Optional desired height of images in table in pixels - """ - logger.info("Exporting scenes to html:\n %s:", output_html_filename) - if not css: - css = """ - table.mytable { - font-family: times; - font-size:12px; - color:#000000; - border-width: 1px; - border-color: #eeeeee; - border-collapse: collapse; - background-color: #ffffff; - width=100%; - max-width:550px; - table-layout:fixed; - } - table.mytable th { - border-width: 1px; - padding: 8px; - border-style: solid; - border-color: #eeeeee; - background-color: #e6eed6; - color:#000000; - } - table.mytable td { - border-width: 1px; - padding: 8px; - border-style: solid; - border-color: #eeeeee; - } - #code { - display:inline; - font-family: courier; - color: #3d9400; - } - #string { - display:inline; - font-weight: bold; - } - """ - - # Output Timecode list - timecode_table = SimpleTable( - [ - ["Timecode List:"] - + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]]) - ], - css_class=css_class, - ) - - # Output list of scenes - header_row = [ - "Scene Number", - "Start Frame", - "Start Timecode", - "Start Time (seconds)", - "End Frame", - "End Timecode", - "End Time (seconds)", - "Length (frames)", - "Length (timecode)", - "Length (seconds)", - ] - for i, (start, end) in enumerate(scene_list): - duration = end - start - - row = SimpleTableRow( - [ - "%d" % (i + 1), - "%d" % (start.get_frames() + 1), - start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), - end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), - duration.get_timecode(), - "%.3f" % duration.get_seconds(), - ] - ) - - if image_filenames: - for image in image_filenames[i]: - row.add_cell( - SimpleTableCell(SimpleTableImage(image, width=image_width, height=image_height)) - ) - - if i == 0: - scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) - else: - scene_table.add_row(row=row) - - # Write html file - page = HTMLPage() - page.add_table(timecode_table) - page.add_table(scene_table) - page.css = css - page.save(output_html_filename) - - -def _scale_image( - image: np.ndarray, - aspect_ratio: float, - height: ty.Optional[int], - width: ty.Optional[int], - scale: ty.Optional[float], - interpolation: Interpolation, -) -> np.ndarray: - # TODO: Combine this resize with the ones below. - if aspect_ratio is not None: - image = cv2.resize( - image, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value - ) - image_height = image.shape[0] - image_width = image.shape[1] - - # Figure out what kind of resizing needs to be done - if height or width: - if height and not width: - factor = height / float(image_height) - width = int(factor * image_width) - if width and not height: - factor = width / float(image_width) - height = int(factor * image_height) - assert height > 0 and width > 0 - image = cv2.resize(image, (width, height), interpolation=interpolation.value) - elif scale: - image = cv2.resize(image, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value) - return image - - -class _ImageExtractor: - def __init__( - self, - num_images: int = 3, - frame_margin: int = 1, - image_extension: str = "jpg", - imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, - image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, - interpolation: Interpolation = Interpolation.CUBIC, - ): - """Multi-threaded implementation of save-images functionality. Uses background threads to - handle image encoding and saving images to disk to improve parallelism. - - This object is thread-safe. - - Arguments: - num_images: Number of images to generate for each scene. Minimum is 1. - frame_margin: Number of frames to pad each scene around the beginning - and end (e.g. moves the first/last image into the scene by N frames). - Can set to 0, but will result in some video files failing to extract - the very last frame. - image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). - encoder_param: Quality/compression efficiency, based on type of image: - 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. - 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. - image_name_template: Template to use for output filanames. Can use template variables - $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. - *NOTE*: Should not include the image extension (set `image_extension` instead). - scale: Optional factor by which to rescale saved images. A scaling factor of 1 would - not result in rescaling. A value < 1 results in a smaller saved image, while a - value > 1 results in an image larger than the original. This value is ignored if - either the height or width values are specified. - height: Optional value for the height of the saved images. Specifying both the height - and width will resize images to an exact size, regardless of aspect ratio. - Specifying only height will rescale the image to that number of pixels in height - while preserving the aspect ratio. - width: Optional value for the width of the saved images. Specifying both the width - and height will resize images to an exact size, regardless of aspect ratio. - Specifying only width will rescale the image to that number of pixels wide - while preserving the aspect ratio. - interpolation: Type of interpolation to use when resizing images. - """ - self._num_images = num_images - self._frame_margin = frame_margin - self._image_extension = image_extension - self._image_name_template = image_name_template - self._scale = scale - self._height = height - self._width = width - self._interpolation = interpolation - self._imwrite_param = imwrite_param if imwrite_param else {} - - def run( - self, - video: VideoStream, - scene_list: SceneList, - output_dir: ty.Optional[str] = None, - show_progress=False, - ) -> ty.Dict[int, ty.List[str]]: - """Run image extraction on `video` using the current parameters. Thread-safe. - - Arguments: - video: The video to process. - scene_list: The scenes detected in the video. - output_dir: Directory to write files to. - show_progress: If `true` and tqdm is available, shows a progress bar. - """ - # Setup flags and init progress bar if available. - completed = True - logger.info( - f"Saving {self._num_images} images per scene [format={self._image_extension}] {output_dir if output_dir else ''} " - ) - progress_bar = None - if show_progress: - progress_bar = tqdm( - total=len(scene_list) * self._num_images, unit="images", dynamic_ncols=True - ) - - timecode_list = self.generate_timecode_list(scene_list) - image_filenames = {i: [] for i in range(len(timecode_list))} - - filename_template = Template(self._image_name_template) - logger.debug("Writing images with template %s", filename_template.template) - scene_num_format = "%0" - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" - image_num_format = "%0" - image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d" - - def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode): - return "%s.%s" % ( - filename_template.safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=scene_num_format % (scene_number + 1), - IMAGE_NUMBER=image_num_format % (image_number + 1), - FRAME_NUMBER=image_timecode.get_frames(), - TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), - TIMECODE=image_timecode.get_timecode().replace(":", ";"), - ), - self._image_extension, - ) - - MAX_QUEUED_ENCODE_FRAMES = 4 - MAX_QUEUED_SAVE_IMAGES = 4 - encode_queue = queue.Queue(MAX_QUEUED_ENCODE_FRAMES) - save_queue = queue.Queue(MAX_QUEUED_SAVE_IMAGES) - error_queue = queue.Queue(2) # Queue size must be the same as the # of worker threads! - - def check_error_queue(): - try: - return error_queue.get(block=False) - except queue.Empty: - pass - return None - - def launch_thread(callable, *args, **kwargs): - def capture_errors(callable, *args, **kwargs): - try: - return callable(*args, **kwargs) - # Errors we capture in `error_queue` will be re-raised by this thread. - except: # noqa: E722 - error_queue.put(sys.exc_info()) - return None - - thread = threading.Thread( - target=capture_errors, - args=( - callable, - *args, - ), - kwargs=kwargs, - daemon=True, - ) - thread.start() - return thread - - def checked_put(work_queue: queue.Queue, item: ty.Any): - error = None - while True: - try: - work_queue.put(item, timeout=0.1) - return - except queue.Full: - error = check_error_queue() - if error is not None: - break - continue - raise error[1].with_traceback(error[2]) - - encode_thread = launch_thread( - self.image_encode_thread, - video, - encode_queue, - save_queue, - ) - save_thread = launch_thread(self.image_save_thread, save_queue, progress_bar) - - for i, scene_timecodes in enumerate(timecode_list): - for j, timecode in enumerate(scene_timecodes): - video.seek(timecode) - frame_im = video.read() - if frame_im is not None and frame_im is not False: - file_path = format_filename(i, j, timecode) - image_filenames[i].append(file_path) - checked_put( - encode_queue, (frame_im, get_and_create_path(file_path, output_dir)) - ) - else: - completed = False - break - - checked_put(encode_queue, (None, None)) - encode_thread.join() - checked_put(save_queue, (None, None)) - save_thread.join() - - error = check_error_queue() - if error is not None: - raise error[1].with_traceback(error[2]) - - if progress_bar is not None: - progress_bar.close() - if not completed: - logger.error("Could not generate all output images.") - - return image_filenames - - def image_encode_thread( - self, - video: VideoStream, - encode_queue: queue.Queue, - save_queue: queue.Queue, - ): - aspect_ratio = video.aspect_ratio - if abs(aspect_ratio - 1.0) < 0.01: - aspect_ratio = None - # TODO: Validate that encoder_param is within the proper range. - # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. - while True: - frame_im, dest_path = encode_queue.get() - if frame_im is None: - return - frame_im = self.resize_image( - frame_im, - aspect_ratio, - ) - (is_ok, encoded) = cv2.imencode( - f".{self._image_extension}", frame_im, self._imwrite_param - ) - if not is_ok: - continue - save_queue.put((encoded, dest_path)) - - def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): - while True: - encoded, dest_path = save_queue.get() - if encoded is None: - return - if encoded is not False: - encoded.tofile(Path(dest_path)) - if progress_bar is not None: - progress_bar.update(1) - - def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: - """Generates a list of timecodes for each scene in `scene_list` based on the current config - parameters.""" - framerate = scene_list[0][0]._framerate - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - return [ - ( - FrameTimecode(int(f), fps=framerate) - for f in ( - # middle frames - a[len(a) // 2] - if (0 < j < self._num_images - 1) or self._num_images == 1 - # first frame - else min(a[0] + self._frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - self._frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, self._num_images)) - ) - ) - for r in ( - # pad ranges to number of images - r - if 1 + r[-1] - r[0] >= self._num_images - else list(r) + [r[-1]] * (self._num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.get_frames(), - start.get_frames() - + max( - 1, # guard against zero length scenes - end.get_frames() - start.get_frames(), - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ) - ] - - def resize_image( - self, - image: np.ndarray, - aspect_ratio: float, - ) -> np.ndarray: - return _scale_image( - image, aspect_ratio, self._height, self._width, self._scale, self._interpolation - ) - - -def save_images( - scene_list: SceneList, - video: VideoStream, - num_images: int = 3, - frame_margin: int = 1, - image_extension: str = "jpg", - encoder_param: int = 95, - image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: ty.Optional[str] = None, - show_progress: ty.Optional[bool] = False, - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, - interpolation: Interpolation = Interpolation.CUBIC, - threading: bool = True, -) -> ty.Dict[int, ty.List[str]]: - """Save a set number of images from each scene, given a list of scenes - and the associated video/frame source. - - Arguments: - scene_list: A list of scenes (pairs of FrameTimecode objects) returned - from calling a SceneManager's detect_scenes() method. - video: A VideoStream object corresponding to the scene list. - Note that the video will be closed/re-opened and seeked through. - num_images: Number of images to generate for each scene. Minimum is 1. - frame_margin: Number of frames to pad each scene around the beginning - and end (e.g. moves the first/last image into the scene by N frames). - Can set to 0, but will result in some video files failing to extract - the very last frame. - image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). - encoder_param: Quality/compression efficiency, based on type of image: - 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. - 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. - image_name_template: Template to use for naming image files. Can use the template variables - $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $TIMECODE, $FRAME_NUMBER, $TIMESTAMP_MS. - Should not include an extension. - output_dir: Directory to output the images into. If not set, the output - is created in the working directory. - show_progress: If True, shows a progress bar if tqdm is installed. - scale: Optional factor by which to rescale saved images. A scaling factor of 1 would - not result in rescaling. A value < 1 results in a smaller saved image, while a - value > 1 results in an image larger than the original. This value is ignored if - either the height or width values are specified. - height: Optional value for the height of the saved images. Specifying both the height - and width will resize images to an exact size, regardless of aspect ratio. - Specifying only height will rescale the image to that number of pixels in height - while preserving the aspect ratio. - width: Optional value for the width of the saved images. Specifying both the width - and height will resize images to an exact size, regardless of aspect ratio. - Specifying only width will rescale the image to that number of pixels wide - while preserving the aspect ratio. - interpolation: Type of interpolation to use when resizing images. - threading: Offload image encoding and disk IO to background threads to improve performance. - - Returns: - Dictionary of the format { scene_num : [image_paths] }, where scene_num is the - number of the scene in scene_list (starting from 1), and image_paths is a list of - the paths to the newly saved/created images. - - Raises: - ValueError: Raised if any arguments are invalid or out of range (e.g. - if num_images is negative). - """ - - if not scene_list: - return {} - if num_images <= 0 or frame_margin < 0: - raise ValueError() - - # TODO: Validate that encoder_param is within the proper range. - # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. - imwrite_param = ( - [get_cv2_imwrite_params()[image_extension], encoder_param] - if encoder_param is not None - else [] - ) - video.reset() - - if threading: - extractor = _ImageExtractor( - num_images, - frame_margin, - image_extension, - imwrite_param, - image_name_template, - scale, - height, - width, - interpolation, - ) - return extractor.run(video, scene_list, output_dir, show_progress) - - # Setup flags and init progress bar if available. - completed = True - logger.info( - f"Saving {num_images} images per scene [format={image_extension}] {output_dir if output_dir else ''} " - ) - progress_bar = None - if show_progress: - progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) - - filename_template = Template(image_name_template) - - scene_num_format = "%0" - scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" - image_num_format = "%0" - image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - - framerate = scene_list[0][0]._framerate - - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - timecode_list = [ - [ - FrameTimecode(int(f), fps=framerate) - for f in [ - # middle frames - a[len(a) // 2] - if (0 < j < num_images - 1) or num_images == 1 - # first frame - else min(a[0] + frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, num_images)) - ] - ] - for i, r in enumerate( - [ - # pad ranges to number of images - r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.get_frames(), - start.get_frames() - + max( - 1, # guard against zero length scenes - end.get_frames() - start.get_frames(), - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ] - ) - ] - - image_filenames = {i: [] for i in range(len(timecode_list))} - aspect_ratio = video.aspect_ratio - if abs(aspect_ratio - 1.0) < 0.01: - aspect_ratio = None - - logger.debug("Writing images with template %s", filename_template.template) - for i, scene_timecodes in enumerate(timecode_list): - for j, image_timecode in enumerate(scene_timecodes): - video.seek(image_timecode) - frame_im = video.read() - if frame_im is not None and frame_im is not False: - # TODO: Add extension to template. - # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = "%s.%s" % ( - filename_template.safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=scene_num_format % (i + 1), - IMAGE_NUMBER=image_num_format % (j + 1), - FRAME_NUMBER=image_timecode.get_frames(), - TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), - TIMECODE=image_timecode.get_timecode().replace(":", ";"), - ), - image_extension, - ) - image_filenames[i].append(file_path) - # TODO: Combine this resize with the ones below. - if aspect_ratio is not None: - frame_im = cv2.resize( - frame_im, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=interpolation.value - ) - frame_height = frame_im.shape[0] - frame_width = frame_im.shape[1] - - # Figure out what kind of resizing needs to be done - if height or width: - if height and not width: - factor = height / float(frame_height) - width = int(factor * frame_width) - if width and not height: - factor = width / float(frame_width) - height = int(factor * frame_height) - assert height > 0 and width > 0 - frame_im = cv2.resize( - frame_im, (width, height), interpolation=interpolation.value - ) - elif scale: - frame_im = cv2.resize( - frame_im, (0, 0), fx=scale, fy=scale, interpolation=interpolation.value - ) - path = Path(get_and_create_path(file_path, output_dir)) - (is_ok, encoded) = cv2.imencode(f".{image_extension}", frame_im, imwrite_param) - if is_ok: - encoded.tofile(path) - else: - logger.error(f"Failed to encode image for {file_path}") - # - else: - completed = False - break - if progress_bar is not None: - progress_bar.update(1) - - if progress_bar is not None: - progress_bar.close() - - if not completed: - logger.error("Could not generate all output images.") - - return image_filenames - - ## ## SceneManager Class Implementation ## diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d7280ad..bd2c71ac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,14 +14,13 @@ import subprocess import typing as ty from pathlib import Path -from string import Template import cv2 import numpy as np import pytest import scenedetect -from scenedetect.video_splitter import is_ffmpeg_available, is_mkvmerge_available +from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available # These tests validate that the CLI itself functions correctly, mainly based on the return # return code from the process. We do not yet check for correctness of the output, just a diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 00000000..db3f2307 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,193 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tests for scenedetect.output module.""" + +from pathlib import Path + +import pytest + +from scenedetect import ( + ContentDetector, + FrameTimecode, + SceneManager, + VideoStreamCv2, + open_video, + save_images, +) +from scenedetect.output import ( + SceneMetadata, + VideoMetadata, + is_ffmpeg_available, + split_video_ffmpeg, +) + +FFMPEG_ARGS = ( + "-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency" +) +"""Only encodes a small crop of the frame and tuned for performance to speed up tests.""" + + +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): + video = open_video(test_movie_clip) + # Extract three hard-coded scenes for testing, each 30 frames. + scenes = [ + (video.base_timecode + 30, video.base_timecode + 60), + (video.base_timecode + 60, video.base_timecode + 90), + (video.base_timecode + 90, video.base_timecode + 120), + ] + assert ( + split_video_ffmpeg(test_movie_clip, scenes, output_dir=tmp_path, arg_override=FFMPEG_ARGS) + == 0 + ) + # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. + video_name = Path(test_movie_clip).stem + entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) + assert len(entries) == len(scenes) + + +@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): + video = open_video(test_movie_clip) + # Extract three hard-coded scenes for testing, each 30 frames. + scenes = [ + (video.base_timecode + 30, video.base_timecode + 60), + (video.base_timecode + 60, video.base_timecode + 90), + (video.base_timecode + 90, video.base_timecode + 120), + ] + + # Custom filename formatter: + def name_formatter(video: VideoMetadata, scene: SceneMetadata): + return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" + + assert ( + split_video_ffmpeg( + test_movie_clip, + scenes, + output_dir=tmp_path, + arg_override=FFMPEG_ARGS, + formatter=name_formatter, + ) + == 0 + ) + video_name = Path(test_movie_clip).stem + entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) + assert len(entries) == len(scenes) + + +# TODO: Add tests for `split_video_mkvmerge`. + + +def test_save_images(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images function.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" + ) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=False, + ) + + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +def test_save_images_singlethreaded(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images function.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" + ) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=True, + ) + + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +# TODO: Test other functionality against zero width scenes. +def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" + video = VideoStreamCv2(test_video_file) + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)] + ] + NUM_IMAGES = 10 + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=10, + image_extension="jpg", + image_name_template=image_name_template, + ) + assert len(image_filenames) == 3 + assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index a5477078..7779fc7b 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -16,14 +16,13 @@ """ import typing as ty -from pathlib import Path import pytest from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.common import FrameTimecode from scenedetect.detectors import AdaptiveDetector, ContentDetector -from scenedetect.scene_manager import SceneManager, save_images +from scenedetect.scene_manager import SceneManager TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] @@ -84,112 +83,6 @@ def test_get_scene_list_start_in_scene(test_video_file): assert scene_list[0][1] == end_time -def test_save_images(test_video_file, tmp_path: Path): - """Test scenedetect.scene_manager.save_images function.""" - video = VideoStreamCv2(test_video_file) - sm = SceneManager() - sm.add_detector(ContentDetector()) - - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = ( - "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" - ) - - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)] - ] - - image_filenames = save_images( - scene_list=scene_list, - output_dir=tmp_path, - video=video, - num_images=3, - image_extension="jpg", - image_name_template=image_name_template, - threading=False, - ) - - # Ensure images got created, and the proper number got created. - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" - total_images += 1 - - assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - - -def test_save_images_singlethreaded(test_video_file, tmp_path: Path): - """Test scenedetect.scene_manager.save_images function.""" - video = VideoStreamCv2(test_video_file) - sm = SceneManager() - sm.add_detector(ContentDetector()) - - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = ( - "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.$TIMESTAMP_MS.$TIMECODE" - ) - - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)] - ] - - image_filenames = save_images( - scene_list=scene_list, - output_dir=tmp_path, - video=video, - num_images=3, - image_extension="jpg", - image_name_template=image_name_template, - threading=True, - ) - - # Ensure images got created, and the proper number got created. - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" - total_images += 1 - - assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - - -# TODO: Test other functionality against zero width scenes. -def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): - """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" - video = VideoStreamCv2(test_video_file) - image_name_glob = "scenedetect.tempfile.*.jpg" - image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" - - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)] - ] - NUM_IMAGES = 10 - image_filenames = save_images( - scene_list=scene_list, - output_dir=tmp_path, - video=video, - num_images=10, - image_extension="jpg", - image_name_template=image_name_template, - ) - assert len(image_filenames) == 3 - assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" - total_images += 1 - - assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - - # TODO: This would be more readable if the callbacks were defined within the test case, e.g. # split up the callback function and callback lambda test cases. class FakeCallback: diff --git a/tests/test_video_splitter.py b/tests/test_video_splitter.py deleted file mode 100644 index d0c4c9c0..00000000 --- a/tests/test_video_splitter.py +++ /dev/null @@ -1,80 +0,0 @@ -# -# PySceneDetect: Python-Based Video Scene Detector -# ------------------------------------------------------------------- -# [ Site: https://scenedetect.com ] -# [ Docs: https://scenedetect.com/docs/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2024 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""Tests for scenedetect.video_splitter module.""" - -from pathlib import Path - -import pytest - -from scenedetect import open_video -from scenedetect.video_splitter import ( - SceneMetadata, - VideoMetadata, - is_ffmpeg_available, - split_video_ffmpeg, -) - -FFMPEG_ARGS = ( - "-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency" -) -"""Only encodes a small crop of the frame and tuned for performance to speed up tests.""" - - -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") -def test_split_video_ffmpeg_default(tmp_path, test_movie_clip): - video = open_video(test_movie_clip) - # Extract three hard-coded scenes for testing, each 30 frames. - scenes = [ - (video.base_timecode + 30, video.base_timecode + 60), - (video.base_timecode + 60, video.base_timecode + 90), - (video.base_timecode + 90, video.base_timecode + 120), - ] - assert ( - split_video_ffmpeg(test_movie_clip, scenes, output_dir=tmp_path, arg_override=FFMPEG_ARGS) - == 0 - ) - # The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4. - video_name = Path(test_movie_clip).stem - entries = sorted(tmp_path.glob(f"{video_name}-Scene-*")) - assert len(entries) == len(scenes) - - -@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available") -def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip): - video = open_video(test_movie_clip) - # Extract three hard-coded scenes for testing, each 30 frames. - scenes = [ - (video.base_timecode + 30, video.base_timecode + 60), - (video.base_timecode + 60, video.base_timecode + 90), - (video.base_timecode + 90, video.base_timecode + 120), - ] - - # Custom filename formatter: - def name_formatter(video: VideoMetadata, scene: SceneMetadata): - return "abc" + video.name + "-123-" + str(scene.index) + ".mp4" - - assert ( - split_video_ffmpeg( - test_movie_clip, - scenes, - output_dir=tmp_path, - arg_override=FFMPEG_ARGS, - formatter=name_formatter, - ) - == 0 - ) - video_name = Path(test_movie_clip).stem - entries = sorted(tmp_path.glob(f"abc{video_name}-123-*")) - assert len(entries) == len(scenes) - - -# TODO: Add tests for `split_video_mkvmerge`. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 3f1ba8de..fa51e665 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -652,9 +652,9 @@ Development ### Release Notes -PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs, with the goal of simplifying usage and integration. +PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. -Applications written for the 0.6 API may need to be modified to work with the new 0.7 API. These changes should be minimal in most cases, and backwards compatibility has been added where possible to reduce the scope of breaking changes. +Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. ### CLI Changes @@ -668,9 +668,11 @@ Applications written for the 0.6 API may need to be modified to work with the ne * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` - * Reorganized submodules: - * `scenedetect.scene_detector` is now `scenedetect.detector` - * `scenedetect.frame_timecode` is now `scenedetect.common` + * Move existing functionality to new submodules: + * `scenedetect.scene_detector` moved to `scenedetect.detector` + * `scenedetect.frame_timecode` moved to `scenedetect.common` + * Output functionality from `scenedetect.scene_manager` moved to `scenedetect.output` + * `scenedetect.video_splitter` moved to `scenedetect.output.video` * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead @@ -682,8 +684,3 @@ Applications written for the 0.6 API may need to be modified to work with the ne * Remove deprecated `SceneManager.get_event_list()` method * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) - -#### Deprecation - - * `scenedetect.scene_detector` module is now deprecated, import from `scenedetect` or `scenedetect.detector` instead - * `scenedetect.frame_timecode` module is now deprecated, import from `scenedetect` or `scenedetect.common` instead From 453dd28b43ee44b44286c9c87e1970dd41480027 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 23 Mar 2025 21:17:54 -0400 Subject: [PATCH 236/407] [docs] Update changelog --- website/pages/changelog.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index fa51e665..3835ce07 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -665,14 +665,14 @@ Although there have been minimal changes to most API examples, there are several #### Breaking - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface: + * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` * Move existing functionality to new submodules: * `scenedetect.scene_detector` moved to `scenedetect.detector` * `scenedetect.frame_timecode` moved to `scenedetect.common` - * Output functionality from `scenedetect.scene_manager` moved to `scenedetect.output` - * `scenedetect.video_splitter` moved to `scenedetect.output.video` + * Output functionality from `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead From 35695720a685efb24f7191634806136b7dfb3eb5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 23 Mar 2025 21:39:19 -0400 Subject: [PATCH 237/407] [dist] Add missing submodule --- setup.cfg | 1 + website/pages/changelog.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index e35c5861..8794b477 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,6 +49,7 @@ packages = scenedetect._thirdparty scenedetect.backends scenedetect.detectors + scenedetect.output python_requires = >=3.7 [options.extras_require] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 3835ce07..4051ef97 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -665,6 +665,8 @@ Although there have been minimal changes to most API examples, there are several #### Breaking +> Note: Imports that break when upgrading to 0.7 can usually be resolved by importing from `scenedetect` directly, rather than a submodule. The package structure has changed significantly in 0.7. + * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` From 8755b4fc4cf20ff7365b670bea4fb98bbae6c73d Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 23 Mar 2025 22:01:08 -0400 Subject: [PATCH 238/407] [docs] Fix emitting docs for hidden commands --- docs/api/output.rst | 10 ++---- docs/cli.rst | 72 --------------------------------------- docs/generate_cli_docs.py | 2 +- 3 files changed, 4 insertions(+), 80 deletions(-) diff --git a/docs/api/output.rst b/docs/api/output.rst index 3143f455..ec2cb68d 100644 --- a/docs/api/output.rst +++ b/docs/api/output.rst @@ -2,18 +2,14 @@ .. _scenedetect-output: ---------------------------------------------------------------- +------------------------------------------------- Ouptut ---------------------------------------------------------------- +------------------------------------------------- .. automodule:: scenedetect.output :members: -.. autofunction:: scenedetect.output.image.save_images - ---------------------------------------------------------------- -Video ---------------------------------------------------------------- +.. autofunction:: scenedetect.output.save_images .. automodule:: scenedetect.output.video :members: diff --git a/docs/cli.rst b/docs/cli.rst index e469e25f..04d1d577 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -432,46 +432,6 @@ Commands ************************************************************************ -.. _command-save-html: - -.. program:: scenedetect save-html - - -``save-html`` -======================================================================== - -Save scene list to HTML file. - -To customize image generation, specify the :ref:`save-images ` command before :ref:`save-html `. This command always uses the result of the preceeding :ref:`save-images ` command, or runs it with the default config values unless ``--no-images`` is set. - - -Options ------------------------------------------------------------------------- - - -.. option:: -f NAME, --filename NAME - - Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes. - - Default: ``$VIDEO_NAME-Scenes.html`` - -.. option:: -n, --no-images - - Do not include images with the result. - -.. option:: -w pixels, --image-width pixels - - Width in pixels of the images in the resulting HTML table. - -.. option:: -h pixels, --image-height pixels - - Height in pixels of the images in the resulting HTML table. - -.. option:: -s, --show - - Automatically open resulting HTML when processing is complete. - - .. _command-list-scenes: .. program:: scenedetect list-scenes @@ -795,38 +755,6 @@ Options Disable shifting frame numbers by start time. -.. _command-save-xml: - -.. program:: scenedetect save-xml - - -``save-xml`` -======================================================================== - -[IN DEVELOPMENT] Save cuts in XML format. - - -Options ------------------------------------------------------------------------- - - -.. option:: -f NAME, --filename NAME - - Filename format to use. - - Default: ``$VIDEO_NAME.xml`` - -.. option:: --format TYPE - - Format to export. TYPE must be one of: fcpx, fcp. - - Default: ``XmlFormat.FCPX`` - -.. option:: -o DIR, --output DIR - - Output directory to save XML file to. Overrides global option :option:`-o/--output `. - - .. _command-split-video: .. program:: scenedetect split-video diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index e4092047..a1ba2eca 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -252,7 +252,7 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) - commands = list(filter(lambda command: not ctx.command.hidden, commands)) + commands = list(filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands)) # ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ generate_title("``scenedetect`` 🎬 Command", level=0), From b71e262a16dedb44bd320607e3ff67b117f9469d Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 24 Mar 2025 21:15:28 -0400 Subject: [PATCH 239/407] [docs] Reorganize docs for 0.7 Place things in a more natural order and reduce verbosity to make it easier to identify what each module does. --- docs/api.rst | 36 ++++++++++++++++++++---------------- docs/api/output.rst | 23 ++++++++++++++++++----- docs/index.rst | 8 ++++---- scenedetect/_cli/config.py | 4 ++-- scenedetect/output/video.py | 16 ++++++++-------- 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 7e6a1b2a..bcf9fd96 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,13 +3,11 @@ ``scenedetect`` 🎬 Package *********************************************************************** -The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Getting Started`_ section below for some common use cases and integrations. The `scenedetect` package contains several modules: +The `scenedetect` API is easy to integrate with most application workflows, while also being highly extensible. See the `Getting Started`_ section below for some common use cases and integrations. The `scenedetect` package is organized into several sub-modules: - * :ref:`scenedetect 🎬 `: Includes the :func:`scenedetect.detect ` function which takes a path and a :ref:`detector ` to find scene transitions (:ref:`example `), and :func:`scenedetect.open_video ` for video input + * :ref:`scenedetect 🎬 `: high-level functions like :func:`scenedetect.detect() ` to quickly analyze a video with any :ref:`detection algorithm ` (:ref:`example `) and get a list of timecode pairs as a result - * :ref:`scenedetect.scene_manager 🎞️ `: The :class:`SceneManager ` acts as a way to coordinate detecting scenes (via `SceneDetector` instances) on video frames (via :ref:`VideoStream ` instances). - - * :ref:`scenedetect.detectors 🕵️ `: Detection algorithms: + * :ref:`scenedetect.detectors 🕵️ `: detection algorithms: * :mod:`ContentDetector `: detects fast cuts using weighted average of HSV changes @@ -21,13 +19,7 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :mod:`HashDetector `: finds fast cuts using perceptual image hashing - * :ref:`scenedetect.video_stream 🎥 `: Video input is handled through the :class:`VideoStream ` interface. Implementations for common video libraries are provided in :mod:`scenedetect.backends`: - - * OpenCV: :class:`VideoStreamCv2 ` - * PyAV: :class:`VideoStreamAv ` - * MoviePy: :class:`VideoStreamMoviePy ` - - * :ref:`scenedetect.output ✂️ `: Output formats: + * :ref:`scenedetect.output ✂️ `: Output formats: * :func:`split_video_ffmpeg ` and :func:`split_video_mkvmerge ` split a video based on the detected scenes @@ -35,13 +27,25 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :func:`write_scene_list ` can be used to save scene/cut info as CSV, :func:`write_scene_list_html ` for HTML - * :ref:`scenedetect.common ⏱️ `: Contains common types such as :class:`FrameTimecode ` used for timecode handing. + * :ref:`scenedetect.backends 🎥 `: PySceneDetect supports multiple libraries as an input backend: + + * OpenCV: :class:`VideoStreamCv2 ` + + * PyAV: :class:`VideoStreamAv ` + + * MoviePy: :class:`VideoStreamMoviePy ` + + * :ref:`scenedetect.common ⏱️ `: common functionality such as :class:`FrameTimecode ` for timecode handling + + * :ref:`scenedetect.scene_manager 🎞️ `: the :class:`SceneManager ` coordinates performing scene detection on a video with one or more detectors + + * :ref:`scenedetect.detector 🌐 `: the interface (:class:`SceneDetector `) that detectors must implement to be compatible with PySceneDetect - * :ref:`scenedetect.detector 🌐 `: Contains :class:`SceneDetector ` interface which detection algorithms must implement. + * :ref:`scenedetect.video_stream `: the interface (:class:`VideoStream `) that detectors must implement to be compatible with PySceneDetect - * :ref:`scenedetect.stats_manager 🧮 `: Contains :class:`StatsManager ` class for caching frame metrics and loading/saving them to disk in CSV format for analysis. + * :ref:`scenedetect.stats_manager 🧮 `: the :class:`StatsManager ` allows you to store detection metrics for each frame and save them to CSV for further analysis - * :ref:`scenedetect.platform 🐱‍💻 `: Logging and utility functions. + * :ref:`scenedetect.platform 🐱‍💻 `: logging and utility functions Most types/functions are also available directly from the `scenedetect` package to make imports simpler. diff --git a/docs/api/output.rst b/docs/api/output.rst index ec2cb68d..480e4371 100644 --- a/docs/api/output.rst +++ b/docs/api/output.rst @@ -6,10 +6,23 @@ Ouptut ------------------------------------------------- -.. automodule:: scenedetect.output - :members: - .. autofunction:: scenedetect.output.save_images -.. automodule:: scenedetect.output.video - :members: +.. autofunction:: scenedetect.output.is_ffmpeg_available + +.. autofunction:: scenedetect.output.split_video_ffmpeg + +.. autofunction:: scenedetect.output.is_mkvmerge_available + +.. autofunction:: scenedetect.output.split_video_mkvmerge + +.. autofunction:: scenedetect.output.write_scene_list_html + +.. autofunction:: scenedetect.output.write_scene_list + +.. autoclass:: scenedetect.output.SceneMetadata + +.. autoclass:: scenedetect.output.VideoMetadata + +.. autofunction:: scenedetect.output.default_formatter + diff --git a/docs/index.rst b/docs/index.rst index fc06e2f1..f3de773a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,13 +46,13 @@ Table of Contents api api/detectors - api/scene_manager - api/common - api/backends api/output - api/stats_manager + api/backends + api/common + api/scene_manager api/detector api/video_stream + api/stats_manager api/platform ======================================================================= diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 36dbc4c5..89662948 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -28,7 +28,7 @@ from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter from scenedetect.detectors import ContentDetector -from scenedetect.output.video import DEFAULT_FFMPEG_ARGS +from scenedetect.output.video import _DEFAULT_FFMPEG_ARGS from scenedetect.scene_manager import Interpolation PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] @@ -441,7 +441,7 @@ class XmlFormat(Enum): "output": None, }, "split-video": { - "args": DEFAULT_FFMPEG_ARGS, + "args": _DEFAULT_FFMPEG_ARGS, "copy": False, "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 020d95d2..cde0be07 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -43,7 +43,7 @@ logger = logging.getLogger("pyscenedetect") -COMMAND_TOO_LONG_STRING = """ +_COMMAND_TOO_LONG_STRING = """ Cannot split video due to too many scenes (resulting command is too large to process). To work around this issue, you can split the video manually by exporting a list of cuts with the @@ -52,10 +52,10 @@ for details. Sorry about that! """ -FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() +_FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" -DEFAULT_FFMPEG_ARGS = ( +_DEFAULT_FFMPEG_ARGS = ( "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac" ) """Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function.""" @@ -87,7 +87,7 @@ def is_ffmpeg_available() -> bool: Returns: True if `ffmpeg` can be invoked, False otherwise. """ - return FFMPEG_PATH is not None + return _FFMPEG_PATH is not None ## @@ -232,7 +232,7 @@ def split_video_mkvmerge( float(total_frames) / (time.time() - processing_start_time), ) except CommandTooLong: - logger.error(COMMAND_TOO_LONG_STRING) + logger.error(_COMMAND_TOO_LONG_STRING) except OSError: logger.error( "mkvmerge could not be found on the system." @@ -249,7 +249,7 @@ def split_video_ffmpeg( output_dir: ty.Optional[Path] = None, output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", video_name: ty.Optional[str] = None, - arg_override: str = DEFAULT_FFMPEG_ARGS, + arg_override: str = _DEFAULT_FFMPEG_ARGS, show_progress: bool = False, show_output: bool = False, suppress_output=None, @@ -329,7 +329,7 @@ def split_video_ffmpeg( output_path.parent.mkdir(parents=True, exist_ok=True) # Gracefully handle case where FFMPEG_PATH might be unset. - call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else "ffmpeg"] + call_list = [_FFMPEG_PATH if _FFMPEG_PATH is not None else "ffmpeg"] if not show_output: call_list += ["-v", "quiet"] elif i > 0: @@ -371,7 +371,7 @@ def split_video_ffmpeg( ) except CommandTooLong: - logger.error(COMMAND_TOO_LONG_STRING) + logger.error(_COMMAND_TOO_LONG_STRING) except OSError: logger.error( "ffmpeg could not be found on the system." From f57b64de3215b80e321709583f212cb4b3f53bae Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 27 Mar 2025 21:47:46 -0400 Subject: [PATCH 240/407] [docs] Update features with output format list --- website/pages/features.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/website/pages/features.md b/website/pages/features.md index 61136faf..ab1dc736 100644 --- a/website/pages/features.md +++ b/website/pages/features.md @@ -27,13 +27,18 @@ ## Features - - exports list of scenes to .CSV file and terminal (both timecodes and frame numbers) with `list-scenes` command - exports timecodes in standard format (HH:MM:SS.nnn), comma-separated for easy copy-and-paste into external tools and analysis with spreadsheet software - statistics/analysis mode to export frame-by-frame video metrics via the `-s [FILE]`/`--stats [FILE]` argument (e.g. `--stats metrics.csv`) - output-suppression (quiet) mode for better automation with external scripts/programs (`-q`/`--quiet`) - save an image of the first and last frame of each detected scene via the `save-images` command - split the input video automatically if `ffmpeg` or `mkvmerge` is available via the `split-video` command +### Output Formats + + - **EDL**: `save-edl` command (save as edit decision list in CMX 3600 format, compatible with most editors) + - **HTML**: `save-html` command (save HTML table that can be viewed with browser) + - **OTIO**: `save-otio` command (save as [OpenTimelineIO](https://github.com/AcademySoftwareFoundation/OpenTimelineIO) file) + - **QP**: `save-qp` command (can be used with x264 `--qpfile`) ### Detection Methods From dbbe00a93351664fc43fa6ba507114e3ed69c6d0 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 16 Apr 2025 21:41:05 -0400 Subject: [PATCH 241/407] [build] Bump minimum Ubuntu build version --- .github/workflows/build.yml | 3 +-- scenedetect/common.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6807a714..52ef6876 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,8 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # TODO: Bump ubuntu 20 to 22 when past EOL date. - os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] + os: [macos-13, macos-14, ubuntu-22.04, ubuntu-latest, windows-latest] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. diff --git a/scenedetect/common.py b/scenedetect/common.py index 77755754..803666b5 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -209,6 +209,9 @@ def __init__( else: self._frame_num = self._parse_timecode_number(timecode) + # TODO(v0.7): Add a PTS property as well and slowly transition over to that, since we don't + # always know the position as a "frame number". However, for the reverse case, we CAN state + # the presentation time if we know the frame number (for a fixed framerate video). @property def frame_num(self) -> ty.Optional[int]: return self._frame_num From 34dffabd8666bf4cbb94ff1995f74fcf593eb368 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 2 May 2025 22:51:06 -0400 Subject: [PATCH 242/407] [stats] Update TODO --- scenedetect/stats_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 169a7930..ee232b7e 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -118,7 +118,8 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: """Register a list of metric keys that will be used by the detector.""" self._metric_keys = self._metric_keys.union(set(metric_keys)) - # TODO(v1.0): This interface is difficult to use, we should support the dictionary protocol. + # TODO(#507): This interface is difficult to use, we should support the dictionary protocol. + # Ideally this should work with Panadas. This could be done with the v0.7 API change. def get_metrics( self, timecode: FrameTimecode, metric_keys: ty.Iterable[str] ) -> ty.List[ty.Any]: From 1292cac8140dddb416e010ea0efbdc5dc5a904f5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 29 Jun 2025 23:03:27 -0400 Subject: [PATCH 243/407] [adaptive_detector] Remove deprecated min_delta_hsv argument --- docs/cli.rst | 4 ---- scenedetect/_cli/__init__.py | 12 ------------ scenedetect/_cli/config.py | 2 -- scenedetect/_cli/context.py | 17 ----------------- scenedetect/detectors/adaptive_detector.py | 5 ----- website/pages/changelog.md | 1 + 6 files changed, 1 insertion(+), 40 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 04d1d577..6df3d9f7 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -178,11 +178,7 @@ Options Default: ``15.0`` -.. option:: -d VAL, --min-delta-hsv VAL - [DEPRECATED] Use :option:`-c/--min-content-val <-c>` instead. - - Default: ``15.0`` .. option:: -f VAL, --frame-window VAL diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 8d658f51..a0c639d2 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -615,16 +615,6 @@ def detect_content_command( help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' % (USER_CONFIG.get_help_string("detect-adaptive", "min-content-val")), ) -@click.option( - "--min-delta-hsv", - "-d", - metavar="VAL", - type=click.FLOAT, - default=None, - help="[DEPRECATED] Use -c/--min-content-val instead.%s" - % (USER_CONFIG.get_help_string("detect-adaptive", "min-delta-hsv")), - hidden=True, -) @click.option( "--frame-window", "-f", @@ -677,7 +667,6 @@ def detect_adaptive_command( ctx: click.Context, threshold: ty.Optional[float], min_content_val: ty.Optional[float], - min_delta_hsv: ty.Optional[float], frame_window: ty.Optional[int], weights: ty.Optional[ty.Tuple[float, float, float, float]], luma_only: bool, @@ -689,7 +678,6 @@ def detect_adaptive_command( detector_args = ctx.get_detect_adaptive_params( threshold=threshold, min_content_val=min_content_val, - min_delta_hsv=min_delta_hsv, frame_window=frame_window, luma_only=luma_only, min_scene_len=min_scene_len, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 89662948..67a80d25 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -325,7 +325,6 @@ class XmlFormat(Enum): DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 -# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv CONFIG_MAP: ConfigDict = { "backend-opencv": { "max-decode-attempts": 5, @@ -339,7 +338,6 @@ class XmlFormat(Enum): "kernel-size": KernelSizeValue(-1), "luma-only": False, "min-content-val": RangeValue(15.0, min_val=0.0, max_val=255.0), - "min-delta-hsv": RangeValue(15.0, min_val=0.0, max_val=255.0), "min-scene-len": TimecodeValue(0), "threshold": RangeValue(3.0, min_val=0.0, max_val=255.0), "weights": ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS), diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index b94aa71b..b23287e3 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -357,26 +357,9 @@ def get_detect_adaptive_params( min_scene_len: ty.Optional[str] = None, weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, kernel_size: ty.Optional[int] = None, - min_delta_hsv: ty.Optional[float] = None, ) -> ty.Dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" - # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv. - if min_delta_hsv is not None: - logger.error("-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.") - if min_content_val is None: - min_content_val = min_delta_hsv - # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val. - if not self.config.is_default("detect-adaptive", "min-delta-hsv"): - logger.error( - "[detect-adaptive] config file option `min-delta-hsv` is deprecated" - ", use `min-delta-hsv` instead." - ) - if self.config.is_default("detect-adaptive", "min-content-val"): - self.config.config_dict["detect-adaptive"]["min-content-val"] = ( - self.config.config_dict["detect-adaptive"]["min-deleta-hsv"] - ) - if self.drop_short_scenes: min_scene_len = 0 else: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 1d553ce1..fa23795f 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -44,7 +44,6 @@ def __init__( weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: ty.Optional[int] = None, - min_delta_hsv: ty.Optional[float] = None, ): """ Arguments: @@ -65,11 +64,7 @@ def __init__( Overrides `weights` if both are set. kernel_size: Size of kernel to use for post edge detection filtering. If None, automatically set based on video resolution. - min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead. """ - if min_delta_hsv is not None: - logger.error("min_delta_hsv is deprecated, use min_content_val instead.") - min_content_val = min_delta_hsv if window_width < 1: raise ValueError("window_width must be at least 1.") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 4051ef97..1a470f34 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -658,6 +658,7 @@ Although there have been minimal changes to most API examples, there are several ### CLI Changes +- [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command. - [feature] WORK IN PROGRESS: New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) From 0b7ff069ebf26c3c5b7b2d9b616d98fff7945d8d Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 29 Jun 2025 23:38:40 -0400 Subject: [PATCH 244/407] [api] Upgrade deprecation logs to warnings --- README.md | 4 +- scenedetect/_cli/commands.py | 28 ++++----- scenedetect/_cli/config.py | 4 +- scenedetect/_cli/controller.py | 4 +- scenedetect/backends/moviepy.py | 4 +- scenedetect/backends/opencv.py | 10 ++- scenedetect/backends/pyav.py | 2 +- scenedetect/common.py | 70 +++++++++++++-------- scenedetect/detectors/threshold_detector.py | 8 ++- scenedetect/output/__init__.py | 24 +++---- scenedetect/output/image.py | 24 +++---- scenedetect/output/video.py | 14 ++--- scenedetect/scene_manager.py | 17 +++-- scenedetect/stats_manager.py | 2 +- tests/test_api.py | 8 +-- tests/test_backend_opencv.py | 2 +- tests/test_detectors.py | 4 +- tests/test_frame_timecode.py | 48 +++++++------- tests/test_scene_manager.py | 6 +- tests/test_video_stream.py | 2 +- website/pages/changelog.md | 13 +++- 21 files changed, 169 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 012c1225..455e03f4 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ scene_list = detect('my_video.mp4', ContentDetector()) for i, scene in enumerate(scene_list): print(' Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( i+1, - scene[0].get_timecode(), scene[0].get_frames(), - scene[1].get_timecode(), scene[1].get_frames(),)) + scene[0].get_timecode(), scene[0].frame_num, + scene[1].get_timecode(), scene[1].frame_num,)) ``` We can also split the video into each scene if `ffmpeg` is installed (`mkvmerge` is also supported): diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index a9e7320d..d2a19f9f 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -158,9 +158,9 @@ def list_scenes( " | %5d | %11d | %s | %11d | %s |" % ( i + 1, - start_time.get_frames() + 1, + start_time.frame_num + 1, start_time.get_timecode(), - end_time.get_frames(), + end_time.frame_num, end_time.get_timecode(), ) for i, (start_time, end_time) in enumerate(scenes) @@ -278,11 +278,11 @@ def save_edl( # Converts FrameTimecode to HH:MM:SS:FF # TODO: This should be part of the FrameTimecode object itself. def get_edl_timecode(timecode: FrameTimecode): - total_seconds = timecode.get_seconds() + total_seconds = timecode.seconds hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) seconds = int(total_seconds % 60) - frames_part = int((total_seconds * timecode.get_framerate()) % timecode.get_framerate()) + frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" edl_content = [] @@ -331,7 +331,7 @@ def _save_xml_fcpx( # TODO: We should calculate duration from the scene list. duration = context.video_stream.duration - duration = str(duration.get_seconds()) + "s" # TODO: Is float okay here? + duration = str(duration.seconds) + "s" # TODO: Is float okay here? path = Path(context.video_stream.path).absolute() ElementTree.SubElement( resources, @@ -355,8 +355,8 @@ def _save_xml_fcpx( spine = ElementTree.SubElement(sequence, "spine") for i, (start, end) in enumerate(scenes): - start_seconds = start.get_seconds() - duration_seconds = (end - start).get_seconds() + start_seconds = start.seconds + duration_seconds = (end - start).seconds clip = ElementTree.SubElement( spine, "clip", @@ -402,7 +402,7 @@ def _save_xml_fcp( ElementTree.SubElement(sequence, "name").text = context.video_stream.name duration = scenes[-1][1] - scenes[0][0] - ElementTree.SubElement(sequence, "duration").text = f"{duration.get_frames()}" + ElementTree.SubElement(sequence, "duration").text = f"{duration.frame_num}" rate = ElementTree.SubElement(sequence, "rate") ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) @@ -430,10 +430,10 @@ def _save_xml_fcp( ElementTree.fromstring(f"{context.video_stream.frame_rate}") ) # TODO: Are these supposed to be frame numbers or another format? - ElementTree.SubElement(clip, "start").text = str(start.get_frames()) - ElementTree.SubElement(clip, "end").text = str(end.get_frames()) - ElementTree.SubElement(clip, "in").text = str(start.get_frames()) - ElementTree.SubElement(clip, "out").text = str(end.get_frames()) + ElementTree.SubElement(clip, "start").text = str(start.frame_num) + ElementTree.SubElement(clip, "end").text = str(end.frame_num) + ElementTree.SubElement(clip, "in").text = str(start.frame_num) + ElementTree.SubElement(clip, "out").text = str(end.frame_num) file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}") ElementTree.SubElement(file_ref, "name").text = context.video_stream.name @@ -534,12 +534,12 @@ def save_otio( "duration": { "OTIO_SCHEMA": "RationalTime.1", "rate": frame_rate, - "value": float((end - start).get_frames()), + "value": float((end - start).frame_num), }, "start_time": { "OTIO_SCHEMA": "RationalTime.1", "rate": frame_rate, - "value": float(start.get_frames()), + "value": float(start.frame_num), }, }, "enabled": True, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 67a80d25..ee851da8 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -297,11 +297,11 @@ class TimecodeFormat(Enum): def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.FRAMES: - return str(timecode.get_frames()) + return str(timecode.frame_num) if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return "%.3f" % timecode.get_seconds() + return "%.3f" % timecode.seconds raise RuntimeError("Unhandled format specifier.") diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index af730b5b..48ff340d 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -68,7 +68,7 @@ def run_scenedetect(context: CliContext): logger.info( "Detected %d scenes, average shot length %.1f seconds.", len(scenes), - sum([(end_time - start_time).get_seconds() for start_time, end_time in scenes]) + sum([(end_time - start_time).seconds for start_time, end_time in scenes]) / float(len(scenes)), ) else: @@ -106,7 +106,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: logger.critical( "Failed to seek to %s / frame %d: %s", context.start_time.get_timecode(), - context.start_time.get_frames(), + context.start_time.frame_num, str(ex), ) return None diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index fe2a5774..96c38a04 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -143,7 +143,7 @@ def position_ms(self) -> float: The first frame has a time of 0.0 ms. This method will always return 0.0 if no frames have been `read`.""" - return self.position.get_seconds() * 1000.0 + return self.position.seconds * 1000.0 @property def frame_number(self) -> int: @@ -176,7 +176,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: - self._last_frame = self._reader.get_frame(target.get_seconds()) + self._last_frame = self._reader.get_frame(target.seconds) if hasattr(self._reader, "last_read") and target >= self.duration: raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") self._frame_number = min( diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index ac03ae10..c10deca3 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -20,6 +20,7 @@ import math import os.path import typing as ty +import warnings from fractions import Fraction from logging import getLogger @@ -89,9 +90,12 @@ def __init__( ValueError: specified framerate is invalid """ super().__init__() - # TODO(v0.7): Replace with DeprecationWarning that `path_or_device` will be removed in v0.8. if path_or_device is not None: - logger.error("path_or_device is deprecated, use path or VideoCaptureAdapter instead.") + warnings.warn( + "The `path_or_device` argument is deprecated, use `path` or `VideoCaptureAdapter` instead.", + DeprecationWarning, + stacklevel=2, + ) path = path_or_device if path is None: raise ValueError("Path must be specified!") @@ -224,7 +228,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): # Have to seek one behind and call grab() after to that the VideoCapture # returns a valid timestamp when using CAP_PROP_POS_MSEC. - target_frame_cv2 = (self.base_timecode + target).get_frames() + target_frame_cv2 = (self.base_timecode + target).frame_num if target_frame_cv2 > 0: target_frame_cv2 -= 1 self._cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame_cv2) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 57ab1f66..32f57ead 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -252,7 +252,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: if target >= 1: target = target - 1 target_pts = self._video_stream.start_time + int( - (self.base_timecode + target).get_seconds() / self._video_stream.time_base + (self.base_timecode + target).seconds / self._video_stream.time_base ) self._frame = None self._container.seek(target_pts, stream=self._video_stream) diff --git a/scenedetect/common.py b/scenedetect/common.py index 803666b5..a120f1d1 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -63,6 +63,7 @@ import math import typing as ty +import warnings from dataclasses import dataclass from enum import Enum from fractions import Fraction @@ -220,28 +221,32 @@ def frame_num(self) -> ty.Optional[int]: def framerate(self) -> ty.Optional[int]: return self._framerate - # TODO(v0.7): Mark this as deprecated (use frame_num instead). 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 :meth:`get_seconds` method). + """[DEPRECATED] Get the current time/position in number of frames. - If using to compare a :class:`FrameTimecode` with a frame number, - you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 10``). + Use the `frame_num` property instead. - Returns: - int: The current time in frames (the current frame number). + :meta private: """ + warnings.warn( + "get_frames() is deprecated, use the `frame_num` property instead.", + DeprecationWarning, + stacklevel=2, + ) return self.frame_num - # TODO(v0.7): Mark this as deprecated (use framerate instead). def get_framerate(self) -> float: - """Get Framerate: Returns the framerate used by the FrameTimecode object. + """[DEPRECATED] Get Framerate: Returns the framerate used by the FrameTimecode object. - Returns: - float: Framerate of the current FrameTimecode object, in frames per second. + Use the `framerate` property instead. + + :meta private: """ + warnings.warn( + "get_framerate() is deprecated, use the `framerate` property instead.", + DeprecationWarning, + stacklevel=2, + ) return self.framerate # TODO(v0.7): Figure out how to deal with VFR here. @@ -258,20 +263,33 @@ def equal_framerate(self, fps) -> bool: # TODO(v0.7): Support this comparison in the case FPS is not set but a timecode is. return math.fabs(self.framerate - fps) < MAX_FPS_DELTA - # TODO(v0.7): Add a `seconds` property to replace this and deprecate the existing one. + @property + def seconds(self) -> float: + """The frame's position in number of seconds.""" + if self._timecode: + return self._timecode.seconds + # Assume constant framerate if we don't have timing information. + return float(self._frame_num) / self._framerate + def get_seconds(self) -> float: - """Get the frame's position in number of seconds. + """[DEPRECATED] Get the frame's position in number of seconds. + + Use the `seconds` property instead. If using to compare a :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. + + :meta private: """ - if self._timecode: - return self._timecode.seconds - # Assume constant framerate if we don't have timing information. - return float(self._frame_num) / self._framerate + warnings.warn( + "get_seconds() is deprecated, use the `seconds` property instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.seconds def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: """Get a formatted timecode string of the form HH:MM:SS[.nnn]. @@ -285,7 +303,7 @@ def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: 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() + secs = self.seconds hrs = int(secs / _SECONDS_PER_HOUR) secs -= hrs * _SECONDS_PER_HOUR mins = int(secs / _SECONDS_PER_MINUTE) @@ -438,7 +456,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTim if isinstance(other, int): return self._frame_num == other elif isinstance(other, float): - return self.get_seconds() == other + return self.seconds == other elif isinstance(other, str): return self._frame_num == self._parse_timecode_string(other) elif isinstance(other, FrameTimecode): @@ -462,7 +480,7 @@ def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self._frame_num < other elif isinstance(other, float): - return self.get_seconds() < other + return self.seconds < other elif isinstance(other, str): return self._frame_num < self._parse_timecode_string(other) elif isinstance(other, FrameTimecode): @@ -481,7 +499,7 @@ def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self._frame_num <= other elif isinstance(other, float): - return self.get_seconds() <= other + return self.seconds <= other elif isinstance(other, str): return self._frame_num <= self._parse_timecode_string(other) elif isinstance(other, FrameTimecode): @@ -500,7 +518,7 @@ def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self._frame_num > other elif isinstance(other, float): - return self.get_seconds() > other + return self.seconds > other elif isinstance(other, str): return self._frame_num > self._parse_timecode_string(other) elif isinstance(other, FrameTimecode): @@ -519,7 +537,7 @@ def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if isinstance(other, int): return self._frame_num >= other elif isinstance(other, float): - return self.get_seconds() >= other + return self.seconds >= other elif isinstance(other, str): return self._frame_num >= self._parse_timecode_string(other) elif isinstance(other, FrameTimecode): @@ -541,7 +559,7 @@ def __int__(self) -> int: return self._frame_num def __float__(self) -> float: - return self.get_seconds() + return self.seconds def __str__(self) -> str: return self.get_timecode() diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index f41dfe5e..c948f9d1 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -16,6 +16,7 @@ """ import typing as ty +import warnings from enum import Enum from logging import getLogger @@ -68,9 +69,12 @@ def __init__( method: How to treat `threshold` when detecting fade events. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. """ - # TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8. if block_size is not None: - logger.error("block_size is deprecated.") + warnings.warn( + "The `block_size` argument is deprecated and will be removed in v0.8.", + DeprecationWarning, + stacklevel=2, + ) super().__init__() self.threshold = int(threshold) diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 5f6375f9..3acd48f8 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -98,15 +98,15 @@ def write_scene_list( csv_writer.writerow( [ "%d" % (i + 1), - "%d" % (start.get_frames() + 1), + "%d" % (start.frame_num + 1), start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), + "%.3f" % start.seconds, + "%d" % end.frame_num, end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), + "%.3f" % end.seconds, + "%d" % duration.frame_num, duration.get_timecode(), - "%.3f" % duration.get_seconds(), + "%.3f" % duration.seconds, ] ) @@ -204,15 +204,15 @@ def write_scene_list_html( row = SimpleTableRow( [ "%d" % (i + 1), - "%d" % (start.get_frames() + 1), + "%d" % (start.frame_num + 1), start.get_timecode(), - "%.3f" % start.get_seconds(), - "%d" % end.get_frames(), + "%.3f" % start.seconds, + "%d" % end.frame_num, end.get_timecode(), - "%.3f" % end.get_seconds(), - "%d" % duration.get_frames(), + "%.3f" % end.seconds, + "%d" % duration.frame_num, duration.get_timecode(), - "%.3f" % duration.get_seconds(), + "%.3f" % duration.seconds, ] ) diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 5955f2fe..3df176c9 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -162,8 +162,8 @@ def format_filename(scene_number: int, image_number: int, image_timecode: FrameT VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (scene_number + 1), IMAGE_NUMBER=image_num_format % (image_number + 1), - FRAME_NUMBER=image_timecode.get_frames(), - TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + FRAME_NUMBER=image_timecode.frame_num, + TIMESTAMP_MS=int(image_timecode.seconds * 1000), TIMECODE=image_timecode.get_timecode().replace(":", ";"), ), self._image_extension, @@ -319,11 +319,11 @@ def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[F # create range of frames in scene for r in ( range( - start.get_frames(), - start.get_frames() + start.frame_num, + start.frame_num + max( 1, # guard against zero length scenes - end.get_frames() - start.get_frames(), + end.frame_num - start.frame_num, ), ) # for each scene in scene list @@ -456,7 +456,7 @@ def save_images( timecode_list = [ [ FrameTimecode(int(f), fps=framerate) - for f in [ + for f in ( # middle frames a[len(a) // 2] if (0 < j < num_images - 1) or num_images == 1 @@ -467,7 +467,7 @@ def save_images( else max(a[-1] - frame_margin, a[0]) # for each evenly-split array of frames in the scene list for j, a in enumerate(np.array_split(r, num_images)) - ] + ) ] for i, r in enumerate( [ @@ -476,11 +476,11 @@ def save_images( # create range of frames in scene for r in ( range( - start.get_frames(), - start.get_frames() + start.frame_num, + start.frame_num + max( 1, # guard against zero length scenes - end.get_frames() - start.get_frames(), + end.frame_num - start.frame_num, ), ) # for each scene in scene list @@ -508,8 +508,8 @@ def save_images( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), IMAGE_NUMBER=image_num_format % (j + 1), - FRAME_NUMBER=image_timecode.get_frames(), - TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + FRAME_NUMBER=image_timecode.frame_num, + TIMESTAMP_MS=int(image_timecode.seconds * 1000), TIMECODE=image_timecode.get_timecode().replace(":", ";"), ), image_extension, diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index cde0be07..737cb92d 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -137,8 +137,8 @@ def default_formatter(template: str) -> PathFormatter: SCENE_NUMBER=format_scene_number(video, scene), START_TIME=str(scene.start.get_timecode().replace(":", ";")), END_TIME=str(scene.end.get_timecode().replace(":", ";")), - START_FRAME=str(scene.start.get_frames()), - END_FRAME=str(scene.end.get_frames()), + START_FRAME=str(scene.start.frame_num), + END_FRAME=str(scene.end.frame_num), ) return formatter @@ -220,7 +220,7 @@ def split_video_mkvmerge( ), input_video_path, ] - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() + total_frames = scene_list[-1][1].frame_num - scene_list[0][0].frame_num processing_start_time = time.time() ret_val = 0 try: @@ -316,7 +316,7 @@ def split_video_ffmpeg( try: progress_bar = None - total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames() + total_frames = scene_list[-1][1].frame_num - scene_list[0][0].frame_num if show_progress: progress_bar = tqdm(total=total_frames, unit="frame", miniters=1, dynamic_ncols=True) processing_start_time = time.time() @@ -341,11 +341,11 @@ def split_video_ffmpeg( "-nostdin", "-y", "-ss", - str(start_time.get_seconds()), + str(start_time.seconds), "-i", input_video_path, "-t", - str(duration.get_seconds()), + str(duration.seconds), ] call_list += arg_override call_list += ["-sn"] @@ -360,7 +360,7 @@ def split_video_ffmpeg( logger.error("Error splitting video (ffmpeg returned %d).", ret_val) break if progress_bar: - progress_bar.update(duration.get_frames()) + progress_bar.update(duration.frame_num) if progress_bar: progress_bar.close() diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 11d4812e..e94858b3 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -81,6 +81,7 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import sys import threading import typing as ty +import warnings import cv2 import numpy as np @@ -452,6 +453,7 @@ def detect_scenes( complete processing the video frame source. callback: If set, called after each scene/event detected. frame_source: [DEPRECATED] DO NOT USE. For compatibility with previous version. + :meta private: Returns: int: Number of frames read and processed from the frame source. Raises: @@ -460,6 +462,11 @@ def detect_scenes( """ # TODO(v0.7): Add DeprecationWarning that `frame_source` will be removed in v0.8. if frame_source is not None: + warnings.warn( + "The `frame_source` argument is deprecated, use `video` instead.", + DeprecationWarning, + stacklevel=2, + ) video = frame_source # TODO(v0.8): Remove default value for `video` after `frame_source` is removed. if video is None: @@ -518,7 +525,7 @@ def detect_scenes( if end_time is not None and end_time < video.duration: total_frames = end_time - start_frame_num else: - total_frames = video.duration.get_frames() - start_frame_num + total_frames = video.duration.frame_num - start_frame_num progress_bar = None if show_progress: @@ -684,9 +691,11 @@ def get_cut_list( was detected in the input video, which can also be passed to external tools for automated splitting of the input into individual scenes. - :meta private: """ - # TODO(v0.7): Use the warnings module to turn this into a warning. if show_warning: - logger.error("`get_cut_list()` is deprecated and will be removed in a future release.") + warnings.warn( + "get_cut_list() is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) return self._get_cutting_list() diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index ee232b7e..e6b873ab 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -190,7 +190,7 @@ def save_to_csv( logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: csv_writer.writerow( - [frame_key.get_frames() + 1, frame_key.get_timecode()] + [frame_key.frame_num + 1, frame_key.get_timecode()] + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] ) diff --git a/tests/test_api.py b/tests/test_api.py index c353a96b..e86243ad 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -79,16 +79,16 @@ def test_api_timecode_types(): base_timecode = FrameTimecode(timecode=0, fps=10.0) # Frames (int) timecode = base_timecode + 1 - assert timecode.get_frames() == 1 + assert timecode.frame_num == 1 # Seconds (float) timecode = base_timecode + 1.0 - assert timecode.get_frames() == 10 + assert timecode.frame_num == 10 # Timecode (str, 'HH:MM:SS' or 'HH:MM:SSS.nnn') timecode = base_timecode + "00:00:01.500" - assert timecode.get_frames() == 15 + assert timecode.frame_num == 15 # Seconds (str, 'SSSs' or 'SSSS.SSSs') timecode = base_timecode + "1.5s" - assert timecode.get_frames() == 15 + assert timecode.frame_num == 15 def test_api_stats_manager(test_video_file: str): diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index 3f106ab8..d1d66020 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -49,4 +49,4 @@ def test_capture_adapter(test_movie_clip: str): assert scene_manager.detect_scenes(video=adapter, duration=adapter.base_timecode + 10.0) scenes = scene_manager.get_scene_list() assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST) - assert [start.get_frames() for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST + assert [start.frame_num for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 109872be..43ebfd23 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -187,7 +187,7 @@ def get_fade_in_out_test_cases(): @pytest.mark.parametrize("test_case", get_fast_cut_test_cases()) def test_detect_fast_cuts(test_case: TestCase): scene_list = test_case.detect() - start_frames = [timecode.get_frames() for timecode, _ in scene_list] + start_frames = [timecode.frame_num for timecode, _ in scene_list] assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time @@ -197,7 +197,7 @@ def test_detect_fast_cuts(test_case: TestCase): @pytest.mark.parametrize("test_case", get_fade_in_out_test_cases()) def test_detect_fades(test_case: TestCase): scene_list = test_case.detect() - start_frames = [timecode.get_frames() for timecode, _ in scene_list] + start_frames = [timecode.frame_num for timecode, _ in scene_list] assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time assert scene_list[-1][1] == test_case.end_time diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 9f403345..83eb0477 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -149,38 +149,34 @@ def test_timecode_string(): def test_get_frames(): """Test FrameTimecode get_frames() method.""" - assert FrameTimecode(timecode=1, fps=1.0).get_frames(), 1 - assert FrameTimecode(timecode=1000, fps=60.0).get_frames(), 1000 - assert FrameTimecode(timecode=1000000000, fps=29.97).get_frames(), 1000000000 + assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1 + assert FrameTimecode(timecode=1000, fps=60.0).frame_num == 1000 + assert FrameTimecode(timecode=1000000000, fps=29.97).frame_num == 1000000000 - assert FrameTimecode(timecode=1.0, fps=1.0).get_frames(), int(1.0 / 1.0) - assert FrameTimecode(timecode=1000.0, fps=60.0).get_frames(), int(1000.0 * 60.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_frames(), int(1000000000.0 * 29.97) + assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == int(1.0 / 1.0) + assert FrameTimecode(timecode=1000.0, fps=60.0).frame_num == int(1000.0 * 60.0) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == int(1000000000.0 * 29.97) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_frames(), 2 - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_frames(), 5 - assert FrameTimecode(timecode="00:00:01", fps=10).get_frames(), 10 - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_frames(), 60 + assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 + assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 + assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 def test_get_seconds(): """Test FrameTimecode get_seconds() method.""" - assert FrameTimecode(timecode=1, fps=1.0).get_seconds(), pytest.approx(1.0 / 1.0) - assert FrameTimecode(timecode=1000, fps=60.0).get_seconds(), pytest.approx(1000 / 60.0) - assert FrameTimecode(timecode=1000000000, fps=29.97).get_seconds(), pytest.approx( - 1000000000 / 29.97 - ) + assert FrameTimecode(timecode=1, fps=1.0).seconds, pytest.approx(1.0 / 1.0) + assert FrameTimecode(timecode=1000, fps=60.0).seconds, pytest.approx(1000 / 60.0) + assert FrameTimecode(timecode=1000000000, fps=29.97).seconds, pytest.approx(1000000000 / 29.97) - assert FrameTimecode(timecode=1.0, fps=1.0).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode=1000.0, fps=60.0).get_seconds(), pytest.approx(1000.0) - assert FrameTimecode(timecode=1000000000.0, fps=29.97).get_seconds(), pytest.approx( - 1000000000.0 - ) + assert FrameTimecode(timecode=1.0, fps=1.0).seconds, pytest.approx(1.0) + assert FrameTimecode(timecode=1000.0, fps=60.0).seconds, pytest.approx(1000.0) + assert FrameTimecode(timecode=1000000000.0, fps=29.97).seconds, pytest.approx(1000000000.0) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_seconds(), pytest.approx(2.0) - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_seconds(), pytest.approx(0.5) - assert FrameTimecode(timecode="00:00:01", fps=10).get_seconds(), pytest.approx(1.0) - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_seconds(), pytest.approx(60.0) + assert FrameTimecode(timecode="00:00:02.0000", fps=1).seconds, pytest.approx(2.0) + assert FrameTimecode(timecode="00:00:00.5", fps=10).seconds, pytest.approx(0.5) + assert FrameTimecode(timecode="00:00:01", fps=10).seconds, pytest.approx(1.0) + assert FrameTimecode(timecode="00:01:00.000", fps=1).seconds, pytest.approx(60.0) def test_get_timecode(): @@ -281,8 +277,8 @@ def test_identity(frame_num, fps): """Test FrameTimecode values, when used in init return the same values""" frame_time_code = FrameTimecode(frame_num, fps=fps) assert FrameTimecode(frame_time_code) == frame_time_code - assert FrameTimecode(frame_time_code.get_frames(), fps=fps) == frame_time_code - assert FrameTimecode(frame_time_code.get_seconds(), fps=fps) == frame_time_code + assert FrameTimecode(frame_time_code.frame_num, fps=fps) == frame_time_code + assert FrameTimecode(frame_time_code.seconds, fps=fps) == frame_time_code assert FrameTimecode(frame_time_code.get_timecode(), fps=fps) == frame_time_code diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 7779fc7b..b1e144d2 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -37,14 +37,14 @@ def test_scene_list(test_video_file): start_time = FrameTimecode("00:00:05", video_fps) end_time = FrameTimecode("00:00:10", video_fps) - assert end_time.get_frames() > start_time.get_frames() + assert end_time.frame_num > start_time.frame_num video.seek(start_time) sm.auto_downscale = True num_frames = sm.detect_scenes(video=video, end_time=end_time) - assert num_frames == (end_time.get_frames() - start_time.get_frames()) + assert num_frames == (end_time.frame_num - start_time.frame_num) scene_list = sm.get_scene_list() assert scene_list @@ -56,7 +56,7 @@ def test_scene_list(test_video_file): assert scene_list[-1][1] == end_time for i, _ in enumerate(scene_list): - assert scene_list[i][0].get_frames() < scene_list[i][1].get_frames() + assert scene_list[i][0].frame_num < scene_list[i][1].frame_num if i > 0: # Ensure frame list is sorted (i.e. end time frame of # one scene is equal to the start time of the next). diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 0948c756..922be83d 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -148,7 +148,7 @@ def test_properties(self, vs_type: ty.Type[VideoStream], test_video: VideoParame stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) - assert stream.duration.get_frames() == test_video.total_frames + assert stream.duration.frame_num == test_video.total_frames file_name = os.path.basename(test_video.path) last_dot_pos = file_name.rfind(".") assert stream.name == file_name[:last_dot_pos] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1a470f34..50809f2d 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -658,8 +658,10 @@ Although there have been minimal changes to most API examples, there are several ### CLI Changes -- [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command. -- [feature] WORK IN PROGRESS: New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +### CLI Changes + +- [feature] [WIP] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command ### API Changes @@ -686,4 +688,11 @@ Although there have been minimal changes to most API examples, there are several * Remove deprecated `SparseSceneDetector` interface * Remove deprecated `SceneManager.get_event_list()` method * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) + * Remove deprecated `AdaptiveDetector` constructor argument `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) + + #### General + + * Deprecated functionality preserved from v0.6 now uses the `warnings` module + * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods + * Add new `Timecode` type to represent frame timings in terms of the video's source timebase From 08d333f63c3b3cc968db9a49c74ce37a8c67e531 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:19:15 -0400 Subject: [PATCH 245/407] [save-edl] Remove PTS correction in end timestamp calculation #516 --- scenedetect/_cli/commands.py | 2 +- tests/test_cli.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 3d747fa0..38fa4679 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -292,7 +292,7 @@ def get_edl_timecode(timecode: FrameTimecode): # Add each shot as an edit entry for i, (start, end) in enumerate(scenes): in_tc = get_edl_timecode(start) - out_tc = get_edl_timecode(end - 1) # Correct for presentation time + out_tc = get_edl_timecode(end) # Correct for presentation time # Format the edit entry according to CMX 3600 format event_line = f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}" edl_content.append(event_line) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d7280ad..533f6f9b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -104,7 +104,7 @@ def invoke_scenedetect( if config_file: command += " -c %s" % config_file command += " " + args.format(**value_dict) - return subprocess.call(command.strip().split(" ")) + return subprocess.call(command.strip().split(" "), shell=True) def test_cli_no_args(): @@ -757,8 +757,8 @@ def test_cli_save_edl(tmp_path: Path): TITLE: {DEFAULT_VIDEO_NAME} FCM: NON-DROP FRAME -001 AX V C 00:00:02:00 00:00:03:17 00:00:02:00 00:00:03:17 -002 AX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 +001 AX V C 00:00:02:00 00:00:03:18 00:00:02:00 00:00:03:18 +002 AX V C 00:00:03:18 00:00:06:00 00:00:03:18 00:00:06:00 """ assert output_path.read_text() == EXPECTED_EDL_OUTPUT @@ -778,8 +778,8 @@ def test_cli_save_edl_with_params(tmp_path: Path): TITLE: title FCM: NON-DROP FRAME -001 BX V C 00:00:02:00 00:00:03:17 00:00:02:00 00:00:03:17 -002 BX V C 00:00:03:18 00:00:05:23 00:00:03:18 00:00:05:23 +001 BX V C 00:00:02:00 00:00:03:18 00:00:02:00 00:00:03:18 +002 BX V C 00:00:03:18 00:00:06:00 00:00:03:18 00:00:06:00 """ assert output_path.read_text() == EXPECTED_EDL_OUTPUT From c483d7b28a94b10189771429b0c3c435cb27a0d5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:23:06 -0400 Subject: [PATCH 246/407] [dist] Update ffmpeg 7.1 -> 8.0 --- appveyor.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 4029ec9a..2758a834 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -20,6 +20,7 @@ environment: secure: of3o1pInqCJYwKLFsiadbsRYazCmCuZq7r2roaYvYXmBvm6e6JHsRU47waylTmhm ai_license_salt: secure: +NKWwlkEptlThgfeL35pLo7EsnkJc+4WODm8tTg1aO5fc0duQ4r100fHQYj6nzhyUdy3Dhs/mOLkxD8rNbBiEQ== + ffmpeg_version: "8.0" # SignPath Config for Code Signing deploy: @@ -41,7 +42,7 @@ install: - python -m pip install --upgrade -r dist/requirements_windows.txt --no-binary imageio-ffmpeg # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-full_build.7z + - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-7.1-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' @@ -72,7 +73,7 @@ install: - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn - - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.5\\bin\\x86' + - 'SET PATH=%PATH%;C:\\Program Files (x86)\\Caphyon\\Advanced Installer 22.9.1\\bin\\x86' # License path must be absolute - AdvancedInstaller.com /RegisterOffline "%cd%\license65.dat" # Create MSI installer From 26f9cbaf3d473e3698450e24381db273a1a47e18 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:24:43 -0400 Subject: [PATCH 247/407] [releases] Prepare v0.6.7 --- .github/workflows/generate-docs.yml | 2 +- README.md | 2 +- appveyor.yml | 2 +- dist/installer/PySceneDetect.aip | 8 ++++---- scenedetect/__init__.py | 2 +- website/pages/changelog.md | 13 +++++++++++++ website/pages/docs.md | 1 + website/pages/download.md | 8 ++++---- website/pages/index.md | 2 +- 9 files changed, 27 insertions(+), 13 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index e64076b1..74b20deb 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -16,7 +16,7 @@ jobs: env: # TODO: Figure out a better way to handle figuring out what version /latest should be, # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.6' + scenedetect_docs_latest: '0.6.7' scenedetect_docs_dest: '' steps: diff --git a/README.md b/README.md index 012c1225..db5be2db 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Video Cut Detection and Analysis Tool ---------------------------------------------------------- -### Latest Release: v0.6.6 (March 9, 2025) +### Latest Release: v0.6.7 (August 24, 2025) **Website**: [scenedetect.com](https://www.scenedetect.com) diff --git a/appveyor.yml b/appveyor.yml index 2758a834..8d142330 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -43,7 +43,7 @@ install: # Checkout build resources and third party software used for testing. - git checkout refs/remotes/origin/resources -- dist/ - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - - 7z e ffmpeg-7.1-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r + - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index 1fea79b0..c590f12e 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -23,10 +23,10 @@ - + - + @@ -125,7 +125,7 @@ - + @@ -1642,7 +1642,7 @@ - + diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index cd35626f..c1d4ded9 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.6" +__version__ = "0.6.7" init_logger() logger = getLogger("pyscenedetect") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0801a045..6a8c090d 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,19 @@ Releases ## PySceneDetect 0.6 +### PySceneDetect 0.6.7 (August 24, 2025) + +#### Release Notes + +Minor update to fix issues with importing EDL files into DaVinci Resolve and other editors. + +#### Changelog + + - [bugfix] Fix `save-edl` end timestamp being too short by 1 frame [#516](https://github.com/Breakthrough/PySceneDetect/issues/516) + - [general] Updates to Windows distributions: + - ffmpeg 7.1 -> 8.0 + + ### PySceneDetect 0.6.6 (March 9, 2025) #### Release Notes diff --git a/website/pages/docs.md b/website/pages/docs.md index 96555cdb..346036b2 100644 --- a/website/pages/docs.md +++ b/website/pages/docs.md @@ -4,6 +4,7 @@ ## Stable * [latest](latest/) + * [v0.6.7](0.6.7/) * [v0.6.6](0.6.6/) * [v0.6.5](0.6.5/) * [v0.6.4](0.6.4/) diff --git a/website/pages/download.md b/website/pages/download.md index 4d3b6d62..e356c800 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -20,10 +20,10 @@ PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi ## Windows Build (64-bit Only)  
    -

    Latest Release: v0.6.6

    -

      Release Date:  March 9, 2025

    -  Installer  (recommended)      -  Portable .zip      +

    Latest Release: v0.6.7

    +

      Release Date:  August 24, 2025

    +  Installer  (recommended)      +  Portable .zip        Getting Started
    diff --git a/website/pages/index.md b/website/pages/index.md index 05d1866b..5dadd243 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -2,7 +2,7 @@ PySceneDetect
    -

      Latest Release: v0.6.6 (March 9, 2025)

    +

      Latest Release: v0.6.7 (August 24, 2025)

      Download        Changelog        Documentation        Getting Started
    See the changelog for the latest release notes and known issues. From 886ca4ba25cb2fabe9a216ff0b675323e0952f43 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Aug 2025 22:47:24 -0400 Subject: [PATCH 248/407] [dist] Fix installer files --- appveyor.yml | 4 +++- dist/installer/PySceneDetect.aip | 9 --------- tests/test_cli.py | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 8d142330..af16f038 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -99,7 +99,9 @@ test_script: - git checkout refs/remotes/origin/resources -- tests/resources/ - move dist\scenedetect\ffmpeg.exe ffmpeg.exe # Run unit tests - - pytest + # TODO: We are at the new build time limit for this plan apparently, 10 mins. Figure out a + # strategy to deal with that (see if we can use Github as a builder?). + # - pytest # Test Windows build - move ffmpeg.exe dist\scenedetect\ffmpeg.exe - cd dist/scenedetect diff --git a/dist/installer/PySceneDetect.aip b/dist/installer/PySceneDetect.aip index c590f12e..4e692898 100644 --- a/dist/installer/PySceneDetect.aip +++ b/dist/installer/PySceneDetect.aip @@ -366,7 +366,6 @@ - @@ -639,13 +638,6 @@ - - - - - - - @@ -1897,7 +1889,6 @@ - diff --git a/tests/test_cli.py b/tests/test_cli.py index 533f6f9b..c390f139 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -104,7 +104,7 @@ def invoke_scenedetect( if config_file: command += " -c %s" % config_file command += " " + args.format(**value_dict) - return subprocess.call(command.strip().split(" "), shell=True) + return subprocess.call(command.strip().split(" ")) def test_cli_no_args(): From de5a63b96c7ce85ec15ba6214275397bd4eb8e3c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 30 Aug 2025 00:18:06 -0400 Subject: [PATCH 249/407] [scene_detector] Make detector interface an ABC and cleanup properties --- scenedetect/detector.py | 56 ++++++++++++++++++++---------------- scenedetect/scene_manager.py | 3 -- website/pages/changelog.md | 2 ++ 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 1e0b2e7c..5f903601 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -25,6 +25,7 @@ """ import typing as ty +from abc import ABC, abstractmethod from enum import Enum import numpy @@ -33,36 +34,18 @@ from scenedetect.stats_manager import StatsManager -class SceneDetector: +class SceneDetector(ABC): """Base class to inherit from when implementing a scene detection algorithm. This API is not yet stable and subject to change. """ - # TODO(v0.7): Make this a proper abstract base class. + def __init__(self): + self._stats_manager: ty.Optional[StatsManager] = None - # TODO(v0.7): This should be a property. - stats_manager: ty.Optional[StatsManager] = None - """Optional :class:`StatsManager ` to - use for caching frame metrics to and from.""" - - def stats_manager_required(self) -> bool: - """Stats Manager Required: Prototype indicating if detector requires stats. - - Returns: - True if a StatsManager is required for the detector, False otherwise. - """ - return False - - def get_metrics(self) -> ty.List[str]: - """Returns a list of all metric names/keys used by this detector. - - Returns: - List of strings of frame metric key names that will be used by - the detector when a StatsManager is passed to process_frame. - """ - return [] + # Required Methods + @abstractmethod def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray ) -> ty.List[FrameTimecode]: @@ -75,7 +58,8 @@ def process_frame( Returns: List of timecodes where scene cuts have been detected, if any. """ - return [] + + # Optional Methods def post_process(self, timecode: int) -> ty.List[FrameTimecode]: """Called after there are no more frames to process. @@ -95,6 +79,30 @@ def event_buffer_length(self) -> int: """ return 0 + # Frame Stats/Metrics + + @property + def stats_manager(self) -> ty.Optional[StatsManager]: + """Optional :class:`StatsManager ` to use for + storing frame metrics. When this detector is added to a parent + :class:`SceneManager `, then this is set to the + same :class:`StatsManager ` of the parent - but + only if it has one itself.""" + return self._stats_manager + + @stats_manager.setter + def stats_manager(self, value: ty.Optional[StatsManager]): + self._stats_manager = value + + def get_metrics(self) -> ty.List[str]: + """Returns a list of all metric names/keys used by this detector. + + Returns: + List of strings of frame metric key names that will be used by + the detector when a StatsManager is passed to process_frame. + """ + return [] + class FlashFilter: """Filters fast-cuts to enforce minimum scene length.""" diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index e94858b3..4746b288 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -313,9 +313,6 @@ def add_detector(self, detector: SceneDetector) -> None: Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ - if self._stats_manager is None and detector.stats_manager_required(): - assert not self._detector_list - self._stats_manager = StatsManager() detector.stats_manager = self._stats_manager if self._stats_manager is not None: diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 96c8aa76..586bf7c3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -703,6 +703,8 @@ Although there have been minimal changes to most API examples, there are several * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) * Remove deprecated `AdaptiveDetector` constructor argument `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) + * Remove `SceneDetector.stats_manager_required` property as it is no longer required + * Remove `SceneDetector` is now a Python abstract class (`abc.ABC`) but all method names remain the same #### General From 727f94bd1048218e03728daf7e5d500e6bccfd23 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 30 Aug 2025 00:51:20 -0400 Subject: [PATCH 250/407] [common] Simplify FrameTimecode implementation --- scenedetect/common.py | 166 ++++++++--------------------------- tests/test_frame_timecode.py | 8 +- 2 files changed, 41 insertions(+), 133 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index a120f1d1..6d594f82 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -402,21 +402,44 @@ def _parse_timecode_string(self, input: str) -> int: raise ValueError("Timecode seconds value must be positive.") return self._seconds_to_frames(as_float) - def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> int: + """Get the frame number from `other` for arithmetic operations.""" if isinstance(other, int): - self._frame_num += other - elif isinstance(other, FrameTimecode): + return other + if isinstance(other, float): + return self._seconds_to_frames(other) + if isinstance(other, str): + return self._parse_timecode_string(other) + if 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.") + return other._frame_num + raise ValueError("FrameTimecode instances require equal framerate for arithmetic.") + raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") + + def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if other is None: + return False + # Allow comparison with other types by converting them to frames. + # If the framerate is not equal, a TypeError will be raised. + return self.frame_num == self._get_other_as_frames(other) + + def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return not self == other + + def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num < self._get_other_as_frames(other) + + def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num <= self._get_other_as_frames(other) + + def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num > self._get_other_as_frames(other) + + def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + return self.frame_num >= self._get_other_as_frames(other) + + def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": + self._frame_num += self._get_other_as_frames(other) if self._frame_num < 0: # Required to allow adding negative seconds/frames. self._frame_num = 0 return self @@ -427,22 +450,7 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.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) - ) + self._frame_num -= self._get_other_as_frames(other) if self._frame_num < 0: self._frame_num = 0 return self @@ -452,106 +460,6 @@ def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi to_return -= other return to_return - def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if isinstance(other, int): - return self._frame_num == other - elif isinstance(other, float): - return self.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: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - return not self == other - - def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num < other - elif isinstance(other, float): - return self.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: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num <= other - elif isinstance(other, float): - return self.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: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num > other - elif isinstance(other, float): - return self.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: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if isinstance(other, int): - return self._frame_num >= other - elif isinstance(other, float): - return self.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. diff --git a/tests/test_frame_timecode.py b/tests/test_frame_timecode.py index 83eb0477..a1081d45 100644 --- a/tests/test_frame_timecode.py +++ b/tests/test_frame_timecode.py @@ -200,9 +200,9 @@ def test_equality(): assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) # Comparing FrameTimecodes with different framerates raises a TypeError. - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert x == FrameTimecode(timecode=1.0, fps=100.0) - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert x == FrameTimecode(timecode=1.0, fps=10.1) assert x == FrameTimecode(x) @@ -249,7 +249,7 @@ def test_addition(): assert x + 10 == "00:00:02.000" - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert FrameTimecode("00:00:02.000", fps=20.0) == x + 10 @@ -268,7 +268,7 @@ def test_subtraction(): assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) - with pytest.raises(TypeError): + with pytest.raises(ValueError): assert FrameTimecode("00:00:02.000", fps=20.0) == x - 10 From ef1f39b72a1bc95b8baf35efa0a76ca7d65323c7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 31 Aug 2025 23:13:41 -0400 Subject: [PATCH 251/407] [common] Allow PTS mode to pass scanning loop Most commands still fail due to missing arithmetic operators, but a quick spot check by overriding or skipping a few let me run `split-video` successfully. That will be fixed in a follow up however. --- scenedetect/common.py | 67 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 6d594f82..2fa9bb4e 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -131,7 +131,7 @@ class Interpolation(Enum): # # We might be able to avoid changing the detector interface if we just have them work directly with # PTS and convert them back to FrameTimecodes with the same time base. -@dataclass +@dataclass(frozen=True) class Timecode: """Timing information associated with a given frame.""" @@ -181,6 +181,7 @@ def __init__( if isinstance(timecode, FrameTimecode): self._framerate = timecode._framerate if fps is None else fps self._frame_num = timecode._frame_num + self._timecode = timecode._timecode return # Timecode. @@ -411,35 +412,60 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] if isinstance(other, str): return self._parse_timecode_string(other) if isinstance(other, FrameTimecode): - if self.equal_framerate(other._framerate): + # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. + if self._framerate and other._framerate and not self.equal_framerate(other._framerate): + raise ValueError( + "FrameTimecode instances require equal framerate for frame-based arithmetic." + ) + if other._frame_num is not None: return other._frame_num - raise ValueError("FrameTimecode instances require equal framerate for arithmetic.") + # If other has no frame_num, it must have a timecode. Convert to frames. + return self._seconds_to_frames(other.seconds) raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False - # Allow comparison with other types by converting them to frames. - # If the framerate is not equal, a TypeError will be raised. + if self._timecode: + return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - return not self == other + if other is None: + return True + if self._timecode: + return self.seconds != self._get_other_as_seconds(other) + return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if self._timecode: + return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - self._frame_num += self._get_other_as_frames(other) + if self._timecode: + new_seconds = self.seconds + self._get_other_as_seconds(other) + # TODO: This is incorrect for VFR, need a better way to handle this. + # For now, we convert back to a frame number. + self._frame_num = self._seconds_to_frames(new_seconds) + self._timecode = None + else: + self._frame_num += self._get_other_as_frames(other) if self._frame_num < 0: # Required to allow adding negative seconds/frames. self._frame_num = 0 return self @@ -450,7 +476,14 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - self._frame_num -= self._get_other_as_frames(other) + if self._timecode: + new_seconds = self.seconds - self._get_other_as_seconds(other) + # TODO: This is incorrect for VFR, need a better way to handle this. + # For now, we convert back to a frame number. + self._frame_num = self._seconds_to_frames(new_seconds) + self._timecode = None + else: + self._frame_num -= self._get_other_as_frames(other) if self._frame_num < 0: self._frame_num = 0 return self @@ -473,7 +506,25 @@ def __str__(self) -> str: return self.get_timecode() def __repr__(self) -> str: + if self._timecode: + return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self._frame_num, self._framerate) def __hash__(self) -> int: + if self._timecode: + return hash(self._timecode) return self._frame_num + + def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: + """Get the time in seconds from `other` for arithmetic operations.""" + if isinstance(other, int): + return float(other) / self._framerate + if isinstance(other, float): + return other + if isinstance(other, str): + # This is not ideal, but we need a framerate to parse strings. + # We create a temporary FrameTimecode to do this. + return FrameTimecode(timecode=other, fps=self._framerate).seconds + if isinstance(other, FrameTimecode): + return other.seconds + raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") From ad9d35dd94f8daa2d46a69c484b01d469040c826 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 2 Sep 2025 21:26:10 -0400 Subject: [PATCH 252/407] [tests] Rename test_frame_timecode -> test_timecode --- tests/{test_frame_timecode.py => test_timecode.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_frame_timecode.py => test_timecode.py} (100%) diff --git a/tests/test_frame_timecode.py b/tests/test_timecode.py similarity index 100% rename from tests/test_frame_timecode.py rename to tests/test_timecode.py From adbaad43ebc65d677cf63e5ec53d188a4596d7f5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Sep 2025 22:29:19 -0400 Subject: [PATCH 253/407] [site] Allow URLs of the form scenedetect.com/issue/200 --- website/mkdocs.yml | 3 +++ website/pages/issue.md | 4 ++++ website/pages/issues.md | 4 ++++ website/pages/redirect.js | 22 ++++++++++++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 website/pages/issue.md create mode 100644 website/pages/issues.md create mode 100644 website/pages/redirect.js diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 501fc51e..a7c87a77 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -39,3 +39,6 @@ markdown_extensions: [fenced_code] extra_css: - style.css + +extra_javascript: + - redirect.js diff --git a/website/pages/issue.md b/website/pages/issue.md new file mode 100644 index 00000000..b9393d82 --- /dev/null +++ b/website/pages/issue.md @@ -0,0 +1,4 @@ + +# Going to Github Issues... + +Redirecting to Github Issues. diff --git a/website/pages/issues.md b/website/pages/issues.md new file mode 100644 index 00000000..b9393d82 --- /dev/null +++ b/website/pages/issues.md @@ -0,0 +1,4 @@ + +# Going to Github Issues... + +Redirecting to Github Issues. diff --git a/website/pages/redirect.js b/website/pages/redirect.js new file mode 100644 index 00000000..832f1f53 --- /dev/null +++ b/website/pages/redirect.js @@ -0,0 +1,22 @@ + +(function() { + var path = window.location.pathname; + + const TARGETS = [ 'issue', 'issues' ] + for (const target of TARGETS) { + if (path == target) { + window.location.href = 'https://github.com/Breakthrough/PySceneDetect/issues/'; + } + } + + const PREFIXES = ['/issue/', '/issues/'] + for (const prefix of PREFIXES) { + if (path.startsWith(prefix)) { + var issueNumber = path.substring(prefix.length); + if (issueNumber) { + var newUrl = 'https://github.com/Breakthrough/PySceneDetect/issues/' + issueNumber; + window.location.href = newUrl; + } + } + } +})(); From 8f94e0474b1437c9ba9546e907d59ad47a6d2666 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Sep 2025 22:32:40 -0400 Subject: [PATCH 254/407] [dist] Exclude click 8.3.0 as per #521 --- requirements.txt | 3 ++- requirements_headless.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2c45e1a5..fd5524ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python platformdirs diff --git a/requirements_headless.txt b/requirements_headless.txt index 4dfedd38..f32dc452 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements for Headless Machines # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python-headless platformdirs From b83789ba32e2cb1a927778d4a0e2712360dea080 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Sep 2025 20:04:27 -0400 Subject: [PATCH 255/407] [site] Fix custom redirects for issue tracker #522 --- website/mkdocs.yml | 3 --- website/overrides/404.html | 40 ++++++++++++++++++++++++++++++++++++++ website/pages/issue.md | 4 ---- website/pages/issues.md | 4 ---- website/pages/redirect.js | 22 --------------------- 5 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 website/overrides/404.html delete mode 100644 website/pages/issue.md delete mode 100644 website/pages/issues.md delete mode 100644 website/pages/redirect.js diff --git a/website/mkdocs.yml b/website/mkdocs.yml index a7c87a77..501fc51e 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -39,6 +39,3 @@ markdown_extensions: [fenced_code] extra_css: - style.css - -extra_javascript: - - redirect.js diff --git a/website/overrides/404.html b/website/overrides/404.html new file mode 100644 index 00000000..72994aec --- /dev/null +++ b/website/overrides/404.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block content %} +

    Page Not Found

    +

    This page does not exist.

    + + +{% endblock %} \ No newline at end of file diff --git a/website/pages/issue.md b/website/pages/issue.md deleted file mode 100644 index b9393d82..00000000 --- a/website/pages/issue.md +++ /dev/null @@ -1,4 +0,0 @@ - -# Going to Github Issues... - -Redirecting to Github Issues. diff --git a/website/pages/issues.md b/website/pages/issues.md deleted file mode 100644 index b9393d82..00000000 --- a/website/pages/issues.md +++ /dev/null @@ -1,4 +0,0 @@ - -# Going to Github Issues... - -Redirecting to Github Issues. diff --git a/website/pages/redirect.js b/website/pages/redirect.js deleted file mode 100644 index 832f1f53..00000000 --- a/website/pages/redirect.js +++ /dev/null @@ -1,22 +0,0 @@ - -(function() { - var path = window.location.pathname; - - const TARGETS = [ 'issue', 'issues' ] - for (const target of TARGETS) { - if (path == target) { - window.location.href = 'https://github.com/Breakthrough/PySceneDetect/issues/'; - } - } - - const PREFIXES = ['/issue/', '/issues/'] - for (const prefix of PREFIXES) { - if (path.startsWith(prefix)) { - var issueNumber = path.substring(prefix.length); - if (issueNumber) { - var newUrl = 'https://github.com/Breakthrough/PySceneDetect/issues/' + issueNumber; - window.location.href = newUrl; - } - } - } -})(); From 3632ecabc1612e825a43edc354c5286bbd5928f1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 22 Sep 2025 20:13:03 -0400 Subject: [PATCH 256/407] [project] Update TODO style to be clickable --- scenedetect/_cli/commands.py | 2 +- scenedetect/_cli/controller.py | 2 +- scenedetect/backends/moviepy.py | 9 +++++---- scenedetect/backends/opencv.py | 6 +++--- scenedetect/backends/pyav.py | 3 ++- scenedetect/detectors/adaptive_detector.py | 2 -- scenedetect/scene_manager.py | 6 +++--- scenedetect/stats_manager.py | 4 ++-- tests/test_cli.py | 3 ++- tests/test_detectors.py | 8 ++++---- website/overrides/404.html | 8 +++++--- 11 files changed, 28 insertions(+), 25 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 82f66386..6813caa8 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -504,7 +504,7 @@ def save_otio( frame_rate = context.video_stream.frame_rate # List of track mapping to resource type. - # TODO(#497): Allow exporting without an audio track. + # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. track_list = {"Video 1": "Video"} if audio: track_list["Audio 1"] = "Audio" diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 48ff340d..eee05267 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -120,7 +120,7 @@ def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: ) # Handle case where video failure is most likely due to multiple audio tracks (#179). - # TODO(#380): Ensure this does not erroneusly fire. + # TODO(https://scenedetect.com/issues/380): Ensure this does not erroneusly fire. if num_frames <= 0 and isinstance(context.video_stream, VideoStreamCv2): logger.critical( "Failed to read any frames from video file. This could be caused by the video" diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 96c38a04..c4f4e8a5 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -185,10 +185,11 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ) success = True except OSError as ex: - # TODO(#380): Other backends do not currently throw an exception if attempting to seek - # past EOF. We need to ensure consistency for seeking past end of video with respect to - # errors and behaviour, and should probably gracefully stop at the last frame instead - # of throwing an exception. + # TODO(https://scenedetect.com/issues/380): Other backends do not currently throw an + # exception if attempting to seek past EOF. + # + # We need to ensure consistency for seeking past end of video with respect to errors and + # behaviour, and should probably gracefully stop at the last frame instead of throwing. if target >= self.duration: raise SeekError("Target frame is beyond end of video!") from ex raise diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index c10deca3..f8d402f9 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -324,9 +324,9 @@ def _open_capture(self, framerate: ty.Optional[float] = None): cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 -# TODO(#168): Support non-monotonic timing for `position`. VFR timecode support is a -# prerequisite for this. Timecodes are currently calculated by multiplying the framerate -# by number of frames. Actual elapsed time can be obtained via `position_ms` for now. +# TODO(https://scenedetect.com/issues/168): Support non-monotonic timing for `position`. VFR timecode +# support is a prerequisite for this. Timecodes are currently calculated by multiplying the +# framerate by number of frames. Actual elapsed time can be obtained via `position_ms` for now. class VideoCaptureAdapter(VideoStream): """Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports VideoCaptures which may not support seeking. diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 32f57ead..c78183ec 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -70,7 +70,8 @@ def __init__( """ self._container = None - # TODO(#258): See what self._container.discard_corrupt = True does with corrupt videos. + # TODO(https://scenedetect.com/issues/258): See what `self._container.discard_corrupt = True` + # does with corrupt videos. super().__init__() # Ensure specified framerate is valid if set. diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index fa23795f..7a0a23af 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -100,8 +100,6 @@ def get_metrics(self) -> ty.List[str]: def process_frame( self, timecode: FrameTimecode, frame_img: np.ndarray ) -> ty.List[FrameTimecode]: - # TODO(#283): Merge this with ContentDetector and turn it on by default. - super().process_frame(timecode=timecode, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 4746b288..7bb45d38 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -387,9 +387,9 @@ def _process_frame( """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" new_cuts = False - # TODO(#283): This breaks with AdaptiveDetector as cuts differ from the frame number - # being processed. Allow detectors to specify the max frame lookahead they require - # (i.e. any event will never be more than N frames behind the current one). + # TODO(https://scenedetect.com/issues/283): This breaks with AdaptiveDetector as cuts differ + # from the frame number being processed. Allow detectors to specify the max frame lookahead + # they require (i.e. any event will never be more than N frames behind the current one). self._frame_buffer.append(frame_im) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index e6b873ab..61e67970 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -118,8 +118,8 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: """Register a list of metric keys that will be used by the detector.""" self._metric_keys = self._metric_keys.union(set(metric_keys)) - # TODO(#507): This interface is difficult to use, we should support the dictionary protocol. - # Ideally this should work with Panadas. This could be done with the v0.7 API change. + # TODO(https://scenedetect.com/issues/507): We should support the dictionary protocol instead + # of using this bespoke interface. It would be useful for Pandas compatibility as well. def get_metrics( self, timecode: FrameTimecode, metric_keys: ty.Iterable[str] ) -> ty.List[ty.Any]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4be5574d..61022bf8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -520,7 +520,8 @@ def test_cli_save_images_path_handling(tmp_path: Path): assert image.shape == (544, 1280, 3) -# TODO(#134): This works fine with OpenCV currently, but needs to be supported for PyAV and MoviePy. +# TODO(https://scenedetect.com/issues/134): This works fine with OpenCV currently, but needs to be +# supported for PyAV and MoviePy. def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): """Test that `save-images` command rotates images correctly with the default backend.""" assert ( diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 43ebfd23..0e5f4214 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -41,10 +41,10 @@ ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) -# TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores -# regardless of resolution. This will ensure that threshold values will hold true for different -# input sources. Most detectors already provide this guarantee, so this is more to prevent any -# regressions in the future. +# TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively +# consistent frame scores regardless of resolution. This will ensure that threshold values will hold +# true for different input sources. Most detectors already provide this guarantee, so this is more +# to prevent any regressions in the future. # TODO: Reduce code duplication here and in `conftest.py` diff --git a/website/overrides/404.html b/website/overrides/404.html index 72994aec..7b608acb 100644 --- a/website/overrides/404.html +++ b/website/overrides/404.html @@ -12,12 +12,14 @@

    Page Not Found

    var pageTitle = document.getElementById('404-title'); var pageBody = document.getElementById('404-body'); + // The canonical form is "issues" but we allow "issue" as well. const TARGETS = [ '/issue', '/issues', '/issue/', '/issues/' ]; for (const target of TARGETS) { if (path === target) { pageTitle.innerText = 'Redirecting...'; - pageBody.innerText = 'Redirecting to GitHub issues...'; - window.location.href = 'https://github.com/Breakthrough/PySceneDetect/issues/'; + const url = 'https://github.com/Breakthrough/PySceneDetect/issues/'; + pageBody.innerHTML = 'Redirecting to GitHub issues...'; + window.location.href = url; return; } } @@ -28,8 +30,8 @@

    Page Not Found

    var issueNumber = path.substring(prefix.length); if (issueNumber) { pageTitle.innerText = 'Redirecting...'; - pageBody.innerText = 'Redirecting to issue #' + issueNumber + ' on GitHub...'; var newUrl = 'https://github.com/Breakthrough/PySceneDetect/issues/' + issueNumber; + pageBody.innerHTML = 'Redirecting to issue #' + issueNumber + ' on GitHub...'; window.location.href = newUrl; return; } From a87ea690951fab7d34d412ef1b8216c47fa078a4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 20:03:26 -0400 Subject: [PATCH 257/407] [dist] Add new workflow to publish packages to PyPI --- .github/workflows/publish-pypi.yml | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..9ccdbf2e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,106 @@ +name: Publish PyPI Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish' + required: true + environment: + description: 'PyPI environment' + required: true + type: choice + options: + - test + - prod + default: 'test' + +jobs: + verify-builds: + name: Verify build workflows passed + runs-on: ubuntu-latest + steps: + - name: Verify build workflows + uses: actions/github-script@v6 + with: + script: | + const { owner, repo } = context.repo; + const tag = "${{ github.event.inputs.tag }}"; + const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; + let workflowConclusions = {}; + + console.log(`Checking for successful workflow runs for tag: ${tag}`); + + const { data: response } = await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + event: 'push', + }); + + const runsForTag = response.workflow_runs.filter(run => run.head_branch === tag); + + for (const run of runsForTag) { + if (requiredWorkflows.includes(run.name)) { + if (!workflowConclusions[run.name] || new Date(run.created_at) > new Date(workflowConclusions[run.name].created_at)) { + workflowConclusions[run.name] = { + conclusion: run.conclusion, + created_at: run.created_at, + html_url: run.html_url, + }; + } + } + } + + let allSuccess = true; + for (const workflowName of requiredWorkflows) { + if (!workflowConclusions[workflowName]) { + core.setFailed(`Workflow "${workflowName}" was not found for tag ${tag}.`); + allSuccess = false; + } else if (workflowConclusions[workflowName].conclusion !== 'success') { + core.setFailed(`Workflow "${workflowName}" did not succeed for tag ${tag}. Conclusion was "${workflowConclusions[workflowName].conclusion}". See: ${workflowConclusions[workflowName].html_url}`); + allSuccess = false; + } else { + console.log(`✅ Workflow "${workflowName}" succeeded for tag ${tag}.`); + } + } + + if (!allSuccess) { + throw new Error("One or more required build workflows did not succeed."); + } + + build-and-publish: + name: Build and publish Python distributions to ${{ github.event.inputs.environment }} PyPI + runs-on: ubuntu-latest + needs: verify-builds + + + environment: + name: ${{ github.event.inputs.environment == 'test' && 'testpypi' || 'pypi' }} + url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} From 4b21c2783547be0f2f1fd77a843d6f930629ad5c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 21 Sep 2025 22:32:40 -0400 Subject: [PATCH 258/407] [dist] Exclude click 8.3.0 as per #521 --- requirements.txt | 3 ++- requirements_headless.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2c45e1a5..fd5524ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python platformdirs diff --git a/requirements_headless.txt b/requirements_headless.txt index 4dfedd38..f32dc452 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -2,7 +2,8 @@ # PySceneDetect Requirements for Headless Machines # av>=9.2 -click>=8.0 +# click 8.3.0 is excluded as per https://scenedetect.com/issues/521 +click~=8.0, !=8.3.0 numpy opencv-python-headless platformdirs From 7adac99f640e2d52e4aa7714323eb2ba5f125b85 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 20:03:26 -0400 Subject: [PATCH 259/407] [dist] Add new workflow to publish packages to PyPI --- .github/workflows/publish-pypi.yml | 120 +++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..670468d6 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,120 @@ +name: Publish PyPI Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish' + required: true + environment: + description: 'PyPI environment' + required: true + type: choice + options: + - test + - prod + default: 'test' + +jobs: + verify-builds: + name: Verify build workflows passed + runs-on: ubuntu-latest + steps: + - name: Verify build workflows + uses: actions/github-script@v6 + with: + script: | + const { owner, repo } = context.repo; + const tag = "${{ github.event.inputs.tag }}"; + const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; + let workflowConclusions = {}; + + console.log(`Checking for successful workflow runs for tag: ${tag}`); + + const { data: response } = await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + event: 'push', + }); + + const runsForTag = response.workflow_runs.filter(run => run.head_branch === tag); + + for (const run of runsForTag) { + if (requiredWorkflows.includes(run.name)) { + if (!workflowConclusions[run.name] || new Date(run.created_at) > new Date(workflowConclusions[run.name].created_at)) { + workflowConclusions[run.name] = { + conclusion: run.conclusion, + created_at: run.created_at, + html_url: run.html_url, + }; + } + } + } + + let allSuccess = true; + for (const workflowName of requiredWorkflows) { + if (!workflowConclusions[workflowName]) { + core.setFailed(`Workflow "${workflowName}" was not found for tag ${tag}.`); + allSuccess = false; + } else if (workflowConclusions[workflowName].conclusion !== 'success') { + core.setFailed(`Workflow "${workflowName}" did not succeed for tag ${tag}. Conclusion was "${workflowConclusions[workflowName].conclusion}". See: ${workflowConclusions[workflowName].html_url}`); + allSuccess = false; + } else { + console.log(`✅ Workflow "${workflowName}" succeeded for tag ${tag}.`); + } + } + + if (!allSuccess) { + throw new Error("One or more required build workflows did not succeed."); + } + + build-and-publish: + name: Build and publish Python distributions to ${{ github.event.inputs.environment }} PyPI + runs-on: ubuntu-latest + needs: verify-builds + + + environment: + name: ${{ github.event.inputs.environment == 'test' && 'testpypi' || 'pypi' }} + url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build Package + run: | + python -m build + mkdir pkg + mv dist/*.tar.gz pkg/ + mv dist/*.whl pkg/ + + - name: Upload Package + uses: actions/upload-artifact@v4 + with: + name: scenedetect-dist + path: | + pkg/*.tar.gz + pkg/*.whl + + - name: Publish Package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} + packages-dir: pkg/ + print-hash: true From f8e1914f9057a2d9692738ca0fece14fc4b2ecad Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 20:06:20 -0400 Subject: [PATCH 260/407] [dist] Prepare 0.6.7.1 --- .github/workflows/build.yml | 3 +-- requirements.txt | 2 +- requirements_headless.txt | 2 +- scenedetect/__init__.py | 2 +- setup.cfg | 8 ++++---- website/pages/changelog.md | 4 ++++ 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6807a714..52ef6876 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,8 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # TODO: Bump ubuntu 20 to 22 when past EOL date. - os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] + os: [macos-13, macos-14, ubuntu-22.04, ubuntu-latest, windows-latest] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. diff --git a/requirements.txt b/requirements.txt index fd5524ff..bb9dd91d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # av>=9.2 # click 8.3.0 is excluded as per https://scenedetect.com/issues/521 -click~=8.0, !=8.3.0 +click~=8.0,<8.3.0 numpy opencv-python platformdirs diff --git a/requirements_headless.txt b/requirements_headless.txt index f32dc452..b9050ffa 100644 --- a/requirements_headless.txt +++ b/requirements_headless.txt @@ -3,7 +3,7 @@ # av>=9.2 # click 8.3.0 is excluded as per https://scenedetect.com/issues/521 -click~=8.0, !=8.3.0 +click~=8.0,<8.3.0 numpy opencv-python-headless platformdirs diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index c1d4ded9..264d2940 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -56,7 +56,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.6.7" +__version__ = "0.6.7.1" init_logger() logger = getLogger("pyscenedetect") diff --git a/setup.cfg b/setup.cfg index e35c5861..206be961 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ [metadata] name = scenedetect version = attr: scenedetect.__version__ -license = BSD 3-Clause License +license = BSD-3-Clause author = Brandon Castellano author_email = brandon248@gmail.com description = Video scene cut/shot detection program and Python library. @@ -21,7 +21,6 @@ classifiers = Intended Audience :: Developers Intended Audience :: End Users/Desktop Intended Audience :: System Administrators - License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 @@ -39,7 +38,8 @@ keywords = video computer-vision analysis [options] install_requires = - Click + # click <8.3.0 is excluded as per https://scenedetect.com/issues/521. + click~=8.0,<8.3.0 numpy platformdirs tqdm @@ -54,7 +54,7 @@ python_requires = >=3.7 [options.extras_require] opencv = opencv-python opencv-headless = opencv-python-headless -pyav = av +pyav = av>=9.2 moviepy = moviepy [options.entry_points] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6a8c090d..3207580e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -4,6 +4,10 @@ Releases ## PySceneDetect 0.6 +### PySceneDetect 0.6.7.1 (September 24, 2025) + +Re-release of the Python package that fixes dependency version pinning. + ### PySceneDetect 0.6.7 (August 24, 2025) #### Release Notes From 287b54864efe1250a970bc24fa15684a7f46b5a2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 24 Sep 2025 23:14:19 -0400 Subject: [PATCH 261/407] [dist] Update environment names for publish workflow --- .github/workflows/publish-pypi.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 670468d6..fcc75863 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -4,23 +4,23 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to publish' + description: 'Tag To Publish' required: true environment: - description: 'PyPI environment' + description: 'PyPI Environment' required: true type: choice options: - test - - prod + - release default: 'test' jobs: - verify-builds: - name: Verify build workflows passed + verify: + name: Verify Build runs-on: ubuntu-latest steps: - - name: Verify build workflows + - name: Check workflows uses: actions/github-script@v6 with: script: | @@ -68,21 +68,20 @@ jobs: throw new Error("One or more required build workflows did not succeed."); } - build-and-publish: - name: Build and publish Python distributions to ${{ github.event.inputs.environment }} PyPI + publish: + name: Building and Publishing to ${{ github.event.inputs.environment }} PyPI runs-on: ubuntu-latest - needs: verify-builds - + needs: verify environment: - name: ${{ github.event.inputs.environment == 'test' && 'testpypi' || 'pypi' }} + name: ${{ github.event.inputs.environment == 'test' && 'test' || 'release' }} url: ${{ github.event.inputs.environment == 'test' && 'https://test.pypi.org/p/scenedetect' || 'https://pypi.org/p/scenedetect' }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - - name: Checkout code + - name: Checkout ${{ github.event.inputs.tag }} uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.tag }} From 355fe1ab24695fe2ac48e9f04a72be2f0a2cf1ad Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 28 Sep 2025 22:04:36 -0400 Subject: [PATCH 262/407] [api] Add backwards compat. helpers to avoid breaking imports --- scenedetect/frame_timecode.py | 22 ++++++++++++++++++++++ scenedetect/scene_detector.py | 22 ++++++++++++++++++++++ scenedetect/scene_manager.py | 3 +++ scenedetect/video_splitter.py | 22 ++++++++++++++++++++++ tests/test_detectors.py | 9 +++++++++ tests/test_output.py | 9 +++++++++ tests/test_timecode.py | 9 +++++++++ website/pages/changelog.md | 20 +++++++++----------- 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 scenedetect/frame_timecode.py create mode 100644 scenedetect/scene_detector.py create mode 100644 scenedetect/video_splitter.py diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py new file mode 100644 index 00000000..8411cef0 --- /dev/null +++ b/scenedetect/frame_timecode.py @@ -0,0 +1,22 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""DEPRECATED""" + +import warnings + +warnings.warn( + "The `frame_timecode` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) + +from scenedetect.common import * # noqa: E402, F403 diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py new file mode 100644 index 00000000..5de13977 --- /dev/null +++ b/scenedetect/scene_detector.py @@ -0,0 +1,22 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""DEPRECATED""" + +import warnings + +warnings.warn( + "The `scene_detector` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) + +from scenedetect.detector import * # noqa: E402, F403 diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 7bb45d38..890d9fb1 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -95,6 +95,9 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): SceneList, ) from scenedetect.detector import SceneDetector + +# TODO(v0.8): Remove the import * below, for backwards compatibility with v0.6 only. +from scenedetect.output import * # noqa: F403 from scenedetect.platform import tqdm from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import VideoStream diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py new file mode 100644 index 00000000..2b8499da --- /dev/null +++ b/scenedetect/video_splitter.py @@ -0,0 +1,22 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""DEPRECATED""" + +import warnings + +warnings.warn( + "The `video_splitter` submodule is deprecated, import from the base package instead.", + DeprecationWarning, + stacklevel=2, +) + +from scenedetect.output.video import * # noqa: E402, F403 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 0e5f4214..445112ee 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -224,3 +224,12 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len + + +# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. +def test_deprecated_detector_module_emits_warning_on_import(): + SCENE_DETECTOR_WARNING = ( + "The `scene_detector` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): + from scenedetect.scene_detector import SceneDetector as _ diff --git a/tests/test_output.py b/tests/test_output.py index db3f2307..bc1762e5 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -191,3 +191,12 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): total_images += 1 assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +# TODO(v0.8): Remove this test during the removal of `scenedetect.video_splitter`. +def test_deprecated_output_modules_emits_warning_on_import(): + VIDEO_SPLITTER_WARNING = ( + "The `video_splitter` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): + from scenedetect.video_splitter import split_video_ffmpeg as _ diff --git a/tests/test_timecode.py b/tests/test_timecode.py index a1081d45..20fba42b 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -300,3 +300,12 @@ def test_precision(): assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9" assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01" assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" + + +# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. +def test_deprecated_timecode_module_emits_warning_on_import(): + FRAME_TIMECODE_WARNING = ( + "The `frame_timecode` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): + from scenedetect.frame_timecode import FrameTimecode as _ diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 880765b6..6f5f96d3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -685,30 +685,28 @@ Although there have been minimal changes to most API examples, there are several #### Breaking -> Note: Imports that break when upgrading to 0.7 can usually be resolved by importing from `scenedetect` directly, rather than a submodule. The package structure has changed significantly in 0.7. - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` * Move existing functionality to new submodules: - * `scenedetect.scene_detector` moved to `scenedetect.detector` - * `scenedetect.frame_timecode` moved to `scenedetect.common` - * Output functionality from `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + * Detector interface in `scenedetect.scene_detector` moved to `scenedetect.detector` + * Timecode types in `scenedetect.frame_timecode` moved to `scenedetect.common` + * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead * `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them * Remove `FrameTimecode.previous_frame()` method - * Remove `SceneDetector.is_processing_required()` method, already had no effect in v0.6 as part of deprecation + * Remove `SceneDetector.is_processing_required()` method * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called * Remove deprecated `SparseSceneDetector` interface * Remove deprecated `SceneManager.get_event_list()` method - * Remove deprecated `AdaptiveDetector.get_content_val()` method (the same information can be obtained using a `StatsManager`) - * Remove deprecated `AdaptiveDetector` constructor argument `min_delta_hsv` (use `min_content_val` instead) - * Remove `advance` parameter from `VideoStream.read()` (was always set to `True`, callers should handle caching frames now if required) - * Remove `SceneDetector.stats_manager_required` property as it is no longer required - * Remove `SceneDetector` is now a Python abstract class (`abc.ABC`) but all method names remain the same + * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) + * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) + * Remove `advance` parameter from `VideoStream.read()` + * Remove `SceneDetector.stats_manager_required` property, no longer required + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) #### General From 684e4b45ab26cf159d1010b9333fdab2a1bc5a16 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 29 Sep 2025 22:57:00 -0400 Subject: [PATCH 263/407] [cli] Add minimum code to make CLI work with VFR videos with some commands (e.g. list-scenes) #168 Minimum scene length must still be zero for now. --- scenedetect/backends/moviepy.py | 6 +- scenedetect/backends/opencv.py | 8 +- scenedetect/backends/pyav.py | 5 + scenedetect/common.py | 159 +++++++++++++------- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/threshold_detector.py | 2 +- 6 files changed, 126 insertions(+), 56 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index c4f4e8a5..0de86013 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -24,7 +24,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import FrameTimecode +from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream @@ -173,6 +173,10 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ValueError: `target` is not a valid value (i.e. it is negative). """ success = False + if _USE_PTS_IN_DEVELOPMENT: + # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. + raise NotImplementedError() + if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index f8d402f9..44696cef 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -226,6 +226,10 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if target < 0: raise ValueError("Target seek position cannot be negative!") + if _USE_PTS_IN_DEVELOPMENT: + # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. + raise NotImplementedError() + # Have to seek one behind and call grab() after to that the VideoCapture # returns a valid timestamp when using CAP_PROP_POS_MSEC. target_frame_cv2 = (self.base_timecode + target).frame_num @@ -429,8 +433,8 @@ def frame_size(self) -> ty.Tuple[int, int]: @property def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - # TODO(v0.7): This will be incorrect for VFR. See if there is another property we can use - # to estimate the video length correctly. + # TODO(https://scenedetect.com/issue/168): This will be incorrect for VFR. See if there is + # another property we can use to estimate the video length correctly. frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: return self.base_timecode + frame_count diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index c78183ec..35e7e90f 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -249,6 +249,11 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: if target < 0: raise ValueError("Target cannot be negative!") beginning = target == 0 + + if _USE_PTS_IN_DEVELOPMENT: + # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. + raise NotImplementedError() + target = self.base_timecode + target if target >= 1: target = target - 1 diff --git a/scenedetect/common.py b/scenedetect/common.py index 2fa9bb4e..e3b8cb1d 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -70,6 +70,8 @@ import cv2 +# TODO(https://scenedetect.com/issue/168): Ensure both CFR and VFR videos work as intended with this +# flag enabled. When this feature is stable, we can then work on a roll-out plan. _USE_PTS_IN_DEVELOPMENT = False ## @@ -176,12 +178,14 @@ def __init__( self._framerate = fps self._frame_num = None self._timecode: ty.Optional[Timecode] = None + self._seconds: ty.Optional[float] = None # Copy constructor. if isinstance(timecode, FrameTimecode): self._framerate = timecode._framerate if fps is None else fps self._frame_num = timecode._frame_num self._timecode = timecode._timecode + self._seconds = timecode._seconds return # Timecode. @@ -205,21 +209,27 @@ def __init__( self._framerate = float(fps) # Process the timecode value, storing it as an exact number of frames. if isinstance(timecode, str): - # TODO(v0.7): This will be incorrect for VFR videos. Need to represent this format - # differently so we can support start/end times and min_scene_len correctly. - self._frame_num = self._parse_timecode_string(timecode) + self._seconds = self._timecode_to_seconds(timecode) + self._frame_num = self._seconds_to_frames(self._timecode_to_seconds(timecode)) else: self._frame_num = self._parse_timecode_number(timecode) - # TODO(v0.7): Add a PTS property as well and slowly transition over to that, since we don't - # always know the position as a "frame number". However, for the reverse case, we CAN state - # the presentation time if we know the frame number (for a fixed framerate video). @property def frame_num(self) -> ty.Optional[int]: + if self._timecode: + warnings.warn( + message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", + stacklevel=2, + category=UserWarning, + ) + # We can calculate the approx. # of frames by taking the presentation time and the + # time base itself. + (num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio() + return num / den return self._frame_num @property - def framerate(self) -> ty.Optional[int]: + def framerate(self) -> ty.Optional[float]: return self._framerate def get_frames(self) -> int: @@ -250,7 +260,7 @@ def get_framerate(self) -> float: ) return self.framerate - # TODO(v0.7): Figure out how to deal with VFR here. + # TODO(https://scenedetect.com/issue/168): Figure out how to deal with VFR here. def equal_framerate(self, fps) -> bool: """Equal Framerate: Determines if the passed framerate is equal to that of this object. @@ -261,7 +271,8 @@ def equal_framerate(self, fps) -> bool: bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. """ - # TODO(v0.7): Support this comparison in the case FPS is not set but a timecode is. + # TODO(https://scenedetect.com/issue/168): Support this comparison in the case FPS is not + # set but a timecode is. return math.fabs(self.framerate - fps) < MAX_FPS_DELTA @property @@ -269,6 +280,8 @@ def seconds(self) -> float: """The frame's position in number of seconds.""" if self._timecode: return self._timecode.seconds + if self._seconds: + return self._seconds # Assume constant framerate if we don't have timing information. return float(self._frame_num) / self._framerate @@ -355,14 +368,13 @@ def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: else: raise TypeError("Timecode format/type unrecognized.") - def _parse_timecode_string(self, input: 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.0' are all possible valid values, all representing - a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). + def _timecode_to_seconds(self, input: str) -> float: + """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'). Exact frame numbers (int) + requires the `framerate` property was set when the timecode was created. Assuming a + framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', '9000', '300s', and + '300.0' are all possible valid values. These values represent periods of time equal to + 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). Raises: ValueError: Value could not be parsed correctly. @@ -374,7 +386,7 @@ def _parse_timecode_string(self, input: str) -> int: timecode = int(input) if timecode < 0: raise ValueError("Timecode frame number must be positive.") - return timecode + return timecode * self.framerate # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") @@ -392,7 +404,7 @@ def _parse_timecode_string(self, input: str) -> int: 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 * 60) + (mins * 60) - return self._seconds_to_frames(secs) + return secs # Try to parse the number as seconds in the format 1234.5 or 1234s if input.endswith("s"): input = input[:-1] @@ -401,7 +413,7 @@ def _parse_timecode_string(self, input: str) -> int: as_float = float(input) if as_float < 0.0: raise ValueError("Timecode seconds value must be positive.") - return self._seconds_to_frames(as_float) + return as_float def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> int: """Get the frame number from `other` for arithmetic operations.""" @@ -410,7 +422,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] if isinstance(other, float): return self._seconds_to_frames(other) if isinstance(other, str): - return self._parse_timecode_string(other) + return self._seconds_to_frames(self._timecode_to_seconds(other)) if isinstance(other, FrameTimecode): # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. if self._framerate and other._framerate and not self.equal_framerate(other._framerate): @@ -421,53 +433,73 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] return other._frame_num # If other has no frame_num, it must have a timecode. Convert to frames. return self._seconds_to_frames(other.seconds) - raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") + raise TypeError("Cannot obtain frame number for this timecode.") def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if self._timecode: + if self._timecode or self._seconds is not None: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if self._timecode: - new_seconds = self.seconds + self._get_other_as_seconds(other) - # TODO: This is incorrect for VFR, need a better way to handle this. - # For now, we convert back to a frame number. - self._frame_num = self._seconds_to_frames(new_seconds) - self._timecode = None - else: - self._frame_num += self._get_other_as_frames(other) - if self._frame_num < 0: # Required to allow adding negative seconds/frames. - self._frame_num = 0 + other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + + if self._timecode and other_has_timecode: + if self._timecode.time_base != other._timecode.time_base: + raise ValueError("timecodes have different time bases") + self._timecode = Timecode( + pts=max(0, self._timecode.pts + other._timecode.pts), + time_base=self._timecode.time_base, + ) + return self + + # If either input is a timecode, the output shall also be one. The input which isn't a + # timecode is converted into seconds, after which the equivalent timecode is computed. + if self._timecode or other_has_timecode: + timecode: Timecode = self._timecode if self._timecode else other._timecode + seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds + self._timecode = Timecode( + pts=max(0, timecode.pts + round(seconds / timecode.time_base)), + time_base=timecode.time_base, + ) + self._seconds = None + self._framerate = None + self._frame_num = None + return self + + if self._seconds and other._seconds: + self._seconds = max(0, self._seconds + other._seconds) + return self + + self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -476,16 +508,36 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - if self._timecode: - new_seconds = self.seconds - self._get_other_as_seconds(other) - # TODO: This is incorrect for VFR, need a better way to handle this. - # For now, we convert back to a frame number. - self._frame_num = self._seconds_to_frames(new_seconds) - self._timecode = None - else: - self._frame_num -= self._get_other_as_frames(other) - if self._frame_num < 0: - self._frame_num = 0 + other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + + if self._timecode and other_has_timecode: + if self._timecode.time_base != other._timecode.time_base: + raise ValueError("timecodes have different time bases") + self._timecode = Timecode( + pts=max(0, self._timecode.pts - other._timecode.pts), + time_base=self._timecode.time_base, + ) + return self + + # If either input is a timecode, the output shall also be one. The input which isn't a + # timecode is converted into seconds, after which the equivalent timecode is computed. + if self._timecode or other_has_timecode: + timecode: Timecode = self._timecode if self._timecode else other._timecode + seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds + self._timecode = Timecode( + pts=max(0, timecode.pts - round(seconds / timecode.time_base)), + time_base=timecode.time_base, + ) + self._seconds = None + self._framerate = None + self._frame_num = None + return self + + if self._seconds and other._seconds: + self._seconds = max(0, self._seconds - other._seconds) + return self + + self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -518,13 +570,18 @@ def __hash__(self) -> int: def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): + if self._timecode: + # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses + # frame numbers with timestamps to convert to a non-frame based way of temporal + # logic and instead use seconds-based. + if _USE_PTS_IN_DEVELOPMENT and other == 1: + return self.seconds + raise NotImplementedError() return float(other) / self._framerate if isinstance(other, float): return other if isinstance(other, str): - # This is not ideal, but we need a framerate to parse strings. - # We create a temporary FrameTimecode to do this. - return FrameTimecode(timecode=other, fps=self._framerate).seconds + return self._timecode_to_seconds(other) if isinstance(other, FrameTimecode): return other.seconds raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 1b66ff7b..05edeb61 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -137,7 +137,7 @@ def __init__( raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: ty.Optional[float] = None - # TODO(v0.7): Handle timecodes in filter. + # TODO(https://scenedetect.com/issue/168): Handle timecodes in filter. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index c948f9d1..2cfe05b8 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -111,7 +111,7 @@ def process_frame( ty.List[int]: List of frames where scene cuts have been detected. There may be 0 or more frames in the list, and not necessarily the same as frame_num. """ - # TODO(v0.7): We need to consider PTS here instead. The methods below using frame numbers + # TODO(https://scenedetect.com/issue/168): We need to consider PTS here instead. The methods below using frame numbers # won't work for variable framerates. frame_num = timecode.frame_num From 0464ccf7063f1fcba131c6e557b3352293e72c32 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 30 Sep 2025 20:47:01 -0400 Subject: [PATCH 264/407] [timecode] Fix some failing timecode tests after updated arithmetic --- scenedetect/common.py | 33 ++++++++++++++++++++++++++++----- tests/test_cli.py | 2 +- tests/test_timecode.py | 27 +++++---------------------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index e3b8cb1d..40980373 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -210,9 +210,10 @@ def __init__( # Process the timecode value, storing it as an exact number of frames. if isinstance(timecode, str): self._seconds = self._timecode_to_seconds(timecode) - self._frame_num = self._seconds_to_frames(self._timecode_to_seconds(timecode)) + self._frame_num = self._seconds_to_frames(self._seconds) else: self._frame_num = self._parse_timecode_number(timecode) + self._seconds = timecode if isinstance(timecode, float) else None @property def frame_num(self) -> ty.Optional[int]: @@ -379,14 +380,14 @@ def _timecode_to_seconds(self, input: str) -> float: Raises: ValueError: Value could not be parsed correctly. """ - assert self._framerate is not None + assert self._framerate is not None and self._framerate > MAX_FPS_DELTA input = input.strip() # Exact number of frames N if input.isdigit(): timecode = int(input) if timecode < 0: raise ValueError("Timecode frame number must be positive.") - return timecode * self.framerate + return timecode / self.framerate # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") @@ -438,6 +439,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False + if _compare_as_fixed(self, other): + return self._frame_num == other._frame_num if self._timecode or self._seconds is not None: return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -445,26 +448,36 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True + if _compare_as_fixed(self, other): + return self._frame_num != other._frame_num if self._timecode or self._seconds is not None: return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num < other._frame_num if self._timecode or self._seconds is not None: return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num <= other._frame_num if self._timecode or self._seconds is not None: return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num > other._frame_num if self._timecode or self._seconds is not None: return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: + if _compare_as_fixed(self, other): + return self._frame_num >= other._frame_num if self._timecode or self._seconds is not None: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) @@ -495,11 +508,14 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._frame_num = None return self - if self._seconds and other._seconds: + other_has_seconds = isinstance(other, FrameTimecode) and other._seconds + if self._seconds is not None and other_has_seconds: self._seconds = max(0, self._seconds + other._seconds) return self self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) + if self._seconds is not None: + self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other)) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -533,11 +549,14 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._frame_num = None return self - if self._seconds and other._seconds: + other_has_seconds = isinstance(other, FrameTimecode) and other._seconds + if self._seconds is not None and other_has_seconds: self._seconds = max(0, self._seconds - other._seconds) return self self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) + if self._seconds is not None: + self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other)) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -585,3 +604,7 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" if isinstance(other, FrameTimecode): return other.seconds raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") + + +def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: + return a._framerate is not None and isinstance(b, FrameTimecode) and b._framerate is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index 61022bf8..7ba8da2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -691,7 +691,7 @@ def test_cli_load_scenes_with_time_frames(): def test_cli_load_scenes_round_trip(): - """Verify we can use `load-scenes` with the `time` command and get the desired output.""" + """Verify we can use `load-scenes` and get the same scenes as output with `list-scenes`.""" scenes_csv = """ Scene Number,Start Frame 1,49 diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 20fba42b..2f326a24 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -187,7 +187,8 @@ def test_get_timecode(): assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.500" + # If a value is provided in seconds, we store that value internally now. + assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.501" assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" @@ -199,12 +200,6 @@ def test_equality(): assert x == FrameTimecode(timecode=1.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) - # Comparing FrameTimecodes with different framerates raises a TypeError. - with pytest.raises(ValueError): - assert x == FrameTimecode(timecode=1.0, fps=100.0) - with pytest.raises(ValueError): - assert x == FrameTimecode(timecode=1.0, fps=10.1) - assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) @@ -232,11 +227,6 @@ def test_equality(): assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.501" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.502" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.508" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.509" - assert FrameTimecode(timecode="00:00:01.519", fps=10) == "00:00:01.510" def test_addition(): @@ -244,14 +234,11 @@ def test_addition(): x = FrameTimecode(timecode=1.0, fps=10.0) assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0) assert x + 1 == FrameTimecode(1.1, x) + assert x + 10 == "00:00:02.000", str(x + 10) assert x + 10 == 20 assert x + 10 == 2.0 - assert x + 10 == "00:00:02.000" - with pytest.raises(ValueError): - assert FrameTimecode("00:00:02.000", fps=20.0) == x + 10 - def test_subtraction(): """Test FrameTimecode subtraction (-/-=, __sub__) operator.""" @@ -259,17 +246,13 @@ def test_subtraction(): assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0) assert x - 2 == FrameTimecode(0.8, x) assert x - 10 == FrameTimecode(0.0, x) - # TODO(v1.0): Allow negative values + # TODO(v1.0): Allow negative values. For now we clamp. assert x - 11 == FrameTimecode(0.0, x) assert x - 100 == FrameTimecode(0.0, x) - assert x - 1.0 == FrameTimecode(0.0, x) assert x - 100.0 == FrameTimecode(0.0, x) - assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0) - - with pytest.raises(ValueError): - assert FrameTimecode("00:00:02.000", fps=20.0) == x - 10 + assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10 @pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) From 0a8b68dddaaf160d5dc298c5e3430460211e656e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 30 Sep 2025 22:26:43 -0400 Subject: [PATCH 265/407] [timecode] Fix remaining timecode issues --- scenedetect/_cli/controller.py | 13 +++++--- scenedetect/common.py | 55 ++++++++++++++++++++++++---------- tests/test_timecode.py | 10 +++++-- website/pages/changelog.md | 1 + 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index eee05267..be7ac8d2 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -175,10 +175,15 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: if context.load_scenes_column_name not in csv_headers: raise ValueError("specified column header for scene start is not present") col_idx = csv_headers.index(context.load_scenes_column_name) - cut_list = sorted( - FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1 - for row in file_reader - ) + + def calculate_timecode(value: str) -> FrameTimecode: + # Assume other columns are in seconds except frame numbers. + if value.isdigit(): + # Frame numbers start from index 1 in the CLI output so we correct for that. + return FrameTimecode(int(value) - 1, fps=context.video_stream.frame_rate) + return FrameTimecode(value, fps=context.video_stream.frame_rate) + + cut_list = sorted(calculate_timecode(row[col_idx]) for row in file_reader) # `SceneDetector` works on cuts, so we have to skip the first scene and place the first # cut point where the next scenes starts. if cut_list: diff --git a/scenedetect/common.py b/scenedetect/common.py index 40980373..342d6cd4 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -207,17 +207,27 @@ def __init__( ): 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. + # Process the timecode value, storing it as an exact number of frames only if required. + if isinstance(timecode, str) and timecode.isdigit(): + timecode = int(timecode) if isinstance(timecode, str): self._seconds = self._timecode_to_seconds(timecode) - self._frame_num = self._seconds_to_frames(self._seconds) + elif isinstance(timecode, float): + if timecode < 0.0: + raise ValueError("Timecode frame number must be positive and greater than zero.") + self._seconds = timecode + elif isinstance(timecode, int): + if timecode < 0: + raise ValueError("Timecode frame number must be positive and greater than zero.") + self._frame_num = timecode else: - self._frame_num = self._parse_timecode_number(timecode) - self._seconds = timecode if isinstance(timecode, float) else None + raise TypeError("Timecode format/type unrecognized.") @property def frame_num(self) -> ty.Optional[int]: if self._timecode: + # We need to audit anything currently using this property to guarantee temporal + # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). warnings.warn( message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", stacklevel=2, @@ -227,6 +237,8 @@ def frame_num(self) -> ty.Optional[int]: # time base itself. (num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio() return num / den + if self._seconds is not None: + return self._seconds_to_frames(self._seconds) return self._frame_num @property @@ -306,19 +318,26 @@ def get_seconds(self) -> float: ) return self.seconds - def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str: + def get_timecode( + self, precision: int = 3, use_rounding: bool = True, nearest_frame: 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. + nearest_frame: Ensures that the timecode is moved to the nearest frame boundary if this + object has a defined framerate, otherwise has no effect. 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.seconds + if nearest_frame and self.framerate: + secs = self.frame_num / self.framerate + else: + secs = self.seconds hrs = int(secs / _SECONDS_PER_HOUR) secs -= hrs * _SECONDS_PER_HOUR mins = int(secs / _SECONDS_PER_MINUTE) @@ -440,7 +459,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False if _compare_as_fixed(self, other): - return self._frame_num == other._frame_num + return self.frame_num == other.frame_num if self._timecode or self._seconds is not None: return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -449,35 +468,35 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True if _compare_as_fixed(self, other): - return self._frame_num != other._frame_num + return self.frame_num != other.frame_num if self._timecode or self._seconds is not None: return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num < other._frame_num + return self.frame_num < other.frame_num if self._timecode or self._seconds is not None: return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num <= other._frame_num + return self.frame_num <= other.frame_num if self._timecode or self._seconds is not None: return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num > other._frame_num + return self.frame_num > other.frame_num if self._timecode or self._seconds is not None: return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): - return self._frame_num >= other._frame_num + return self.frame_num >= other.frame_num if self._timecode or self._seconds is not None: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) @@ -513,9 +532,11 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._seconds = max(0, self._seconds + other._seconds) return self - self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) if self._seconds is not None: self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other)) + return self + + self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -554,9 +575,11 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT self._seconds = max(0, self._seconds - other._seconds) return self - self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) if self._seconds is not None: self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other)) + return self + + self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -579,7 +602,9 @@ def __str__(self) -> str: def __repr__(self) -> str: if self._timecode: return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" - return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self._frame_num, self._framerate) + if self._seconds is not None: + return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._framerate}]" + return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._framerate}]" def __hash__(self) -> int: if self._timecode: diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 2f326a24..f4ea74c5 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -188,8 +188,14 @@ def test_get_timecode(): assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" # If a value is provided in seconds, we store that value internally now. - assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.501" - assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000" + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=False) + == "00:00:01.501" + ) + assert ( + FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=True) + == "00:00:01.500" + ) def test_equality(): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6f5f96d3..5ad1a3d3 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -713,3 +713,4 @@ Although there have been minimal changes to most API examples, there are several * Deprecated functionality preserved from v0.6 now uses the `warnings` module * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods * Add new `Timecode` type to represent frame timings in terms of the video's source timebase + * Expand `FrameTimecode` representations to preserve accuracy (previously all timecodes were rounded to frame boundaries) \ No newline at end of file From e86147f4e8dadc38709609bc0edc57d626bbcb39 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 1 Oct 2025 21:35:22 -0400 Subject: [PATCH 266/407] [timecode] Use fractions for internal framerate representation --- scenedetect/common.py | 49 ++++++++++-------- tests/test_timecode.py | 111 ++++++++++++++++++++++------------------- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 342d6cd4..c09b12c4 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -92,8 +92,8 @@ TimecodePair = ty.Tuple["FrameTimecode", "FrameTimecode"] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" -MAX_FPS_DELTA: float = 1.0 / 100000 -"""Maximum amount two framerates can differ by for equality testing.""" +MAX_FPS_DELTA: float = 1.0 / 1000000000.0 +"""Maximum amount two framerates can differ by for equality testing. Currently 1 frame/nanosec.""" _SECONDS_PER_MINUTE = 60.0 _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE @@ -160,13 +160,14 @@ class FrameTimecode: def __init__( self, timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None, - fps: ty.Union[int, float, str, "FrameTimecode"] = None, + fps: ty.Union[float, "FrameTimecode", Fraction] = None, ): """ Arguments: timecode: A frame number (`int`), number of seconds (`float`), timecode string in the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`, or a `Timecode`. - fps: The framerate or FrameTimecode to use as a time base for all arithmetic. + fps: The framerate to use for distance between frames and to calculate frame numbers. + For a VFR video, this may just be the average framerate. Raises: TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. @@ -175,7 +176,7 @@ def __init__( # 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 = fps + self._framerate: Fraction = None self._frame_num = None self._timecode: ty.Optional[Timecode] = None self._seconds: ty.Optional[float] = None @@ -188,25 +189,31 @@ def __init__( self._seconds = timecode._seconds return - # Timecode. - if isinstance(timecode, Timecode): - self._timecode = timecode - return + if not isinstance(fps, (float, Fraction, FrameTimecode)): + raise TypeError("fps must be of type float, Fraction, or FrameTimecode.") # Ensure args are consistent with API. if fps is None: - raise TypeError("Framerate (fps) is a required argument.") + raise TypeError("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) + self._framerate = fps._framerate + elif isinstance(fps, float): + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + self._framerate = Fraction.from_float(fps) + elif isinstance(fps, Fraction): + if float(fps) <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + self._framerate = fps + else: + raise TypeError( + f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" + ) + + # Timecode with a time base. + if isinstance(timecode, Timecode): + self._timecode = timecode + return # Process the timecode value, storing it as an exact number of frames only if required. if isinstance(timecode, str) and timecode.isdigit(): timecode = int(timecode) @@ -243,7 +250,7 @@ def frame_num(self) -> ty.Optional[int]: @property def framerate(self) -> ty.Optional[float]: - return self._framerate + return float(self._framerate) def get_frames(self) -> int: """[DEPRECATED] Get the current time/position in number of frames. diff --git a/tests/test_timecode.py b/tests/test_timecode.py index f4ea74c5..cbddeb79 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -25,6 +25,7 @@ # Standard Library Imports from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from fractions import Fraction def test_framerate(): @@ -38,11 +39,11 @@ def test_framerate(): FrameTimecode(timecode=None, fps=FrameTimecode(timecode=0, fps=None)) # Test zero FPS/negative. with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=0) + FrameTimecode(timecode=0, fps=0.0) with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-1) + FrameTimecode(timecode=0, fps=-1.0) with pytest.raises(ValueError): - FrameTimecode(timecode=0, fps=-100) + FrameTimecode(timecode=0, fps=-100.0) with pytest.raises(ValueError): FrameTimecode(timecode=0, fps=0.0) with pytest.raises(ValueError): @@ -52,26 +53,28 @@ def test_framerate(): with pytest.raises(ValueError): FrameTimecode(timecode=0, fps=MAX_FPS_DELTA / 2) # Test positive framerates. - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 - assert FrameTimecode(timecode=0, fps=10).frame_num == 0 + assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=10.0).frame_num == 0 assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA * 2).frame_num == 0 - assert FrameTimecode(timecode=0, fps=1000).frame_num == 0 assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 + assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0 + # Reject framerates too small for equality testing or potential divide by zero situations. + with pytest.raises(ValueError): + assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0 def test_timecode_numeric(): """Test FrameTimecode constructor argument "timecode" with numeric arguments.""" with pytest.raises(ValueError): - FrameTimecode(timecode=-1, fps=1) + FrameTimecode(timecode=-1, fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode=-1.0, fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode=-0.1, fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode=-1.0 / 1000, fps=1.0) - assert FrameTimecode(timecode=0, fps=1).frame_num == 0 - assert FrameTimecode(timecode=1, fps=1).frame_num == 1 + assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0 + assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1 assert FrameTimecode(timecode=0.0, fps=1.0).frame_num == 0 assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == 1 @@ -80,13 +83,13 @@ def test_timecode_string(): """Test FrameTimecode constructor argument "timecode" with string arguments.""" # Invalid strings: with pytest.raises(ValueError): - FrameTimecode(timecode="-1", fps=1) + FrameTimecode(timecode="-1", fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode="-1.0", fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode="-0.1", fps=1.0) with pytest.raises(ValueError): - FrameTimecode(timecode="1.9x", fps=1) + FrameTimecode(timecode="1.9x", fps=1.0) with pytest.raises(ValueError): FrameTimecode(timecode="1x", fps=1.0) with pytest.raises(ValueError): @@ -95,21 +98,21 @@ def test_timecode_string(): FrameTimecode(timecode="1.0-", fps=1.0) # Frame number integer [int->str] ('%d', integer number as string) - assert FrameTimecode(timecode="0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1", fps=1).frame_num == 1 + assert FrameTimecode(timecode="0", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1", fps=1.0).frame_num == 1 assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10 # Seconds format [float->str] ('%f', number as string) - assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1 + assert FrameTimecode(timecode="0.0", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1.0", fps=1.0).frame_num == 1 assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11 # Seconds format [float->str] ('%fs', number as string followed by 's' for seconds) - assert FrameTimecode(timecode="0s", fps=1).frame_num == 0 - assert FrameTimecode(timecode="1s", fps=1).frame_num == 1 + assert FrameTimecode(timecode="0s", fps=1.0).frame_num == 0 + assert FrameTimecode(timecode="1s", fps=1.0).frame_num == 1 assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10 assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10 @@ -117,34 +120,34 @@ def test_timecode_string(): assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11 # Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional) - assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1 - assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:00:01", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="00:00:01.9999", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:02.0001", fps=1.0).frame_num == 2 # MM:SS[.nnn] is also allowed - assert FrameTimecode(timecode="00:01", fps=1).frame_num == 1 - assert FrameTimecode(timecode="00:01.9999", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:02.0001", fps=1).frame_num == 2 + assert FrameTimecode(timecode="00:01", fps=1.0).frame_num == 1 + assert FrameTimecode(timecode="00:01.9999", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:02.0001", fps=1.0).frame_num == 2 # Conversion edge cases - assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 - assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 - assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1 - assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1 + assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10 + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5 + assert FrameTimecode(timecode="00:00:00.100", fps=10.0).frame_num == 1 + assert FrameTimecode(timecode="00:00:00.001", fps=1000.0).frame_num == 1 - assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 - assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:00:59.999", fps=1.0).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60 + assert FrameTimecode(timecode="00:01:00.001", fps=1.0).frame_num == 60 - assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600 - assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600 + assert FrameTimecode(timecode="00:59:59.999", fps=1.0).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.000", fps=1.0).frame_num == 3600 + assert FrameTimecode(timecode="01:00:00.001", fps=1.0).frame_num == 3600 # Check too many ":" characters (https://github.com/Breakthrough/PySceneDetect/issues/476) with pytest.raises(ValueError): - FrameTimecode(timecode="01:01:00:00.001", fps=1) + FrameTimecode(timecode="01:01:00:00.001", fps=1.0) def test_get_frames(): @@ -157,10 +160,10 @@ def test_get_frames(): assert FrameTimecode(timecode=1000.0, fps=60.0).frame_num == int(1000.0 * 60.0) assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == int(1000000000.0 * 29.97) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2 - assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5 - assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10 - assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60 + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2 + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5 + assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10 + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60 def test_get_seconds(): @@ -173,10 +176,10 @@ def test_get_seconds(): assert FrameTimecode(timecode=1000.0, fps=60.0).seconds, pytest.approx(1000.0) assert FrameTimecode(timecode=1000000000.0, fps=29.97).seconds, pytest.approx(1000000000.0) - assert FrameTimecode(timecode="00:00:02.0000", fps=1).seconds, pytest.approx(2.0) - assert FrameTimecode(timecode="00:00:00.5", fps=10).seconds, pytest.approx(0.5) - assert FrameTimecode(timecode="00:00:01", fps=10).seconds, pytest.approx(1.0) - assert FrameTimecode(timecode="00:01:00.000", fps=1).seconds, pytest.approx(60.0) + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).seconds, pytest.approx(2.0) + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).seconds, pytest.approx(0.5) + assert FrameTimecode(timecode="00:00:01", fps=10.0).seconds, pytest.approx(1.0) + assert FrameTimecode(timecode="00:01:00.000", fps=1.0).seconds, pytest.approx(60.0) def test_get_timecode(): @@ -185,15 +188,15 @@ def test_get_timecode(): assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117" assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234" - assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000" - assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500" + assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).get_timecode() == "00:00:02.000" + assert FrameTimecode(timecode="00:00:00.5", fps=10.0).get_timecode() == "00:00:00.500" # If a value is provided in seconds, we store that value internally now. assert ( - FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=False) + FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=False) == "00:00:01.501" ) assert ( - FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=True) + FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=True) == "00:00:01.500" ) @@ -204,8 +207,10 @@ def test_equality(): assert x == x assert x == FrameTimecode(timecode=1.0, fps=10.0) assert x == FrameTimecode(timecode=1.0, fps=10.0) + assert x == FrameTimecode(timecode=1.0, fps=Fraction(10, 1)) assert x != FrameTimecode(timecode=10.0, fps=10.0) assert x != FrameTimecode(timecode=10.0, fps=10.0) + assert x != FrameTimecode(timecode=10.0, fps=Fraction(100, 10)) assert x == FrameTimecode(x) assert x == FrameTimecode(1.0, x) assert x == FrameTimecode(10, x) @@ -231,8 +236,8 @@ def test_equality(): with pytest.raises(TypeError): assert x == {0: 0} - assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500" - assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500" + assert FrameTimecode(timecode="00:00:00.5", fps=10.0) == "00:00:00.500" + assert FrameTimecode(timecode="00:00:01.500", fps=10.0) == "00:00:01.500" def test_addition(): @@ -261,7 +266,9 @@ def test_subtraction(): assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10 -@pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)]) +@pytest.mark.parametrize( + "frame_num,fps", [(1, 1.0), (61, 14.0), (29, 25.0), (126, Fraction(24000, 1001))] +) def test_identity(frame_num, fps): """Test FrameTimecode values, when used in init return the same values""" frame_time_code = FrameTimecode(frame_num, fps=fps) From a501c3b0f3e810b119717ab1c70a1d907b1b5d8b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 1 Oct 2025 21:49:01 -0400 Subject: [PATCH 267/407] [timecode] Add pts and time base properties --- scenedetect/backends/opencv.py | 7 ++- scenedetect/backends/pyav.py | 28 ++++++---- scenedetect/common.py | 66 +++++++++++++++-------- scenedetect/detector.py | 37 ++++++------- scenedetect/detectors/content_detector.py | 3 +- scenedetect/output/image.py | 5 +- scenedetect/scene_manager.py | 15 +++--- scenedetect/video_stream.py | 1 + tests/test_timecode.py | 3 +- website/pages/changelog.md | 3 +- 10 files changed, 99 insertions(+), 69 deletions(-) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 44696cef..24d7d3c5 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -206,6 +206,8 @@ def timecode(self) -> Timecode: @property def position(self) -> FrameTimecode: + # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or + # add a config option before landing this. if _USE_PTS_IN_DEVELOPMENT: return FrameTimecode(timecode=self.timecode, fps=self.frame_rate) if self.frame_number < 1: @@ -226,10 +228,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if target < 0: raise ValueError("Target seek position cannot be negative!") - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. - raise NotImplementedError() - + # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. # Have to seek one behind and call grab() after to that the VideoCapture # returns a valid timestamp when using CAP_PROP_POS_MSEC. target_frame_cv2 = (self.base_timecode + target).frame_num diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 35e7e90f..b6537774 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -12,6 +12,7 @@ """:class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" import typing as ty +from fractions import Fraction from logging import getLogger import av @@ -22,7 +23,6 @@ from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") - VALID_THREAD_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] @@ -36,7 +36,7 @@ class VideoStreamAv(VideoStream): def __init__( self, path_or_io: ty.Union[ty.AnyStr, ty.BinaryIO], - framerate: ty.Optional[float] = None, + framerate: ty.Optional[ty.Union[float, Fraction]] = None, name: ty.Optional[str] = None, threading_mode: ty.Optional[str] = None, suppress_output: bool = False, @@ -123,15 +123,14 @@ def __init__( ) if frame_rate is None or frame_rate == 0: raise FrameRateUnavailable() - # TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations. - # See https://pyav.org/docs/develop/api/stream.html for details. - frame_rate = frame_rate.numerator / float(frame_rate.denominator) if frame_rate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._frame_rate: float = frame_rate + self._frame_rate: Fraction = frame_rate else: assert framerate >= MAX_FPS_DELTA - self._frame_rate: float = framerate + self._frame_rate: Fraction = ( + framerate if isinstance(framerate, Fraction) else Fraction.from_float(framerate) + ) # Calculate duration after we have set the framerate. self._duration_frames = self._get_duration() @@ -212,6 +211,16 @@ def frame_number(self) -> int: return self.position.frame_num + 1 return 0 + @property + def rate(self) -> Fraction: + return self._video_stream.guessed_rate + + @property + def time_base(self) -> Fraction: + if self._frame: + return self._frame.time_base + return None + @property def aspect_ratio(self) -> float: """Pixel aspect ratio as a float (1.0 represents square pixels).""" @@ -250,10 +259,7 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: raise ValueError("Target cannot be negative!") beginning = target == 0 - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. - raise NotImplementedError() - + # TODO(https://scenedetect.com/issues/168): This breaks with PTS mode enabled. target = self.base_timecode + target if target >= 1: target = target - 1 diff --git a/scenedetect/common.py b/scenedetect/common.py index c09b12c4..4db67d5e 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -172,18 +172,21 @@ def __init__( 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: Fraction = None + # NOTE: FrameTimecode will have either a `Timecode` representation, a `seconds` + # representation, or only a frame number. We cache the calculated values for later use + # for the parameters that are missing. + self._rate: Fraction = None + """Rate at which time passes between frames, measured in frames/sec.""" self._frame_num = None + """Frame number which may be estimated.""" self._timecode: ty.Optional[Timecode] = None + """Presentation timestamp from the backend.""" self._seconds: ty.Optional[float] = None + """An explicit point in time.""" # Copy constructor. if isinstance(timecode, FrameTimecode): - self._framerate = timecode._framerate if fps is None else fps + self._rate = timecode._rate if fps is None else fps self._frame_num = timecode._frame_num self._timecode = timecode._timecode self._seconds = timecode._seconds @@ -196,15 +199,15 @@ def __init__( if fps is None: raise TypeError("fps is a required argument.") if isinstance(fps, FrameTimecode): - self._framerate = fps._framerate + self._rate = fps._rate elif isinstance(fps, float): if fps <= MAX_FPS_DELTA: raise ValueError("Framerate must be positive and greater than zero.") - self._framerate = Fraction.from_float(fps) + self._rate = Fraction.from_float(fps) elif isinstance(fps, Fraction): if float(fps) <= MAX_FPS_DELTA: raise ValueError("Framerate must be positive and greater than zero.") - self._framerate = fps + self._rate = fps else: raise TypeError( f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" @@ -214,9 +217,11 @@ def __init__( if isinstance(timecode, Timecode): self._timecode = timecode return + # Process the timecode value, storing it as an exact number of frames only if required. if isinstance(timecode, str) and timecode.isdigit(): timecode = int(timecode) + if isinstance(timecode, str): self._seconds = self._timecode_to_seconds(timecode) elif isinstance(timecode, float): @@ -232,6 +237,8 @@ def __init__( @property def frame_num(self) -> ty.Optional[int]: + """The frame number. This value will be an estimate if the video is VFR. Prefer using the + `pts` property.""" if self._timecode: # We need to audit anything currently using this property to guarantee temporal # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). @@ -249,8 +256,24 @@ def frame_num(self) -> ty.Optional[int]: return self._frame_num @property - def framerate(self) -> ty.Optional[float]: - return float(self._framerate) + def framerate(self) -> float: + """The framerate to use for distance between frames and to calculate frame numbers. + For a VFR video, this may just be the average framerate.""" + return float(self._rate) + + @property + def time_base(self) -> Fraction: + """The time base in which presentation time is calculated.""" + if self._timecode: + return self._timecode.time_base + return 1 / self._rate + + @property + def pts(self) -> int: + """The presentation timestamp of the frame in units of `time_base`.""" + if self._timecode: + return self._timecode.pts + return self.frame_num def get_frames(self) -> int: """[DEPRECATED] Get the current time/position in number of frames. @@ -302,8 +325,7 @@ def seconds(self) -> float: return self._timecode.seconds if self._seconds: return self._seconds - # Assume constant framerate if we don't have timing information. - return float(self._frame_num) / self._framerate + return float(self._frame_num / self._rate) def get_seconds(self) -> float: """[DEPRECATED] Get the frame's position in number of seconds. @@ -372,7 +394,7 @@ def _seconds_to_frames(self, seconds: float) -> int: *NOTE*: This will not be correct for variable framerate videos. """ - return round(seconds * self._framerate) + return round(seconds * self._rate) def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: """Parse a timecode number, storing it as the exact number of frames. @@ -406,7 +428,7 @@ def _timecode_to_seconds(self, input: str) -> float: Raises: ValueError: Value could not be parsed correctly. """ - assert self._framerate is not None and self._framerate > MAX_FPS_DELTA + assert self._rate is not None and self._rate > MAX_FPS_DELTA input = input.strip() # Exact number of frames N if input.isdigit(): @@ -452,7 +474,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] return self._seconds_to_frames(self._timecode_to_seconds(other)) if isinstance(other, FrameTimecode): # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. - if self._framerate and other._framerate and not self.equal_framerate(other._framerate): + if self._rate and other._rate and not self.equal_framerate(other._rate): raise ValueError( "FrameTimecode instances require equal framerate for frame-based arithmetic." ) @@ -530,7 +552,7 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT time_base=timecode.time_base, ) self._seconds = None - self._framerate = None + self._rate = None self._frame_num = None return self @@ -573,7 +595,7 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT time_base=timecode.time_base, ) self._seconds = None - self._framerate = None + self._rate = None self._frame_num = None return self @@ -610,8 +632,8 @@ def __repr__(self) -> str: if self._timecode: return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" if self._seconds is not None: - return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._framerate}]" - return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._framerate}]" + return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._rate}]" + return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._rate}]" def __hash__(self) -> int: if self._timecode: @@ -628,7 +650,7 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" if _USE_PTS_IN_DEVELOPMENT and other == 1: return self.seconds raise NotImplementedError() - return float(other) / self._framerate + return float(other) / self._rate if isinstance(other, float): return other if isinstance(other, str): @@ -639,4 +661,4 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: - return a._framerate is not None and isinstance(b, FrameTimecode) and b._framerate is not None + return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 5f903601..e7d9e731 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -30,7 +30,7 @@ import numpy -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode +from scenedetect.common import FrameTimecode from scenedetect.stats_manager import StatsManager @@ -49,7 +49,7 @@ def __init__(self): def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray ) -> ty.List[FrameTimecode]: - """Process the next frame. `frame_num` is assumed to be sequential. + """Process the next frame. `timecode` is assumed to be sequential. Args: timecode: Timecode corresponding to the frame being processed. @@ -74,9 +74,8 @@ def post_process(self, timecode: int) -> ty.List[FrameTimecode]: @property def event_buffer_length(self) -> int: - """The amount of frames a given event can be buffered for, in time. Represents maximum - amount any event can be behind `frame_number` in the result of :meth:`process_frame`. - """ + """The amount of frames a given event can be buffered for, in time. This must be set to the + amount of frames a detector might emit an event in the past.""" return 0 # Frame Stats/Metrics @@ -135,30 +134,32 @@ def max_behind(self) -> int: def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: if not self._filter_length > 0: return [timecode] if above_threshold else [] - if _USE_PTS_IN_DEVELOPMENT: - raise NotImplementedError("TODO: Change filter to use units of time instead of frames.") if self._last_above is None: self._last_above = timecode if self._mode == FlashFilter.Mode.MERGE: - return self._filter_merge(frame_num=timecode, above_threshold=above_threshold) + return self._filter_merge(timecode=timecode, above_threshold=above_threshold) elif self._mode == FlashFilter.Mode.SUPPRESS: - return self._filter_suppress(frame_num=timecode, above_threshold=above_threshold) + return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") - def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + framerate = timecode.framerate + assert framerate >= 0 + min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) if not (above_threshold and min_length_met): return [] # Both length and threshold requirements were satisfied. Emit the cut, and wait until both # requirements are met again. - self._last_above = frame_num - return [frame_num] + self._last_above = timecode + return [timecode] - def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: - min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + framerate = timecode.framerate + assert framerate >= 0 + min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: - self._last_above = frame_num + self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. num_merged_frames = self._last_above - self._merge_start @@ -174,9 +175,9 @@ def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: if min_length_met: # Only allow the merge filter once the first cut is emitted. self._merge_enabled = True - return [frame_num] + return [timecode] # Start merging cuts until the length requirement is met. if self._merge_enabled: self._merge_triggered = True - self._merge_start = frame_num + self._merge_start = timecode return [] diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 05edeb61..6dd2355e 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -137,7 +137,8 @@ def __init__( raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) self._frame_score: ty.Optional[float] = None - # TODO(https://scenedetect.com/issue/168): Handle timecodes in filter. + # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling + # `min_scene_len` which should be specified in seconds, not frames. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 3df176c9..3fa54b04 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -293,7 +293,8 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config parameters.""" - framerate = scene_list[0][0]._framerate + # TODO(v0.7): This needs to be fixed as part of PTS overhaul. + framerate = scene_list[0][0].framerate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. return [ ( @@ -450,7 +451,7 @@ def save_images( image_num_format = "%0" image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - framerate = scene_list[0][0]._framerate + framerate = scene_list[0][0]._rate # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. timecode_list = [ diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 890d9fb1..f4982ef8 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -87,7 +87,6 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): import numpy as np from scenedetect.common import ( - _USE_PTS_IN_DEVELOPMENT, CropRegion, CutList, FrameTimecode, @@ -232,7 +231,7 @@ def __init__( self._exception_info = None self._stop = threading.Event() - self._frame_buffer = [] + self._frame_buffer: ty.List[ty.Tuple[FrameTimecode, np.ndarray]] = [] self._frame_buffer_size = 0 self._crop = None @@ -385,7 +384,7 @@ def _process_frame( self, position: FrameTimecode, frame_im: np.ndarray, - callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, + callback: ty.Optional[ty.Callable[[np.ndarray, FrameTimecode], None]] = None, ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" @@ -393,7 +392,7 @@ def _process_frame( # TODO(https://scenedetect.com/issues/283): This breaks with AdaptiveDetector as cuts differ # from the frame number being processed. Allow detectors to specify the max frame lookahead # they require (i.e. any event will never be more than N frames behind the current one). - self._frame_buffer.append(frame_im) + self._frame_buffer.append((position, frame_im)) # frame_buffer[-1] is current frame, -2 is one behind, etc # so index based on cut frame should be [event_frame - (frame_num + 1)] self._frame_buffer = self._frame_buffer[-(self._frame_buffer_size + 1) :] @@ -401,13 +400,11 @@ def _process_frame( cuts = detector.process_frame(position, frame_im) self._cutting_list += cuts new_cuts = True if cuts else False - # TODO: Support callbacks with PTS. if callback: - if _USE_PTS_IN_DEVELOPMENT: - raise NotImplementedError() for cut in cuts: - buffer_index = cut.frame_num - (position.frame_num + 1) - callback(self._frame_buffer[buffer_index], cut.frame_num) + for position, frame in self._frame_buffer: + if cut == position: + callback(frame, position) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 772a922a..26c2cefe 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -34,6 +34,7 @@ import typing as ty from abc import ABC, abstractmethod from dataclasses import dataclass +from fractions import Fraction import numpy as np diff --git a/tests/test_timecode.py b/tests/test_timecode.py index cbddeb79..4d6c0d21 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -21,11 +21,12 @@ """ # Third-Party Library Imports +from fractions import Fraction + import pytest # Standard Library Imports from scenedetect.common import MAX_FPS_DELTA, FrameTimecode -from fractions import Fraction def test_framerate(): diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 5ad1a3d3..6bc47db4 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -713,4 +713,5 @@ Although there have been minimal changes to most API examples, there are several * Deprecated functionality preserved from v0.6 now uses the `warnings` module * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods * Add new `Timecode` type to represent frame timings in terms of the video's source timebase - * Expand `FrameTimecode` representations to preserve accuracy (previously all timecodes were rounded to frame boundaries) \ No newline at end of file + * Add new `time_base` and `pts` properties to `FrameTimecode` to provide more accurate timing information + From f915f996b973af2781841cd86fed1a46a6172eb1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 4 Oct 2025 21:40:17 -0400 Subject: [PATCH 268/407] [timecode] Combine all representations into a variant type --- scenedetect/common.py | 179 ++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 84 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index 4db67d5e..66d60e2f 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -147,6 +147,20 @@ def seconds(self) -> float: return float(self.time_base * self.pts) +@dataclass(frozen=True) +class _FrameNumber: + """Represents a time as a frame number.""" + + value: int + + +@dataclass(frozen=True) +class _Seconds: + """Represents a time in seconds.""" + + value: float + + class FrameTimecode: """Object for frame-based timecodes, using the video framerate to compute back and forth between frame number and seconds/timecode. @@ -172,24 +186,15 @@ def __init__( TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. """ - # NOTE: FrameTimecode will have either a `Timecode` representation, a `seconds` - # representation, or only a frame number. We cache the calculated values for later use - # for the parameters that are missing. + self._time: ty.Union[_FrameNumber, _Seconds, Timecode] + """Internal time representation.""" self._rate: Fraction = None """Rate at which time passes between frames, measured in frames/sec.""" - self._frame_num = None - """Frame number which may be estimated.""" - self._timecode: ty.Optional[Timecode] = None - """Presentation timestamp from the backend.""" - self._seconds: ty.Optional[float] = None - """An explicit point in time.""" # Copy constructor. if isinstance(timecode, FrameTimecode): self._rate = timecode._rate if fps is None else fps - self._frame_num = timecode._frame_num - self._timecode = timecode._timecode - self._seconds = timecode._seconds + self._time = timecode._time return if not isinstance(fps, (float, Fraction, FrameTimecode)): @@ -215,7 +220,7 @@ def __init__( # Timecode with a time base. if isinstance(timecode, Timecode): - self._timecode = timecode + self._time = timecode return # Process the timecode value, storing it as an exact number of frames only if required. @@ -223,15 +228,15 @@ def __init__( timecode = int(timecode) if isinstance(timecode, str): - self._seconds = self._timecode_to_seconds(timecode) + self._time = _Seconds(self._timecode_to_seconds(timecode)) elif isinstance(timecode, float): if timecode < 0.0: raise ValueError("Timecode frame number must be positive and greater than zero.") - self._seconds = timecode + self._time = _Seconds(timecode) elif isinstance(timecode, int): if timecode < 0: raise ValueError("Timecode frame number must be positive and greater than zero.") - self._frame_num = timecode + self._time = _FrameNumber(timecode) else: raise TypeError("Timecode format/type unrecognized.") @@ -239,7 +244,7 @@ def __init__( def frame_num(self) -> ty.Optional[int]: """The frame number. This value will be an estimate if the video is VFR. Prefer using the `pts` property.""" - if self._timecode: + if isinstance(self._time, Timecode): # We need to audit anything currently using this property to guarantee temporal # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). warnings.warn( @@ -249,11 +254,11 @@ def frame_num(self) -> ty.Optional[int]: ) # We can calculate the approx. # of frames by taking the presentation time and the # time base itself. - (num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio() + (num, den) = (self._time.time_base * self._time.pts).as_integer_ratio() return num / den - if self._seconds is not None: - return self._seconds_to_frames(self._seconds) - return self._frame_num + if isinstance(self._time, _Seconds): + return self._seconds_to_frames(self._time.value) + return self._time.value @property def framerate(self) -> float: @@ -264,15 +269,15 @@ def framerate(self) -> float: @property def time_base(self) -> Fraction: """The time base in which presentation time is calculated.""" - if self._timecode: - return self._timecode.time_base + if isinstance(self._time, Timecode): + return self._time.time_base return 1 / self._rate @property def pts(self) -> int: """The presentation timestamp of the frame in units of `time_base`.""" - if self._timecode: - return self._timecode.pts + if isinstance(self._time, Timecode): + return self._time.pts return self.frame_num def get_frames(self) -> int: @@ -321,11 +326,11 @@ def equal_framerate(self, fps) -> bool: @property def seconds(self) -> float: """The frame's position in number of seconds.""" - if self._timecode: - return self._timecode.seconds - if self._seconds: - return self._seconds - return float(self._frame_num / self._rate) + if isinstance(self._time, Timecode): + return self._time.seconds + if isinstance(self._time, _Seconds): + return self._time.value + return float(self._time.value / self._rate) def get_seconds(self) -> float: """[DEPRECATED] Get the frame's position in number of seconds. @@ -478,8 +483,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] raise ValueError( "FrameTimecode instances require equal framerate for frame-based arithmetic." ) - if other._frame_num is not None: - return other._frame_num + if isinstance(other._time, _FrameNumber): + return other._time.value # If other has no frame_num, it must have a timecode. Convert to frames. return self._seconds_to_frames(other.seconds) raise TypeError("Cannot obtain frame number for this timecode.") @@ -489,7 +494,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return False if _compare_as_fixed(self, other): return self.frame_num == other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -498,74 +503,76 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return True if _compare_as_fixed(self, other): return self.frame_num != other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num < other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num <= other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num > other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num >= other.frame_num - if self._timecode or self._seconds is not None: + if isinstance(self._time, (Timecode, _Seconds)): return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) - if self._timecode and other_has_timecode: - if self._timecode.time_base != other._timecode.time_base: + if isinstance(self._time, Timecode) and other_is_timecode: + if self._time.time_base != other._time.time_base: raise ValueError("timecodes have different time bases") - self._timecode = Timecode( - pts=max(0, self._timecode.pts + other._timecode.pts), - time_base=self._timecode.time_base, + self._time = Timecode( + pts=max(0, self._time.pts + other._time.pts), + time_base=self._time.time_base, ) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if self._timecode or other_has_timecode: - timecode: Timecode = self._timecode if self._timecode else other._timecode - seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds - self._timecode = Timecode( + if isinstance(self._time, Timecode) or other_is_timecode: + timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time + seconds: float = ( + self._get_other_as_seconds(other) + if isinstance(self._time, Timecode) + else self.seconds + ) + self._time = Timecode( pts=max(0, timecode.pts + round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._seconds = None self._rate = None - self._frame_num = None return self - other_has_seconds = isinstance(other, FrameTimecode) and other._seconds - if self._seconds is not None and other_has_seconds: - self._seconds = max(0, self._seconds + other._seconds) + other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) + if isinstance(self._time, _Seconds) and other_is_seconds: + self._time = _Seconds(max(0, self._time.value + other._time.value)) return self - if self._seconds is not None: - self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other)) + if isinstance(self._time, _Seconds): + self._time = _Seconds(max(0.0, self._time.value + self._get_other_as_seconds(other))) return self - self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other)) + self._time = _FrameNumber(max(0, self._time.value + self._get_other_as_frames(other))) return self def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -574,41 +581,43 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi return to_return def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_has_timecode = isinstance(other, FrameTimecode) and other._timecode + other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) - if self._timecode and other_has_timecode: - if self._timecode.time_base != other._timecode.time_base: + if isinstance(self._time, Timecode) and other_is_timecode: + if self._time.time_base != other._time.time_base: raise ValueError("timecodes have different time bases") - self._timecode = Timecode( - pts=max(0, self._timecode.pts - other._timecode.pts), - time_base=self._timecode.time_base, + self._time = Timecode( + pts=max(0, self._time.pts - other._time.pts), + time_base=self._time.time_base, ) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if self._timecode or other_has_timecode: - timecode: Timecode = self._timecode if self._timecode else other._timecode - seconds: float = self._get_other_as_seconds(other) if self._timecode else self.seconds - self._timecode = Timecode( + if isinstance(self._time, Timecode) or other_is_timecode: + timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time + seconds: float = ( + self._get_other_as_seconds(other) + if isinstance(self._time, Timecode) + else self.seconds + ) + self._time = Timecode( pts=max(0, timecode.pts - round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._seconds = None self._rate = None - self._frame_num = None return self - other_has_seconds = isinstance(other, FrameTimecode) and other._seconds - if self._seconds is not None and other_has_seconds: - self._seconds = max(0, self._seconds - other._seconds) + other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) + if isinstance(self._time, _Seconds) and other_is_seconds: + self._time = _Seconds(max(0, self._time.value - other._time.value)) return self - if self._seconds is not None: - self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other)) + if isinstance(self._time, _Seconds): + self._time = _Seconds(max(0.0, self._time.value - self._get_other_as_seconds(other))) return self - self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other)) + self._time = _FrameNumber(max(0, self._time.value - self._get_other_as_frames(other))) return self def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": @@ -620,7 +629,9 @@ def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi # need to use relevant property instead. def __int__(self) -> int: - return self._frame_num + if isinstance(self._time, _FrameNumber): + return self._time.value + return self.frame_num def __float__(self) -> float: return self.seconds @@ -629,21 +640,21 @@ def __str__(self) -> str: return self.get_timecode() def __repr__(self) -> str: - if self._timecode: - return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]" - if self._seconds is not None: - return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._rate}]" - return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._rate}]" + if isinstance(self._time, Timecode): + return f"{self.get_timecode()} [pts={self._time.pts}, time_base={self._time.time_base}]" + if isinstance(self._time, _Seconds): + return f"{self.get_timecode()} [seconds={self._time.value}, fps={self._rate}]" + return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]" def __hash__(self) -> int: - if self._timecode: - return hash(self._timecode) - return self._frame_num + if isinstance(self._time, Timecode): + return hash(self._time) + return self.frame_num def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): - if self._timecode: + if isinstance(self._time, Timecode): # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses # frame numbers with timestamps to convert to a non-frame based way of temporal # logic and instead use seconds-based. From 98f4be6c46fdb0367c8c415e3177848ebbea0b9c Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 5 Oct 2025 00:12:17 -0400 Subject: [PATCH 269/407] [build] Drop Python 3.7 and macos 13 support. The macos13 builder will be retired soon so remove it early to avoid breaking the build. Python 3.7 is not supported on all builders already and has been EOL for > 2 years now. --- .github/workflows/build.yml | 16 +++------------- scenedetect/backends/moviepy.py | 2 +- scenedetect/common.py | 5 +++++ setup.cfg | 3 +-- tests/test_cli.py | 4 ++-- website/pages/changelog.md | 10 +--------- website/pages/download.md | 2 +- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52ef6876..d04800dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,21 +26,11 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-13, macos-14, ubuntu-22.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] - exclude: - # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. - - os: macos-14 - python-version: "3.7" - # ubuntu 24+ does not have Python 3.7 - - os: ubuntu-latest - python-version: "3.7" - + os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] env: # Version is extracted below and used to find correct package install path. scenedetect_version: "" - # Setuptools must be pinned for the Python 3.7 builders. - setuptools_version: "${{ matrix.python-version == '3.7' && '==62.3.4' || '' }}" steps: - uses: actions/checkout@v4 @@ -61,7 +51,7 @@ jobs: - name: Install Dependencies run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools${{ env.setuptools_version }} + python -m pip install --upgrade pip build wheel virtualenv setuptools pip install -r requirements_headless.txt --only-binary av,opencv-python-headless - name: Install MoviePy diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 0de86013..14758952 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -116,7 +116,7 @@ def duration(self) -> ty.Optional[FrameTimecode]: @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - # TODO: Use cached_property once Python 3.7 support is deprecated. + # TODO: Use cached_property. if self._aspect_ratio is None: # MoviePy doesn't support extracting the aspect ratio yet, so for now we just fall # back to using OpenCV to determine it. diff --git a/scenedetect/common.py b/scenedetect/common.py index 66d60e2f..4bf3493f 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -352,6 +352,11 @@ def get_seconds(self) -> float: ) return self.seconds + # TODO(https://scenedetect.com/issue/168): We should remove `nearest_frame` if possible, it + # assumes constant framerate and causes more problems than it solves. Setting it to False makes + # test_cli_load_scenes_with_time_frames in test_cli.py fail due to differences in end time. + # We may also just need to clamp end time to the one specified by the user, this may not be + # happening in the code. def get_timecode( self, precision: int = 3, use_rounding: bool = True, nearest_frame: bool = True ) -> str: diff --git a/setup.cfg b/setup.cfg index 53d0f0cb..e966ab12 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,6 @@ classifiers = Intended Audience :: System Administrators Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -50,7 +49,7 @@ packages = scenedetect.backends scenedetect.detectors scenedetect.output -python_requires = >=3.7 +python_requires = >=3.8 [options.extras_require] opencv = opencv-python diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ba8da2e..29fd2738 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -630,7 +630,7 @@ def test_cli_backend_unsupported(): ) -def test_cli_load_scenes(): +def test_cli_load_scenes_options(): """Ensure we can load scenes both with and without the cut row.""" assert invoke_scenedetect("-i {VIDEO} time {TIME} {DETECTOR} list-scenes") == 0 assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 @@ -648,7 +648,7 @@ def test_cli_load_scenes(): assert invoke_scenedetect("-i {VIDEO} time {TIME} load-scenes -i {VIDEO_NAME}-Scenes.csv") == 0 -def test_cli_load_scenes_with_time_frames(): +def test_cli_load_scenes_output(): """Verify we can use `load-scenes` with the `time` command and get the desired output.""" scenes_csv = """ Scene Number,Start Frame diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6bc47db4..92ed8226 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -671,20 +671,15 @@ Development PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. -Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. - -### CLI Changes +Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.8**. ### CLI Changes - [feature] [WIP] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command - ### API Changes -#### Breaking - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` @@ -707,9 +702,6 @@ Although there have been minimal changes to most API examples, there are several * Remove `advance` parameter from `VideoStream.read()` * Remove `SceneDetector.stats_manager_required` property, no longer required * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) - - #### General - * Deprecated functionality preserved from v0.6 now uses the `warnings` module * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods * Add new `Timecode` type to represent frame timings in terms of the video's source timebase diff --git a/website/pages/download.md b/website/pages/download.md index e356c800..ea8641f1 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -3,7 +3,7 @@ PySceneDetect is completely free software, and can be downloaded from the links below. See the [license and copyright information](copyright.md) page for details. If you have trouble running PySceneDetect, ensure that you have all the required dependencies listed in the [Dependencies](#dependencies) section below. -PySceneDetect requires at least Python 3.7 or higher. +PySceneDetect requires at least Python 3.8 or higher. ## Install via pip       From 85d79b633c11590afb0aebd5b39bdc5d71af32d7 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 11 Nov 2025 23:08:44 -0500 Subject: [PATCH 270/407] [docs] Place AdaptiveDetector first in listing This is now the default so we place it first accordingly. --- docs/api.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index bcf9fd96..76cff84d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,12 +9,12 @@ The `scenedetect` API is easy to integrate with most application workflows, whil * :ref:`scenedetect.detectors 🕵️ `: detection algorithms: + * :mod:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes + * :mod:`ContentDetector `: detects fast cuts using weighted average of HSV changes * :mod:`ThresholdDetector `: finds fades in/out using average pixel intensity changes in RGB - * :mod:`AdaptiveDetector ` finds fast cuts using rolling average of HSL changes - * :mod:`HistogramDetector ` finds fast cuts using HSV histogram changes * :mod:`HashDetector `: finds fast cuts using perceptual image hashing From 78d7fa4ea6cbf53d2baef43d47b69f566bbb64c5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 18 Jan 2026 19:32:33 -0500 Subject: [PATCH 271/407] [dist] Update installer license --- appveyor.yml | 4 ++-- dist/installer/license65.dat.enc | Bin 416 -> 416 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index af16f038..73a245c4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,9 +17,9 @@ environment: - PYTHON: "C:\\Python313-x64" # Encrypted AdvancedInstaller License ai_license_secret: - secure: of3o1pInqCJYwKLFsiadbsRYazCmCuZq7r2roaYvYXmBvm6e6JHsRU47waylTmhm + secure: QRCPoNYF1nqgXDn7pHgBzg== ai_license_salt: - secure: +NKWwlkEptlThgfeL35pLo7EsnkJc+4WODm8tTg1aO5fc0duQ4r100fHQYj6nzhyUdy3Dhs/mOLkxD8rNbBiEQ== + secure: +Gy+SRk8JUsaM+5pMEKITiJxdLilrxHpkKlrZzR3C9DPwdgYLGxt5sJn6uXuAJg7e6JsKHcT7tRks/HcSKkHPw== ffmpeg_version: "8.0" # SignPath Config for Code Signing diff --git a/dist/installer/license65.dat.enc b/dist/installer/license65.dat.enc index 9288aaa8979170348a9bbd639446d6e0e0c39d12..d3d500e1deb62f05749c08ae854bd0316fafa8cb 100644 GIT binary patch literal 416 zcmV;R0bl-a#}mx1%5sc6dL%54G6Op^B+T4%iKT#*t3J4MGjsVU7|sacBhX`lNc#Qs zH;2*KlW*TW)g#RhV29~I|G6S|-!5Zd)G^`XlQUaK`!J zF0A`-2f&x{koa2Wzv1y5s)3caCfyTAAv7W!;teqhWoZ>>u(qunb>zO#!0IfmzFK5_16@hOv)vNTV7Yy^u$ET%s-K>2sb4aYp z9qosKTQuD*DGW~z)$>!~UiNRi4x)0`TH~h-&P!c#3wkoOGDl$a7b!RS{!t=7o08Wmd5&h_3{LJGRz3IN z;zAb-qwr31L9@lM-M_7?9mb{1)8yP=q<~G{ss(VNI1YiC4!=X1YvPS2aBEN*JTFPrO{pBSRikT z8?iOfizfG+K-MMYxFtxt*~bD~CE*9Wn8}?v=PK>7WCkO8(;@0WITYe)qsxIzVD~OT<2?IeL284*v zB*>zYGT%qH2c@wHk!#oUCl{z6{*zdnzD8dKk4xnYv?13x=0unypjge_7aq+=#qzj1 z$Y`dK=0~aBWRuYw8X=9G|F^Kn0?@n0itHNe1zq|(AJVc<4ZixZh}~k&_iSl9mDzg} KL}oOSvplSj!O*|} From e2c11c9b9526299605700fa7baf854597f74a3b4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 19:51:20 -0500 Subject: [PATCH 272/407] [timecode] Expand PTS support #168 Allows much more tests to pass now, down to just a few. --- scenedetect/backends/opencv.py | 9 ++++- scenedetect/backends/pyav.py | 9 +++-- scenedetect/common.py | 61 ++++++++++++++++++++++++---------- scenedetect/scene_manager.py | 2 +- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 24d7d3c5..298f0301 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -209,7 +209,14 @@ def position(self) -> FrameTimecode: # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or # add a config option before landing this. if _USE_PTS_IN_DEVELOPMENT: - return FrameTimecode(timecode=self.timecode, fps=self.frame_rate) + timecode = self.timecode + # If PTS is 0 but we've read frames, derive from frame number. + # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. + if timecode.pts == 0 and self.frame_number > 0: + time_sec = (self.frame_number - 1) / self.frame_rate + pts = round(time_sec * 1000) + timecode = Timecode(pts=pts, time_base=Fraction(1, 1000)) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) if self.frame_number < 1: return self.base_timecode return self.base_timecode + (self.frame_number - 1) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index b6537774..8692cdb5 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -182,11 +182,11 @@ def position(self) -> FrameTimecode: This can be interpreted as presentation time stamp, thus frame 1 corresponds to the presentation time 0. Returns 0 even if `frame_number` is 1.""" + if self._frame is None: + return self.base_timecode if _USE_PTS_IN_DEVELOPMENT: timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) return FrameTimecode(timecode=timecode, fps=self.frame_rate) - if self._frame is None: - return self.base_timecode return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate) @property @@ -205,9 +205,8 @@ def frame_number(self) -> int: if self._frame: if _USE_PTS_IN_DEVELOPMENT: - return FrameTimecode( - round(self._frame.time * self.frame_rate), self.frame_rate - ).frame_num + # frame_number is 1-indexed, so add 1 to the 0-based frame position. + return round(self._frame.time * self.frame_rate) + 1 return self.position.frame_num + 1 return 0 diff --git a/scenedetect/common.py b/scenedetect/common.py index 4bf3493f..e4ab7e48 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -252,18 +252,22 @@ def frame_num(self) -> ty.Optional[int]: stacklevel=2, category=UserWarning, ) - # We can calculate the approx. # of frames by taking the presentation time and the - # time base itself. - (num, den) = (self._time.time_base * self._time.pts).as_integer_ratio() - return num / den + # Calculate approximate frame number from seconds and framerate. + if self._rate is not None: + return round(self._time.seconds * float(self._rate)) + # No framerate available - return estimate based on time. + return round(self._time.seconds) if isinstance(self._time, _Seconds): return self._seconds_to_frames(self._time.value) return self._time.value @property - def framerate(self) -> float: + def framerate(self) -> ty.Optional[float]: """The framerate to use for distance between frames and to calculate frame numbers. - For a VFR video, this may just be the average framerate.""" + For a VFR video, this may just be the average framerate. Returns None if framerate + is unknown (e.g. when working with pure Timecode representations).""" + if self._rate is None: + return None return float(self._rate) @property @@ -499,6 +503,9 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return False if _compare_as_fixed(self, other): return self.frame_num == other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num == other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds == self._get_other_as_seconds(other) return self.frame_num == self._get_other_as_frames(other) @@ -508,6 +515,9 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return True if _compare_as_fixed(self, other): return self.frame_num != other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num != other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds != self._get_other_as_seconds(other) return self.frame_num != self._get_other_as_frames(other) @@ -515,6 +525,9 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num < other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num < other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds < self._get_other_as_seconds(other) return self.frame_num < self._get_other_as_frames(other) @@ -522,6 +535,9 @@ def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num <= other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num <= other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds <= self._get_other_as_seconds(other) return self.frame_num <= self._get_other_as_frames(other) @@ -529,6 +545,9 @@ def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num > other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num > other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds > self._get_other_as_seconds(other) return self.frame_num > self._get_other_as_frames(other) @@ -536,6 +555,9 @@ def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if _compare_as_fixed(self, other): return self.frame_num >= other.frame_num + # For integer comparison, use frame numbers to avoid floating point precision issues. + if isinstance(other, int): + return self.frame_num >= other if isinstance(self._time, (Timecode, _Seconds)): return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) @@ -565,7 +587,9 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT pts=max(0, timecode.pts + round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._rate = None + # Preserve rate if available from self or other. + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate return self other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) @@ -610,7 +634,9 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT pts=max(0, timecode.pts - round(seconds / timecode.time_base)), time_base=timecode.time_base, ) - self._rate = None + # Preserve rate if available from self or other. + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate return self other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) @@ -652,21 +678,20 @@ def __repr__(self) -> str: return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]" def __hash__(self) -> int: - if isinstance(self._time, Timecode): - return hash(self._time) + # Use frame_num for consistent hashing regardless of internal representation. + # This ensures that FrameTimecodes representing the same frame have the same hash, + # enabling proper dictionary lookups in StatsManager. return self.frame_num def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float: """Get the time in seconds from `other` for arithmetic operations.""" if isinstance(other, int): - if isinstance(self._time, Timecode): - # TODO(https://scenedetect.com/issue/168): We need to convert every place that uses - # frame numbers with timestamps to convert to a non-frame based way of temporal - # logic and instead use seconds-based. - if _USE_PTS_IN_DEVELOPMENT and other == 1: - return self.seconds - raise NotImplementedError() - return float(other) / self._rate + # Convert frame number to seconds using framerate. + if self._rate is None: + raise NotImplementedError( + "Cannot convert frame number to seconds without framerate" + ) + return float(other) / float(self._rate) if isinstance(other, float): return other if isinstance(other, str): diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index f4982ef8..8b47b32b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -404,7 +404,7 @@ def _process_frame( for cut in cuts: for position, frame in self._frame_buffer: if cut == position: - callback(frame, position) + callback(frame, int(position)) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: From 75243b026465bea7b2af007be40654457e423c81 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 19:55:18 -0500 Subject: [PATCH 273/407] [general] Add `strict=True` to all zip() calls as per new ruff lint --- benchmark/autoshot_dataset.py | 2 +- benchmark/bbc_dataset.py | 2 +- scenedetect/detectors/content_detector.py | 3 +- scenedetect/detectors/transnet_v2.py | 213 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 scenedetect/detectors/transnet_v2.py diff --git a/benchmark/autoshot_dataset.py b/benchmark/autoshot_dataset.py index 5312704e..80a58da1 100644 --- a/benchmark/autoshot_dataset.py +++ b/benchmark/autoshot_dataset.py @@ -17,7 +17,7 @@ def __init__(self, dataset_dir: str): self._scene_files = [ file for file in sorted(glob.glob(os.path.join(dataset_dir, "annotations", "*.txt"))) ] - for video_file, scene_file in zip(self._video_files, self._scene_files): + for video_file, scene_file in zip(self._video_files, self._scene_files, strict=True): video_id = os.path.basename(video_file).split(".")[0] scene_id = os.path.basename(scene_file).split(".")[0] assert video_id == scene_id diff --git a/benchmark/bbc_dataset.py b/benchmark/bbc_dataset.py index 1bb7693e..5feb54ae 100644 --- a/benchmark/bbc_dataset.py +++ b/benchmark/bbc_dataset.py @@ -18,7 +18,7 @@ def __init__(self, dataset_dir: str): file for file in sorted(glob.glob(os.path.join(dataset_dir, "fixed", "*.txt"))) ] assert len(self._video_files) == len(self._scene_files) - for video_file, scene_file in zip(self._video_files, self._scene_files): + for video_file, scene_file in zip(self._video_files, self._scene_files, strict=True): video_id = os.path.basename(video_file).replace("bbc_", "").split(".")[0] scene_id = os.path.basename(scene_file).split("-")[0] assert video_id == scene_id diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 6dd2355e..6cf757fa 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -173,7 +173,8 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr ) frame_score: float = sum( - component * weight for (component, weight) in zip(score_components, self._weights) + component * weight + for (component, weight) in zip(score_components, self._weights, strict=True) ) / sum(abs(weight) for weight in self._weights) # Record components and frame score if needed for analysis. diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py new file mode 100644 index 00000000..752749cd --- /dev/null +++ b/scenedetect/detectors/transnet_v2.py @@ -0,0 +1,213 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2024 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +""":class:`TransnetV2Detector` uses a pretrained neural network. + +This detector is available from the command-line as the `detect-transnetv2` command. +""" + +import typing as ty +import warnings +from enum import Enum +from logging import getLogger +from pathlib import Path + +import cv2 +import numpy as np + +from scenedetect.common import FrameTimecode, Timecode +from scenedetect.detector import FlashFilter, SceneDetector + +logger = getLogger("pyscenedetect") + + +class Detector: + def __init__(self, threshold: float, flash_filter: FlashFilter): + self.i = 0 + self.y_prev = 0 + self.threshold = threshold + self.flash_filter = flash_filter + + def push(self, ys: np.ndarray, ts: np.ndarray): + predictions = (ys > self.threshold).astype(np.uint8) + + cuts = [] + for y, t in zip(predictions, ts, strict=True): + if self.y_prev == 0 and y == 1 and self.i > 0: + cuts.append(t) + self.y_prev = y + self.i += 1 + + return cuts + + +class Predictor: + def __init__( + self, + model_path: ty.Union[str, Path], + flash_filter: FlashFilter, + onnx_providers: ty.Union[ty.List[str], None], + threshold, + ): + import onnxruntime as ort + + ort.set_default_logger_severity(3) + + if onnx_providers is None: + onnx_providers = ort.get_available_providers() + + sess_opt = ort.SessionOptions() + sess_opt.log_severity_level = 3 + + self.session = ort.InferenceSession(model_path, sess_opt=sess_opt, providers=onnx_providers) + + self.pixels = None + self.time = None + + self.det = Detector(threshold, flash_filter) + + def _inference(self, pixels: np.ndarray, time: np.ndarray): + pred = np.array(self.session.run(["output"], {"input": pixels}))[0] + + cuts = [] + for i in range(pred.shape[0]): + cuts.extend(self.det.push(pred[i, 25:75, 0], time[i, 25:75])) + return cuts + + def push(self, pixels: np.ndarray, time: np.ndarray): + if self.pixels is None: + self.pixels = pixels + self.time = time + + return self._inference( + np.stack( + ( + np.tile(np.expand_dims(pixels[0], axis=0), (100, 1, 1, 1)), + np.concatenate( + ( + np.tile(np.expand_dims(pixels[0], axis=0), (25, 1, 1, 1)), + pixels[:75], + ), + 0, + ), + ) + ), + np.stack( + ( + np.tile(np.expand_dims(time[0], axis=0), (100,)), + np.concatenate( + (np.tile(np.expand_dims(time[0], axis=0), (25,)), time[:75]), 0 + ), + ) + ), + ) + else: + c1 = self.pixels + c2 = pixels + + t1 = self.time + t2 = time + + self.pixels = pixels + self.time = time + + return self._inference( + np.stack( + (np.concatenate((c1[25:], c2[:25]), 0), np.concatenate((c1[75:], c2[:75]), 0)) + ), + np.stack( + (np.concatenate((t1[25:], t2[:25]), 0), np.concatenate((t1[75:], t2[:75]), 0)) + ), + ) + + +class TransnetV2Detector(SceneDetector): + def __init__( + self, + model_path: ty.Union[str, Path] = "tests/resources/transnetv2.onnx", + onnx_providers: ty.Union[ty.List[str], None] = None, + threshold: float = 0.5, + min_scene_len: int = 15, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, + ): + super().__init__() + + self.px = np.zeros((2, 100, 27, 48, 3), dtype=np.uint8) + self.time = np.zeros((2, 100), dtype=np.int64) + + self.blank = np.zeros(self.px.shape[2:], dtype=np.uint8) + + self.i = 0 + self.j = 0 + + self.predictor = Predictor( + model_path=model_path, + flash_filter=FlashFilter(mode=filter_mode, length=min_scene_len), + onnx_providers=onnx_providers, + threshold=threshold, + ) + # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling + # `min_scene_len` which should be specified in seconds, not frames. + self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) + + def mk_ft(self, pts: int): + # t = Timecode(pts=pts, time_base=self.time_base) + t = float(pts * self.time_base) + return FrameTimecode(t, fps=self._fps) + + def process_frame( + self, timecode: FrameTimecode, frame_img: np.ndarray + ) -> ty.List[FrameTimecode]: + """Process the next frame.""" + + self.time_base = timecode.time_base + self._fps = timecode._rate + + pixels = cv2.resize(frame_img, (48, 27), interpolation=cv2.INTER_AREA) + + self.px[self.j, self.i] = pixels + self.time[self.j, self.i] = timecode.pts + self.i += 1 + + if self.i >= 100: + cuts = self.predictor.push(self.px[self.j], self.time[self.j]) + self.j = 1 - self.j + self.i = 0 + + filtered_cuts = [] + for cut in cuts: + filtered_cuts += self._flash_filter.filter(self.mk_ft(cut), True) + return filtered_cuts + else: + return [] + + def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + """Writes a final scene cut if the last detected fade was a fade-out.""" + + cuts = [] + + last_time = timecode.pts + blank_frame = self.blank[:] + + self.px[self.j, self.i :] = blank_frame + self.time[self.j, self.i :] = last_time + cuts.extend(self.predictor.push(self.px[self.j], self.time[self.j])) + + self.j = 1 - self.j + + self.px[self.j, :] = blank_frame + self.time[self.j, :] = last_time + cuts.extend(self.predictor.push(self.px[self.j], self.time[self.j])) + + filtered_cuts = [] + for cut in cuts: + filtered_cuts += self._flash_filter.filter(self.mk_ft(cut), True) + return filtered_cuts From d75833fb2bb1ff70ea48a8841a05de6c65a7aff8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 20:06:46 -0500 Subject: [PATCH 274/407] [dist] Bump minimum supported Python version to 3.10 3.9 is the last EOL build and only gets a small number (< 100 per day). --- .github/workflows/build.yml | 2 +- setup.cfg | 6 +++--- website/pages/changelog.md | 2 +- website/pages/download.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d04800dc..957dfce8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [macos-14, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] env: # Version is extracted below and used to find correct package install path. scenedetect_version: "" diff --git a/setup.cfg b/setup.cfg index e966ab12..c01def6e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,8 +23,6 @@ classifiers = Intended Audience :: System Administrators Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 @@ -49,7 +47,7 @@ packages = scenedetect.backends scenedetect.detectors scenedetect.output -python_requires = >=3.8 +python_requires = >=3.10 [options.extras_require] opencv = opencv-python @@ -67,3 +65,5 @@ test = pytest [tool:pytest] addopts = --verbose python_files = tests/*.py +filterwarnings = + ignore:TODO.*Update caller to handle VFR:UserWarning diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 92ed8226..e9da750c 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -671,7 +671,7 @@ Development PySceneDetect is a major breaking release which overhauls how timestamps are handled throughout the API. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. -Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.8**. +Although there have been minimal changes to most API examples, there are several breaking changes. Applications written for the 0.6 API *may* require modification to work with the new API. Minimum supported Python version is now **Python 3.10**. ### CLI Changes diff --git a/website/pages/download.md b/website/pages/download.md index ea8641f1..ffa78d12 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -3,7 +3,7 @@ PySceneDetect is completely free software, and can be downloaded from the links below. See the [license and copyright information](copyright.md) page for details. If you have trouble running PySceneDetect, ensure that you have all the required dependencies listed in the [Dependencies](#dependencies) section below. -PySceneDetect requires at least Python 3.8 or higher. +PySceneDetect requires at least Python 3.10 or higher. ## Install via pip       From 34dd1dec2c2a22917af926301b0d52695c62aa1e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 16 Feb 2026 21:59:39 -0500 Subject: [PATCH 275/407] [dist] Add new project logo and update icon --- dist/pyscenedetect.ico | Bin 410598 -> 876 bytes dist/pyscenedetect.svg | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 dist/pyscenedetect.svg diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index b52baa836ba7ca1dff483e4a112a84ef96e495fd..f902e72ebe63a9dd26677f739c639e7f8cf50fde 100644 GIT binary patch literal 876 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 Q1GWRU1GWSI@jLMU0cE-|{Qv*} diff --git a/dist/pyscenedetect.svg b/dist/pyscenedetect.svg new file mode 100644 index 00000000..9f4f6a89 --- /dev/null +++ b/dist/pyscenedetect.svg @@ -0,0 +1,52 @@ + + + + + + + + + + From ee95841ad19f81523831e223ee42601848351134 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Feb 2026 21:31:28 -0500 Subject: [PATCH 276/407] [dist] Add script to generate icon file from SVG --- dist/generate_ico.py | 90 +++++++++++++++++++++++++++++++++++++++++ dist/pyscenedetect.ico | Bin 876 -> 21797 bytes dist/pyscenedetect.svg | 15 +++---- 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 dist/generate_ico.py diff --git a/dist/generate_ico.py b/dist/generate_ico.py new file mode 100644 index 00000000..c18d57b7 --- /dev/null +++ b/dist/generate_ico.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +"""Generate pyscenedetect.ico from pyscenedetect.svg. + +Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). +""" + +import contextlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageFilter + +# Different raster sizes to include in the ICO file. +SIZES = [16, 24, 32, 48, 64, 128, 256] + +# Sharpen smaller sizes to improve ledgibility. +SHARPEN_AMOUNT = { + 16: 200, + 24: 200, + 32: 100, + 48: 50, + 64: 50, +} + +DIST_DIR = Path(__file__).resolve().parent +SVG_PATH = DIST_DIR / "pyscenedetect.svg" +ICO_PATH = DIST_DIR / "pyscenedetect.ico" + + +def find_inkscape() -> str: + """Find the Inkscape executable.""" + inkscape = shutil.which("inkscape") + if inkscape: + return inkscape + # Common Windows install path + candidate = Path(r"C:\Program Files\Inkscape\bin\inkscape.exe") + if candidate.exists(): + return str(candidate) + print("Error: Inkscape not found. Please install it or add it to PATH.", file=sys.stderr) + sys.exit(1) + + +def render_svg(inkscape: str, svg: Path, output: Path, size: int): + """Render an SVG to a PNG at the given size using Inkscape.""" + subprocess.run( + [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(size), "-h", str(size)], + check=True, + capture_output=True, + ) + + +def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: + """Render the SVG at all icon sizes, applying sharpening where configured.""" + images = [] + for size in SIZES: + png_path = work_dir / f"icon_{size}.png" + print(f" Rendering {size}x{size}...") + render_svg(inkscape, SVG_PATH, png_path, size) + img = Image.open(png_path).copy() + if size in SHARPEN_AMOUNT: + img = img.filter(ImageFilter.UnsharpMask(radius=0.5, percent=SHARPEN_AMOUNT[size], threshold=0)) + print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + img.save(png_path) + images.append(img) + return images + + +def main(): + persist_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else None + if persist_dir: + persist_dir.mkdir(parents=True, exist_ok=True) + print(f"Persisting PNGs to: {persist_dir}") + + inkscape = find_inkscape() + print(f"Using Inkscape: {inkscape}") + print(f"Input SVG: {SVG_PATH}") + + ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() + with ctx as work: + images = render_all_sizes(inkscape, Path(work)) + images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) + + print(f"Output ICO: {ICO_PATH}") + + +if __name__ == "__main__": + main() diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index f902e72ebe63a9dd26677f739c639e7f8cf50fde..608b234438e8e606080f16cef1d9abb77e6fd7a1 100644 GIT binary patch literal 21797 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 + id="rect1" + style="fill:#49505a;fill-opacity:1" /> + style="stroke-width:0.909753;fill:#d7f1ea;fill-opacity:1" /> From fd3bf7a2f7e1dbb204be83a178707fbbd12a3a18 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Feb 2026 21:56:58 -0500 Subject: [PATCH 277/407] [dist] Update logo colors --- dist/pyscenedetect.ico | Bin 21797 -> 23367 bytes dist/pyscenedetect.svg | 16 ++++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index 608b234438e8e606080f16cef1d9abb77e6fd7a1..07b3f17cbb183eccb7c320fe8d4858a1af681fc4 100644 GIT binary patch literal 23367 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 + style="fill:#e0e8f0;fill-opacity:1" /> + style="fill:#2a3545;fill-opacity:1;stroke-width:0.986138" /> From b661a1cc799b6928d2d5e6a82998214730f57ede Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 17 Feb 2026 22:04:20 -0500 Subject: [PATCH 278/407] [dist] Add new logo SVG with text --- dist/pyscenedetect-logo.svg | 65 +++++++++++++++++++++++++++++++++++++ dist/pyscenedetect.svg | 6 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 dist/pyscenedetect-logo.svg diff --git a/dist/pyscenedetect-logo.svg b/dist/pyscenedetect-logo.svg new file mode 100644 index 00000000..7f09ad70 --- /dev/null +++ b/dist/pyscenedetect-logo.svg @@ -0,0 +1,65 @@ + + + + + + + + + + PySceneDetect + diff --git a/dist/pyscenedetect.svg b/dist/pyscenedetect.svg index f768ce6a..46dfbcdc 100644 --- a/dist/pyscenedetect.svg +++ b/dist/pyscenedetect.svg @@ -23,8 +23,8 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="1.1396485" - inkscape:cx="-11.407026" - inkscape:cy="169.78919" + inkscape:cx="338.26219" + inkscape:cy="35.537273" inkscape:window-width="3440" inkscape:window-height="1369" inkscape:window-x="-8" @@ -41,7 +41,7 @@ ry="2.5" fill="#e0e8f0" id="rect1" - style="fill:#e0e8f0;fill-opacity:1" /> + style="display:inline;fill:#e0e8f0;fill-opacity:1" /> Date: Fri, 20 Feb 2026 22:24:44 -0500 Subject: [PATCH 279/407] [dist] Improve logo ledgibility at small sizes --- dist/generate_ico.py | 85 ++++++++++++++++++++++++------ dist/logo/pyscenedetect-24-48.svg | 72 +++++++++++++++++++++++++ dist/logo/pyscenedetect-32.svg | 72 +++++++++++++++++++++++++ dist/logo/pyscenedetect-64+.svg | 70 ++++++++++++++++++++++++ dist/pyscenedetect.ico | Bin 23367 -> 20638 bytes dist/pyscenedetect.svg | 53 ------------------- 6 files changed, 282 insertions(+), 70 deletions(-) create mode 100644 dist/logo/pyscenedetect-24-48.svg create mode 100644 dist/logo/pyscenedetect-32.svg create mode 100644 dist/logo/pyscenedetect-64+.svg delete mode 100644 dist/pyscenedetect.svg diff --git a/dist/generate_ico.py b/dist/generate_ico.py index c18d57b7..c9280e22 100644 --- a/dist/generate_ico.py +++ b/dist/generate_ico.py @@ -13,22 +13,67 @@ from PIL import Image, ImageFilter -# Different raster sizes to include in the ICO file. -SIZES = [16, 24, 32, 48, 64, 128, 256] +# Colors matching the SVG design +BG = (224, 232, 240, 255) # #e0e8f0 +FG = (42, 53, 69, 255) # #2a3545 + +RASTER_SIZES = [16, 24, 32, 48, 64, 128, 256] -# Sharpen smaller sizes to improve ledgibility. SHARPEN_AMOUNT = { - 16: 200, - 24: 200, - 32: 100, - 48: 50, - 64: 50, + 24: 75, + 32: 75, + 48: 75, + 64: 100, + 128: 150, + 256: 150, } +SHARPEN_RADIUS = 0.5 + DIST_DIR = Path(__file__).resolve().parent -SVG_PATH = DIST_DIR / "pyscenedetect.svg" +LOGO_DIR = DIST_DIR / "logo" ICO_PATH = DIST_DIR / "pyscenedetect.ico" +SVG_FOR_SIZE: dict[int, Path] = { + 24: LOGO_DIR / "pyscenedetect-24-48.svg", + 32: LOGO_DIR / "pyscenedetect-32.svg", + 48: LOGO_DIR / "pyscenedetect-24-48.svg", + 64: LOGO_DIR / "pyscenedetect-64+.svg", + 128: LOGO_DIR / "pyscenedetect-64+.svg", + 256: LOGO_DIR / "pyscenedetect-64+.svg", +} + + +def make_icon_16() -> Image.Image: + """Create a hand-crafted 16x16 clapperboard icon.""" + img = Image.new("RGBA", (16, 16), FG) + px = img.load() + + # Clear 1px padding on all sides + for i in range(16): + px[0, i] = BG + px[15, i] = BG + px[i, 0] = BG + px[i, 15] = BG + + # Arm stripe gaps (rows 2–4): clear pixels not part of a complete stripe. + # A stripe x+y=s spans all 3 arm rows only when 5 <= s <= 16. + for y in range(2, 5): + for x in range(1, 15): + if y < 4 and x < 3: + continue + if y > 2 and x > 12: + continue + if not ((x + y) % 4 < 2 and 5 <= (x + y) <= 16): + px[x, y] = BG + + # Slate interior (rows 8–12, cols 3–12) + for y in range(8, 13): + for x in range(3, 13): + px[x, y] = BG + + return img + def find_inkscape() -> str: """Find the Inkscape executable.""" @@ -55,15 +100,21 @@ def render_svg(inkscape: str, svg: Path, output: Path, size: int): def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: """Render the SVG at all icon sizes, applying sharpening where configured.""" images = [] - for size in SIZES: + for size in RASTER_SIZES: png_path = work_dir / f"icon_{size}.png" - print(f" Rendering {size}x{size}...") - render_svg(inkscape, SVG_PATH, png_path, size) - img = Image.open(png_path).copy() - if size in SHARPEN_AMOUNT: - img = img.filter(ImageFilter.UnsharpMask(radius=0.5, percent=SHARPEN_AMOUNT[size], threshold=0)) - print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + if size == 16: + print(f" Using hand-crafted {size}x{size} icon...") + img = make_icon_16() img.save(png_path) + else: + svg_path = SVG_FOR_SIZE[size] + print(f" Rendering {size}x{size} using {svg_path.name}...") + render_svg(inkscape, svg_path, png_path, size) + img = Image.open(png_path).copy() + if size in SHARPEN_AMOUNT: + img = img.filter(ImageFilter.UnsharpMask(radius=SHARPEN_RADIUS, percent=SHARPEN_AMOUNT[size], threshold=0)) + print(f" Sharpened {size}x{size} (USM {SHARPEN_AMOUNT[size]}%)") + img.save(png_path) images.append(img) return images @@ -76,7 +127,7 @@ def main(): inkscape = find_inkscape() print(f"Using Inkscape: {inkscape}") - print(f"Input SVG: {SVG_PATH}") + print(f"Logo directory: {LOGO_DIR}") ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() with ctx as work: diff --git a/dist/logo/pyscenedetect-24-48.svg b/dist/logo/pyscenedetect-24-48.svg new file mode 100644 index 00000000..b1b53949 --- /dev/null +++ b/dist/logo/pyscenedetect-24-48.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + diff --git a/dist/logo/pyscenedetect-32.svg b/dist/logo/pyscenedetect-32.svg new file mode 100644 index 00000000..63225e8f --- /dev/null +++ b/dist/logo/pyscenedetect-32.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + diff --git a/dist/logo/pyscenedetect-64+.svg b/dist/logo/pyscenedetect-64+.svg new file mode 100644 index 00000000..9c2c987a --- /dev/null +++ b/dist/logo/pyscenedetect-64+.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index 07b3f17cbb183eccb7c320fe8d4858a1af681fc4..3886a457aade06e7724196d4336473eff1708ac1 100644 GIT binary patch literal 20638 zcmagFbyOWe(>Hi8?ykX|1P|`+9^BpCFYfM;;0^(TyA#}lyUWGhU6%KG_S>_6?AdqD z8L6o{(>2}GRlln00RVsiSO6Lt;BzAd{QNw+f9`mA|D{o(000EwCx`UE^fWX8fd9EO zGyj*CfCT`K5dZ)oq5slSpV!P706;*%f9X+l002bzKiB`Wkpgtd0RRz303cFHK@u6^ z%cnE|Sz1c$$LINfG(dxc`8<-`d>DP6bfm?ERY6%MK9@Uv{xApt#E8plWGdO4rtxz8 zv>Nyz=2{~3wZz0Y0HEE5deT(_^(dC z)#V)s)Ufz(&T5_7gXHIBVr9TwPJ>{s2D84X51{n`3=TAkwg9eLlS$D2xah@-(_cr-zQl^@Y|e`{k4gRuep#l4u?rXY zWB`^cyjDtn**8_Sz%pw9mvtkL&n9jk65(Re0f8CyU;eN8eJdpe|G}}{T#t~A-3K4-}L@r@r z;eg0>G5xN^r}qwth$v3a_pPbiQo}HV%rYa)qI}ZvLYR1#75d-!M83KQt`(>2?~$)MxjtQ*s}ZjWd;Vt!xV?=>?E{Yx(G(hiA2#Vje&-uT3YQ_VoGZj9ZbCQ&G%*gX+oF)At`uGx5nfUcQZM`VoyyEF zSydHz;^^f~>0|ulU?N+b$BZJKuwQ9K#9$IXV!W3Njk()KJ1YZK&j!QA$fnjb!H1WZ zy=+Gcdgu2^Tj`=6Z4{DLN@;;aUvRO_6dRF#A#n0$7%8Kw&tbq=cacrCj2HBG#`Zdp zh<>zu-uiKC#aq)Ld*C2fFMQQ0| z{3N72&NFI>3SPv}2)>B_bPYeGYn*tWtgI|8j%>ExuBQMM8o#3+?0Pq|$Rmpf;>}H0 z*Gh5wixAfz0=UekxI;*;Y5q%pQja#+w$M4F3G_&$J|Q;iZsvY$kn>@ioC|89-K^iq zcLnjn29x!$Rjft=DGa1_(i3})Z=h?gE{oX!=+cHNE@?t)$vApK)QJ(ixD&M0ElL5W zqp>=ANphoW_H6YJDq$N>Bki#f@b!N& z2c(~T(*KwP7cZ|{0027ezvf^-!_HPk4bN}ltn;kRoygCY6<9|eao%@l5*%wq2^Pit zc-9cbBB#Vaj$$Uo!%I5!gEOV%5yE^wM8(*PCrhGbfxt}sWgqNjFNMzBhsUwt{?POQ zPJ3{rTVJs6#@gK9UHkixb;EaYV5N1tAjREs#~%>-h#LY-u;o~?Wrln9+}XCZ11QW&jf$W zQXPxee&ZGMfLAm^_gs{AE_CBBTpA^!5IM{sRH+pnL|Bg1#o21J)8nObg-$z6S=q)w zgt;f_natPv5oS48HvXz>84UtrwiFdR+l6IMdi_EF#!4QE9jrDBPniOeaQ2>&yuM0C+7Swc45vLjF$lL`)15W_iT0{OI)SzKe2U=0j0tC5jwrNugb5h%uNf z`fFETA$%9JB7Z=VX>hT%BM;H#OWE|4Qn*ko>ind7)MJt?yprPYnc9QpGZ<8f?mGIP zKl#E|gaCm;$U%()s(jqIrtv+%J^+1k)`U3z>Q_R{5Q}xRql=V*M5!2sGsppM5oU$n zB4K#5Vz6Ym+^-Qs6}MXkoM^I@&0l{YJgHHYzH6@q=_zly@`wxOsmoB7<`AKHr^Gq; z3P^MQognjqo=&j+0V6sJ%UHqAki~b!UUl!j)t5pv)j&y~4Bm}&aAcneTv*^&Vz;v; zI`W&yKk44!=M|g5fP6NM&WZ=r3OXN>j*iMW9lO#rv0Y$1i{=suX<2=pc%2EBzu-WW zjc;hquV|pGT%ALjz>90}Pg-8>@NI5Ec9C&)Oq<2~!64w_#9yshm%((+a9}_zIN~

    h3Y0Mu<51_*#2J( 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 QjyrNQxrD>(|M!;s8(P!R%K!iX literal 23367 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 - - - - - - - - - From 0e0dfea4cb023b09c6d94d98ab1868c5edfafbb3 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 22 Feb 2026 19:57:38 -0500 Subject: [PATCH 280/407] [dist] Finalize new large project logo --- dist/generate_ico.py | 10 +- ...edetect-24-48.svg => pyscenedetect-24.svg} | 19 +++- dist/logo/pyscenedetect-32.svg | 21 ++-- dist/logo/pyscenedetect-64+.svg | 70 ------------- dist/logo/pyscenedetect.svg | 99 ++++++++++++++++++ dist/pyscenedetect.ico | Bin 20638 -> 27826 bytes 6 files changed, 133 insertions(+), 86 deletions(-) rename dist/logo/{pyscenedetect-24-48.svg => pyscenedetect-24.svg} (82%) delete mode 100644 dist/logo/pyscenedetect-64+.svg create mode 100644 dist/logo/pyscenedetect.svg diff --git a/dist/generate_ico.py b/dist/generate_ico.py index c9280e22..9dd535c1 100644 --- a/dist/generate_ico.py +++ b/dist/generate_ico.py @@ -35,12 +35,12 @@ ICO_PATH = DIST_DIR / "pyscenedetect.ico" SVG_FOR_SIZE: dict[int, Path] = { - 24: LOGO_DIR / "pyscenedetect-24-48.svg", + 24: LOGO_DIR / "pyscenedetect-24.svg", 32: LOGO_DIR / "pyscenedetect-32.svg", - 48: LOGO_DIR / "pyscenedetect-24-48.svg", - 64: LOGO_DIR / "pyscenedetect-64+.svg", - 128: LOGO_DIR / "pyscenedetect-64+.svg", - 256: LOGO_DIR / "pyscenedetect-64+.svg", + 48: LOGO_DIR / "pyscenedetect.svg", + 64: LOGO_DIR / "pyscenedetect.svg", + 128: LOGO_DIR / "pyscenedetect.svg", + 256: LOGO_DIR / "pyscenedetect.svg", } diff --git a/dist/logo/pyscenedetect-24-48.svg b/dist/logo/pyscenedetect-24.svg similarity index 82% rename from dist/logo/pyscenedetect-24-48.svg rename to dist/logo/pyscenedetect-24.svg index b1b53949..9eb62d50 100644 --- a/dist/logo/pyscenedetect-24-48.svg +++ b/dist/logo/pyscenedetect-24.svg @@ -1,11 +1,20 @@ + diff --git a/dist/logo/pyscenedetect-32.svg b/dist/logo/pyscenedetect-32.svg index 63225e8f..a6e64900 100644 --- a/dist/logo/pyscenedetect-32.svg +++ b/dist/logo/pyscenedetect-32.svg @@ -1,11 +1,20 @@ + diff --git a/dist/logo/pyscenedetect-64+.svg b/dist/logo/pyscenedetect-64+.svg deleted file mode 100644 index 9c2c987a..00000000 --- a/dist/logo/pyscenedetect-64+.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - diff --git a/dist/logo/pyscenedetect.svg b/dist/logo/pyscenedetect.svg new file mode 100644 index 00000000..ad5c55e2 --- /dev/null +++ b/dist/logo/pyscenedetect.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + diff --git a/dist/pyscenedetect.ico b/dist/pyscenedetect.ico index 3886a457aade06e7724196d4336473eff1708ac1..019c86150820b2975616d560c6cd01bc7cdd0aeb 100644 GIT binary patch literal 27826 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni literal 20638 zcmagFbyOWe(>Hi8?ykX|1P|`+9^BpCFYfM;;0^(TyA#}lyUWGhU6%KG_S>_6?AdqD z8L6o{(>2}GRlln00RVsiSO6Lt;BzAd{QNw+f9`mA|D{o(000EwCx`UE^fWX8fd9EO zGyj*CfCT`K5dZ)oq5slSpV!P706;*%f9X+l002bzKiB`Wkpgtd0RRz303cFHK@u6^ z%cnE|Sz1c$$LINfG(dxc`8<-`d>DP6bfm?ERY6%MK9@Uv{xApt#E8plWGdO4rtxz8 zv>Nyz=2{~3wZz0Y0HEE5deT(_^(dC z)#V)s)Ufz(&T5_7gXHIBVr9TwPJ>{s2D84X51{n`3=TAkwg9eLlS$D2xah@-(_cr-zQl^@Y|e`{k4gRuep#l4u?rXY zWB`^cyjDtn**8_Sz%pw9mvtkL&n9jk65(Re0f8CyU;eN8eJdpe|G}}{T#t~A-3K4-}L@r@r z;eg0>G5xN^r}qwth$v3a_pPbiQo}HV%rYa)qI}ZvLYR1#75d-!M83KQt`(>2?~$)MxjtQ*s}ZjWd;Vt!xV?=>?E{Yx(G(hiA2#Vje&-uT3YQ_VoGZj9ZbCQ&G%*gX+oF)At`uGx5nfUcQZM`VoyyEF zSydHz;^^f~>0|ulU?N+b$BZJKuwQ9K#9$IXV!W3Njk()KJ1YZK&j!QA$fnjb!H1WZ zy=+Gcdgu2^Tj`=6Z4{DLN@;;aUvRO_6dRF#A#n0$7%8Kw&tbq=cacrCj2HBG#`Zdp zh<>zu-uiKC#aq)Ld*C2fFMQQ0| z{3N72&NFI>3SPv}2)>B_bPYeGYn*tWtgI|8j%>ExuBQMM8o#3+?0Pq|$Rmpf;>}H0 z*Gh5wixAfz0=UekxI;*;Y5q%pQja#+w$M4F3G_&$J|Q;iZsvY$kn>@ioC|89-K^iq zcLnjn29x!$Rjft=DGa1_(i3})Z=h?gE{oX!=+cHNE@?t)$vApK)QJ(ixD&M0ElL5W zqp>=ANphoW_H6YJDq$N>Bki#f@b!N& z2c(~T(*KwP7cZ|{0027ezvf^-!_HPk4bN}ltn;kRoygCY6<9|eao%@l5*%wq2^Pit zc-9cbBB#Vaj$$Uo!%I5!gEOV%5yE^wM8(*PCrhGbfxt}sWgqNjFNMzBhsUwt{?POQ zPJ3{rTVJs6#@gK9UHkixb;EaYV5N1tAjREs#~%>-h#LY-u;o~?Wrln9+}XCZ11QW&jf$W zQXPxee&ZGMfLAm^_gs{AE_CBBTpA^!5IM{sRH+pnL|Bg1#o21J)8nObg-$z6S=q)w zgt;f_natPv5oS48HvXz>84UtrwiFdR+l6IMdi_EF#!4QE9jrDBPniOeaQ2>&yuM0C+7Swc45vLjF$lL`)15W_iT0{OI)SzKe2U=0j0tC5jwrNugb5h%uNf z`fFETA$%9JB7Z=VX>hT%BM;H#OWE|4Qn*ko>ind7)MJt?yprPYnc9QpGZ<8f?mGIP zKl#E|gaCm;$U%()s(jqIrtv+%J^+1k)`U3z>Q_R{5Q}xRql=V*M5!2sGsppM5oU$n zB4K#5Vz6Ym+^-Qs6}MXkoM^I@&0l{YJgHHYzH6@q=_zly@`wxOsmoB7<`AKHr^Gq; z3P^MQognjqo=&j+0V6sJ%UHqAki~b!UUl!j)t5pv)j&y~4Bm}&aAcneTv*^&Vz;v; zI`W&yKk44!=M|g5fP6NM&WZ=r3OXN>j*iMW9lO#rv0Y$1i{=suX<2=pc%2EBzu-WW zjc;hquV|pGT%ALjz>90}Pg-8>@NI5Ec9C&)Oq<2~!64w_#9yshm%((+a9}_zIN~

      Latest Release: v0.6.7 (August 24, 2025)

      Download        Changelog        Documentation        Getting Started -
    -See the changelog for the latest release notes and known issues.
    **PySceneDetect** is a tool for **detecting shot changes in videos** ([example](cli.md)), and can **automatically split the video into separate clips**. PySceneDetect is free and open-source software, and has several [detection methods](features.md#detection-methods) to find fast-cuts and threshold-based fades. diff --git a/website/pages/style.css b/website/pages/style.css index f5ca61cf..348f44f8 100644 --- a/website/pages/style.css +++ b/website/pages/style.css @@ -18,4 +18,9 @@ padding:4px 6px; margin-bottom:.809em; max-width:100% +} + + +#side-nav-logo { + margin-bottom: -1em; } \ No newline at end of file From 11381c504ae05bd585a1305eea87ab45008602e1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 26 Feb 2026 21:42:36 -0500 Subject: [PATCH 284/407] [docs] Add favicon for website and docs --- dist/generate_assets.py | 9 +++++++++ docs/_static/favicon.ico | Bin 0 -> 27826 bytes docs/conf.py | 1 + website/mkdocs.yml | 1 + website/pages/img/favicon.ico | Bin 0 -> 27826 bytes 5 files changed, 11 insertions(+) create mode 100644 docs/_static/favicon.ico create mode 100644 website/pages/img/favicon.ico diff --git a/dist/generate_assets.py b/dist/generate_assets.py index 699b1b8b..a6f71a5b 100644 --- a/dist/generate_assets.py +++ b/dist/generate_assets.py @@ -48,6 +48,11 @@ class LogoOutput(NamedTuple): # Heights match the natural SVG aspect ratio (1024x480). # _small outputs use the -bg variant (background included). +FAVICON_OUTPUTS: list[Path] = [ + REPO_DIR / "docs" / "_static" / "favicon.ico", + REPO_DIR / "website" / "pages" / "img" / "favicon.ico", +] + LOGO_OUTPUTS: list[LogoOutput] = [ LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo.png", 900, 422, LOGO_SVG), LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo_small.png", 300, 141, LOGO_BG_SVG), @@ -165,6 +170,10 @@ def main(): images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) print(f"Output ICO: {ICO_PATH}") + print("Copying favicons...") + for dest in FAVICON_OUTPUTS: + shutil.copy2(ICO_PATH, dest) + print(f" {dest.relative_to(REPO_DIR)}") render_logos(inkscape) diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..019c86150820b2975616d560c6cd01bc7cdd0aeb GIT binary patch literal 27826 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni literal 0 HcmV?d00001 diff --git a/docs/conf.py b/docs/conf.py index 60ad6188..72ade37d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,6 +78,7 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] html_css_files = ["pyscenedetect.css"] +html_favicon = "favicon.ico" # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 501fc51e..71ed0b0b 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -12,6 +12,7 @@ copyright: 'Copyright © 2014-2024 Brandon Castellano. All rights reserved. theme: name: readthedocs logo: img/pyscenedetect_logo_small.png + favicon: img/favicon.ico custom_dir: overrides # TODO: deprecated option for this theme google_analytics: ['UA-72551323-1', 'auto'] diff --git a/website/pages/img/favicon.ico b/website/pages/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..019c86150820b2975616d560c6cd01bc7cdd0aeb GIT binary patch literal 27826 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni literal 0 HcmV?d00001 From 4da7268e22b3daea1f994d7f339ee5fe7228cb73 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:21:56 -0600 Subject: [PATCH 285/407] ci: Add minimal workflow_dispatch for docker-publish --- .github/workflows/docker-publish.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..972f2973 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,14 @@ +name: Publish Docker Image + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Placeholder Step + run: echo "This workflow is meant to be run from the fng-infra-docker-ci branch." From 054fbd94d7b684689fbdc0ca6cf1524975daa467 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:17:12 -0600 Subject: [PATCH 286/407] feat: add Docker support and GHCR publish workflow --- .dockerignore | 64 ++++++++++++++++++++++++++++ .github/workflows/docker-publish.yml | 64 +++++++++++++++++++++++++--- .gitignore | 1 + Dockerfile | 33 ++++++++++++++ 4 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f3fcd2d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,64 @@ +# .dockerignore +# Copyright (C) 2026 FNGarvin. All rights reserved. +# License: BSD-3-Clause + +# Version control +.git +.gitignore +.github + +# Python / Build artifacts +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing / Tooling +.pytest_cache/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.mypy_cache/ +.hypothesis/ + +# IDE / Project files +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Project specific exclusions (to keep the image lean) +test_clips/ +tests/ +docs/ +website/ +benchmark/ +scenedetect.cfg + +# EOF .dockerignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 972f2973..07b859b2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,14 +1,68 @@ name: Publish Docker Image +# Copyright (C) 2026 FNGarvin. All rights reserved. +# License: BSD-3-Clause + on: workflow_dispatch: + push: + branches: [ "main", "fng-infra-docker-ci" ] + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - build: + build-and-push: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - - - name: Placeholder Step - run: echo "This workflow is meant to be run from the fng-infra-docker-ci branch." + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + +# EOF docker-publish.yml diff --git a/.gitignore b/.gitignore index c6d36daa..8c19057b 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ dmypy.json .pyre/ .pytype/ cython_debug/ +test_clips/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5b13ffe1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Containerfile for PySceneDetect +# Copyright (C) 2026 FNGarvin. All rights reserved. +# License: BSD-3-Clause + +FROM python:3.11.11-slim + +# Create a non-root user for security hardening +RUN useradd -m scenedetect + +# Set working directory and copy files with correct ownership +WORKDIR /app +COPY --chown=scenedetect:scenedetect . . + +# Install necessary system dependencies as root first +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + mkvtoolnix && \ + rm -rf /var/lib/apt/lists/* + +# Install PySceneDetect with headless OpenCV and other optional media backends +# pyav is highly recommended for faster/more robust video decodes +# moviepy provides an alternative video splitting backend +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install ".[opencv-headless,pyav,moviepy]" + +# Switch to the non-root user +USER scenedetect + +# The default behavior is to run the CLI +ENTRYPOINT ["scenedetect"] + +# EOF Dockerfile From 043b0ecd986cb9d1b101d7e143367892531b8e98 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:31:01 -0600 Subject: [PATCH 287/407] security: upgrade jinja2 to 3.1.6 to fix sandbox breakout vulnerability --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index 8455efc2..945314b3 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.1.5 +jinja2==3.1.6 \ No newline at end of file From be276dfb6d2da4a5906372a40252bb10da53c86a Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:32:59 -0600 Subject: [PATCH 288/407] security: prevent script injection in verify-build job --- .github/workflows/publish-pypi.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fcc75863..77181f96 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -22,10 +22,12 @@ jobs: steps: - name: Check workflows uses: actions/github-script@v6 + env: + TAG: ${{ github.event.inputs.tag }} with: script: | const { owner, repo } = context.repo; - const tag = "${{ github.event.inputs.tag }}"; + const tag = process.env.TAG; const requiredWorkflows = ['Windows Distribution', 'Python Distribution']; let workflowConclusions = {}; From 4bcce298f003e6454014fd94ba81268aca396063 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 5 Apr 2026 21:04:58 -0400 Subject: [PATCH 289/407] [docs] Update docs for v0.7 release --- docs/api.rst | 1 + docs/api/migration_guide.rst | 206 +++++++++++++++++++++++++++++++++++ website/pages/changelog.md | 54 ++++++--- 3 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 docs/api/migration_guide.rst diff --git a/docs/api.rst b/docs/api.rst index 76cff84d..54378ccc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -105,6 +105,7 @@ Module Reference :caption: PySceneDetect Module Documentation :name: fullapitoc + api/migration_guide api/detectors api/scene_manager api/common diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst new file mode 100644 index 00000000..a3ae9fa7 --- /dev/null +++ b/docs/api/migration_guide.rst @@ -0,0 +1,206 @@ + +.. _scenedetect-migration-guide: + +*********************************************************************** +Migration Guide: v0.6 to v0.7 +*********************************************************************** + +PySceneDetect v0.7 is a major release that overhauls timestamp handling to support variable framerate (VFR) videos. While the high-level :func:`scenedetect.detect` workflow is largely unchanged, several internal APIs have been restructured. This guide covers the changes needed to update applications from v0.6 to v0.7. + +The minimum supported Python version is now **Python 3.10**. + + +======================================================================= +Quick Check +======================================================================= + +If your code only uses :func:`scenedetect.detect` with a built-in detector, it should work without changes: + +.. code:: python + + # This still works in v0.7 + from scenedetect import detect, ContentDetector + scenes = detect("video.mp4", ContentDetector()) + + +======================================================================= +Import Changes +======================================================================= + +Several submodules have been reorganized. If you import directly from `scenedetect` you do not need to make any changes. Update imports as follows: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - v0.6 + - v0.7 + * - ``from scenedetect.frame_timecode import FrameTimecode`` + - ``from scenedetect.common import FrameTimecode`` + * - ``from scenedetect.scene_detector import SceneDetector`` + - ``from scenedetect.detector import SceneDetector`` + * - ``from scenedetect.video_splitter import split_video_ffmpeg`` + - ``from scenedetect.output import split_video_ffmpeg`` + * - ``from scenedetect.video_splitter import split_video_mkvmerge`` + - ``from scenedetect.output import split_video_mkvmerge`` + * - ``from scenedetect.scene_manager import save_images`` + - ``from scenedetect.output import save_images`` + * - ``from scenedetect.scene_manager import write_scene_list`` + - ``from scenedetect.output import write_scene_list`` + * - ``from scenedetect.scene_manager import write_scene_list_html`` + - ``from scenedetect.output import write_scene_list_html`` + * - ``from scenedetect.video_manager import VideoManager`` + - Removed. Use :func:`scenedetect.open_video` instead. + +.. note:: + + Most commonly used types and functions are also available directly from the top-level ``scenedetect`` package (e.g. ``from scenedetect import FrameTimecode``), which has not changed. + + +======================================================================= +Custom Detector Changes +======================================================================= + +If you have written a custom :class:`SceneDetector ` subclass, there are several interface changes. + +``process_frame`` Signature +----------------------------------------------------------------------- + +The ``frame_num`` parameter (``int``) has been replaced with ``timecode`` (:class:`FrameTimecode `): + +.. code:: python + + # v0.6 + class MyDetector(SceneDetector): + def process_frame(self, frame_num: int, frame_img) -> List[int]: + ... + + # v0.7 + class MyDetector(SceneDetector): + def process_frame(self, timecode: FrameTimecode, frame_img) -> List[FrameTimecode]: + ... + +The same change applies to ``post_process()``. If you need the frame number, use ``timecode.frame_num``. + +``SceneDetector`` is Now Abstract +----------------------------------------------------------------------- + +``SceneDetector`` is now a Python `abstract class `_. Subclasses **must** implement ``process_frame()``. + +Removed Methods and Properties +----------------------------------------------------------------------- + +The following have been removed from the ``SceneDetector`` interface: + +- ``is_processing_required()`` - detectors can now assume they always have frame data +- ``stats_manager_required`` property - no longer needed +- ``SparseSceneDetector`` interface - removed entirely + + +======================================================================= +``FrameTimecode`` Changes +======================================================================= + +Read-Only Properties +----------------------------------------------------------------------- + +``frame_num`` and ``framerate`` are now read-only properties. To change them, construct a new ``FrameTimecode``: + +.. code:: python + + # v0.6 - direct assignment + tc.frame_num = 100 # No longer works + + # v0.7 - construct new instance + tc = FrameTimecode(100, tc.framerate) + +New Properties +----------------------------------------------------------------------- + +Access ``frame_num``, ``framerate``, and ``seconds`` as properties instead of getter methods: + +.. code:: python + + tc = FrameTimecode(100, 24.0) + tc.frame_num # 100 + tc.framerate # Fraction(24, 1) + tc.seconds # ~4.167 + +Removed Methods +----------------------------------------------------------------------- + +- ``previous_frame()`` - removed, use ``FrameTimecode(tc.frame_num - 1, tc.framerate)`` instead + + +======================================================================= +Framerate and Timestamp Changes +======================================================================= + +Rational Framerates +----------------------------------------------------------------------- + +``VideoStream.frame_rate`` now returns a ``Fraction`` instead of ``float``. Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values: + +.. code:: python + + from fractions import Fraction + video = open_video("video.mp4") + assert isinstance(video.frame_rate, Fraction) + # e.g. Fraction(24000, 1001) instead of 23.976023976... + +PTS-Backed Timestamps +----------------------------------------------------------------------- + +All backends now return presentation timestamp (PTS) backed values from ``VideoStream.position``. This enables correct handling of VFR videos. + +``FrameTimecode`` has new ``time_base`` and ``pts`` properties for accessing the underlying timing information. For VFR videos, ``frame_num`` is now an approximation based on PTS-derived time rather than a sequential count. + + +======================================================================= +``StatsManager`` Changes +======================================================================= + +The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now take a ``FrameTimecode`` instead of ``int`` for the frame identifier, matching the detector interface change. + + +======================================================================= +Removed APIs +======================================================================= + +The following deprecated APIs have been fully removed in v0.7: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Removed + - Replacement + * - ``scenedetect.video_manager`` module + - :func:`scenedetect.open_video` + * - ``base_timecode`` parameter (various functions) + - No longer needed, remove the argument + * - ``video_manager`` parameter (various functions) + - Use ``video`` parameter instead + * - ``SceneManager.get_event_list()`` + - Use ``SceneManager.get_cut_list()`` or ``SceneManager.get_scene_list()`` + * - ``AdaptiveDetector.get_content_val()`` + - Use ``StatsManager`` to query metrics + * - ``AdaptiveDetector(min_delta_hsv=...)`` + - Use ``min_content_val`` parameter instead + * - ``VideoStream.read(advance=...)`` + - Call ``read()`` without the ``advance`` parameter + * - ``SparseSceneDetector`` + - No direct replacement, use ``SceneDetector`` + +.. note:: + + Deprecated v0.6 compatibility shims that still exist now emit warnings using the ``warnings`` module. Address any ``DeprecationWarning`` messages to prepare for future releases. + + +======================================================================= +CLI Changes +======================================================================= + +- The ``-d``/``--min-delta-hsv`` option on ``detect-adaptive`` has been removed. Use ``-c``/``--min-content-val`` instead. +- VFR videos now work correctly with both the OpenCV and PyAV backends. +- New ``save-xml`` command for exporting scenes in Final Cut Pro XML format. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e9da750c..acfb9ffd 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -680,30 +680,48 @@ Although there have been minimal changes to most API examples, there are several ### API Changes - * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface (#168)[https://github.com/Breakthrough/PySceneDetect/issues/168]: +**VFR & Timestamp Overhaul:** + + * Add new `Timecode` type to represent frame timings in terms of the video's source timebase + * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information + * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` + * `VideoStream.frame_rate` now returns `Fraction` instead of `float` + * Framerates are now stored as rational `Fraction` values (e.g. `Fraction(24000, 1001)` instead of `23.976`) to avoid float precision loss + * Common NTSC rates (23.976, 29.97, 59.94) are automatically detected from float values + * `FrameTimecode.frame_num` is now approximate for VFR video (based on PTS-derived time) + +**Detector Interface:** + + * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface [#168](https://github.com/Breakthrough/PySceneDetect/issues/168): * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` - * Move existing functionality to new submodules: - * Detector interface in `scenedetect.scene_detector` moved to `scenedetect.detector` - * Timecode types in `scenedetect.frame_timecode` moved to `scenedetect.common` - * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) - * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) - * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead - * Remove deprecated parameter `base_timecode` from various functions, there is no need to provide it - * Remove deprecated parameter `video_manager` from various functions, use `video` parameter instead - * `FrameTimecode` fields `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them - * Remove `FrameTimecode.previous_frame()` method - * Remove `SceneDetector.is_processing_required()` method + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called + * Remove `SceneDetector.is_processing_required()` method + * Remove `SceneDetector.stats_manager_required` property, no longer required * Remove deprecated `SparseSceneDetector` interface + +**Module Reorganization:** + + * `scenedetect.scene_detector` moved to `scenedetect.detector` + * `scenedetect.frame_timecode` moved to `scenedetect.common` + * Image/HTML/CSV export in `scenedetect.scene_manager` moved to `scenedetect.output` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + * `scenedetect.video_splitter` moved to `scenedetect.output.video` [#463](https://github.com/Breakthrough/PySceneDetect/issues/463) + +**FrameTimecode:** + + * `frame_num` and `framerate` are now read-only properties, construct a new `FrameTimecode` to change them + * Add properties to access `frame_num`, `framerate`, and `seconds` instead of getter methods + * Remove `FrameTimecode.previous_frame()` method + * Deprecated functionality preserved from v0.6 now uses the `warnings` module + +**Removals:** + + * Remove deprecated module `scenedetect.video_manager`, use [the `scenedetect.open_video()` function](https://www.scenedetect.com/docs/head/api.html#scenedetect.open_video) instead + * Remove deprecated parameters `base_timecode` and `video_manager` from various functions * Remove deprecated `SceneManager.get_event_list()` method * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` - * Remove `SceneDetector.stats_manager_required` property, no longer required - * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) - * Deprecated functionality preserved from v0.6 now uses the `warnings` module - * Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods - * Add new `Timecode` type to represent frame timings in terms of the video's source timebase - * Add new `time_base` and `pts` properties to `FrameTimecode` to provide more accurate timing information + From f0b66ef0027eb4c5f4ae3e57bacd0528080bc908 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:20:08 -0500 Subject: [PATCH 290/407] chore: update file headers to match project standard --- .dockerignore | 15 +++++++++++---- .github/workflows/docker-publish.yml | 5 +---- Dockerfile | 15 +++++++++++---- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.dockerignore b/.dockerignore index f3fcd2d2..a6f82cd7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,14 @@ -# .dockerignore -# Copyright (C) 2026 FNGarvin. All rights reserved. -# License: BSD-3-Clause +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# # Version control .git @@ -61,4 +69,3 @@ website/ benchmark/ scenedetect.cfg -# EOF .dockerignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 07b859b2..09e27df2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,8 +1,6 @@ +# Build and publish PySceneDetect Docker image to GitHub Container Registry (GHCR). name: Publish Docker Image -# Copyright (C) 2026 FNGarvin. All rights reserved. -# License: BSD-3-Clause - on: workflow_dispatch: push: @@ -65,4 +63,3 @@ jobs: subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true -# EOF docker-publish.yml diff --git a/Dockerfile b/Dockerfile index 5b13ffe1..fbe029cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,14 @@ -# Containerfile for PySceneDetect -# Copyright (C) 2026 FNGarvin. All rights reserved. -# License: BSD-3-Clause +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# FROM python:3.11.11-slim @@ -30,4 +38,3 @@ USER scenedetect # The default behavior is to run the CLI ENTRYPOINT ["scenedetect"] -# EOF Dockerfile From ebe45afe884d1bb82ba113dbe65ecf5c81a3086f Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 12 Apr 2026 19:00:48 -0400 Subject: [PATCH 291/407] Finalize VFR Support (#540) * [timecode] Finalize VFR support #168 * [timecode] Fix VFR timing, persistent decoder, and output command accuracy - Fix PyAV read() to reuse persistent decoder generator, preventing the last frame from being dropped at EOF due to B-frame buffer flush on GC - Fix _handle_eof() to seek by PTS seconds instead of frame number (which is now a CFR-equivalent approximation, not a decode count) - Fix get_timecode() to skip nearest-frame snapping for Timecode-backed FrameTimecodes, so scene boundary timecodes are PTS-accurate for VFR - Fix FlashFilter to cache min_scene_len threshold in seconds from first frame's framerate, avoiding incorrect thresholds when OpenCV reports wrong average fps - Fix FCP7 XML: use seconds*fps for frame numbers, dynamic NTSC flag - Fix OTIO: use seconds*frame_rate for RationalTime values - Add $START_PTS and $END_PTS (ms) to split-video filename templates - Refactor test_vfr.py: use open_video(), add EXPECTED_SCENES_VFR ground truth, parameterize scene detection test for both pyav and opencv backends * [save-images] Fix VFR seek accuracy for OpenCV and image position generation OpenCV's CAP_PROP_POS_FRAMES does not map linearly to time in VFR video (e.g. at the same timestamp, PyAV and OpenCV report frame indices that differ by 35+ frames), causing thumbnails to land in the wrong scene. Two fixes: 1. VideoStreamCv2.seek(): switch from CAP_PROP_POS_FRAMES to CAP_PROP_POS_MSEC for time-accurate seeking on both CFR and VFR video. Seeking one nominal frame before the target ensures the subsequent read() returns the frame at the target. 2. ImageSaver.generate_timecode_list(): rewrite to use seconds-based arithmetic instead of frame-number ranges. This avoids the frame_num approximation (round(seconds * avg_fps)) which gives wrong indices for VFR video. * [cli] Round OTIO rational time values to 10 microsecond precision * [backends] Fix OpenCV seeking with VFR videos * [tests] Cleanup test imports and move deprecated import tests to test_api * [tests] Expand VFR test coverage and rework CLI tests Use Click's CliRunner rather than subprocesses for CLI tests. Add CSV, EDL, and expand OTIO tests for VFR and compare that the OpenCV and PyAV backends return equal results for both CFR and VFR video. --- docs/cli/backends.rst | 6 + scenedetect/_cli/commands.py | 33 +- scenedetect/backends/moviepy.py | 22 +- scenedetect/backends/opencv.py | 90 ++--- scenedetect/backends/pyav.py | 47 ++- scenedetect/common.py | 105 ++--- scenedetect/detector.py | 25 +- scenedetect/detectors/threshold_detector.py | 53 +-- scenedetect/output/image.py | 69 ++-- scenedetect/output/video.py | 5 +- scenedetect/video_stream.py | 4 +- tests/conftest.py | 11 + tests/helpers.py | 38 ++ tests/test_api.py | 25 ++ tests/test_cli.py | 140 ++++--- tests/test_detectors.py | 9 - tests/test_output.py | 9 - tests/test_stats_manager.py | 2 - tests/test_timecode.py | 49 ++- tests/test_vfr.py | 401 ++++++++++++++++++++ website/pages/changelog.md | 8 +- 21 files changed, 859 insertions(+), 292 deletions(-) create mode 100644 tests/helpers.py create mode 100644 tests/test_vfr.py diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst index 8df76a58..2e28102b 100644 --- a/docs/cli/backends.rst +++ b/docs/cli/backends.rst @@ -21,6 +21,8 @@ It is mostly reliable and fast, although can occasionally run into issues proces The OpenCV backend also supports image sequences as inputs (e.g. ``frame%02d.jpg`` if you want to load frame001.jpg, frame002.jpg, frame003.jpg...). Make sure to specify the framerate manually (``-f``/``--framerate``) to ensure accurate timing calculations. +Variable framerate (VFR) video is supported. Scene detection uses PTS-derived timestamps from ``CAP_PROP_POS_MSEC`` for accurate timecodes. Seeking compensates for OpenCV's average-fps-based internal seek approximation, so output timecodes remain accurate across the full video. + ======================================================================= PyAV @@ -28,6 +30,8 @@ PyAV The `PyAV `_ backend (`av package `_) is a more robust backend that handles multiple audio tracks and frame decode errors gracefully. +Variable framerate (VFR) video is fully supported. PyAV uses native PTS timestamps directly from the container, giving the most accurate timecodes for VFR content. + This backend can be used by specifying ``-b pyav`` via command line, or setting ``backend = pyav`` under the ``[global]`` section of your :ref:`config file `. @@ -41,4 +45,6 @@ MoviePy launches ffmpeg as a subprocess, and can be used with various types of i The MoviePy backend is still under development and is not included with current Windows distribution. To enable MoviePy support, you must install PySceneDetect using `python` and `pip`. + Variable framerate (VFR) video is **not supported**. MoviePy assumes a fixed framerate, so timecodes for VFR content will be inaccurate. Use the PyAV or OpenCV backend instead. + This backend can be used by specifying ``-b moviepy`` via command line, or setting ``backend = moviepy`` under the ``[global]`` section of your :ref:`config file `. diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 6813caa8..0003f30e 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -401,17 +401,19 @@ def _save_xml_fcp( sequence = ElementTree.SubElement(project, "sequence") ElementTree.SubElement(sequence, "name").text = context.video_stream.name + fps = float(context.video_stream.frame_rate) + ntsc = "True" if context.video_stream.frame_rate.denominator != 1 else "False" duration = scenes[-1][1] - scenes[0][0] - ElementTree.SubElement(sequence, "duration").text = f"{duration.frame_num}" + ElementTree.SubElement(sequence, "duration").text = str(round(duration.seconds * fps)) rate = ElementTree.SubElement(sequence, "rate") - ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) - ElementTree.SubElement(rate, "ntsc").text = "False" + ElementTree.SubElement(rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(rate, "ntsc").text = ntsc timecode = ElementTree.SubElement(sequence, "timecode") tc_rate = ElementTree.SubElement(timecode, "rate") - ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate) - ElementTree.SubElement(tc_rate, "ntsc").text = "False" + ElementTree.SubElement(tc_rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(tc_rate, "ntsc").text = ntsc ElementTree.SubElement(timecode, "frame").text = "0" ElementTree.SubElement(timecode, "displayformat").text = "NDF" @@ -427,13 +429,13 @@ def _save_xml_fcp( ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" ElementTree.SubElement(clip, "enabled").text = "TRUE" ElementTree.SubElement(clip, "rate").append( - ElementTree.fromstring(f"{context.video_stream.frame_rate}") + ElementTree.fromstring(f"{round(fps)}") ) - # TODO: Are these supposed to be frame numbers or another format? - ElementTree.SubElement(clip, "start").text = str(start.frame_num) - ElementTree.SubElement(clip, "end").text = str(end.frame_num) - ElementTree.SubElement(clip, "in").text = str(start.frame_num) - ElementTree.SubElement(clip, "out").text = str(end.frame_num) + # Frame numbers relative to the declared fps, computed from PTS seconds. + ElementTree.SubElement(clip, "start").text = str(round(start.seconds * fps)) + ElementTree.SubElement(clip, "end").text = str(round(end.seconds * fps)) + ElementTree.SubElement(clip, "in").text = str(round(start.seconds * fps)) + ElementTree.SubElement(clip, "out").text = str(round(end.seconds * fps)) file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}") ElementTree.SubElement(file_ref, "name").text = context.video_stream.name @@ -485,6 +487,9 @@ def save_xml( logger.error(f"Unknown format: {format}") +# TODO: We have to export framerate as a float for OTIO's current format. When OTIO supports +# fractional timecodes, we should export the framerate as a rational number instead. +# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190 def save_otio( context: CliContext, scenes: SceneList, @@ -501,7 +506,7 @@ def save_otio( video_name = context.video_stream.name video_path = os.path.abspath(context.video_stream.path) video_base_name = os.path.basename(context.video_stream.path) - frame_rate = context.video_stream.frame_rate + frame_rate = float(context.video_stream.frame_rate) # List of track mapping to resource type. # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. @@ -534,12 +539,12 @@ def save_otio( "duration": { "OTIO_SCHEMA": "RationalTime.1", "rate": frame_rate, - "value": float((end - start).frame_num), + "value": round((end - start).seconds * frame_rate, 6), }, "start_time": { "OTIO_SCHEMA": "RationalTime.1", "rate": frame_rate, - "value": float(start.frame_num), + "value": round(start.seconds * frame_rate, 6), }, }, "enabled": True, diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 14758952..6624cdbe 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -17,6 +17,7 @@ """ import typing as ty +from fractions import Fraction from logging import getLogger import cv2 @@ -24,7 +25,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, FrameTimecode +from scenedetect.common import FrameTimecode, Timecode, framerate_to_fraction from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream @@ -83,9 +84,9 @@ def __init__( """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: - """Framerate in frames/sec.""" - return self._reader.fps + def frame_rate(self) -> Fraction: + """Framerate in frames/sec as a rational Fraction.""" + return framerate_to_fraction(self._reader.fps) @property def path(self) -> ty.Union[bytes, str]: @@ -135,7 +136,14 @@ def position(self) -> FrameTimecode: calling `read`. This will always return 0 (e.g. be equal to `base_timecode`) if no frames have been `read` yet.""" frame_number = max(self._frame_number - 1, 0) - return FrameTimecode(frame_number, self.frame_rate) + # Synthesize a Timecode from the frame count and rational framerate. + # MoviePy assumes CFR, so this is equivalent to frame-based timing. + # Use the framerate denominator as the time_base denominator for exact timing. + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = frame_number * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=fps) @property def position_ms(self) -> float: @@ -173,10 +181,6 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): ValueError: `target` is not a valid value (i.e. it is negative). """ success = False - if _USE_PTS_IN_DEVELOPMENT: - # TODO(https://scenedetect.com/issue/168): Need to handle PTS here. - raise NotImplementedError() - if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 298f0301..5401119a 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -27,7 +27,7 @@ import cv2 import numpy as np -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction from scenedetect.platform import get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, @@ -111,7 +111,7 @@ def __init__( self._cap: ty.Optional[cv2.VideoCapture] = ( None # Reference to underlying cv2.VideoCapture object. ) - self._frame_rate: ty.Optional[float] = None + self._frame_rate: ty.Optional[Fraction] = None # VideoCapture state self._has_grabbed = False @@ -144,7 +144,7 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: + def frame_rate(self) -> Fraction: assert self._frame_rate return self._frame_rate @@ -196,30 +196,25 @@ def aspect_ratio(self) -> float: @property def timecode(self) -> Timecode: - """Current position within stream as a Timecode. This is not frame accurate.""" + """Current position within stream as a Timecode.""" # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we - # use `CAP_PROP_POS_MSEC` instead, with a time base of 1/1000. Unfortunately this means that - # rounding errors will affect frame accuracy with this backend. - pts = self._cap.get(cv2.CAP_PROP_POS_MSEC) - time_base = Fraction(1, 1000) - return Timecode(pts=round(pts), time_base=time_base) + # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to + # avoid frame-boundary rounding errors at common framerates like 24000/1001. + ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + time_base = Fraction(1, 1000000) + return Timecode(pts=round(ms * 1000), time_base=time_base) @property def position(self) -> FrameTimecode: - # TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or - # add a config option before landing this. - if _USE_PTS_IN_DEVELOPMENT: - timecode = self.timecode - # If PTS is 0 but we've read frames, derive from frame number. - # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. - if timecode.pts == 0 and self.frame_number > 0: - time_sec = (self.frame_number - 1) / self.frame_rate - pts = round(time_sec * 1000) - timecode = Timecode(pts=pts, time_base=Fraction(1, 1000)) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) - if self.frame_number < 1: - return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + timecode = self.timecode + # If PTS is 0 but we've read frames, derive from frame number. + # This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable. + if timecode.pts == 0 and self.frame_number > 0: + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = (self.frame_number - 1) * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) @property def position_ms(self) -> float: @@ -235,23 +230,32 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if target < 0: raise ValueError("Target seek position cannot be negative!") - # TODO(https://scenedetect.com/issue/168): Shouldn't use frames for VFR video here. - # Have to seek one behind and call grab() after to that the VideoCapture - # returns a valid timestamp when using CAP_PROP_POS_MSEC. - target_frame_cv2 = (self.base_timecode + target).frame_num - if target_frame_cv2 > 0: - target_frame_cv2 -= 1 - self._cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame_cv2) + target_secs = (self.base_timecode + target).seconds self._has_grabbed = False - # Preemptively grab the frame behind the target position if possible. - if target > 0: + if target_secs > 0: + # Seek one frame before target so the next read() returns the frame at target. + one_frame_ms = 1000.0 / float(self._frame_rate) + seek_ms = max(0.0, target_secs * 1000.0 - one_frame_ms) + self._cap.set(cv2.CAP_PROP_POS_MSEC, seek_ms) self._has_grabbed = self._cap.grab() - # If we seeked past the end of the video, need to seek one frame backwards - # from the current position and grab that frame instead. + if self._has_grabbed: + # VFR correction: set(CAP_PROP_POS_MSEC) converts time using avg_fps internally, + # which can land ~1s too early for VFR video. Read forward until we reach the + # intended position. The threshold (2x one_frame_ms) never triggers for CFR. + actual_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + corrections = 0 + while actual_ms < seek_ms - 2.0 * one_frame_ms and corrections < 100: + if not self._cap.grab(): + break + actual_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) + corrections += 1 + # If we seeked past the end, back up one frame. if not self._has_grabbed: seek_pos = round(self._cap.get(cv2.CAP_PROP_POS_FRAMES) - 1.0) self._cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, seek_pos)) self._has_grabbed = self._cap.grab() + else: + self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0) def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" @@ -329,14 +333,11 @@ def _open_capture(self, framerate: ty.Optional[float] = None): raise FrameRateUnavailable() self._cap = cap - self._frame_rate = framerate + self._frame_rate = framerate_to_fraction(framerate) self._has_grabbed = False cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 -# TODO(https://scenedetect.com/issues/168): Support non-monotonic timing for `position`. VFR timecode -# support is a prerequisite for this. Timecodes are currently calculated by multiplying the -# framerate by number of frames. Actual elapsed time can be obtained via `position_ms` for now. class VideoCaptureAdapter(VideoStream): """Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports VideoCaptures which may not support seeking. @@ -378,7 +379,7 @@ def __init__( raise FrameRateUnavailable() self._cap = cap - self._frame_rate: float = framerate + self._frame_rate: Fraction = framerate_to_fraction(framerate) self._num_frames = 0 self._max_read_attempts = max_read_attempts self._decode_failures = 0 @@ -408,7 +409,7 @@ def capture(self) -> cv2.VideoCapture: """Unique name used to identify this backend.""" @property - def frame_rate(self) -> float: + def frame_rate(self) -> Fraction: """Framerate in frames/sec.""" assert self._frame_rate return self._frame_rate @@ -439,8 +440,6 @@ def frame_size(self) -> ty.Tuple[int, int]: @property def duration(self) -> ty.Optional[FrameTimecode]: """Duration of the stream as a FrameTimecode, or None if non terminating.""" - # TODO(https://scenedetect.com/issue/168): This will be incorrect for VFR. See if there is - # another property we can use to estimate the video length correctly. frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: return self.base_timecode + frame_count @@ -455,7 +454,12 @@ def aspect_ratio(self) -> float: def position(self) -> FrameTimecode: if self.frame_number < 1: return self.base_timecode - return self.base_timecode + (self.frame_number - 1) + # Synthesize a Timecode from frame count and rational framerate. + fps = self.frame_rate + time_base = Fraction(1, fps.numerator) + pts = (self.frame_number - 1) * fps.denominator + timecode = Timecode(pts=pts, time_base=time_base) + return FrameTimecode(timecode=timecode, fps=fps) @property def position_ms(self) -> float: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 8692cdb5..a1ade9b4 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -18,7 +18,7 @@ import av import numpy as np -from scenedetect.common import _USE_PTS_IN_DEVELOPMENT, MAX_FPS_DELTA, FrameTimecode, Timecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode from scenedetect.platform import get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream @@ -81,6 +81,8 @@ def __init__( self._name = "" if name is None else name self._path = "" self._frame: ty.Optional[av.VideoFrame] = None + self._decoder: ty.Optional[ty.Generator] = None + self._decode_count: int = 0 self._reopened = True if threading_mode: @@ -172,8 +174,8 @@ def duration(self) -> FrameTimecode: return self.base_timecode + self._duration_frames @property - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction.""" return self._frame_rate @property @@ -184,10 +186,8 @@ def position(self) -> FrameTimecode: to the presentation time 0. Returns 0 even if `frame_number` is 1.""" if self._frame is None: return self.base_timecode - if _USE_PTS_IN_DEVELOPMENT: - timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) - return FrameTimecode(timecode=timecode, fps=self.frame_rate) - return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate) + timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base) + return FrameTimecode(timecode=timecode, fps=self.frame_rate) @property def position_ms(self) -> float: @@ -199,16 +199,13 @@ def position_ms(self) -> float: @property def frame_number(self) -> int: - """Current position within stream as the frame number. + """Current position within stream as the frame number (CFR-equivalent). - Will return 0 until the first frame is `read`.""" - - if self._frame: - if _USE_PTS_IN_DEVELOPMENT: - # frame_number is 1-indexed, so add 1 to the 0-based frame position. - return round(self._frame.time * self.frame_rate) + 1 - return self.position.frame_num + 1 - return 0 + Will return 0 until the first frame is `read`. For VFR video this is an approximation + derived from PTS × framerate; use `position` for accurate PTS-based timing.""" + if self._frame is None: + return 0 + return round(self._frame.time * float(self.frame_rate)) + 1 @property def rate(self) -> Fraction: @@ -258,7 +255,6 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: raise ValueError("Target cannot be negative!") beginning = target == 0 - # TODO(https://scenedetect.com/issues/168): This breaks with PTS mode enabled. target = self.base_timecode + target if target >= 1: target = target - 1 @@ -266,6 +262,8 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: (self.base_timecode + target).seconds / self._video_stream.time_base ) self._frame = None + self._decoder = None + self._decode_count = 0 self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -277,15 +275,23 @@ def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" self._container.close() self._frame = None + self._decoder = None + self._decode_count = 0 try: self._container = av.open(self._path if self._path else self._io) except Exception as ex: raise VideoOpenFailure() from ex def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + # Reuse a persistent decoder generator so the codec's internal frame buffer (used for + # B-frame reordering) is never flushed prematurely. Creating a new generator each call + # caused the last buffered frame to be lost at EOF. + if self._decoder is None: + self._decoder = self._container.decode(video=0) try: last_frame = self._frame - self._frame = next(self._container.decode(video=0)) + self._frame = next(self._decoder) + self._decode_count += 1 except av.error.EOFError: self._frame = last_frame if self._handle_eof(): @@ -350,7 +356,7 @@ def _handle_eof(self): # Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode. if not self.is_seekable or self._video_stream.thread_type not in ("AUTO", "FRAME"): return False - last_frame = self.frame_number + last_pos_secs = self.position.seconds orig_pos = self._io.tell() try: self._io.seek(0) @@ -360,5 +366,6 @@ def _handle_eof(self): raise self._container.close() self._container = container - self.seek(last_frame) + self._decoder = None + self.seek(last_pos_secs) return True diff --git a/scenedetect/common.py b/scenedetect/common.py index e4ab7e48..545ef7df 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -70,10 +70,6 @@ import cv2 -# TODO(https://scenedetect.com/issue/168): Ensure both CFR and VFR videos work as intended with this -# flag enabled. When this feature is stable, we can then work on a roll-out plan. -_USE_PTS_IN_DEVELOPMENT = False - ## ## Type Aliases ## @@ -99,6 +95,34 @@ _SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE _MINUTES_PER_HOUR = 60.0 +# Common framerates mapped from their float representation to exact rational values. +_COMMON_FRAMERATES: ty.Dict[Fraction, Fraction] = { + Fraction(24000, 1001): Fraction(24000, 1001), # 23.976... + Fraction(30000, 1001): Fraction(30000, 1001), # 29.97... + Fraction(60000, 1001): Fraction(60000, 1001), # 59.94... + Fraction(120000, 1001): Fraction(120000, 1001), # 119.88... +} + + +def framerate_to_fraction(fps: float) -> Fraction: + """Convert a float framerate to an exact rational Fraction. + + Recognizes common NTSC framerates (23.976, 29.97, 59.94, 119.88) and maps them to their + exact rational representation (e.g. 24000/1001). For other values, uses limit_denominator + to find a clean rational approximation, or returns the exact integer fraction for whole + number framerates. + """ + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + # Integer framerates are exact. + if fps == int(fps): + return Fraction(int(fps), 1) + # Check against known common framerates using limit_denominator to find the closest match. + candidate = Fraction(fps).limit_denominator(10000) + if candidate in _COMMON_FRAMERATES: + return _COMMON_FRAMERATES[candidate] + return candidate + class Interpolation(Enum): """Interpolation method used for image resizing. Based on constants defined in OpenCV.""" @@ -115,24 +139,6 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -# TODO(@Breakthrough): How should we deal with frame numbers when we have a `Timecode`? -# -# Each backend has slight nuances we have to take into account: -# - PyAV: Does not include a position in frames, we can probably estimate it. Need to also compare -# with how OpenCV handles this. It also seems to fail to decode the last frame. This library -# provides the most accurate timing information however. -# - OpenCV: Lacks any kind of timebase, only provides position in milliseconds and as frames. -# This is probably sufficient, since we could just use 1ms as a timebase. -# - MoviePy: Assumes fixed framerate and doesn't include timing information. Fixing this is -# probably not feasible, so we should make sure the docs warn users about this. -# -# In the meantime, having backends provide accurate timing information is controlled by a hard-coded -# constant `_USE_PTS_IN_DEVELOPMENT` in each backend implementation that supports it. It still does -# not work correctly however, as we have to modify detectors themselves to work with FrameTimecode -# objects instead of integer frame numbers like they do now. -# -# We might be able to avoid changing the detector interface if we just have them work directly with -# PTS and convert them back to FrameTimecodes with the same time base. @dataclass(frozen=True) class Timecode: """Timing information associated with a given frame.""" @@ -242,16 +248,9 @@ def __init__( @property def frame_num(self) -> ty.Optional[int]: - """The frame number. This value will be an estimate if the video is VFR. Prefer using the - `pts` property.""" + """The frame number. For VFR video or Timecode-backed objects, this is an approximation + based on the average framerate. Prefer using `pts` and `time_base` for precise timing.""" if isinstance(self._time, Timecode): - # We need to audit anything currently using this property to guarantee temporal - # consistency when handling VFR videos (i.e. no assumptions on fixed frame rate). - warnings.warn( - message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.", - stacklevel=2, - category=UserWarning, - ) # Calculate approximate frame number from seconds and framerate. if self._rate is not None: return round(self._time.seconds * float(self._rate)) @@ -312,7 +311,6 @@ def get_framerate(self) -> float: ) return self.framerate - # TODO(https://scenedetect.com/issue/168): Figure out how to deal with VFR here. def equal_framerate(self, fps) -> bool: """Equal Framerate: Determines if the passed framerate is equal to that of this object. @@ -323,8 +321,8 @@ def equal_framerate(self, fps) -> bool: bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. """ - # TODO(https://scenedetect.com/issue/168): Support this comparison in the case FPS is not - # set but a timecode is. + if self.framerate is None: + return False return math.fabs(self.framerate - fps) < MAX_FPS_DELTA @property @@ -377,7 +375,10 @@ def get_timecode( str: The current time in the form ``"HH:MM:SS[.nnn]"``. """ # Compute hours and minutes based off of seconds, and update seconds. - if nearest_frame and self.framerate: + # For PTS-backed timecodes, the PTS already represents an exact frame boundary, so we use + # `seconds` directly. For non-PTS timecodes, `nearest_frame` snaps to the nearest frame + # boundary using frame_num, which avoids floating point drift in CFR video display. + if nearest_frame and self.framerate and not isinstance(self._time, Timecode): secs = self.frame_num / self.framerate else: secs = self.seconds @@ -566,12 +567,17 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base != other._time.time_base: - raise ValueError("timecodes have different time bases") - self._time = Timecode( - pts=max(0, self._time.pts + other._time.pts), - time_base=self._time.time_base, - ) + if self._time.time_base == other._time.time_base: + self._time = Timecode( + pts=max(0, self._time.pts + other._time.pts), + time_base=self._time.time_base, + ) + return self + # Different time bases: use the finer (smaller) one for better precision. + time_base = min(self._time.time_base, other._time.time_base) + self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) + other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + self._time = Timecode(pts=max(0, self_pts + other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a @@ -613,12 +619,17 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base != other._time.time_base: - raise ValueError("timecodes have different time bases") - self._time = Timecode( - pts=max(0, self._time.pts - other._time.pts), - time_base=self._time.time_base, - ) + if self._time.time_base == other._time.time_base: + self._time = Timecode( + pts=max(0, self._time.pts - other._time.pts), + time_base=self._time.time_base, + ) + return self + # Different time bases: use the finer (smaller) one for better precision. + time_base = min(self._time.time_base, other._time.time_base) + self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) + other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + self._time = Timecode(pts=max(0, self_pts - other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a diff --git a/scenedetect/detector.py b/scenedetect/detector.py index e7d9e731..7c3e1b70 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -122,6 +122,7 @@ def __init__(self, mode: Mode, length: int): """ self._mode = mode self._filter_length = length # Number of frames to use for activating the filter. + self._filter_secs: ty.Optional[float] = None # Threshold in seconds, computed on first use. self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. @@ -143,9 +144,12 @@ def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[Fram raise RuntimeError("Unhandled FlashFilter mode.") def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: - framerate = timecode.framerate - assert framerate >= 0 - min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) + assert timecode.framerate >= 0 + # Compute the threshold in seconds once from the first frame's framerate. This avoids + # using an incorrect average fps (e.g. OpenCV on VFR video) on subsequent frames. + if self._filter_secs is None: + self._filter_secs = self._filter_length / timecode.framerate + min_length_met: bool = (timecode - self._last_above) >= self._filter_secs if not (above_threshold and min_length_met): return [] # Both length and threshold requirements were satisfied. Emit the cut, and wait until both @@ -154,16 +158,21 @@ def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty return [timecode] def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: - framerate = timecode.framerate - assert framerate >= 0 - min_length_met: bool = (timecode - self._last_above) >= (self._filter_length / framerate) + assert timecode.framerate >= 0 + # Compute the threshold in seconds once from the first frame's framerate. + if self._filter_secs is None: + self._filter_secs = self._filter_length / timecode.framerate + min_length_met: bool = (timecode - self._last_above) >= self._filter_secs # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. - num_merged_frames = self._last_above - self._merge_start - if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + if ( + min_length_met + and not above_threshold + and (self._last_above - self._merge_start) >= self._filter_secs + ): self._merge_triggered = False return [self._last_above] # Keep merging until enough frames pass below the threshold. diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 2cfe05b8..8d28cd62 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -88,7 +88,7 @@ def __init__( self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. self.last_fade = { - "frame": 0, # frame number where the last detected fade is + "frame": None, # FrameTimecode where the last detected fade is "type": None, # type of fade, can be either 'in' or 'out' } self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] @@ -100,40 +100,29 @@ def get_metrics(self) -> ty.List[str]: def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray ) -> ty.List[FrameTimecode]: - """Process the next frame. `frame_num` is assumed to be sequential. + """Process the next frame. Args: - frame_num (int): Frame number of frame that is being passed. Can start from any value - but must remain sequential. - frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`. + timecode: FrameTimecode of the current frame position. + frame_img (numpy.ndarray or None): Video frame corresponding to `timecode`. Returns: - ty.List[int]: List of frames where scene cuts have been detected. There may be 0 - or more frames in the list, and not necessarily the same as frame_num. + List of FrameTimecodes where scene cuts have been detected. """ - # TODO(https://scenedetect.com/issue/168): We need to consider PTS here instead. The methods below using frame numbers - # won't work for variable framerates. - frame_num = timecode.frame_num - # Initialize last scene cut point at the beginning of the frames of interest. if self.last_scene_cut is None: - self.last_scene_cut = frame_num - - # Compare the # of pixels under threshold in current_frame & last_frame. - # If absolute value of pixel intensity delta is above the threshold, - # then we trigger a new scene cut/break. + self.last_scene_cut = timecode - # List of cuts to return. - cuts = [] + cuts: ty.List[FrameTimecode] = [] # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on # user-supplied values, we supply the average pixel intensity as this # frame metric instead (to assist with manually selecting a threshold) if (self.stats_manager is not None) and ( - self.stats_manager.metrics_exist(frame_num, self._metric_keys) + self.stats_manager.metrics_exist(timecode, self._metric_keys) ): - frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] + frame_avg = self.stats_manager.get_metrics(timecode, self._metric_keys)[0] else: frame_avg = numpy.mean(frame_img) if self.stats_manager is not None: @@ -146,32 +135,31 @@ def process_frame( ): # Just faded out of a scene, wait for next fade in. self.last_fade["type"] = "out" - self.last_fade["frame"] = frame_num + self.last_fade["frame"] = timecode elif self.last_fade["type"] == "out" and ( (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold) ): # Only add the scene if min_scene_len frames have passed. - if (frame_num - self.last_scene_cut) >= self.min_scene_len: + if (timecode - self.last_scene_cut) >= self.min_scene_len: # Just faded into a new scene, compute timecode for the scene # split based on the fade bias. f_out = self.last_fade["frame"] - f_split = int( - (frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2 - ) - cuts.append(f_split) - self.last_scene_cut = frame_num + duration = (timecode - f_out).seconds + split_seconds = f_out.seconds + (duration * (1.0 + self.fade_bias)) / 2.0 + cuts.append(FrameTimecode(split_seconds, fps=timecode)) + self.last_scene_cut = timecode self.last_fade["type"] = "in" - self.last_fade["frame"] = frame_num + self.last_fade["frame"] = timecode else: - self.last_fade["frame"] = 0 + self.last_fade["frame"] = timecode if frame_avg < self.threshold: self.last_fade["type"] = "out" else: self.last_fade["type"] = "in" self.processed_frame = True - return [FrameTimecode(cut, fps=timecode) for cut in cuts] + return cuts def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out. @@ -185,14 +173,15 @@ def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cuts = [] + cuts: ty.List[FrameTimecode] = [] if ( self.last_fade["type"] == "out" and self.add_final_scene + and self.last_fade["frame"] is not None and ( (self.last_scene_cut is None and timecode >= self.min_scene_len) or (timecode - self.last_scene_cut) >= self.min_scene_len ) ): cuts.append(self.last_fade["frame"]) - return [FrameTimecode(cut, fps=timecode) for cut in cuts] + return cuts diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 3fa54b04..d5cf00de 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -290,48 +290,37 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): if progress_bar is not None: progress_bar.update(1) - def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]: + def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.List[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config - parameters.""" - # TODO(v0.7): This needs to be fixed as part of PTS overhaul. + parameters. + + Uses PTS-accurate seconds-based timing so results are correct for both CFR and VFR video. + """ framerate = scene_list[0][0].framerate - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - return [ - ( - FrameTimecode(int(f), fps=framerate) - for f in ( - # middle frames - a[len(a) // 2] - if (0 < j < self._num_images - 1) or self._num_images == 1 - # first frame - else min(a[0] + self._frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - self._frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, self._num_images)) - ) - ) - for r in ( - # pad ranges to number of images - r - if 1 + r[-1] - r[0] >= self._num_images - else list(r) + [r[-1]] * (self._num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.frame_num, - start.frame_num - + max( - 1, # guard against zero length scenes - end.frame_num - start.frame_num, - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ) - ] + # Convert frame_margin to seconds using the nominal framerate. + margin_secs = self._frame_margin / framerate + result = [] + for start, end in scene_list: + duration_secs = (end - start).seconds + if duration_secs <= 0: + result.append([start] * self._num_images) + continue + segment_secs = duration_secs / self._num_images + timecodes = [] + for j in range(self._num_images): + seg_start = start.seconds + j * segment_secs + seg_end = start.seconds + (j + 1) * segment_secs + if self._num_images == 1: + t = start.seconds + duration_secs / 2.0 + elif j == 0: + t = min(seg_start + margin_secs, seg_end) + elif j == self._num_images - 1: + t = max(seg_end - margin_secs, seg_start) + else: + t = (seg_start + seg_end) / 2.0 + timecodes.append(FrameTimecode(t, fps=framerate)) + result.append(timecodes) + return result def resize_image( self, diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 737cb92d..07b01c1b 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -125,7 +125,8 @@ class SceneMetadata: def default_formatter(template: str) -> PathFormatter: """Formats filenames using a template string which allows the following variables: - `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME` + `$VIDEO_NAME`, `$SCENE_NUMBER`, `$START_TIME`, `$END_TIME`, `$START_FRAME`, `$END_FRAME`, + `$START_PTS`, `$END_PTS` (presentation timestamp in milliseconds, accurate for VFR video) """ MIN_DIGITS = 3 format_scene_number: PathFormatter = lambda video, scene: ( @@ -139,6 +140,8 @@ def default_formatter(template: str) -> PathFormatter: END_TIME=str(scene.end.get_timecode().replace(":", ";")), START_FRAME=str(scene.start.frame_num), END_FRAME=str(scene.end.frame_num), + START_PTS=str(round(scene.start.seconds * 1000)), + END_PTS=str(round(scene.end.seconds * 1000)), ) return formatter diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 26c2cefe..18a04b2c 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -124,8 +124,8 @@ def is_seekable(self) -> bool: @property @abstractmethod - def frame_rate(self) -> float: - """Frame rate in frames/sec.""" + def frame_rate(self) -> Fraction: + """Frame rate in frames/sec as a rational Fraction (e.g. Fraction(24000, 1001)).""" ... @property diff --git a/tests/conftest.py b/tests/conftest.py index 25bf517e..8cef4b0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,17 @@ def test_vfr_video() -> str: return check_exists("tests/resources/goldeneye-vfr.mp4") +@pytest.fixture +def test_vfr_drop3_video() -> str: + """Synthetic VFR video created from goldeneye.mp4 by dropping every 3rd frame. + + Frame pattern: keeps frames where (n+1) % 3 != 0 (i.e. drops frames 2,5,8,...). + Resulting PTS durations alternate: 1001, 2002, 1001, 2002, ... (time_base=1/24000). + Nominal fps: 24000/1001. Average fps: ~16 fps. Duration: ~10s, 160 frames. + """ + return check_exists("tests/resources/goldeneye-vfr-drop3.mp4") + + @pytest.fixture def corrupt_video_file() -> str: """Video containing a corrupted frame causing a decode failure.""" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..4bdb6d7c --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,38 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Shared test helpers.""" + +import typing as ty + +from click.testing import CliRunner + +from scenedetect._cli import scenedetect as _scenedetect_cli +from scenedetect._cli.context import CliContext +from scenedetect._cli.controller import run_scenedetect + + +def invoke_cli(args: ty.List[str], catch_exceptions: bool = False) -> ty.Tuple[int, str]: + """Invoke the scenedetect CLI in-process using Click's CliRunner. + + Replicates the two-step execution of ``__main__.py``: + + 1. ``scenedetect.main(obj=context)`` — parse args and register callbacks on ``CliContext`` + 2. ``run_scenedetect(context)`` — execute detection and output commands + + Returns ``(exit_code, output_text)``. + """ + context = CliContext() + runner = CliRunner() + result = runner.invoke(_scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions) + if result.exit_code == 0: + run_scenedetect(context) + return result.exit_code, result.output diff --git a/tests/test_api.py b/tests/test_api.py index e86243ad..853b7c32 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -142,3 +142,28 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): scene_manager = SceneManager() scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video, duration=total_frames, callback=on_new_scene) + + +# TODO(v0.8): Remove this test when these deprecated modules are removed from the codebase. +def test_deprecated_modules_emits_warning_on_import(): + import importlib + + import pytest + + SCENE_DETECTOR_WARNING = ( + "The `scene_detector` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): + importlib.import_module("scenedetect.scene_detector") + + FRAME_TIMECODE_WARNING = ( + "The `frame_timecode` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): + importlib.import_module("scenedetect.frame_timecode") + + VIDEO_SPLITTER_WARNING = ( + "The `video_splitter` submodule is deprecated, import from the base package instead." + ) + with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): + importlib.import_module("scenedetect.video_splitter") diff --git a/tests/test_cli.py b/tests/test_cli.py index 29fd2738..3f77e91f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,37 +12,36 @@ import os import subprocess -import typing as ty -from pathlib import Path - -import cv2 -import numpy as np -import pytest - -import scenedetect -from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available # These tests validate that the CLI itself functions correctly, mainly based on the return # return code from the process. We do not yet check for correctness of the output, just a # successful invocation of the command (i.e. no exceptions/errors). - # TODO: Add some basic correctness tests to validate the output (just look for the # last expected log message or extract # of scenes). Might need to refactor the test cases # since we need to calculate the output file names for commands that write to disk. - # TODO: Define error/exit codes explicitly. Right now these tests only verify that the # exit code is zero or nonzero. - # TODO: These tests are very expensive since they spin up new Python interpreters. # Move most of these test cases (e.g. argument validation) to ones that interface directly # with the scenedetect._cli module. Click also supports unit testing directly, so we should # probably use that instead of spinning up new subprocesses for each run of the controller. # That will also allow splitting up the validation of argument parsing logic from the controller # logic by creating a CLI context with the desired parameters. - # TODO: Missing tests for --min-scene-len and --drop-short-scenes. +import sys +import typing as ty +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +import scenedetect +from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available +from tests.helpers import invoke_cli + +SCENEDETECT_CMD = sys.executable + " -m scenedetect" -SCENEDETECT_CMD = "python -m scenedetect" ALL_DETECTORS = [ "detect-content", "detect-threshold", @@ -305,14 +304,22 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): def test_cli_list_scenes(tmp_path: Path): """Test `list-scenes` command.""" - # Regular invocation - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} list-scenes", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "list-scenes", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Scenes.csv") assert os.path.exists(output_path) EXPECTED_CSV_OUTPUT = """Timecode List:,00:00:03.754 @@ -744,13 +751,22 @@ def test_cli_load_scenes_round_trip(): def test_cli_save_edl(tmp_path: Path): """Test `save-edl` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-edl", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-edl", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.edl") assert os.path.exists(output_path) EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} @@ -765,13 +781,28 @@ def test_cli_save_edl(tmp_path: Path): def test_cli_save_edl_with_params(tmp_path: Path): """Test `save-edl` command but override the other options.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-edl -t title -r BX -f file_no_ext", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-edl", + "-t", + "title", + "-r", + "BX", + "-f", + "file_no_ext", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath("file_no_ext") assert os.path.exists(output_path) EXPECTED_EDL_OUTPUT = f"""* CREATED WITH PYSCENEDETECT {scenedetect.__version__} @@ -786,13 +817,22 @@ def test_cli_save_edl_with_params(tmp_path: Path): def test_cli_save_otio(tmp_path: Path): """Test `save-otio` command.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-otio", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-otio", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") assert os.path.exists(output_path) EXPECTED_OTIO_OUTPUT = """{ @@ -994,13 +1034,23 @@ def test_cli_save_otio(tmp_path: Path): def test_cli_save_otio_no_audio(tmp_path: Path): """Test `save-otio` command without audio.""" - assert ( - invoke_scenedetect( - "-i {VIDEO} time {TIME} {DETECTOR} save-otio --no-audio", - output_dir=tmp_path, - ) - == 0 + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-otio", + "--no-audio", + ] ) + assert exit_code == 0 output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.otio") assert os.path.exists(output_path) EXPECTED_OTIO_OUTPUT = """{ diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 445112ee..0e5f4214 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -224,12 +224,3 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len - - -# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. -def test_deprecated_detector_module_emits_warning_on_import(): - SCENE_DETECTOR_WARNING = ( - "The `scene_detector` submodule is deprecated, import from the base package instead." - ) - with pytest.warns(DeprecationWarning, match=SCENE_DETECTOR_WARNING): - from scenedetect.scene_detector import SceneDetector as _ diff --git a/tests/test_output.py b/tests/test_output.py index bc1762e5..db3f2307 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -191,12 +191,3 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): total_images += 1 assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - - -# TODO(v0.8): Remove this test during the removal of `scenedetect.video_splitter`. -def test_deprecated_output_modules_emits_warning_on_import(): - VIDEO_SPLITTER_WARNING = ( - "The `video_splitter` submodule is deprecated, import from the base package instead." - ) - with pytest.warns(DeprecationWarning, match=VIDEO_SPLITTER_WARNING): - from scenedetect.video_splitter import split_video_ffmpeg as _ diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 0c32371e..03bef2c7 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -27,8 +27,6 @@ """ import csv -import os -import random from pathlib import Path import pytest diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 4d6c0d21..f4296923 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -26,7 +26,7 @@ import pytest # Standard Library Imports -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction def test_framerate(): @@ -299,10 +299,43 @@ def test_precision(): assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00" -# TODO(v0.8): Remove this test during the removal of `scenedetect.scene_detector`. -def test_deprecated_timecode_module_emits_warning_on_import(): - FRAME_TIMECODE_WARNING = ( - "The `frame_timecode` submodule is deprecated, import from the base package instead." - ) - with pytest.warns(DeprecationWarning, match=FRAME_TIMECODE_WARNING): - from scenedetect.frame_timecode import FrameTimecode as _ +def test_rational_framerate_precision(): + """Rational framerates should round-trip frame/second conversions without drift.""" + fps = Fraction(24000, 1001) + # Verify that frame_num round-trips through seconds without drift over many frames. + for frame in [0, 1, 100, 1000, 10000, 100000]: + tc = FrameTimecode(frame, fps) + assert tc.frame_num == frame, f"Frame {frame} drifted to {tc.frame_num}" + + +def test_ntsc_framerate_detection(): + """Common NTSC framerates should be detected from float values.""" + assert framerate_to_fraction(23.976023976023978) == Fraction(24000, 1001) + assert framerate_to_fraction(29.97002997002997) == Fraction(30000, 1001) + assert framerate_to_fraction(59.94005994005994) == Fraction(60000, 1001) + assert framerate_to_fraction(24.0) == Fraction(24, 1) + assert framerate_to_fraction(30.0) == Fraction(30, 1) + assert framerate_to_fraction(60.0) == Fraction(60, 1) + assert framerate_to_fraction(25.0) == Fraction(25, 1) + + +def test_timecode_arithmetic_mixed_time_base(): + """Arithmetic with FrameTimecodes using different time_bases should work.""" + fps = Fraction(24000, 1001) + # Timecode with time_base 1/24000 (from PyAV) + tc_pyav = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # Timecode with time_base 1/1000000 (from OpenCV microseconds) + tc_cv2 = FrameTimecode(timecode=Timecode(pts=41708, time_base=Fraction(1, 1000000)), fps=fps) + # Both represent approximately 1 frame duration. Addition/subtraction shouldn't raise. + result = tc_pyav + tc_cv2 + assert result.seconds > 0 + result = tc_pyav - tc_cv2 + assert result.seconds >= 0 # Clamped to 0 if negative + + +def test_timecode_frame_num_for_vfr(): + """frame_num should return approximate values for Timecode-backed objects without warning.""" + fps = Fraction(24000, 1001) + tc = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps) + # Should not raise or warn - just return the approximate frame number. + assert tc.frame_num == 1 diff --git a/tests/test_vfr.py b/tests/test_vfr.py new file mode 100644 index 00000000..09aab163 --- /dev/null +++ b/tests/test_vfr.py @@ -0,0 +1,401 @@ +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2025 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Tests for VFR (Variable Frame Rate) video support.""" + +import csv +import json +import os +import typing as ty + +import cv2 +import numpy as np +import pytest + +from scenedetect import SceneManager, open_video +from scenedetect.common import Timecode +from scenedetect.detectors import ContentDetector +from scenedetect.output import save_images, write_scene_list +from scenedetect.stats_manager import StatsManager +from tests.helpers import invoke_cli + +# Expected scene cuts for `goldeneye-vfr.mp4` detected with ContentDetector() and end_time=10.0s. +# Entries are (start_timecode, end_timecode). All backends should agree on cut timecodes since +# CAP_PROP_POS_MSEC gives accurate PTS-derived timestamps. The last scene ends at the clip +# boundary (end_time) which may vary slightly between backends based on frame counting. +EXPECTED_SCENES_VFR: ty.List[ty.Tuple[str, str]] = [ + ("00:00:00.000", "00:00:03.921"), + ("00:00:03.921", "00:00:09.676"), +] + +# Expected scene cuts for `goldeneye-vfr-drop3.mp4` — a synthetic VFR clip created from the first +# 10s of goldeneye.mp4 by dropping every 3rd frame (frames 2,5,8,...). PTS durations alternate +# between 1001 and 2002 (time_base=1/24000), nominal fps=24000/1001, avg fps≈16. The last scene +# ends at the clip boundary and may vary slightly between backends. +EXPECTED_SCENES_VFR_DROP3: ty.List[ty.Tuple[str, str]] = [ + ("00:00:00.000", "00:00:03.754"), + ("00:00:03.754", "00:00:08.759"), +] + + +def _tc_to_secs(tc: str) -> float: + """Parse a HH:MM:SS.mmm timecode string to seconds.""" + h, m, rest = tc.split(":") + s, ms = rest.split(".") + return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000 + + +def test_vfr_position_is_timecode(test_vfr_video: str): + """Position should be a Timecode-backed FrameTimecode.""" + video = open_video(test_vfr_video, backend="pyav") + assert video.read() is not False + assert isinstance(video.position._time, Timecode) + + +def test_vfr_position_monotonic_pyav(test_vfr_video: str): + """PTS-based position should be monotonically non-decreasing (PyAV).""" + video = open_video(test_vfr_video, backend="pyav") + last_seconds = -1.0 + frame_count = 0 + while True: + frame = video.read() + if frame is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count > 0 + + +def test_vfr_position_monotonic_opencv(test_vfr_video: str): + """PTS-based position should be monotonically non-decreasing (OpenCV).""" + video = open_video(test_vfr_video, backend="opencv") + last_seconds = -1.0 + frame_count = 0 + while True: + frame = video.read() + if frame is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count > 0 + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_scene_detection(test_vfr_video: str, backend: str): + """Scene detection on VFR video should produce timestamps matching known ground truth. + + Both PyAV (native PTS) and OpenCV (CAP_PROP_POS_MSEC) should agree on scene cuts since + both expose accurate PTS-derived timestamps. + """ + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + scene_list = sm.get_scene_list() + + # The last scene ends at the clip boundary which may vary by backend; only check known cuts. + assert len(scene_list) >= len(EXPECTED_SCENES_VFR), ( + f"[{backend}] Expected at least {len(EXPECTED_SCENES_VFR)} scenes, got {len(scene_list)}" + ) + for i, ((start, end), (exp_start_tc, exp_end_tc)) in enumerate( + zip(scene_list, EXPECTED_SCENES_VFR, strict=False) + ): + assert start.get_timecode() == exp_start_tc, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start_tc!r}, got {start.get_timecode()!r}" + ) + assert end.get_timecode() == exp_end_tc, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end_tc!r}, got {end.get_timecode()!r}" + ) + + +def test_vfr_seek_pyav(test_vfr_video: str): + """Seeking should work with VFR video.""" + video = open_video(test_vfr_video, backend="pyav") + target_time = 2.0 # seconds + video.seek(target_time) + frame = video.read() + assert frame is not False + # Position should be close to target (within 1 second for keyframe-based seeking). + assert abs(video.position.seconds - target_time) < 1.0 + + +def test_vfr_stats_manager(test_vfr_video: str): + """StatsManager should work correctly with VFR video.""" + video = open_video(test_vfr_video, backend="pyav") + stats = StatsManager() + sm = SceneManager(stats_manager=stats) + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + assert len(sm.get_scene_list()) > 0 + + +def test_vfr_csv_output(test_vfr_video: str, tmp_path): + """CSV export should work correctly with VFR video.""" + from scenedetect.output import write_scene_list + + video = open_video(test_vfr_video, backend="pyav") + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + scene_list = sm.get_scene_list() + assert len(scene_list) > 0 + + csv_path = os.path.join(str(tmp_path), "scenes.csv") + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list) + + # Verify CSV contains valid data. + with open(csv_path, "r") as f: + reader = csv.reader(f) + rows = list(reader) + assert len(rows) >= 3 # 2 header rows + data + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str): + """Synthetic VFR video (drop every 3rd frame, alternating 1x/2x durations) should produce + timecodes matching known ground truth with both backends.""" + video = open_video(test_vfr_drop3_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, show_progress=False) + scene_list = sm.get_scene_list() + + assert len(scene_list) >= len(EXPECTED_SCENES_VFR_DROP3), ( + f"[{backend}] Expected at least {len(EXPECTED_SCENES_VFR_DROP3)} scenes, got {len(scene_list)}" + ) + for i, ((start, end), (exp_start_tc, exp_end_tc)) in enumerate( + zip(scene_list, EXPECTED_SCENES_VFR_DROP3, strict=False) + ): + assert start.get_timecode() == exp_start_tc, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start_tc!r}, got {start.get_timecode()!r}" + ) + assert end.get_timecode() == exp_end_tc, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end_tc!r}, got {end.get_timecode()!r}" + ) + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str): + """PTS-based position should be monotonically non-decreasing on synthetic VFR video.""" + video = open_video(test_vfr_drop3_video, backend=backend) + last_seconds = -1.0 + frame_count = 0 + while True: + if video.read() is False: + break + current = video.position.seconds + assert current >= last_seconds, ( + f"[{backend}] Position decreased at frame {frame_count}: {current} < {last_seconds}" + ) + last_seconds = current + frame_count += 1 + assert frame_count == 160 # 2/3 of original 240 frames in 10s at 24000/1001 + + +def test_cfr_position_is_timecode(test_movie_clip: str): + """CFR video positions should also be Timecode-backed with PTS support.""" + video = open_video(test_movie_clip, backend="pyav") + assert video.read() is not False + assert isinstance(video.position._time, Timecode) + + +def test_cfr_frame_num_exact(test_movie_clip: str): + """For CFR video, frame_num should be exact (not approximate).""" + video = open_video(test_movie_clip, backend="pyav") + for expected_frame in range(1, 11): + assert video.read() is not False + assert video.position.frame_num == expected_frame - 1 + + +def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path): + """OpenCV save-images thumbnails should match PyAV thumbnails for all scenes. + + If the OpenCV seek off-by-one bug is present, scene thumbnails will show content from the + wrong scene; MSE against PyAV (ground truth) will be very high for those scenes. + """ + # Run save-images for both backends with 1 image per scene for simplicity. + scene_lists = {} + for backend in ("pyav", "opencv"): + out_dir = tmp_path / backend + out_dir.mkdir() + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video) + scene_lists[backend] = sm.get_scene_list() + assert len(scene_lists[backend]) > 0 + save_images(scene_lists[backend], video, num_images=1, output_dir=str(out_dir)) + + pyav_imgs = sorted((tmp_path / "pyav").glob("*.jpg")) + opencv_imgs = sorted((tmp_path / "opencv").glob("*.jpg")) + assert len(pyav_imgs) > 0 + assert len(pyav_imgs) == len(opencv_imgs), ( + f"Image count mismatch: pyav={len(pyav_imgs)}, opencv={len(opencv_imgs)}" + ) + + # Compare every corresponding thumbnail. Wrong-scene content produces very high MSE. + MAX_MSE = 5000 + for pyav_path, opencv_path in zip(pyav_imgs, opencv_imgs, strict=False): + img_pyav = cv2.imread(str(pyav_path)) + img_opencv = cv2.imread(str(opencv_path)) + assert img_pyav is not None, f"Failed to load {pyav_path}" + assert img_opencv is not None, f"Failed to load {opencv_path}" + if img_pyav.shape != img_opencv.shape: + # Resize opencv image to match pyav dimensions before comparing. + img_opencv = cv2.resize(img_opencv, (img_pyav.shape[1], img_pyav.shape[0])) + mse = float(np.mean((img_pyav.astype(np.float32) - img_opencv.astype(np.float32)) ** 2)) + assert mse < MAX_MSE, ( + f"Thumbnail mismatch for {pyav_path.name} vs {opencv_path.name}: MSE={mse:.0f}" + ) + + +# ------------------------------------------------------------------ +# Output format tests +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path): + """CSV timecodes for VFR video should match known ground truth for both backends.""" + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + scene_list = sm.get_scene_list() + assert len(scene_list) >= len(EXPECTED_SCENES_VFR) + + csv_path = tmp_path / "scenes.csv" + with open(csv_path, "w", newline="") as f: + write_scene_list(f, scene_list, include_cut_list=False) + + with open(csv_path) as f: + rows = list(csv.DictReader(f)) + + for i, (row, (exp_start, exp_end)) in enumerate(zip(rows, EXPECTED_SCENES_VFR, strict=False)): + assert row["Start Timecode"] == exp_start, ( + f"[{backend}] Scene {i + 1} start: expected {exp_start!r}, got {row['Start Timecode']!r}" + ) + assert row["End Timecode"] == exp_end, ( + f"[{backend}] Scene {i + 1} end: expected {exp_end!r}, got {row['End Timecode']!r}" + ) + + +@pytest.mark.parametrize("backend", ["pyav", "opencv"]) +def test_vfr_otio_export(test_vfr_video: str, backend: str, tmp_path): + """OTIO export for VFR video should have no spurious float precision and correct timecodes. + + Regression test for the float precision bug where seconds * frame_rate could produce + values like 90.00000000000001 instead of 90.0 for CFR video. + """ + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-b", + backend, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-otio", + ] + ) + assert exit_code == 0 + + otio_path = next(tmp_path.glob("*.otio")) + data = json.loads(otio_path.read_text()) + frame_rate = data["global_start_time"]["rate"] + one_frame_secs = 1.0 / frame_rate + + clips = data["tracks"]["children"][0]["children"] + assert len(clips) >= len(EXPECTED_SCENES_VFR) + + for i, (clip, (exp_start_tc, exp_end_tc)) in enumerate( + zip(clips, EXPECTED_SCENES_VFR, strict=False) + ): + sr = clip["source_range"] + start_val = sr["start_time"]["value"] + dur_val = sr["duration"]["value"] + + # No spurious float precision: values should have at most 6 decimal places. + assert round(start_val, 6) == start_val, ( + f"[{backend}] Clip {i + 1} start_time.value has excess precision: {start_val!r}" + ) + assert round(dur_val, 6) == dur_val, ( + f"[{backend}] Clip {i + 1} duration.value has excess precision: {dur_val!r}" + ) + + # Values should round-trip to the expected timecodes within 1 frame. + start_secs = start_val / frame_rate + end_secs = (start_val + dur_val) / frame_rate + assert abs(start_secs - _tc_to_secs(exp_start_tc)) < one_frame_secs, ( + f"[{backend}] Clip {i + 1} start: {start_secs:.4f}s vs expected {exp_start_tc}" + ) + assert abs(end_secs - _tc_to_secs(exp_end_tc)) < one_frame_secs, ( + f"[{backend}] Clip {i + 1} end: {end_secs:.4f}s vs expected {exp_end_tc}" + ) + + +def test_vfr_edl_export(test_vfr_video: str, tmp_path): + """EDL export for VFR video should succeed and contain valid edit entries. + + EDL uses HH:MM:SS:FF frame counts at nominal fps, which is an approximation for VFR + content. This test only verifies structural correctness, not exact timecodes. + """ + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-edl", + ] + ) + assert exit_code == 0 + edl_path = next(tmp_path.glob("*.edl")) + content = edl_path.read_text() + assert "FCM: NON-DROP FRAME" in content + assert "001 AX V" in content + + +def test_vfr_csv_backend_conformance(test_vfr_video: str): + """PyAV and OpenCV should produce identical scene timecodes for VFR video. + + Only the known interior scenes are compared; the last scene's end time may vary slightly + between backends since it reflects the clip boundary rather than a detected cut. + """ + timecodes: ty.Dict[str, ty.List[ty.Tuple[str, str]]] = {} + for backend in ("pyav", "opencv"): + video = open_video(test_vfr_video, backend=backend) + sm = SceneManager() + sm.add_detector(ContentDetector()) + sm.detect_scenes(video=video, end_time=10.0) + timecodes[backend] = [(s.get_timecode(), e.get_timecode()) for s, e in sm.get_scene_list()] + # Compare only the known scenes (last scene's end varies by backend at the clip boundary). + n = len(EXPECTED_SCENES_VFR) + assert timecodes["pyav"][:n] == timecodes["opencv"][:n], ( + f"Backend timecode mismatch:\n pyav: {timecodes['pyav']}\n opencv: {timecodes['opencv']}" + ) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index acfb9ffd..535e2d5a 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -675,7 +675,9 @@ Although there have been minimal changes to most API examples, there are several ### CLI Changes -- [feature] [WIP] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters +- [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command ### API Changes @@ -723,5 +725,5 @@ Although there have been minimal changes to most API examples, there are several * Remove deprecated `AdaptiveDetector.get_content_val()` method (use `StatsManager` instead) * Remove deprecated `AdaptiveDetector` constructor arg `min_delta_hsv` (use `min_content_val` instead) * Remove `advance` parameter from `VideoStream.read()` - - + * Remove `SceneDetector.stats_manager_required` property, no longer required + * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) From fda8c580834fa6de8efe2d0ce270e9fceef2016f Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 17:19:02 -0400 Subject: [PATCH 292/407] Use time-based units instead of frame-based for default parameters (#532) [api] Make sure all values are temporal --- scenedetect.cfg | 3 +- scenedetect/_cli/__init__.py | 10 +- scenedetect/_cli/config.py | 2 +- scenedetect/detector.py | 35 +++++- scenedetect/detectors/adaptive_detector.py | 7 +- scenedetect/detectors/content_detector.py | 7 +- scenedetect/detectors/hash_detector.py | 7 +- scenedetect/detectors/histogram_detector.py | 12 +- scenedetect/detectors/threshold_detector.py | 7 +- scenedetect/detectors/transnet_v2.py | 2 +- scenedetect/output/image.py | 127 ++++++++------------ tests/test_detectors.py | 22 ++++ tests/test_output.py | 32 +++++ website/pages/changelog.md | 2 + 14 files changed, 170 insertions(+), 105 deletions(-) diff --git a/scenedetect.cfg b/scenedetect.cfg index fd6241cd..f0901cc0 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -227,7 +227,8 @@ # Compression amount for png images (0 to 9). Only affects size, not quality. #compression = 3 -# Number of frames to ignore around each scene cut when selecting frames. +# Padding around each scene cut when selecting frames. Accepts a number of frames (1), +# seconds with `s` suffix (0.1s), or timecode (00:00:00.100). #frame-margin = 1 # Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double). diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index a0c639d2..88dc9654 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1397,11 +1397,11 @@ def split_video_command( @click.option( "-m", "--frame-margin", - metavar="N", + metavar="DURATION", default=None, - type=click.INT, - help="Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s" - % (USER_CONFIG.get_help_string("save-images", "num-images")), + type=click.STRING, + help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).%s" + % (USER_CONFIG.get_help_string("save-images", "frame-margin")), ) @click.option( "--scale", @@ -1441,7 +1441,7 @@ def save_images_command( quality: ty.Optional[int] = None, png: bool = False, compression: ty.Optional[int] = None, - frame_margin: ty.Optional[int] = None, + frame_margin: ty.Optional[str] = None, scale: ty.Optional[float] = None, height: ty.Optional[int] = None, width: ty.Optional[int] = None, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index ee851da8..80fc082f 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -412,7 +412,7 @@ class XmlFormat(Enum): "compression": RangeValue(3, min_val=0, max_val=9), "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", "format": "jpeg", - "frame-margin": 1, + "frame-margin": TimecodeValue(1), "height": 0, "num-images": 3, "output": None, diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 7c3e1b70..8afd0f71 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -24,6 +24,7 @@ event (in, out, cut, etc...). """ +import math import typing as ty from abc import ABC, abstractmethod from enum import Enum @@ -114,15 +115,26 @@ class Mode(Enum): SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" - def __init__(self, mode: Mode, length: int): + def __init__(self, mode: Mode, length: ty.Union[int, float, str]): """ Arguments: mode: The mode to use when enforcing `length`. - length: Number of frames to use when filtering cuts. + length: Minimum scene length. Accepts an `int` (number of frames), `float` (seconds), + or `str` (timecode, e.g. ``"0.6s"`` or ``"00:00:00.600"``). """ self._mode = mode - self._filter_length = length # Number of frames to use for activating the filter. - self._filter_secs: ty.Optional[float] = None # Threshold in seconds, computed on first use. + # Frame count (int) and seconds (float) representations of `length`. Exactly one is + # populated up front; the other is computed on the first frame once the framerate is + # known. Temporal inputs (float/non-digit str) populate `_filter_secs`; integer inputs + # (int/digit str) populate `_filter_length`. + self._filter_length: int = 0 + self._filter_secs: ty.Optional[float] = None + if isinstance(length, float): + self._filter_secs = length + elif isinstance(length, str) and not length.strip().isdigit(): + self._filter_secs = FrameTimecode(timecode=length, fps=100.0).seconds + else: + self._filter_length = int(length) self._last_above = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. @@ -130,10 +142,21 @@ def __init__(self, mode: Mode, length: int): @property def max_behind(self) -> int: - return 0 if self._mode == FlashFilter.Mode.SUPPRESS else self._filter_length + if self._mode == FlashFilter.Mode.SUPPRESS: + return 0 + if self._filter_secs is not None: + # Estimate using 240fps so the event buffer is large enough for any reasonable input. + return math.ceil(self._filter_secs * 240.0) + return self._filter_length + + @property + def _is_disabled(self) -> bool: + if self._filter_secs is not None: + return self._filter_secs <= 0.0 + return self._filter_length <= 0 def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: - if not self._filter_length > 0: + if self._is_disabled: return [timecode] if above_threshold else [] if self._last_above is None: self._last_above = timecode diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 7a0a23af..f1917d77 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -38,7 +38,7 @@ class AdaptiveDetector(ContentDetector): def __init__( self, adaptive_threshold: float = 3.0, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, window_width: int = 2, min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, @@ -49,8 +49,9 @@ def __init__( Arguments: adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a new scene (see frame metric adaptive_ratio in stats file). - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). window_width: Size of window (number of frames) before and after each frame to average together in order to detect deviations from the mean. Must be at least 1. min_content_val: Minimum threshold (float) that the content_val must exceed in order to diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 6cf757fa..268233c3 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -104,7 +104,7 @@ class _FrameData: def __init__( self, threshold: float = 27.0, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: ty.Optional[int] = None, @@ -113,8 +113,9 @@ def __init__( """ Arguments: threshold: Threshold the average change in pixel intensity must exceed to trigger a cut. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). weights: Weight to place on each component when calculating frame score (`content_val` in a statsfile, the value `threshold` is compared against). luma_only: If True, only considers changes in the luminance channel of the video. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 484f49d5..af0994d1 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -41,8 +41,9 @@ class HashDetector(SceneDetector): size: Size of square of low frequency data to use for the DCT lowpass: How much high frequency information to filter from the DCT. A value of 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). """ def __init__( @@ -50,7 +51,7 @@ def __init__( threshold: float = 0.395, size: int = 16, lowpass: int = 2, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, ): super(HashDetector, self).__init__() self._threshold = threshold diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 812c5852..8502e1e5 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -30,7 +30,12 @@ class HistogramDetector(SceneDetector): METRIC_KEYS = ["hist_diff"] - def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int = 15): + def __init__( + self, + threshold: float = 0.05, + bins: int = 256, + min_scene_len: ty.Union[int, float, str] = 15, + ): """ Arguments: threshold: maximum relative difference between 0.0 and 1.0 that the histograms can @@ -38,8 +43,9 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int YUV, and normalized based on the number of bins. Higher dicfferences imply greater change in content, so larger threshold values are less sensitive to cuts. bins: Number of bins to use for the histogram. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). """ super().__init__() # Internally, threshold represents the correlation between two histograms and has values diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 8d28cd62..edb63024 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -48,7 +48,7 @@ class Method(Enum): def __init__( self, threshold: float = 12, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, fade_bias: float = 0.0, add_final_scene: bool = False, method: Method = Method.FLOOR, @@ -58,8 +58,9 @@ def __init__( Arguments: threshold: 8-bit intensity value that each pixel value (R, G, and B) must be <= to in order to trigger a fade in/out. - min_scene_len: Once a cut is detected, this many frames must pass before a new one can - be added to the scene list. Can be an int or FrameTimecode type. + min_scene_len: Once a cut is detected, this much time must pass before a new one can + be added to the scene list. Accepts an int (frames), float (seconds), or + str (e.g. ``"0.6s"``, ``"00:00:00.600"``). fade_bias: Float between -1.0 and +1.0 representing the percentage of timecode skew for the start of a scene (-1.0 causing a cut at the fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 752749cd..c559938e 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -135,7 +135,7 @@ def __init__( model_path: ty.Union[str, Path] = "tests/resources/transnetv2.onnx", onnx_providers: ty.Union[ty.List[str], None] = None, threshold: float = 0.5, - min_scene_len: int = 15, + min_scene_len: ty.Union[int, float, str] = 15, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): super().__init__() diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index d5cf00de..842e1460 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -34,6 +34,41 @@ logger = logging.getLogger("pyscenedetect") +def _generate_timecode_list( + scene_list: SceneList, + num_images: int, + frame_margin: ty.Union[int, float, str], +) -> ty.List[ty.List[FrameTimecode]]: + """Generate per-scene image timecodes using PTS-accurate seconds-based timing. + + `frame_margin` accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``). + """ + framerate = scene_list[0][0].framerate + margin_secs = FrameTimecode(timecode=frame_margin, fps=framerate).seconds + result = [] + for start, end in scene_list: + duration_secs = (end - start).seconds + if duration_secs <= 0: + result.append([start] * num_images) + continue + segment_secs = duration_secs / num_images + timecodes = [] + for j in range(num_images): + seg_start = start.seconds + j * segment_secs + seg_end = start.seconds + (j + 1) * segment_secs + if num_images == 1: + t = start.seconds + duration_secs / 2.0 + elif j == 0: + t = min(seg_start + margin_secs, seg_end) + elif j == num_images - 1: + t = max(seg_end - margin_secs, seg_start) + else: + t = (seg_start + seg_end) / 2.0 + timecodes.append(FrameTimecode(t, fps=framerate)) + result.append(timecodes) + return result + + def _scale_image( image: np.ndarray, aspect_ratio: float, @@ -69,7 +104,7 @@ class _ImageExtractor: def __init__( self, num_images: int = 3, - frame_margin: int = 1, + frame_margin: ty.Union[int, float, str] = 1, image_extension: str = "jpg", imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", @@ -85,10 +120,10 @@ def __init__( Arguments: num_images: Number of images to generate for each scene. Minimum is 1. - frame_margin: Number of frames to pad each scene around the beginning - and end (e.g. moves the first/last image into the scene by N frames). - Can set to 0, but will result in some video files failing to extract - the very last frame. + frame_margin: Padding around the beginning and end of each scene used when + selecting which frames to extract. Accepts an int (frames), float (seconds), + or str (e.g. ``"0.1s"``, ``"00:00:00.100"``). Can be 0, but some video files + may then fail to extract the very last frame. image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. @@ -296,31 +331,7 @@ def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.List[Frame Uses PTS-accurate seconds-based timing so results are correct for both CFR and VFR video. """ - framerate = scene_list[0][0].framerate - # Convert frame_margin to seconds using the nominal framerate. - margin_secs = self._frame_margin / framerate - result = [] - for start, end in scene_list: - duration_secs = (end - start).seconds - if duration_secs <= 0: - result.append([start] * self._num_images) - continue - segment_secs = duration_secs / self._num_images - timecodes = [] - for j in range(self._num_images): - seg_start = start.seconds + j * segment_secs - seg_end = start.seconds + (j + 1) * segment_secs - if self._num_images == 1: - t = start.seconds + duration_secs / 2.0 - elif j == 0: - t = min(seg_start + margin_secs, seg_end) - elif j == self._num_images - 1: - t = max(seg_end - margin_secs, seg_start) - else: - t = (seg_start + seg_end) / 2.0 - timecodes.append(FrameTimecode(t, fps=framerate)) - result.append(timecodes) - return result + return _generate_timecode_list(scene_list, self._num_images, self._frame_margin) def resize_image( self, @@ -336,7 +347,7 @@ def save_images( scene_list: SceneList, video: VideoStream, num_images: int = 3, - frame_margin: int = 1, + frame_margin: ty.Union[int, float, str] = 1, image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", @@ -357,10 +368,10 @@ def save_images( video: A VideoStream object corresponding to the scene list. Note that the video will be closed/re-opened and seeked through. num_images: Number of images to generate for each scene. Minimum is 1. - frame_margin: Number of frames to pad each scene around the beginning - and end (e.g. moves the first/last image into the scene by N frames). - Can set to 0, but will result in some video files failing to extract - the very last frame. + frame_margin: Padding around the beginning and end of each scene used when + selecting which frames to extract. Accepts an int (frames), float (seconds), + or str (e.g. ``"0.1s"``, ``"00:00:00.100"``). Can be 0, but some video files + may then fail to extract the very last frame. image_extension: Type of image to save (must be one of 'jpg', 'png', or 'webp'). encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. @@ -398,8 +409,10 @@ def save_images( if not scene_list: return {} - if num_images <= 0 or frame_margin < 0: - raise ValueError() + if num_images <= 0: + raise ValueError("num_images must be greater than 0") + if isinstance(frame_margin, (int, float)) and frame_margin < 0: + raise ValueError("frame_margin must be non-negative") # TODO: Validate that encoder_param is within the proper range. # Should be between 0 and 100 (inclusive) for jpg/webp, and 1-9 for png. @@ -440,45 +453,7 @@ def save_images( image_num_format = "%0" image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - framerate = scene_list[0][0]._rate - - # TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly. - timecode_list = [ - [ - FrameTimecode(int(f), fps=framerate) - for f in ( - # middle frames - a[len(a) // 2] - if (0 < j < num_images - 1) or num_images == 1 - # first frame - else min(a[0] + frame_margin, a[-1]) - if j == 0 - # last frame - else max(a[-1] - frame_margin, a[0]) - # for each evenly-split array of frames in the scene list - for j, a in enumerate(np.array_split(r, num_images)) - ) - ] - for i, r in enumerate( - [ - # pad ranges to number of images - r if 1 + r[-1] - r[0] >= num_images else list(r) + [r[-1]] * (num_images - len(r)) - # create range of frames in scene - for r in ( - range( - start.frame_num, - start.frame_num - + max( - 1, # guard against zero length scenes - end.frame_num - start.frame_num, - ), - ) - # for each scene in scene list - for start, end in scene_list - ) - ] - ) - ] + timecode_list = _generate_timecode_list(scene_list, num_images, frame_margin) image_filenames = {i: [] for i in range(len(timecode_list))} aspect_ratio = video.aspect_ratio diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 0e5f4214..8db13213 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -224,3 +224,25 @@ def test_detectors_with_stats(test_video_file): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len + + +@pytest.mark.parametrize("detector_type", FAST_CUT_DETECTORS) +@pytest.mark.parametrize( + "min_scene_len", + # 30 frames at goldeneye.mp4's 24000/1001 (~23.976) fps is ~1.2513s. All four forms should + # produce identical cut lists, demonstrating that detectors accept temporal as well as + # frame-count values. + [30, 1.25, "1.25s", "00:00:01.250"], +) +def test_min_scene_len_accepts_time_values(detector_type, min_scene_len): + """Detectors accept min_scene_len as int (frames), float (seconds), or str (timecode).""" + test_case = TestCase( + path=get_absolute_path("resources/goldeneye.mp4"), + detector=detector_type(min_scene_len=min_scene_len), + start_time=1199, + end_time=1450, + scene_boundaries=[1199, 1260, 1334, 1365], + ) + scene_list = test_case.detect() + start_frames = [timecode.frame_num for timecode, _ in scene_list] + assert start_frames == test_case.scene_boundaries diff --git a/tests/test_output.py b/tests/test_output.py index db3f2307..3936f5e8 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -161,6 +161,38 @@ def test_save_images_singlethreaded(test_video_file, tmp_path: Path): assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) +@pytest.mark.parametrize("frame_margin", [1, 0.1, "0.1s", "00:00:00.100"]) +def test_save_images_frame_margin_accepts_time_values( + test_video_file, tmp_path: Path, frame_margin +): + """save_images() should accept frame counts (int), seconds (float), and timecode strings.""" + video = VideoStreamCv2(test_video_file) + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300)] + ] + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template="scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER", + frame_margin=frame_margin, + ) + for paths in image_filenames.values(): + for path in paths: + assert tmp_path.joinpath(path).exists() + + +def test_save_images_rejects_negative_margin(test_video_file, tmp_path: Path): + video = VideoStreamCv2(test_video_file) + scene_list = [(FrameTimecode(0, video.frame_rate), FrameTimecode(10, video.frame_rate))] + with pytest.raises(ValueError): + save_images(scene_list=scene_list, output_dir=tmp_path, video=video, frame_margin=-1) + + # TODO: Test other functionality against zero width scenes. def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 535e2d5a..3f4fa857 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -677,6 +677,7 @@ Although there have been minimal changes to most API examples, there are several - [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters - [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command @@ -702,6 +703,7 @@ Although there have been minimal changes to most API examples, there are several * Remove `SceneDetector.is_processing_required()` method * Remove `SceneDetector.stats_manager_required` property, no longer required * Remove deprecated `SparseSceneDetector` interface + * Detector `min_scene_len` and `save_images()` `frame_margin` arguments now accept seconds (`float`) and timecode strings (e.g. `"0.6s"`, `"00:00:00.600"`) in addition to a frame count (`int`); these are evaluated using the source video's timing for correct behavior on VFR videos [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) **Module Reorganization:** From 129ec328cd71d803f78a0836e4d52677fdf46556 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 17:22:26 -0400 Subject: [PATCH 293/407] [build] Use package managers for ffmpeg install (#541) * [build] Use package managers for ffmpeg install * [build] Enable ffmpeg tests on ARM --- .github/actions/setup-ffmpeg/action.yml | 89 ++++++++++++++++--------- .github/workflows/build.yml | 3 - 2 files changed, 58 insertions(+), 34 deletions(-) diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml index 2bfb4f7e..fbf89347 100644 --- a/.github/actions/setup-ffmpeg/action.yml +++ b/.github/actions/setup-ffmpeg/action.yml @@ -1,41 +1,68 @@ name: 'Setup FFmpeg' +description: 'Ensure ffmpeg is available on the runner, using OS package managers as a fallback.' inputs: github-token: - required: true + description: 'Unused; kept for backward compatibility with existing callers.' + required: false + default: '' runs: using: 'composite' steps: - - name: Setup FFmpeg (latest) - id: latest - continue-on-error: true - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} + - name: Check for preinstalled ffmpeg + id: check + shell: bash + run: | + if command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg already available at: $(command -v ffmpeg)" + ffmpeg -version | head -n 1 + echo "installed=true" >> "$GITHUB_OUTPUT" + else + echo "ffmpeg not found on PATH; will install via package manager." + echo "installed=false" >> "$GITHUB_OUTPUT" + fi - - name: Setup FFmpeg (7.0.0) - if: ${{ steps.latest.outcome == 'failure' }} - id: v7-0-0 - continue-on-error: true - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "7.0.0" + - name: Install ffmpeg (Linux) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Linux' }} + shell: bash + run: | + for attempt in 1 2 3; do + echo "apt-get attempt $attempt" + if sudo apt-get update && sudo apt-get install -y ffmpeg; then + exit 0 + fi + sleep 10 + done + echo "Failed to install ffmpeg via apt-get after 3 attempts" >&2 + exit 1 - - name: Setup FFmpeg (6.1.1) - if: ${{ steps.v7-0-0.outcome == 'failure' }} - id: v6-1-1 - continue-on-error: true - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "6.1.1" + - name: Install ffmpeg (macOS) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'macOS' }} + shell: bash + run: | + for attempt in 1 2 3; do + echo "brew attempt $attempt" + if brew install ffmpeg; then + exit 0 + fi + sleep 10 + done + echo "Failed to install ffmpeg via brew after 3 attempts" >&2 + exit 1 - # The oldest version we allow falling back to must not have `continue-on-error: true` - - name: Setup FFmpeg (6.1.0) - if: ${{ steps.v6-1-1.outcome == 'failure' }} - id: v6-1-0 - uses: FedericoCarboni/setup-ffmpeg@v3 - with: - github-token: ${{ inputs.github-token }} - ffmpeg-version: "6.1.0" + - name: Install ffmpeg (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + shell: pwsh + run: | + for ($attempt = 1; $attempt -le 3; $attempt++) { + Write-Host "choco attempt $attempt" + choco install ffmpeg -y --no-progress + if ($LASTEXITCODE -eq 0) { exit 0 } + Start-Sleep -Seconds 10 + } + Write-Error "Failed to install ffmpeg via choco after 3 attempts" + exit 1 + + - name: Verify ffmpeg + shell: bash + run: ffmpeg -version | head -n 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 957dfce8..71ecf7ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,9 +36,6 @@ jobs: - uses: actions/checkout@v4 - name: Setup FFmpeg - # TODO: This action currently does not work for non-x64 builders (e.g. macos-14): - # https://github.com/federicocarboni/setup-ffmpeg/issues/21 - if: ${{ runner.arch == 'X64' }} uses: ./.github/actions/setup-ffmpeg with: github-token: ${{ secrets.GITHUB_TOKEN }} From e67b0a356adb2f61e47ee540e3c63654ce41cfcf Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Sun, 19 Apr 2026 22:56:28 -0400 Subject: [PATCH 294/407] [bugfix] Retry transient `OSError` exceptions with the MoviePy backend (#542) * [tests] Add deflake for #496 * [backends] Add retries for OSError in VideoStreamMoviePy #496 --- scenedetect/backends/moviepy.py | 40 ++++++++++++++++++++++++++++++--- tests/test_video_stream.py | 23 +++++++++---------- website/pages/changelog.md | 1 + 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 6624cdbe..8701c47d 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -16,6 +16,7 @@ image sequences or AviSynth scripts are supported as inputs. """ +import time import typing as ty from fractions import Fraction from logging import getLogger @@ -31,6 +32,33 @@ logger = getLogger("pyscenedetect") +# MoviePy spawns ffmpeg as a subprocess and reads frame bytes over stdout. Under +# load the parent can read before the child has flushed its first write, which +# surfaces as OSError (see #496). A short retry clears nearly all such flakes. +_FFMPEG_RETRY_COUNT = 2 +_FFMPEG_RETRY_BACKOFF_SECS = 0.5 + + +def _retry_on_oserror(op_name: str, fn: ty.Callable): + """Run ``fn``, retrying up to ``_FFMPEG_RETRY_COUNT`` times on ``OSError``.""" + last_exc: ty.Optional[OSError] = None + for attempt in range(_FFMPEG_RETRY_COUNT + 1): + try: + return fn() + except OSError as ex: + last_exc = ex + if attempt < _FFMPEG_RETRY_COUNT: + logger.warning( + "ffmpeg %s failed (attempt %d/%d), retrying: %s", + op_name, + attempt + 1, + _FFMPEG_RETRY_COUNT + 1, + ex, + ) + time.sleep(_FFMPEG_RETRY_BACKOFF_SECS) + assert last_exc is not None + raise last_exc + class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" @@ -63,7 +91,9 @@ def __init__( # cases return IOErrors (e.g. could not read duration/video resolution). These # should be mapped to specific errors, e.g. write a function to map MoviePy # exceptions to a new set of equivalents. - self._reader = FFMPEG_VideoReader(path, print_infos=print_infos) + self._reader = _retry_on_oserror( + "open", lambda: FFMPEG_VideoReader(path, print_infos=print_infos) + ) # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame # will always be the current frame, and self._reader.lastread will be the next. @@ -184,7 +214,9 @@ def seek(self, target: ty.Union[FrameTimecode, float, int]): if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) try: - self._last_frame = self._reader.get_frame(target.seconds) + self._last_frame = _retry_on_oserror( + "seek", lambda: self._reader.get_frame(target.seconds) + ) if hasattr(self._reader, "last_read") and target >= self.duration: raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") self._frame_number = min( @@ -212,7 +244,9 @@ def reset(self, print_infos=False): self._last_frame_rgb = None self._frame_number = 0 self._eof = False - self._reader = FFMPEG_VideoReader(self._path, print_infos=print_infos) + self._reader = _retry_on_oserror( + "reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) + ) def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: if not hasattr(self._reader, "lastread") or self._eof: diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 922be83d..115c16e4 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -121,20 +121,17 @@ def get_test_video_params() -> ty.List[VideoParameters]: ] +_VS_TYPES: list = [vs for vs in (VideoStreamCv2, VideoStreamAv) if vs is not None] +if VideoStreamMoviePy is not None: + _VS_TYPES.append( + pytest.param( + VideoStreamMoviePy, + marks=pytest.mark.flaky(reruns=3, reruns_delay=2, only_rerun=["OSError"]), + ) + ) + pytestmark = [ - pytest.mark.parametrize( - "vs_type", - list( - filter( - lambda x: x is not None, - [ - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - ], - ) - ), - ), + pytest.mark.parametrize("vs_type", _VS_TYPES), pytest.mark.filterwarnings(MOVIEPY_WARNING_FILTER), ] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 3f4fa857..e7d61ed0 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -679,6 +679,7 @@ Although there have been minimal changes to most API examples, there are several - [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision +- [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496) - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command ### API Changes From c0d7c3afbdc8dda108c7e4c93187f447025f82a8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 22 Apr 2026 22:46:48 -0400 Subject: [PATCH 295/407] [lint] Fix E731 warnings, add W rules, and un-exclude docs/ --- docs/generate_cli_docs.py | 27 ++++++++++++++++----------- pyproject.toml | 6 +----- scenedetect/output/video.py | 31 +++++++++++++++++-------------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index a1ba2eca..6c5bf13c 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -20,10 +20,10 @@ currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parentdir = os.path.dirname(currentdir) sys.path.insert(0, parentdir) -# Third-party imports -import click +# Third-party imports (must follow sys.path mutation above). +import click # noqa: E402 -from scenedetect._cli import scenedetect +from scenedetect._cli import scenedetect # noqa: E402 StrGenerator = ty.Generator[str, None, None] @@ -80,7 +80,7 @@ def patch_help(s: str, commands: ty.List[str]) -> str: for command in [command for command in commands if command not in INFO_COMMANDS]: - def add_link(_match: re.Match) -> str: + def add_link(_match: re.Match, command: str = command) -> str: return ":ref:`%s `" % (command, command) s = re.sub("``%s``(?!\\n)" % command, add_link, s) @@ -116,7 +116,7 @@ def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: def _add_backquotes(s: re.Match) -> str: to_add: str = s.string[s.start() : s.end()] - flag = re.search("-+[\w-]+[^\.\=\s\/]*", to_add) + flag = re.search(r"-+[\w-]+[^\.\=\s\/]*", to_add) if flag is not None and flag.string[flag.start() : flag.end()] in refs: # add cross reference cross_ref = flag.string[flag.start() : flag.end()] @@ -129,7 +129,7 @@ def _add_backquotes(s: re.Match) -> str: def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: - default = re.search("\[default: .*\]", s) + default = re.search(r"\[default: .*\]", s) if default is not None: span = default.span() assert span[1] == len(s) @@ -145,11 +145,11 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. # -c/--command - s = re.sub("-\w/--\w[\w-]*", transform, s) + s = re.sub(r"-\w/--\w[\w-]*", transform, s) # --arg=value, --arg=1.2.3, --arg=1,2,3 - s = re.sub('-+[\w-]+=[^"\s\)]+(? ty.Tuple[str, ty.List[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) commands: ty.List[str] = ctx.command.list_commands(ctx) - commands = list(filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands)) + commands = list( + filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands) + ) # ctx.to_info_dict lacks metavar so we have to use the context directly. actions = [ generate_title("``scenedetect`` 🎬 Command", level=0), @@ -268,7 +270,10 @@ def create_help() -> ty.Tuple[str, ty.List[str]]: def main(): help, commands = create_help() help = patch_help(help, commands) - help = ".. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified.\n" + help + help = ( + ".. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified.\n" + + help + ) with open("docs/cli.rst", "wb") as f: f.write(help.encode()) diff --git a/pyproject.toml b/pyproject.toml index 8186012b..9ec220e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,6 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.ruff] -exclude = [ - "docs" -] line-length = 100 indent-width = 4 @@ -31,6 +28,7 @@ select = [ "B", # pycodestyle "E", + "W", # Pyflakes "F", # isort @@ -46,8 +44,6 @@ ignore = [ "F401", # TODO: Line too long "E501", - # TODO: Do not assign a `lambda` expression, use a `def` - "E731", ] fixable = ["ALL"] unfixable = [] diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 07b01c1b..a503db6b 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -129,20 +129,23 @@ def default_formatter(template: str) -> PathFormatter: `$START_PTS`, `$END_PTS` (presentation timestamp in milliseconds, accurate for VFR video) """ MIN_DIGITS = 3 - format_scene_number: PathFormatter = lambda video, scene: ( - ("%0" + str(max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1)) + "d") - % (scene.index + 1) - ) - formatter: PathFormatter = lambda video, scene: Template(template).safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=format_scene_number(video, scene), - START_TIME=str(scene.start.get_timecode().replace(":", ";")), - END_TIME=str(scene.end.get_timecode().replace(":", ";")), - START_FRAME=str(scene.start.frame_num), - END_FRAME=str(scene.end.frame_num), - START_PTS=str(round(scene.start.seconds * 1000)), - END_PTS=str(round(scene.end.seconds * 1000)), - ) + + def format_scene_number(video: VideoMetadata, scene: SceneMetadata) -> str: + width = max(MIN_DIGITS, math.floor(math.log(video.total_scenes, 10)) + 1) + return ("%0" + str(width) + "d") % (scene.index + 1) + + def formatter(video: VideoMetadata, scene: SceneMetadata) -> str: + return Template(template).safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=format_scene_number(video, scene), + START_TIME=str(scene.start.get_timecode().replace(":", ";")), + END_TIME=str(scene.end.get_timecode().replace(":", ";")), + START_FRAME=str(scene.start.frame_num), + END_FRAME=str(scene.end.frame_num), + START_PTS=str(round(scene.start.seconds * 1000)), + END_PTS=str(round(scene.end.seconds * 1000)), + ) + return formatter From 7fce8414ae1cfb921cb7086c49c7e9e0648696cb Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Wed, 22 Apr 2026 22:49:58 -0400 Subject: [PATCH 296/407] Add New Output Methods to Python API + Finalize FCPX Support (#543) * [cli] Finalize `save-xml` for release Tested FCPX and FCP7 formats on DaVinci Resolve. * [api] Add EDL, FCP7/X, and OTIO formats to scenedetect.output Rename save-xml -> save-fcp. * [docs] Regenerate CLI docs --- docs/api/migration_guide.rst | 2 +- docs/api/output.rst | 8 + docs/cli.rst | 40 +++- scenedetect.cfg | 14 ++ scenedetect/_cli/__init__.py | 28 +-- scenedetect/_cli/commands.py | 348 +++++------------------------ scenedetect/_cli/config.py | 14 +- scenedetect/output/__init__.py | 389 +++++++++++++++++++++++++++++++++ tests/test_cli.py | 109 +++++++++ tests/test_output.py | 214 ++++++++++++++++++ tests/test_vfr.py | 26 +++ website/pages/changelog.md | 3 +- 12 files changed, 874 insertions(+), 321 deletions(-) diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index a3ae9fa7..e361a3a6 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -203,4 +203,4 @@ CLI Changes - The ``-d``/``--min-delta-hsv`` option on ``detect-adaptive`` has been removed. Use ``-c``/``--min-content-val`` instead. - VFR videos now work correctly with both the OpenCV and PyAV backends. -- New ``save-xml`` command for exporting scenes in Final Cut Pro XML format. +- New ``save-fcp`` command for exporting scenes in Final Cut Pro XML format. diff --git a/docs/api/output.rst b/docs/api/output.rst index 480e4371..8b594b9b 100644 --- a/docs/api/output.rst +++ b/docs/api/output.rst @@ -20,6 +20,14 @@ Ouptut .. autofunction:: scenedetect.output.write_scene_list +.. autofunction:: scenedetect.output.write_scene_list_edl + +.. autofunction:: scenedetect.output.write_scene_list_fcpx + +.. autofunction:: scenedetect.output.write_scene_list_fcp7 + +.. autofunction:: scenedetect.output.write_scene_list_otio + .. autoclass:: scenedetect.output.SceneMetadata .. autoclass:: scenedetect.output.VideoMetadata diff --git a/docs/cli.rst b/docs/cli.rst index 6df3d9f7..cca81f67 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -178,8 +178,6 @@ Options Default: ``15.0`` - - .. option:: -f VAL, --frame-window VAL Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean. @@ -552,6 +550,38 @@ Options Output directory to save EDL file to. Overrides global option :option:`-o/--output `. +.. _command-save-fcp: + +.. program:: scenedetect save-fcp + + +``save-fcp`` +======================================================================== + +Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX). + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME.xml`` + +.. option:: --format TYPE + + Format to export. TYPE must be one of: fcpx, fcp7. + + Default: ``FcpFormat.FCPX`` + +.. option:: -o DIR, --output DIR + + Output directory to save XML file to. Overrides global option :option:`-o/--output `. + + .. _command-save-html: .. program:: scenedetect save-html @@ -658,11 +688,11 @@ Options Default: ``3`` -.. option:: -m N, --frame-margin N +.. option:: -m DURATION, --frame-margin DURATION - Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries. + Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100). - Default: ``3`` + Default: ``1`` .. option:: -s S, --scale S diff --git a/scenedetect.cfg b/scenedetect.cfg index f0901cc0..a18189e5 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -345,6 +345,20 @@ #disable-shift = no +[save-fcp] + +# Filename format of XML file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME.xml + +# Format of the XML file. Must be one of: +# - fcpx: Final Cut Pro X (FCPXML, default) +# - fcp7: Final Cut Pro 7 (xmeml) +#format = fcpx + +# Folder to output XML file to. Overrides [global] output option. +#output = /usr/tmp/images + + # # BACKEND OPTIONS # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 88dc9654..59479bcd 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1614,27 +1614,27 @@ def save_qp_command( ctx.add_command(cli_commands.save_qp, save_qp_args) -SAVE_XML_HELP = """[IN DEVELOPMENT] Save cuts in XML format.""" +SAVE_FCP_HELP = """Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX).""" -@click.command("save-xml", cls=Command, help=SAVE_XML_HELP, hidden=True) +@click.command("save-fcp", cls=Command, help=SAVE_FCP_HELP) @click.option( "--filename", "-f", metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-xml", "filename")), + help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-fcp", "filename")), ) @click.option( "--format", metavar="TYPE", - type=click.Choice(CHOICE_MAP["save-xml"]["format"], False), + type=click.Choice(CHOICE_MAP["save-fcp"]["format"], False), default=None, help="Format to export. TYPE must be one of: %s.%s" % ( - ", ".join(CHOICE_MAP["save-xml"]["format"]), - USER_CONFIG.get_help_string("save-xml", "format"), + ", ".join(CHOICE_MAP["save-fcp"]["format"]), + USER_CONFIG.get_help_string("save-fcp", "format"), ), ) @click.option( @@ -1643,10 +1643,10 @@ def save_qp_command( metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), help="Output directory to save XML file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-xml", "output", show_default=False)), + % (USER_CONFIG.get_help_string("save-fcp", "output", show_default=False)), ) @click.pass_context -def save_xml_command( +def save_fcp_command( ctx: click.Context, filename: ty.Optional[ty.AnyStr], format: ty.Optional[ty.AnyStr], @@ -1655,12 +1655,12 @@ def save_xml_command( ctx = ctx.obj assert isinstance(ctx, CliContext) - save_xml_args = { - "filename": ctx.config.get_value("save-xml", "filename", filename), - "format": ctx.config.get_value("save-xml", "format", format), - "output": ctx.config.get_value("save-xml", "output", output), + save_fcp_args = { + "filename": ctx.config.get_value("save-fcp", "filename", filename), + "format": ctx.config.get_value("save-fcp", "format", format), + "output": ctx.config.get_value("save-fcp", "output", output), } - ctx.add_command(cli_commands.save_xml, save_xml_args) + ctx.add_command(cli_commands.save_fcp, save_fcp_args) SAVE_OTIO_HELP = """Save cuts as an OTIO timeline. @@ -1757,7 +1757,7 @@ def save_otio_command( scenedetect.add_command(save_html_command) scenedetect.add_command(save_images_command) scenedetect.add_command(save_qp_command) -scenedetect.add_command(save_xml_command) +scenedetect.add_command(save_fcp_command) scenedetect.add_command(save_otio_command) scenedetect.add_command(split_video_command) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 0003f30e..730ff277 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -15,27 +15,23 @@ current command-line context, as well as the processing result (scenes and cuts). """ -import json import logging -import os.path import typing as ty import webbrowser -from datetime import datetime -from pathlib import Path from string import Template -from xml.dom import minidom -from xml.etree import ElementTree -import scenedetect -from scenedetect._cli.config import XmlFormat +from scenedetect._cli.config import FcpFormat from scenedetect._cli.context import CliContext -from scenedetect.common import FrameTimecode from scenedetect.output import save_images as save_images_impl from scenedetect.output import ( split_video_ffmpeg, split_video_mkvmerge, write_scene_list, + write_scene_list_edl, + write_scene_list_fcp7, + write_scene_list_fcpx, write_scene_list_html, + write_scene_list_otio, ) from scenedetect.platform import get_and_create_path from scenedetect.scene_manager import ( @@ -272,224 +268,63 @@ def save_edl( reel: str, ): """Handles the `save-edl` command. Outputs in CMX 3600 format.""" - # We only use scene information. - del cuts - - # Converts FrameTimecode to HH:MM:SS:FF - # TODO: This should be part of the FrameTimecode object itself. - def get_edl_timecode(timecode: FrameTimecode): - total_seconds = timecode.seconds - hours = int(total_seconds // 3600) - minutes = int((total_seconds % 3600) // 60) - seconds = int(total_seconds % 60) - frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) - return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" - - edl_content = [] - - title = Template(title).safe_substitute(VIDEO_NAME=context.video_stream.name) - edl_content.append(f"TITLE: {title}") - edl_content.append("FCM: NON-DROP FRAME") - edl_content.append("") - - # Add each shot as an edit entry - for i, (start, end) in enumerate(scenes): - in_tc = get_edl_timecode(start) - out_tc = get_edl_timecode(end) # Correct for presentation time - # Format the edit entry according to CMX 3600 format - event_line = f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}" - edl_content.append(event_line) - - edl_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), - output, - ) - logger.info(f"Writing scenes in EDL format to {edl_path}") - with open(edl_path, "w") as f: - f.write(f"* CREATED WITH PYSCENEDETECT {scenedetect.__version__}\n") - f.write("\n".join(edl_content)) - f.write("\n") - - -def _save_xml_fcpx( - context: CliContext, - scenes: SceneList, - filename: str, - output: str, -): - """Saves scenes in Final Cut Pro X XML format.""" - ASSET_ID = "asset1" - FORMAT_ID = "format1" - # TODO: Need to handle other video formats! - VIDEO_FORMAT_TODO_HANDLE_OTHERS = "FFVideoFormat1080p24" - - root = ElementTree.Element("fcpxml", version="1.9") - resources = ElementTree.SubElement(root, "resources") - ElementTree.SubElement(resources, "format", id="format1", name=VIDEO_FORMAT_TODO_HANDLE_OTHERS) - + del cuts # We only use scene information. video_name = context.video_stream.name - - # TODO: We should calculate duration from the scene list. - duration = context.video_stream.duration - duration = str(duration.seconds) + "s" # TODO: Is float okay here? - path = Path(context.video_stream.path).absolute() - ElementTree.SubElement( - resources, - "asset", - id=ASSET_ID, - name=video_name, - src=str(path), - duration=duration, - hasVideo="1", - hasAudio="1", # TODO: Handle case of no audio. - format=FORMAT_ID, - ) - - library = ElementTree.SubElement(root, "library") - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - event = ElementTree.SubElement(library, "event", name=f"Shot Detection {now}") - project = ElementTree.SubElement( - event, "project", name=video_name - ) # TODO: Allow customizing project name. - sequence = ElementTree.SubElement(project, "sequence", format=FORMAT_ID, duration=duration) - spine = ElementTree.SubElement(sequence, "spine") - - for i, (start, end) in enumerate(scenes): - start_seconds = start.seconds - duration_seconds = (end - start).seconds - clip = ElementTree.SubElement( - spine, - "clip", - name=f"Shot {i + 1}", - duration=f"{duration_seconds:.3f}s", - start=f"{start_seconds:.3f}s", - offset=f"{start_seconds:.3f}s", - ) - ElementTree.SubElement( - clip, - "asset-clip", - ref=ASSET_ID, - duration=f"{duration_seconds:.3f}s", - start=f"{start_seconds:.3f}s", - offset="0s", - name=f"Shot {i + 1}", - ) - - pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( - indent=" " - ) - xml_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + edl_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), output, ) - logger.info(f"Writing scenes in FCPX format to {xml_path}") - with open(xml_path, "w") as f: - f.write(pretty_xml) - - -def _save_xml_fcp( - context: CliContext, - scenes: SceneList, - filename: str, - output: str, -): - """Saves scenes in Final Cut Pro 7 XML format.""" - assert scenes - root = ElementTree.Element("xmeml", version="5") - project = ElementTree.SubElement(root, "project") - ElementTree.SubElement(project, "name").text = context.video_stream.name - sequence = ElementTree.SubElement(project, "sequence") - ElementTree.SubElement(sequence, "name").text = context.video_stream.name - - fps = float(context.video_stream.frame_rate) - ntsc = "True" if context.video_stream.frame_rate.denominator != 1 else "False" - duration = scenes[-1][1] - scenes[0][0] - ElementTree.SubElement(sequence, "duration").text = str(round(duration.seconds * fps)) - - rate = ElementTree.SubElement(sequence, "rate") - ElementTree.SubElement(rate, "timebase").text = str(round(fps)) - ElementTree.SubElement(rate, "ntsc").text = ntsc - - timecode = ElementTree.SubElement(sequence, "timecode") - tc_rate = ElementTree.SubElement(timecode, "rate") - ElementTree.SubElement(tc_rate, "timebase").text = str(round(fps)) - ElementTree.SubElement(tc_rate, "ntsc").text = ntsc - ElementTree.SubElement(timecode, "frame").text = "0" - ElementTree.SubElement(timecode, "displayformat").text = "NDF" - - media = ElementTree.SubElement(sequence, "media") - video = ElementTree.SubElement(media, "video") - format = ElementTree.SubElement(video, "format") - ElementTree.SubElement(format, "samplecharacteristics") - track = ElementTree.SubElement(video, "track") - - # Add clips for each shot boundary - for i, (start, end) in enumerate(scenes): - clip = ElementTree.SubElement(track, "clipitem") - ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" - ElementTree.SubElement(clip, "enabled").text = "TRUE" - ElementTree.SubElement(clip, "rate").append( - ElementTree.fromstring(f"{round(fps)}") - ) - # Frame numbers relative to the declared fps, computed from PTS seconds. - ElementTree.SubElement(clip, "start").text = str(round(start.seconds * fps)) - ElementTree.SubElement(clip, "end").text = str(round(end.seconds * fps)) - ElementTree.SubElement(clip, "in").text = str(round(start.seconds * fps)) - ElementTree.SubElement(clip, "out").text = str(round(end.seconds * fps)) - - file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}") - ElementTree.SubElement(file_ref, "name").text = context.video_stream.name - path = Path(context.video_stream.path).absolute() - # TODO: Can we just use path.as_uri() here? - # On Windows this should be: file://localhost/C:/Users/... according to the samples provided - # from https://github.com/Breakthrough/PySceneDetect/issues/156#issuecomment-1076213412. - ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}" - - media_ref = ElementTree.SubElement(file_ref, "media") - video_ref = ElementTree.SubElement(media_ref, "video") - ElementTree.SubElement(video_ref, "samplecharacteristics") - link = ElementTree.SubElement(clip, "link") - ElementTree.SubElement(link, "linkclipref").text = f"file{i + 1}" - ElementTree.SubElement(link, "mediatype").text = "video" - - pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( - indent=" " - ) - xml_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), - output, + write_scene_list_edl( + output_path=edl_path, + scene_list=scenes, + title=Template(title).safe_substitute(VIDEO_NAME=video_name), + reel=reel, ) - logger.info(f"Writing scenes in FCP format to {xml_path}") - with open(xml_path, "w") as f: - f.write(pretty_xml) -def save_xml( +def save_fcp( context: CliContext, scenes: SceneList, cuts: CutList, filename: str, - format: XmlFormat, + format: FcpFormat, output: str, ): - """Handles the `save-xml` command.""" - # We only use scene information. - del cuts - + """Handles the `save-fcp` command.""" + del cuts # We only use scene information. if not scenes: return - if format == XmlFormat.FCPX: - _save_xml_fcpx(context, scenes, filename, output) - elif format == XmlFormat.FCP: - _save_xml_fcp(context, scenes, filename, output) + video_stream = context.video_stream + video_name = str(video_stream.name) + video_path = str(video_stream.path) + xml_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), + output, + ) + if format == FcpFormat.FCPX: + write_scene_list_fcpx( + output_path=xml_path, + scene_list=scenes, + video_path=video_path, + frame_rate=video_stream.frame_rate, + frame_size=video_stream.frame_size, + video_name=video_name, + ) + elif format == FcpFormat.FCP7: + write_scene_list_fcp7( + output_path=xml_path, + scene_list=scenes, + video_path=video_path, + frame_rate=video_stream.frame_rate, + frame_size=video_stream.frame_size, + video_name=video_name, + source_duration=video_stream.duration, + ) else: logger.error(f"Unknown format: {format}") -# TODO: We have to export framerate as a float for OTIO's current format. When OTIO supports -# fractional timecodes, we should export the framerate as a rational number instead. -# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190 def save_otio( context: CliContext, scenes: SceneList, @@ -499,92 +334,19 @@ def save_otio( name: str, audio: bool, ): - """Saves scenes in OTIO format.""" - + """Handles the `save-otio` command.""" del cuts # We only use scene information - - video_name = context.video_stream.name - video_path = os.path.abspath(context.video_stream.path) - video_base_name = os.path.basename(context.video_stream.path) - frame_rate = float(context.video_stream.frame_rate) - - # List of track mapping to resource type. - # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. - track_list = {"Video 1": "Video"} - if audio: - track_list["Audio 1"] = "Audio" - - otio = { - "OTIO_SCHEMA": "Timeline.1", - "name": Template(name).safe_substitute(VIDEO_NAME=video_name), - "global_start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": 0.0, - }, - "tracks": { - "OTIO_SCHEMA": "Stack.1", - "enabled": True, - "children": [ - { - "OTIO_SCHEMA": "Track.1", - "name": track_name, - "enabled": True, - "children": [ - { - "OTIO_SCHEMA": "Clip.2", - "name": video_base_name, - "source_range": { - "OTIO_SCHEMA": "TimeRange.1", - "duration": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": round((end - start).seconds * frame_rate, 6), - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": round(start.seconds * frame_rate, 6), - }, - }, - "enabled": True, - "media_references": { - "DEFAULT_MEDIA": { - "OTIO_SCHEMA": "ExternalReference.1", - "name": video_base_name, - "available_range": { - "OTIO_SCHEMA": "TimeRange.1", - "duration": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": 1980.0, - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": frame_rate, - "value": 0.0, - }, - }, - "available_image_bounds": None, - "target_url": video_path, - } - }, - "active_media_reference_key": "DEFAULT_MEDIA", - } - for (start, end) in scenes - ], - "kind": track_type, - } - for (track_name, track_type) in track_list.items() - ], - }, - } - + video_stream = context.video_stream + video_name = str(video_stream.name) otio_path = get_and_create_path( - Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), + Template(filename).safe_substitute(VIDEO_NAME=video_name), output, ) - logger.info(f"Writing scenes in OTIO format to {otio_path}") - with open(otio_path, "w") as f: - json.dump(otio, f, indent=4) - f.write("\n") + write_scene_list_otio( + output_path=otio_path, + scene_list=scenes, + video_path=str(video_stream.path), + frame_rate=video_stream.frame_rate, + name=Template(name).safe_substitute(VIDEO_NAME=video_name), + audio=audio, + ) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 80fc082f..1bf8dad0 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -305,12 +305,12 @@ def format(self, timecode: FrameTimecode) -> str: raise RuntimeError("Unhandled format specifier.") -class XmlFormat(Enum): - """Format to use with the `save-xml` command.""" +class FcpFormat(Enum): + """Format to use with the `save-fcp` command.""" FCPX = 0 """Final Cut Pro X XML Format""" - FCP = 1 + FCP7 = 1 """Final Cut Pro 7 XML Format""" @@ -433,8 +433,8 @@ class XmlFormat(Enum): "filename": "$VIDEO_NAME.qp", "output": None, }, - "save-xml": { - "format": XmlFormat.FCPX, + "save-fcp": { + "format": FcpFormat.FCPX, "filename": "$VIDEO_NAME.xml", "output": None, }, @@ -480,8 +480,8 @@ class XmlFormat(Enum): "format": ["jpeg", "png", "webp"], "scale-method": [value.name.lower() for value in Interpolation], }, - "save-xml": { - "format": [value.name.lower() for value in XmlFormat], + "save-fcp": { + "format": [value.name.lower() for value in FcpFormat], }, "split-video": { "preset": [ diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 3acd48f8..b913933f 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -16,8 +16,13 @@ """ import csv +import json import logging import typing as ty +from fractions import Fraction +from pathlib import Path +from xml.dom import minidom +from xml.etree import ElementTree from scenedetect._thirdparty.simpletable import ( HTMLPage, @@ -28,6 +33,7 @@ ) from scenedetect.common import ( CutList, + FrameTimecode, SceneList, ) @@ -233,3 +239,386 @@ def write_scene_list_html( page.add_table(scene_table) page.css = css page.save(output_html_filename) + + +def _edl_timecode(timecode: FrameTimecode) -> str: + """Format `timecode` as ``HH:MM:SS:FF`` for a CMX 3600 EDL entry.""" + total_seconds = timecode.seconds + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" + + +def write_scene_list_edl( + output_path: ty.Union[str, Path], + scene_list: SceneList, + title: str = "PySceneDetect", + reel: str = "AX", +): + """Writes the given list of scenes to `output_path` in CMX 3600 EDL format. + + Arguments: + output_path: Path to write the EDL file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end. + title: Title header written as ``TITLE:`` in the EDL. + reel: Reel name used for each event. Typically 2-8 uppercase characters. + """ + output_path = Path(output_path) + lines = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""] + for i, (start, end) in enumerate(scene_list): + in_tc = _edl_timecode(start) + out_tc = _edl_timecode(end) + lines.append(f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}") + logger.info("Writing scenes in EDL format to %s", output_path) + with open(output_path, "w") as f: + # `scenedetect` is imported lazily to avoid a circular import at module load. + import scenedetect + + f.write(f"* CREATED WITH PYSCENEDETECT {scenedetect.__version__}\n") + f.write("\n".join(lines)) + f.write("\n") + + +def _rational_seconds(value: Fraction) -> str: + """Format a `Fraction` as an FCPXML rational time string. + + FCPXML expresses time as ``/s`` (or ``s`` for whole seconds). See + https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference + """ + if value.denominator == 1: + return f"{value.numerator}s" + return f"{value.numerator}/{value.denominator}s" + + +def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: + """Exact seconds for `tc` as a `Fraction`, derived from PTS × time base.""" + return Fraction(tc.pts) * tc.time_base + + +def write_scene_list_fcpx( + output_path: ty.Union[str, Path], + scene_list: SceneList, + video_path: ty.Union[str, Path], + frame_rate: Fraction, + frame_size: ty.Tuple[int, int], + video_name: ty.Optional[str] = None, +): + """Writes the given list of scenes to `output_path` in Final Cut Pro X XML format (FCPXML 1.9). + + The output follows Apple's FCPXML schema with rational-second time values and a custom + ```` derived from the source video's frame rate and resolution. See + https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference + + Arguments: + output_path: Path to write the FCPXML file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes. Must not be empty. + video_path: Path to the source video file; written into the output as a ``file://`` URI. + frame_rate: Source frame rate as a rational `Fraction` (e.g. ``Fraction(24000, 1001)``). + frame_size: Source resolution as a ``(width, height)`` tuple in pixels. + video_name: Display name used for the asset, project, and event. Defaults to the stem + of `video_path`. + """ + assert scene_list + output_path = Path(output_path) + video_path = Path(video_path) + if video_name is None: + video_name = video_path.stem + + ASSET_ID = "r2" + FORMAT_ID = "r1" + + width, height = frame_size + frame_duration = _rational_seconds(Fraction(frame_rate.denominator, frame_rate.numerator)) + src_uri = video_path.absolute().as_uri() + total_duration = _rational_seconds( + _frame_timecode_seconds(scene_list[-1][1] - scene_list[0][0]) + ) + + root = ElementTree.Element("fcpxml", version="1.9") + resources = ElementTree.SubElement(root, "resources") + # `name` is cosmetic: Apple publishes no authoritative FFVideoFormat* list, and editors key + # off frameDuration/width/height. We emit a generated name for display only. + format_name = f"FFVideoFormat{height}p{round(float(frame_rate) * 100):04d}" + ElementTree.SubElement( + resources, + "format", + id=FORMAT_ID, + name=format_name, + frameDuration=frame_duration, + width=str(width), + height=str(height), + ) + asset = ElementTree.SubElement( + resources, + "asset", + id=ASSET_ID, + name=video_name, + start="0s", + duration=total_duration, + hasVideo="1", + format=FORMAT_ID, + ) + ElementTree.SubElement(asset, "media-rep", kind="original-media", src=src_uri) + + library = ElementTree.SubElement(root, "library") + event = ElementTree.SubElement(library, "event", name=video_name) + project = ElementTree.SubElement(event, "project", name=video_name) + sequence = ElementTree.SubElement( + project, + "sequence", + format=FORMAT_ID, + duration=total_duration, + tcStart="0s", + tcFormat="NDF", + ) + spine = ElementTree.SubElement(sequence, "spine") + + for i, (start, end) in enumerate(scene_list): + scene_start = _rational_seconds(_frame_timecode_seconds(start)) + scene_duration = _rational_seconds(_frame_timecode_seconds(end - start)) + ElementTree.SubElement( + spine, + "asset-clip", + name=f"Shot {i + 1}", + ref=ASSET_ID, + offset=scene_start, + start=scene_start, + duration=scene_duration, + ) + + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( + indent=" " + ) + logger.info("Writing scenes in FCPX format to %s", output_path) + with open(output_path, "w") as f: + f.write(pretty_xml) + + +def write_scene_list_fcp7( + output_path: ty.Union[str, Path], + scene_list: SceneList, + video_path: ty.Union[str, Path], + frame_rate: Fraction, + frame_size: ty.Tuple[int, int], + video_name: ty.Optional[str] = None, + source_duration: ty.Optional[FrameTimecode] = None, +): + """Writes the given list of scenes to `output_path` in Final Cut Pro 7 XML (xmeml) format. + + See the xmeml element reference at + https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/. + ``pathurl`` is written as a valid ``file://`` URI per the xmeml spec. + + Arguments: + output_path: Path to write the xmeml file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes. Must not be empty. + video_path: Path to the source video file; written into the output as a ``file://`` URI. + frame_rate: Source frame rate as a rational `Fraction`. + frame_size: Source resolution as a ``(width, height)`` tuple in pixels. + video_name: Display name used for project and sequence. Defaults to the stem of + `video_path`. + source_duration: Total duration of the source media. Required on ```` so NLEs + (DaVinci Resolve, Premiere) can seek into the source — without it the clip plays + frozen. If None, falls back to the last scene's end time. + """ + assert scene_list + output_path = Path(output_path) + video_path = Path(video_path) + if video_name is None: + video_name = video_path.stem + + root = ElementTree.Element("xmeml", version="5") + project = ElementTree.SubElement(root, "project") + ElementTree.SubElement(project, "name").text = video_name + sequence = ElementTree.SubElement(project, "sequence") + ElementTree.SubElement(sequence, "name").text = video_name + + fps = float(frame_rate) + ntsc = "True" if frame_rate.denominator != 1 else "False" + duration = scene_list[-1][1] - scene_list[0][0] + ElementTree.SubElement(sequence, "duration").text = str(round(duration.seconds * fps)) + + rate = ElementTree.SubElement(sequence, "rate") + ElementTree.SubElement(rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(rate, "ntsc").text = ntsc + + timecode = ElementTree.SubElement(sequence, "timecode") + tc_rate = ElementTree.SubElement(timecode, "rate") + ElementTree.SubElement(tc_rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(tc_rate, "ntsc").text = ntsc + ElementTree.SubElement(timecode, "frame").text = "0" + ElementTree.SubElement(timecode, "displayformat").text = "NDF" + + width, height = frame_size + media = ElementTree.SubElement(sequence, "media") + video = ElementTree.SubElement(media, "video") + format = ElementTree.SubElement(video, "format") + sample_chars = ElementTree.SubElement(format, "samplecharacteristics") + ElementTree.SubElement(sample_chars, "width").text = str(width) + ElementTree.SubElement(sample_chars, "height").text = str(height) + track = ElementTree.SubElement(video, "track") + + path_uri = video_path.absolute().as_uri() + source_duration_frames = str( + round( + (source_duration.seconds if source_duration is not None else scene_list[-1][1].seconds) + * fps + ) + ) + FILE_ID = "file1" + + for i, (start, end) in enumerate(scene_list): + clip = ElementTree.SubElement(track, "clipitem") + ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" + ElementTree.SubElement(clip, "enabled").text = "TRUE" + ElementTree.SubElement(clip, "duration").text = source_duration_frames + clip_rate = ElementTree.SubElement(clip, "rate") + ElementTree.SubElement(clip_rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(clip_rate, "ntsc").text = ntsc + # Frame numbers relative to the declared fps, computed from PTS seconds. + ElementTree.SubElement(clip, "start").text = str(round(start.seconds * fps)) + ElementTree.SubElement(clip, "end").text = str(round(end.seconds * fps)) + ElementTree.SubElement(clip, "in").text = str(round(start.seconds * fps)) + ElementTree.SubElement(clip, "out").text = str(round(end.seconds * fps)) + + # xmeml allows a single full `` declaration reused via `` on + # subsequent clipitems. Emit full details on the first, then self-close on the rest. + if i == 0: + file_ref = ElementTree.SubElement(clip, "file", id=FILE_ID) + ElementTree.SubElement(file_ref, "name").text = video_name + ElementTree.SubElement(file_ref, "pathurl").text = path_uri + ElementTree.SubElement(file_ref, "duration").text = source_duration_frames + file_rate = ElementTree.SubElement(file_ref, "rate") + ElementTree.SubElement(file_rate, "timebase").text = str(round(fps)) + ElementTree.SubElement(file_rate, "ntsc").text = ntsc + media_ref = ElementTree.SubElement(file_ref, "media") + video_ref = ElementTree.SubElement(media_ref, "video") + clip_chars = ElementTree.SubElement(video_ref, "samplecharacteristics") + ElementTree.SubElement(clip_chars, "width").text = str(width) + ElementTree.SubElement(clip_chars, "height").text = str(height) + else: + ElementTree.SubElement(clip, "file", id=FILE_ID) + + link = ElementTree.SubElement(clip, "link") + ElementTree.SubElement(link, "linkclipref").text = FILE_ID + ElementTree.SubElement(link, "mediatype").text = "video" + + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( + indent=" " + ) + logger.info("Writing scenes in FCP format to %s", output_path) + with open(output_path, "w") as f: + f.write(pretty_xml) + + +# TODO: We have to export framerate as a float for OTIO's current format. When OTIO supports +# fractional timecodes, we should export the framerate as a rational number instead. +# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190 +def write_scene_list_otio( + output_path: ty.Union[str, Path], + scene_list: SceneList, + video_path: ty.Union[str, Path], + frame_rate: Fraction, + name: ty.Optional[str] = None, + audio: bool = True, +): + """Writes the given list of scenes to `output_path` as an OTIO Timeline.1 JSON document. + + OTIO (OpenTimelineIO) timelines can be imported by many video editors. + + Arguments: + output_path: Path to write the OTIO file to. Parent directories must exist. + scene_list: List of scenes as pairs of FrameTimecodes. + video_path: Path to the source video file; written into the output as an absolute path. + frame_rate: Source frame rate as a rational `Fraction`. Exported as a float, as the + current OTIO format does not support rational timings. + name: Timeline name. Defaults to the stem of `video_path`. + audio: If True (default), include an audio track alongside the video track. + """ + output_path = Path(output_path) + video_path = Path(video_path) + if name is None: + name = video_path.stem + + video_base_name = video_path.name + video_abs_path = str(video_path.absolute()) + fps = float(frame_rate) + + # List of track mapping to resource type. + # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. + track_list = {"Video 1": "Video"} + if audio: + track_list["Audio 1"] = "Audio" + + otio = { + "OTIO_SCHEMA": "Timeline.1", + "name": name, + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 0.0, + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "enabled": True, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "name": track_name, + "enabled": True, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "name": video_base_name, + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": round((end - start).seconds * fps, 6), + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": round(start.seconds * fps, 6), + }, + }, + "enabled": True, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "name": video_base_name, + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 1980.0, + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 0.0, + }, + }, + "available_image_bounds": None, + "target_url": video_abs_path, + } + }, + "active_media_reference_key": "DEFAULT_MEDIA", + } + for (start, end) in scene_list + ], + "kind": track_type, + } + for (track_name, track_type) in track_list.items() + ], + }, + } + + logger.info("Writing scenes in OTIO format to %s", output_path) + with open(output_path, "w") as f: + json.dump(otio, f, indent=4) + f.write("\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3f77e91f..78841978 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1160,3 +1160,112 @@ def test_cli_save_otio_no_audio(tmp_path: Path): assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace( "{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\") ) + + +def test_cli_save_fcp_fcpx(tmp_path: Path): + """Test `save-fcp --format fcpx` produces a valid FCPXML 1.9 file.""" + from xml.etree import ElementTree + + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-fcp", + ] + ) + assert exit_code == 0 + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.xml") + assert os.path.exists(output_path) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "fcpxml" + assert root.attrib["version"] == "1.9" + + # Format carries the rational frameDuration derived from the video's 24000/1001 fps. + fmt = root.find("resources/format") + assert fmt is not None + assert fmt.attrib["frameDuration"] == "1001/24000s" + assert fmt.attrib["width"] == "1280" + assert fmt.attrib["height"] == "544" + + # Asset references the source video via a file:// URI. + media_rep = root.find("resources/asset/media-rep") + assert media_rep is not None + assert media_rep.attrib["src"].startswith("file://") + assert media_rep.attrib["src"].endswith("goldeneye.mp4") + + # Spine contains one `` per scene (not wrapped in ``). + asset_clips = root.findall("library/event/project/sequence/spine/asset-clip") + assert len(asset_clips) == 2 + # All clip time attributes are rational strings ending in "s". + for clip in asset_clips: + for attr in ("offset", "start", "duration"): + assert clip.attrib[attr].endswith("s") + + +def test_cli_save_fcp_fcp7(tmp_path: Path): + """Test `save-fcp --format fcp7` produces a valid FCP7 xmeml file.""" + from xml.etree import ElementTree + + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-fcp", + "--format", + "fcp7", + ] + ) + assert exit_code == 0 + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.xml") + assert os.path.exists(output_path) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "xmeml" + assert root.attrib["version"] == "5" + + # NTSC flag is True for the 23.976 test video. + ntsc = root.find("project/sequence/rate/ntsc") + assert ntsc is not None and ntsc.text == "True" + + # samplecharacteristics carry width/height so Premiere/DaVinci can ingest. + width = root.find("project/sequence/media/video/format/samplecharacteristics/width") + height = root.find("project/sequence/media/video/format/samplecharacteristics/height") + assert width is not None and width.text == "1280" + assert height is not None and height.text == "544" + + # Two clipitems produced; first carries the full block, rest reference it by id. + clipitems = root.findall("project/sequence/media/video/track/clipitem") + assert len(clipitems) == 2 + + first_file = clipitems[0].find("file") + assert first_file is not None + assert first_file.attrib["id"] == "file1" + pathurl = first_file.find("pathurl") + assert pathurl is not None and pathurl.text is not None + assert pathurl.text.startswith("file://") + assert pathurl.text.endswith("goldeneye.mp4") + # Source duration is required for NLEs to seek into the media. + assert first_file.find("duration") is not None + + # Subsequent clipitems reference the same file id without redeclaring. + second_file = clipitems[1].find("file") + assert second_file is not None + assert second_file.attrib["id"] == "file1" + assert second_file.find("pathurl") is None diff --git a/tests/test_output.py b/tests/test_output.py index 3936f5e8..d3efe182 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -11,7 +11,10 @@ # """Tests for scenedetect.output module.""" +import json +from fractions import Fraction from pathlib import Path +from xml.etree import ElementTree import pytest @@ -28,6 +31,10 @@ VideoMetadata, is_ffmpeg_available, split_video_ffmpeg, + write_scene_list_edl, + write_scene_list_fcp7, + write_scene_list_fcpx, + write_scene_list_otio, ) FFMPEG_ARGS = ( @@ -223,3 +230,210 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): total_images += 1 assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) + + +# +# Scene-list export API (EDL / FCPXML / FCP7 xmeml / OTIO) +# +# These tests construct small synthetic scene lists so they do not require video +# decoding and stay fast. They assert the structural invariants each format must +# hold (e.g. rational time strings for FCPXML, `file://` URIs for xmeml, OTIO +# Clip.2 count matching scene count). + +_FPS_NTSC = Fraction(24000, 1001) +_FPS_CFR = Fraction(30, 1) + + +def _fake_scenes(fps: Fraction, frames): + return [(FrameTimecode(start, fps=fps), FrameTimecode(end, fps=fps)) for start, end in frames] + + +def test_write_scene_list_edl(tmp_path: Path): + """EDL output has title header, FCM line, and one event per scene in CMX 3600 format.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, title="my-clip", reel="AX") + + content = output_path.read_text() + assert "TITLE: my-clip" in content + assert "FCM: NON-DROP FRAME" in content + assert "001 AX V C 00:00:00:00 00:00:01:00 00:00:00:00 00:00:01:00" in content + assert "002 AX V C 00:00:01:00 00:00:02:00 00:00:01:00 00:00:02:00" in content + + +def test_write_scene_list_edl_accepts_str_path(tmp_path: Path): + """`output_path` must accept both Path and str.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(str(output_path), scenes) + assert output_path.exists() + + +def test_write_scene_list_fcpx(tmp_path: Path): + """FCPXML output declares version 1.9, rational time strings, and an asset-clip per scene.""" + scenes = _fake_scenes(_FPS_NTSC, [(48, 96), (96, 144)]) + output_path = tmp_path / "scenes.xml" + # `video_path` need not exist; only `.absolute().as_uri()` is called on it. + write_scene_list_fcpx( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "fake_video.mp4", + frame_rate=_FPS_NTSC, + frame_size=(1280, 544), + ) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "fcpxml" + assert root.attrib["version"] == "1.9" + + fmt = root.find("resources/format") + assert fmt is not None + # 24000/1001 fps → frameDuration is the reciprocal: 1001/24000s. + assert fmt.attrib["frameDuration"] == "1001/24000s" + assert fmt.attrib["width"] == "1280" + assert fmt.attrib["height"] == "544" + + media_rep = root.find("resources/asset/media-rep") + assert media_rep is not None + assert media_rep.attrib["src"].startswith("file://") + + clips = root.findall("library/event/project/sequence/spine/asset-clip") + assert len(clips) == 2 + for clip in clips: + for attr in ("offset", "start", "duration"): + assert clip.attrib[attr].endswith("s") + + +def test_write_scene_list_fcpx_video_name_defaults_to_path_stem(tmp_path: Path): + """Omitting `video_name` falls back to the stem of `video_path`.""" + scenes = _fake_scenes(_FPS_NTSC, [(0, 24)]) + output_path = tmp_path / "scenes.xml" + write_scene_list_fcpx( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "my_clip.mp4", + frame_rate=_FPS_NTSC, + frame_size=(640, 360), + ) + root = ElementTree.parse(output_path).getroot() + asset = root.find("resources/asset") + assert asset is not None and asset.attrib["name"] == "my_clip" + + +def test_write_scene_list_fcp7(tmp_path: Path): + """FCP7 xmeml declares version 5, a clipitem per scene, and a shared reference.""" + scenes = _fake_scenes(_FPS_NTSC, [(0, 48), (48, 96)]) + output_path = tmp_path / "scenes.xml" + write_scene_list_fcp7( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "source.mp4", + frame_rate=_FPS_NTSC, + frame_size=(1920, 1080), + source_duration=FrameTimecode(240, fps=_FPS_NTSC), + ) + + root = ElementTree.parse(output_path).getroot() + assert root.tag == "xmeml" + assert root.attrib["version"] == "5" + + ntsc = root.find("project/sequence/rate/ntsc") + assert ntsc is not None and ntsc.text == "True" + + clipitems = root.findall("project/sequence/media/video/track/clipitem") + assert len(clipitems) == 2 + # First clipitem carries the full declaration; later ones reference it by id. + first_file = clipitems[0].find("file") + assert first_file is not None and first_file.attrib["id"] == "file1" + pathurl = first_file.find("pathurl") + assert pathurl is not None and pathurl.text is not None + assert pathurl.text.startswith("file://") + assert first_file.find("duration") is not None + second_file = clipitems[1].find("file") + assert second_file is not None and second_file.attrib["id"] == "file1" + assert second_file.find("pathurl") is None + + +def test_write_scene_list_fcp7_cfr_sets_ntsc_false(tmp_path: Path): + """Integer frame rates (denominator == 1) must set ntsc="False".""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.xml" + write_scene_list_fcp7( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "source.mp4", + frame_rate=_FPS_CFR, + frame_size=(640, 360), + ) + root = ElementTree.parse(output_path).getroot() + ntsc = root.find("project/sequence/rate/ntsc") + assert ntsc is not None and ntsc.text == "False" + + +def test_write_scene_list_otio(tmp_path: Path): + """OTIO output is valid JSON with a Timeline.1 schema and one Clip.2 per scene per track.""" + scenes = _fake_scenes(_FPS_NTSC, [(24, 72), (72, 120)]) + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "clip.mp4", + frame_rate=_FPS_NTSC, + name="my-timeline", + ) + + doc = json.loads(output_path.read_text()) + assert doc["OTIO_SCHEMA"] == "Timeline.1" + assert doc["name"] == "my-timeline" + assert doc["global_start_time"]["rate"] == pytest.approx(float(_FPS_NTSC)) + + tracks = doc["tracks"]["children"] + # Default `audio=True` yields both a video and an audio track. + assert [t["kind"] for t in tracks] == ["Video", "Audio"] + for track in tracks: + assert len(track["children"]) == len(scenes) + for clip in track["children"]: + assert clip["OTIO_SCHEMA"] == "Clip.2" + ref = clip["media_references"]["DEFAULT_MEDIA"] + assert ref["OTIO_SCHEMA"] == "ExternalReference.1" + assert Path(ref["target_url"]).is_absolute() + + +def test_write_scene_list_otio_no_audio(tmp_path: Path): + """`audio=False` omits the audio track.""" + scenes = _fake_scenes(_FPS_NTSC, [(0, 24)]) + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "clip.mp4", + frame_rate=_FPS_NTSC, + audio=False, + ) + doc = json.loads(output_path.read_text()) + tracks = doc["tracks"]["children"] + assert [t["kind"] for t in tracks] == ["Video"] + + +def test_write_scene_list_otio_rational_time_precision(tmp_path: Path): + """Serialized frame-count values must be free of sub-10µs float drift (cf. 914ca31).""" + # Frames on integer-frame boundaries under NTSC 24000/1001: seconds * 23.976... + # should land on integers but floats can produce values like 214.00001 without + # the explicit round(..., 6) in the writer. + scenes = _fake_scenes( + _FPS_NTSC, + [(start, start + 24) for start in (0, 24, 48, 96, 120)], + ) + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path=output_path, + scene_list=scenes, + video_path=tmp_path / "clip.mp4", + frame_rate=_FPS_NTSC, + ) + doc = json.loads(output_path.read_text()) + for track in doc["tracks"]["children"]: + for clip in track["children"]: + for key in ("start_time", "duration"): + value = clip["source_range"][key]["value"] + assert value == round(value, 6), f"value {value!r} carries sub-10µs float drift" diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 09aab163..5b0ad57f 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -381,6 +381,32 @@ def test_vfr_edl_export(test_vfr_video: str, tmp_path): assert "001 AX V" in content +@pytest.mark.parametrize("fcp_format", ["fcpx", "fcp7"]) +def test_vfr_fcp_export(test_vfr_video: str, fcp_format: str, tmp_path): + """`save-fcp` should succeed on VFR video and produce well-formed output in either dialect.""" + from xml.etree import ElementTree + + exit_code, _ = invoke_cli( + [ + "-i", + test_vfr_video, + "-o", + str(tmp_path), + "detect-content", + "time", + "--end", + "10s", + "save-fcp", + "--format", + fcp_format, + ] + ) + assert exit_code == 0 + xml_path = next(tmp_path.glob("*.xml")) + root = ElementTree.parse(xml_path).getroot() + assert root.tag == ("fcpxml" if fcp_format == "fcpx" else "xmeml") + + def test_vfr_csv_backend_conformance(test_vfr_video: str): """PyAV and OpenCV should produce identical scene timecodes for VFR video. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e7d61ed0..6f6acf7f 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -676,7 +676,7 @@ Although there have been minimal changes to most API examples, there are several ### CLI Changes - [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters -- [feature] New `save-xml` command supports saving scenes in Final Cut Pro formats [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) +- [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496) @@ -686,6 +686,7 @@ Although there have been minimal changes to most API examples, there are several **VFR & Timestamp Overhaul:** + * Add `write_scene_list_edl`, `write_scene_list_fcpx`, `write_scene_list_fcp7`, and `write_scene_list_otio` to the `scenedetect.output` module so `save-edl`, `save-fcp`, and `save-otio` can be invoked directly from Python (previously CLI-only) * Add new `Timecode` type to represent frame timings in terms of the video's source timebase * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` From d4fc135bf745a01221b37b4742d8e3d4cfb33b60 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 23 Apr 2026 23:45:23 -0400 Subject: [PATCH 297/407] [actions] Fix ffmpeg install flakiness --- .github/actions/setup-ffmpeg/action.yml | 72 ++++++++++++++++++++----- .github/workflows/build.yml | 4 ++ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml index fbf89347..76cee703 100644 --- a/.github/actions/setup-ffmpeg/action.yml +++ b/.github/actions/setup-ffmpeg/action.yml @@ -9,6 +9,13 @@ inputs: runs: using: 'composite' steps: + - name: Set ffmpeg install config + shell: bash + run: | + VERSION=8.1 + echo "FFMPEG_VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "FFMPEG_ASSET=ffmpeg-${VERSION}-essentials_build.zip" >> "$GITHUB_ENV" + - name: Check for preinstalled ffmpeg id: check shell: bash @@ -26,42 +33,79 @@ runs: if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Linux' }} shell: bash run: | - for attempt in 1 2 3; do + attempts=4 + for attempt in $(seq 1 $attempts); do echo "apt-get attempt $attempt" if sudo apt-get update && sudo apt-get install -y ffmpeg; then exit 0 fi - sleep 10 + if [ "$attempt" -lt "$attempts" ]; then + delay=$(( 15 * (1 << (attempt - 1)) + RANDOM % 6 )) + echo "install failed; sleeping ${delay}s before retry" + sleep "$delay" + fi done - echo "Failed to install ffmpeg via apt-get after 3 attempts" >&2 + echo "Failed to install ffmpeg via apt-get after $attempts attempts" >&2 exit 1 - name: Install ffmpeg (macOS) if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'macOS' }} shell: bash run: | - for attempt in 1 2 3; do + attempts=4 + for attempt in $(seq 1 $attempts); do echo "brew attempt $attempt" if brew install ffmpeg; then exit 0 fi - sleep 10 + if [ "$attempt" -lt "$attempts" ]; then + delay=$(( 15 * (1 << (attempt - 1)) + RANDOM % 6 )) + echo "install failed; sleeping ${delay}s before retry" + sleep "$delay" + fi done - echo "Failed to install ffmpeg via brew after 3 attempts" >&2 + echo "Failed to install ffmpeg via brew after $attempts attempts" >&2 exit 1 - - name: Install ffmpeg (Windows) + - name: Cache ffmpeg (Windows) if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + id: cache-ffmpeg-windows + uses: actions/cache@v4 + with: + path: C:\ffmpeg-bin + key: ffmpeg-windows-${{ env.FFMPEG_VERSION }}-v1 + + - name: Install ffmpeg (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' && steps.cache-ffmpeg-windows.outputs.cache-hit != 'true' }} shell: pwsh + env: + GH_TOKEN: ${{ github.token }} run: | - for ($attempt = 1; $attempt -le 3; $attempt++) { - Write-Host "choco attempt $attempt" - choco install ffmpeg -y --no-progress - if ($LASTEXITCODE -eq 0) { exit 0 } - Start-Sleep -Seconds 10 + $attempts = 4 + $downloaded = $false + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + Write-Host "download attempt $attempt (GyanD/codexffmpeg $env:FFMPEG_VERSION)" + gh release download $env:FFMPEG_VERSION --repo GyanD/codexffmpeg --pattern $env:FFMPEG_ASSET --clobber + if ($LASTEXITCODE -eq 0) { $downloaded = $true; break } + if ($attempt -lt $attempts) { + $delay = [int]([math]::Pow(2, $attempt - 1) * 15) + (Get-Random -Minimum 0 -Maximum 6) + Write-Host "download failed; sleeping ${delay}s before retry" + Start-Sleep -Seconds $delay + } } - Write-Error "Failed to install ffmpeg via choco after 3 attempts" - exit 1 + if (-not $downloaded) { + Write-Error "Failed to download ffmpeg after $attempts attempts" + exit 1 + } + Expand-Archive -Path $env:FFMPEG_ASSET -DestinationPath ffmpeg-extract -Force + New-Item -ItemType Directory -Force -Path C:\ffmpeg-bin | Out-Null + Copy-Item -Path (Join-Path "ffmpeg-extract" "ffmpeg-$env:FFMPEG_VERSION-essentials_build\bin\*.exe") -Destination C:\ffmpeg-bin\ + Remove-Item -Recurse -Force ffmpeg-extract, $env:FFMPEG_ASSET + + - name: Add ffmpeg to PATH (Windows) + if: ${{ steps.check.outputs.installed == 'false' && runner.os == 'Windows' }} + shell: pwsh + run: Add-Content -Path $env:GITHUB_PATH -Value 'C:\ffmpeg-bin' - name: Verify ffmpeg shell: bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71ecf7ce..a8ce7bdb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,11 +9,15 @@ on: - dist/** - scenedetect/** - tests/** + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** push: paths: - dist/** - scenedetect/** - tests/** + - .github/workflows/build.yml + - .github/actions/setup-ffmpeg/** branches: - main - 'releases/**' From 1abaa25d8222aa79b2896d7a292638a442928aae Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 18:54:45 -0400 Subject: [PATCH 298/407] [cli] Fix incorrect deprecation warning for save-html #518 --- scenedetect/_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 59479bcd..74ec70b1 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1028,7 +1028,7 @@ def save_html_command( image_height: ty.Optional[int], show: bool, ): - if ctx.command.name == "save-html": + if ctx.info_name == "export-html": logger.warning("WARNING: export-html is deprecated, use save-html instead.") ctx = ctx.obj assert isinstance(ctx, CliContext) From b8c43bbe9da0a53497478171b27cd90a8daa52bd Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:04:42 -0400 Subject: [PATCH 299/407] [project] Suppress some pyright warnings since many 3p libs don't have stubs --- pyproject.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9ec220e7..e2a9fa51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,18 @@ ignore = [ ] fixable = ["ALL"] unfixable = [] + +[tool.pyright] +include = ["scenedetect", "tests"] +typeCheckingMode = "basic" +# cv2, av, and moviepy ship without type stubs; these reports generate +# unactionable noise without catching real issues in this codebase. +reportMissingTypeStubs = "none" +reportUnknownMemberType = "none" +reportUnknownArgumentType = "none" +reportUnknownVariableType = "none" +reportUnknownParameterType = "none" +reportMissingParameterType = "none" +reportMissingTypeArgument = "none" +reportUntypedFunctionDecorator = "none" +reportPrivateUsage = "none" From 77838573465464931de426c986f2a658c8dfd78b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:15:13 -0400 Subject: [PATCH 300/407] [lint] Add ruff UP rules and migrate all type hints There are some legitimate issues this has surfaced but they are NOT addressed in this commit. This purely changes type hints and string formatting so it should be a no-op. --- benchmark/__main__.py | 2 +- docs/conf.py | 2 +- docs/generate_cli_docs.py | 54 +-- pyproject.toml | 8 +- scenedetect/__init__.py | 8 +- scenedetect/_cli/__init__.py | 494 +++++++++++--------- scenedetect/_cli/commands.py | 13 +- scenedetect/_cli/config.py | 83 ++-- scenedetect/_cli/context.py | 113 ++--- scenedetect/_cli/controller.py | 4 +- scenedetect/_thirdparty/simpletable.py | 9 +- scenedetect/backends/__init__.py | 2 +- scenedetect/backends/moviepy.py | 20 +- scenedetect/backends/opencv.py | 36 +- scenedetect/backends/pyav.py | 26 +- scenedetect/common.py | 22 +- scenedetect/detector.py | 22 +- scenedetect/detectors/adaptive_detector.py | 14 +- scenedetect/detectors/content_detector.py | 16 +- scenedetect/detectors/hash_detector.py | 6 +- scenedetect/detectors/histogram_detector.py | 6 +- scenedetect/detectors/threshold_detector.py | 12 +- scenedetect/detectors/transnet_v2.py | 16 +- scenedetect/output/__init__.py | 64 +-- scenedetect/output/image.py | 44 +- scenedetect/output/video.py | 27 +- scenedetect/platform.py | 20 +- scenedetect/scene_manager.py | 34 +- scenedetect/stats_manager.py | 24 +- scenedetect/video_stream.py | 12 +- tests/conftest.py | 5 +- tests/helpers.py | 2 +- tests/test_api.py | 14 +- tests/test_cli.py | 13 +- tests/test_detectors.py | 15 +- tests/test_scene_manager.py | 2 +- tests/test_vfr.py | 8 +- tests/test_video_stream.py | 33 +- 38 files changed, 672 insertions(+), 633 deletions(-) diff --git a/benchmark/__main__.py b/benchmark/__main__.py index cb92c3ed..91169637 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -115,7 +115,7 @@ def create_parser(): return parser -def run_all_benchmarks(detector: ty.Optional[str], dataset: ty.Optional[str], detailed: bool): +def run_all_benchmarks(detector: str | None, dataset: str | None, detailed: bool): detectors = {detector: _DETECTORS[detector]} if detector else _DETECTORS datasets = {dataset: _DATASETS[dataset]} if dataset else _DATASETS print( diff --git a/docs/conf.py b/docs/conf.py index 72ade37d..a7935b56 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -149,7 +149,7 @@ html_theme = "alabaster" html_theme_options = { "sidebar_width": "235px", - "description": "Version: [%s]" % (release), + "description": f"Version: [{release}]", "show_relbar_bottom": True, "show_relbar_top": False, "github_user": "Breakthrough", diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index 6c5bf13c..f26ae114 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -67,7 +67,7 @@ """ -def patch_help(s: str, commands: ty.List[str]) -> str: +def patch_help(s: str, commands: list[str]) -> str: # Patch some TODOs still not handled correctly below. pos = 0 while True: @@ -81,9 +81,9 @@ def patch_help(s: str, commands: ty.List[str]) -> str: for command in [command for command in commands if command not in INFO_COMMANDS]: def add_link(_match: re.Match, command: str = command) -> str: - return ":ref:`%s `" % (command, command) + return f":ref:`{command} `" - s = re.sub("``%s``(?!\\n)" % command, add_link, s) + s = re.sub(f"``{command}``(?!\\n)", add_link, s) return s @@ -97,7 +97,7 @@ def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator: @dataclass class ReplaceWithReference: - range: ty.Tuple[int, int] + range: tuple[int, int] ref: str ref_type: str @@ -107,10 +107,10 @@ def transform_backquotes(s: str) -> str: def add_backquotes(match: re.Match) -> str: - return "``%s``" % match.string[match.start() : match.end()] + return f"``{match.string[match.start() : match.end()]}``" -def add_backquotes_with_refs(refs: ty.Set[str]) -> ty.Callable[[str], str]: +def add_backquotes_with_refs(refs: set[str]) -> ty.Callable[[str], str]: """Returns a transformation function that backquotes command examples, adding backquotes and references to any found options.""" @@ -121,14 +121,14 @@ def _add_backquotes(s: re.Match) -> str: # add cross reference cross_ref = flag.string[flag.start() : flag.end()] option = s.string[s.start() : s.end()] - return ":option:`%s <%s>`" % (option, cross_ref) + return f":option:`{option} <{cross_ref}>`" else: return add_backquotes(s) return _add_backquotes -def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: +def extract_default_value(s: str) -> tuple[str, str | None]: default = re.search(r"\[default: .*\]", s) if default is not None: span = default.span() @@ -136,11 +136,11 @@ def extract_default_value(s: str) -> ty.Tuple[str, ty.Optional[str]]: s, default = s[: span[0]].strip(), s[span[0] : span[1]][len("[default: ") : -1] # Double-quote any default values that contain spaces. if " " in default and '"' not in default and "," not in default: - default = '"%s"' % default + default = f'"{default}"' return (s, default) -def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: +def transform_add_option_refs(s: str, refs: list[str]) -> str: transform = add_backquotes_with_refs(refs) # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option. # Replace patch to complete this. @@ -153,13 +153,15 @@ def transform_add_option_refs(s: str, refs: ty.List[str]) -> str: return s -def format_option(command: click.Command, opt: click.Option, flags: ty.List[str]) -> StrGenerator: +def format_option(command: click.Command, opt: click.Option, flags: list[str]) -> StrGenerator: if isinstance(opt, click.Argument): - yield "\n.. option:: %s\n" % opt.name + yield f"\n.. option:: {opt.name}\n" return - yield "\n.. option:: %s\n" % ", ".join( - arg if opt.metavar is None else "%s %s" % (arg, opt.metavar) - for arg in sorted(opt.opts, reverse=True) + yield "\n.. option:: {}\n".format( + ", ".join( + arg if opt.metavar is None else f"{arg} {opt.metavar}" + for arg in sorted(opt.opts, reverse=True) + ) ) help = ( @@ -172,23 +174,23 @@ def format_option(command: click.Command, opt: click.Option, flags: ty.List[str] help, default = extract_default_value(help) help = transform_add_option_refs(help, flags) - yield "\n %s\n" % help + yield f"\n {help}\n" if default is not None: - yield "\n Default: ``%s``\n" % default + yield f"\n Default: ``{default}``\n" def generate_command_help( - ctx: click.Context, command: click.Command, parent_name: ty.Optional[str] = None + ctx: click.Context, command: click.Command, parent_name: str | None = None ) -> StrGenerator: # TODO: Add references to long options. Requires splitting out examples. # TODO: Add references to subcommands. Need to add actual refs, since programs can't be ref'd. # TODO: Handle dollar signs in examples by having both escaped and unescaped versions - yield "\n.. _command-%s:\n" % command.name + yield f"\n.. _command-{command.name}:\n" yield "\n.. program:: %s\n\n" % ( - command.name if parent_name is None else "%s %s" % (parent_name, command.name) + command.name if parent_name is None else f"{parent_name} {command.name}" ) if parent_name: - yield from generate_title("``%s``" % command.name, 1) + yield from generate_title(f"``{command.name}``", 1) replacements = [ opt @@ -209,9 +211,9 @@ def generate_command_help( if line.startswith(INDENT): indent = line.count(INDENT) line = line.strip() - yield "%s``%s``\n" % (indent * INDENT, line) if line else "\n" + yield f"{indent * INDENT}``{line}``\n" if line else "\n" else: - yield "%s\n" % line + yield f"{line}\n" if command.params: yield "\n" @@ -221,7 +223,7 @@ def generate_command_help( yield "\n" -def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGenerator: +def generate_subcommands(ctx: click.Context, commands: list[str]) -> StrGenerator: processed = set() for info_command in INFO_COMMANDS: @@ -248,10 +250,10 @@ def generate_subcommands(ctx: click.Context, commands: ty.List[str]) -> StrGener assert set(commands) == processed -def create_help() -> ty.Tuple[str, ty.List[str]]: +def create_help() -> tuple[str, list[str]]: ctx = click.Context(scenedetect, info_name=scenedetect.name) - commands: ty.List[str] = ctx.command.list_commands(ctx) + commands: list[str] = ctx.command.list_commands(ctx) commands = list( filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands) ) diff --git a/pyproject.toml b/pyproject.toml index e2a9fa51..8fa5a4b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,9 @@ select = [ "F", # isort "I", - # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # pyupgrade - #"UP", + "UP", + # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # flake8-simplify #"SIM", ] @@ -48,6 +48,10 @@ ignore = [ fixable = ["ALL"] unfixable = [] +[tool.ruff.lint.per-file-ignores] +# Vendored third-party code: don't rewrite/modernize upstream source. +"scenedetect/_thirdparty/*" = ["UP"] + [tool.pyright] include = ["scenedetect", "tests"] typeCheckingMode = "basic" diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 59fd44d6..e913d682 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -81,7 +81,7 @@ def open_video( path: str, - framerate: ty.Optional[float] = None, + framerate: float | None = None, backend: str = "opencv", **kwargs, ) -> VideoStream: @@ -135,10 +135,10 @@ def open_video( def detect( video_path: str, detector: SceneDetector, - stats_file_path: ty.Optional[str] = None, + stats_file_path: str | None = None, show_progress: bool = False, - start_time: ty.Optional[ty.Union[str, float, int]] = None, - end_time: ty.Optional[ty.Union[str, float, int]] = None, + start_time: str | float | int | None = None, + end_time: str | float | int | None = None, start_in_scene: bool = False, ) -> SceneList: """Perform scene detection on a given video `path` using the specified `detector`. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 74ec70b1..1e1abc22 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -95,7 +95,7 @@ class Command(click.Command): def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: """Writes the help into the formatter if it exists.""" if ctx.parent: - formatter.write(click.style("`%s` Command" % ctx.command.name, fg="cyan")) + formatter.write(click.style(f"`{ctx.command.name}` Command", fg="cyan")) formatter.write_paragraph() formatter.write(click.style(LINE_SEPARATOR, fg="cyan")) formatter.write_paragraph() @@ -117,7 +117,7 @@ def format_help_text(self, ctx: click.Context, formatter: click.HelpFormatter) - if self.help: base_command = ctx.parent.info_name if ctx.parent is not None else ctx.info_name formatted_help = self.help.format( - scenedetect=base_command, scenedetect_with_video="%s -i video.mp4" % base_command + scenedetect=base_command, scenedetect_with_video=f"{base_command} -i video.mp4" ) text = inspect.cleandoc(formatted_help).partition("\f")[0] formatter.write_paragraph() @@ -198,15 +198,16 @@ def print_command_help(ctx: click.Context, command: click.Command): required=False, metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.%s" - % (USER_CONFIG.get_help_string("global", "output", show_default=False)), + help="Output directory for created files. If unset, working directory will be used. May be overridden by command options.{}".format( + USER_CONFIG.get_help_string("global", "output", show_default=False) + ), ) @click.option( "--config", "-c", metavar="FILE", type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help="Path to config file. If unset, tries to load config from %s" % (CONFIG_FILE_PATH), + help=f"Path to config file. If unset, tries to load config from {CONFIG_FILE_PATH}", ) @click.option( "--stats", @@ -229,24 +230,27 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="TIMECODE", type=click.STRING, default=None, - help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633).%s" - % USER_CONFIG.get_help_string("global", "min-scene-len"), + help="Minimum length of any scene. TIMECODE can be specified as number of frames (-m 10), time in seconds (-m 2.5), or timecode (-m 00:02:53.633).{}".format( + USER_CONFIG.get_help_string("global", "min-scene-len") + ), ) @click.option( "--drop-short-scenes", is_flag=True, flag_value=True, default=None, - help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.%s" - % (USER_CONFIG.get_help_string("global", "drop-short-scenes")), + help="Drop scenes shorter than -m/--min-scene-len, instead of combining with neighbors.{}".format( + USER_CONFIG.get_help_string("global", "drop-short-scenes") + ), ) @click.option( "--merge-last-scene", is_flag=True, flag_value=True, default=None, - help="Merge last scene with previous if shorter than -m/--min-scene-len.%s" - % (USER_CONFIG.get_help_string("global", "merge-last-scene")), + help="Merge last scene with previous if shorter than -m/--min-scene-len.{}".format( + USER_CONFIG.get_help_string("global", "merge-last-scene") + ), ) @click.option( "--backend", @@ -254,16 +258,18 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="BACKEND", type=click.Choice(CHOICE_MAP["global"]["backend"]), default=None, - help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" - % (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), + help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: {}]{}".format( + ", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend") + ), ) @click.option( "--crop", metavar="X0 Y0 X1 Y1", type=(int, int, int, int), default=None, - help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).%s" - % (USER_CONFIG.get_help_string("global", "crop", show_default=False)), + help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).{}".format( + USER_CONFIG.get_help_string("global", "crop", show_default=False) + ), ) @click.option( "--downscale", @@ -271,8 +277,9 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="N", type=click.INT, default=None, - help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling.%s" - % (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), + help="Integer factor to downscale video by before processing. If unset, value is selected based on resolution. Set -d 1 to disable downscaling.{}".format( + USER_CONFIG.get_help_string("global", "downscale", show_default=False) + ), ) @click.option( "--frame-skip", @@ -280,8 +287,9 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="N", type=click.INT, default=None, - help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs 1 skips every other frame processing 50%% of the video, -fs 2 processes 33%% of the video frames, -fs 3 processes 25%%, etc... %s" - % USER_CONFIG.get_help_string("global", "frame-skip"), + help="Skip N frames during processing. Reduces processing speed at expense of accuracy. -fs 1 skips every other frame processing 50% of the video, -fs 2 processes 33% of the video frames, -fs 3 processes 25%, etc... {}".format( + USER_CONFIG.get_help_string("global", "frame-skip") + ), ) @click.option( "--verbosity", @@ -289,8 +297,7 @@ def print_command_help(ctx: click.Context, command: click.Command): metavar="LEVEL", type=click.Choice(CHOICE_MAP["global"]["verbosity"], False), default=None, - help="Amount of information to show. LEVEL must be one of: %s. Overrides -q/--quiet.%s" - % ( + help="Amount of information to show. LEVEL must be one of: {}. Overrides -q/--quiet.{}".format( ", ".join(CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string("global", "verbosity"), ), @@ -312,20 +319,20 @@ def print_command_help(ctx: click.Context, command: click.Command): @click.pass_context def scenedetect( ctx: click.Context, - input: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], - stats: ty.Optional[ty.AnyStr], - config: ty.Optional[ty.AnyStr], - framerate: ty.Optional[float], - min_scene_len: ty.Optional[str], - drop_short_scenes: ty.Optional[bool], - merge_last_scene: ty.Optional[bool], - backend: ty.Optional[str], - crop: ty.Optional[ty.Tuple[int, int, int, int]], - downscale: ty.Optional[int], - frame_skip: ty.Optional[int], - verbosity: ty.Optional[str], - logfile: ty.Optional[ty.AnyStr], + input: ty.AnyStr | None, + output: ty.AnyStr | None, + stats: ty.AnyStr | None, + config: ty.AnyStr | None, + framerate: float | None, + min_scene_len: str | None, + drop_short_scenes: bool | None, + merge_last_scene: bool | None, + backend: str | None, + crop: tuple[int, int, int, int] | None, + downscale: int | None, + frame_skip: int | None, + verbosity: str | None, + logfile: ty.AnyStr | None, quiet: bool, ): ctx = ctx.obj @@ -375,7 +382,7 @@ def help_command(ctx: click.Context, command_name: str): if command_name not in all_commands: error_strs = [ "unknown command. List of valid commands:", - " %s" % ", ".join(sorted(all_commands)), + " {}".format(", ".join(sorted(all_commands))), ] raise click.BadParameter("\n".join(error_strs), param_hint="command") click.echo("") @@ -393,7 +400,7 @@ def about_command(ctx: click.Context): """Print license/copyright info.""" click.echo("") click.echo(click.style(LINE_SEPARATOR, fg="cyan")) - click.echo(click.style(" About PySceneDetect %s" % PROGRAM_VERSION, fg="yellow")) + click.echo(click.style(f" About PySceneDetect {PROGRAM_VERSION}", fg="yellow")) click.echo(click.style(LINE_SEPARATOR, fg="cyan")) click.echo(ABOUT_STRING) ctx.exit() @@ -450,9 +457,9 @@ def version_command(ctx: click.Context): @click.pass_context def time_command( ctx: click.Context, - start: ty.Optional[str], - duration: ty.Optional[str], - end: ty.Optional[str], + start: str | None, + duration: str | None, + end: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -507,8 +514,9 @@ def time_command( CONFIG_MAP["detect-content"]["threshold"].max_val, ), default=None, - help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-content", "threshold")), + help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-content", "threshold") + ), ) @click.option( "--weights", @@ -516,16 +524,18 @@ def time_command( type=(float, float, float, float), default=None, metavar="HUE SAT LUM EDGE", - help="Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).%s" - % (USER_CONFIG.get_help_string("detect-content", "weights")), + help="Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).{}".format( + USER_CONFIG.get_help_string("detect-content", "weights") + ), ) @click.option( "--luma-only", "-l", is_flag=True, flag_value=True, - help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0.%s" - % (USER_CONFIG.get_help_string("detect-content", "luma-only")), + help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w 0 0 1 0.{}".format( + USER_CONFIG.get_help_string("detect-content", "luma-only") + ), ) @click.option( "--kernel-size", @@ -533,8 +543,9 @@ def time_command( metavar="N", type=click.INT, default=None, - help="Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.%s" - % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), + help="Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.{}".format( + USER_CONFIG.get_help_string("detect-content", "kernel-size") + ), ) @click.option( "--min-scene-len", @@ -555,8 +566,7 @@ def time_command( metavar="MODE", type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), default=None, - help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" - % ( + help="Mode used to enforce -m/--min-scene-len option. Can be one of: {}. {}".format( ", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), USER_CONFIG.get_help_string("detect-content", "filter-mode"), ), @@ -564,12 +574,12 @@ def time_command( @click.pass_context def detect_content_command( ctx: click.Context, - threshold: ty.Optional[float], - weights: ty.Optional[ty.Tuple[float, float, float, float]], + threshold: float | None, + weights: tuple[float, float, float, float] | None, luma_only: bool, - kernel_size: ty.Optional[int], - min_scene_len: ty.Optional[str], - filter_mode: ty.Optional[str], + kernel_size: int | None, + min_scene_len: str | None, + filter_mode: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -603,8 +613,9 @@ def detect_content_command( metavar="VAL", type=click.FLOAT, default=None, - help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-adaptive", "threshold")), + help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "adaptive_ratio" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-adaptive", "threshold") + ), ) @click.option( "--min-content-val", @@ -612,8 +623,9 @@ def detect_content_command( metavar="VAL", type=click.FLOAT, default=None, - help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.%s' - % (USER_CONFIG.get_help_string("detect-adaptive", "min-content-val")), + help='Minimum threshold (float) that "content_val" must exceed to trigger a cut.{}'.format( + USER_CONFIG.get_help_string("detect-adaptive", "min-content-val") + ), ) @click.option( "--frame-window", @@ -621,24 +633,27 @@ def detect_content_command( metavar="VAL", type=click.INT, default=None, - help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.%s" - % (USER_CONFIG.get_help_string("detect-adaptive", "frame-window")), + help="Size of window to detect deviations from mean. Represents how many frames before/after the current one to use for mean.{}".format( + USER_CONFIG.get_help_string("detect-adaptive", "frame-window") + ), ) @click.option( "--weights", "-w", type=(float, float, float, float), default=None, - help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".%s' - % (USER_CONFIG.get_help_string("detect-content", "weights")), + help='Weights of 4 components ("delta_hue", "delta_sat", "delta_lum", "delta_edges") used to calculate "content_val".{}'.format( + USER_CONFIG.get_help_string("detect-content", "weights") + ), ) @click.option( "--luma-only", "-l", is_flag=True, flag_value=True, - help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".%s' - % (USER_CONFIG.get_help_string("detect-content", "luma-only")), + help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to "--weights 0 0 1 0".{}'.format( + USER_CONFIG.get_help_string("detect-content", "luma-only") + ), ) @click.option( "--kernel-size", @@ -646,8 +661,9 @@ def detect_content_command( metavar="N", type=click.INT, default=None, - help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.%s" - % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), + help="Size of kernel for expanding detected edges. Must be odd number >= 3. If unset, size is estimated using video resolution.{}".format( + USER_CONFIG.get_help_string("detect-content", "kernel-size") + ), ) @click.option( "--min-scene-len", @@ -665,13 +681,13 @@ def detect_content_command( @click.pass_context def detect_adaptive_command( ctx: click.Context, - threshold: ty.Optional[float], - min_content_val: ty.Optional[float], - frame_window: ty.Optional[int], - weights: ty.Optional[ty.Tuple[float, float, float, float]], + threshold: float | None, + min_content_val: float | None, + frame_window: int | None, + weights: tuple[float, float, float, float] | None, luma_only: bool, - kernel_size: ty.Optional[int], - min_scene_len: ty.Optional[str], + kernel_size: int | None, + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -709,8 +725,9 @@ def detect_adaptive_command( CONFIG_MAP["detect-threshold"]["threshold"].max_val, ), default=None, - help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.%s' - % (USER_CONFIG.get_help_string("detect-threshold", "threshold")), + help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.{}'.format( + USER_CONFIG.get_help_string("detect-threshold", "threshold") + ), ) @click.option( "--fade-bias", @@ -721,16 +738,18 @@ def detect_adaptive_command( CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, ), default=None, - help="Percent (%%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s" - % (USER_CONFIG.get_help_string("detect-threshold", "fade-bias")), + help="Percent (%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.{}".format( + USER_CONFIG.get_help_string("detect-threshold", "fade-bias") + ), ) @click.option( "--add-last-scene", "-l", is_flag=True, flag_value=True, - help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.%s" - % (USER_CONFIG.get_help_string("detect-threshold", "add-last-scene")), + help="If set and video ends after a fade-out event, generate a final cut at the last fade-out position.{}".format( + USER_CONFIG.get_help_string("detect-threshold", "add-last-scene") + ), ) @click.option( "--min-scene-len", @@ -748,10 +767,10 @@ def detect_adaptive_command( @click.pass_context def detect_threshold_command( ctx: click.Context, - threshold: ty.Optional[float], - fade_bias: ty.Optional[float], + threshold: float | None, + fade_bias: float | None, add_last_scene: bool, - min_scene_len: ty.Optional[str], + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -789,8 +808,9 @@ def detect_threshold_command( ), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " - "values are more sensitive to changes.%s" - % (USER_CONFIG.get_help_string("detect-hist", "threshold")), + "values are more sensitive to changes.{}".format( + USER_CONFIG.get_help_string("detect-hist", "threshold") + ), ) @click.option( "--bins", @@ -800,8 +820,9 @@ def detect_threshold_command( CONFIG_MAP["detect-hist"]["bins"].min_val, CONFIG_MAP["detect-hist"]["bins"].max_val ), default=None, - help="The number of bins to use for the histogram calculation.%s" - % (USER_CONFIG.get_help_string("detect-hist", "bins")), + help="The number of bins to use for the histogram calculation.{}".format( + USER_CONFIG.get_help_string("detect-hist", "bins") + ), ) @click.option( "--min-scene-len", @@ -821,9 +842,9 @@ def detect_threshold_command( @click.pass_context def detect_hist_command( ctx: click.Context, - threshold: ty.Optional[float], - bins: ty.Optional[int], - min_scene_len: ty.Optional[str], + threshold: float | None, + bins: int | None, + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -859,7 +880,9 @@ def detect_hist_command( default=None, help=( "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " - "more sensitive to changes.%s" % (USER_CONFIG.get_help_string("detect-hash", "threshold")) + "more sensitive to changes.{}".format( + USER_CONFIG.get_help_string("detect-hash", "threshold") + ) ), ) @click.option( @@ -870,8 +893,9 @@ def detect_hist_command( CONFIG_MAP["detect-hash"]["size"].min_val, CONFIG_MAP["detect-hash"]["size"].max_val ), default=None, - help="Size of square of low frequency data to include from the discrete cosine transform.%s" - % (USER_CONFIG.get_help_string("detect-hash", "size")), + help="Size of square of low frequency data to include from the discrete cosine transform.{}".format( + USER_CONFIG.get_help_string("detect-hash", "size") + ), ) @click.option( "--lowpass", @@ -883,8 +907,9 @@ def detect_hist_command( default=None, help=( "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " - "the frequency data, 4 means only keep 1/4, etc...%s" - % (USER_CONFIG.get_help_string("detect-hash", "lowpass")) + "the frequency data, 4 means only keep 1/4, etc...{}".format( + USER_CONFIG.get_help_string("detect-hash", "lowpass") + ) ), ) @click.option( @@ -905,10 +930,10 @@ def detect_hist_command( @click.pass_context def detect_hash_command( ctx: click.Context, - threshold: ty.Optional[float], - size: ty.Optional[int], - lowpass: ty.Optional[int], - min_scene_len: ty.Optional[str], + threshold: float | None, + size: int | None, + lowpass: int | None, + min_scene_len: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -944,13 +969,12 @@ def detect_hash_command( metavar="STRING", type=click.STRING, default=None, - help="Name of column used to mark scene cuts.%s" - % (USER_CONFIG.get_help_string("load-scenes", "start-col-name")), + help="Name of column used to mark scene cuts.{}".format( + USER_CONFIG.get_help_string("load-scenes", "start-col-name") + ), ) @click.pass_context -def load_scenes_command( - ctx: click.Context, input: ty.Optional[str], start_col_name: ty.Optional[str] -): +def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: str | None): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -983,32 +1007,36 @@ def load_scenes_command( metavar="NAME", default="$VIDEO_NAME-Scenes.html", type=click.STRING, - help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.%s" - % (USER_CONFIG.get_help_string("save-html", "filename")), + help="Filename format to use for the scene list HTML file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the format name using single quotes.{}".format( + USER_CONFIG.get_help_string("save-html", "filename") + ), ) @click.option( "--no-images", "-n", is_flag=True, flag_value=True, - help="Do not include images with the result.%s" - % (USER_CONFIG.get_help_string("save-html", "no-images")), + help="Do not include images with the result.{}".format( + USER_CONFIG.get_help_string("save-html", "no-images") + ), ) @click.option( "--image-width", "-w", metavar="pixels", type=click.INT, - help="Width in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("save-html", "image-width", show_default=False)), + help="Width in pixels of the images in the resulting HTML table.{}".format( + USER_CONFIG.get_help_string("save-html", "image-width", show_default=False) + ), ) @click.option( "--image-height", "-h", metavar="pixels", type=click.INT, - help="Height in pixels of the images in the resulting HTML table.%s" - % (USER_CONFIG.get_help_string("save-html", "image-height", show_default=False)), + help="Height in pixels of the images in the resulting HTML table.{}".format( + USER_CONFIG.get_help_string("save-html", "image-height", show_default=False) + ), ) @click.option( "--show", @@ -1016,16 +1044,17 @@ def load_scenes_command( is_flag=True, flag_value=True, default=None, - help="Automatically open resulting HTML when processing is complete.%s" - % (USER_CONFIG.get_help_string("save-html", "show")), + help="Automatically open resulting HTML when processing is complete.{}".format( + USER_CONFIG.get_help_string("save-html", "show") + ), ) @click.pass_context def save_html_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, no_images: bool, - image_width: ty.Optional[int], - image_height: ty.Optional[int], + image_width: int | None, + image_height: int | None, show: bool, ): if ctx.info_name == "export-html": @@ -1067,8 +1096,9 @@ def save_html_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("list-scenes", "output", show_default=False) + ), ) @click.option( "--filename", @@ -1076,8 +1106,9 @@ def save_html_command( metavar="NAME", default="$VIDEO_NAME-Scenes.csv", type=click.STRING, - help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f \\$VIDEO_NAME-Scenes.csv).%s" - % (USER_CONFIG.get_help_string("list-scenes", "filename")), + help="Filename format to use for the scene list CSV file. You can use the $VIDEO_NAME macro in the file name. Note that you may have to wrap the name using single quotes or use escape characters (e.g. -f \\$VIDEO_NAME-Scenes.csv).{}".format( + USER_CONFIG.get_help_string("list-scenes", "filename") + ), ) @click.option( "--no-output-file", @@ -1085,8 +1116,9 @@ def save_html_command( is_flag=True, flag_value=True, default=None, - help="Only print scene list.%s" - % (USER_CONFIG.get_help_string("list-scenes", "no-output-file")), + help="Only print scene list.{}".format( + USER_CONFIG.get_help_string("list-scenes", "no-output-file") + ), ) @click.option( "--quiet", @@ -1094,7 +1126,9 @@ def save_html_command( is_flag=True, flag_value=True, default=None, - help="Suppress printing scene list.%s" % (USER_CONFIG.get_help_string("list-scenes", "quiet")), + help="Suppress printing scene list.{}".format( + USER_CONFIG.get_help_string("list-scenes", "quiet") + ), ) @click.option( "--skip-cuts", @@ -1102,17 +1136,18 @@ def save_html_command( is_flag=True, flag_value=True, default=None, - help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.%s" - % (USER_CONFIG.get_help_string("list-scenes", "skip-cuts")), + help="Skip cutting list as first row in the CSV file. Set for RFC 4180 compliant output.{}".format( + USER_CONFIG.get_help_string("list-scenes", "skip-cuts") + ), ) @click.pass_context def list_scenes_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], - no_output_file: ty.Optional[bool], - quiet: ty.Optional[bool], - skip_cuts: ty.Optional[bool], + output: ty.AnyStr | None, + filename: ty.AnyStr | None, + no_output_file: bool | None, + quiet: bool | None, + skip_cuts: bool | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1156,8 +1191,9 @@ def list_scenes_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save videos to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("split-video", "output", show_default=False)), + help="Output directory to save videos to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("split-video", "output", show_default=False) + ), ) @click.option( "--filename", @@ -1165,8 +1201,9 @@ def list_scenes_command( metavar="NAME", default=None, type=click.STRING, - help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f \\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).%s" - % (USER_CONFIG.get_help_string("split-video", "filename")), + help="File name format to use when saving videos, with or without extension. You can use $VIDEO_NAME and $SCENE_NUMBER macros in the filename. You may have to wrap the format in single quotes or use escape characters to avoid variable expansion (e.g. -f \\$VIDEO_NAME-Scene-\\$SCENE_NUMBER).{}".format( + USER_CONFIG.get_help_string("split-video", "filename") + ), ) @click.option( "--quiet", @@ -1174,24 +1211,27 @@ def list_scenes_command( is_flag=True, flag_value=True, default=False, - help="Hide output from external video splitting tool.%s" - % (USER_CONFIG.get_help_string("split-video", "quiet")), + help="Hide output from external video splitting tool.{}".format( + USER_CONFIG.get_help_string("split-video", "quiet") + ), ) @click.option( "--copy", "-c", is_flag=True, flag_value=True, - help="Copy instead of re-encode. Faster but less precise.%s" - % (USER_CONFIG.get_help_string("split-video", "copy")), + help="Copy instead of re-encode. Faster but less precise.{}".format( + USER_CONFIG.get_help_string("split-video", "copy") + ), ) @click.option( "--high-quality", "-hq", is_flag=True, flag_value=True, - help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow%s" - % (USER_CONFIG.get_help_string("split-video", "high-quality")), + help="Encode video with higher quality, overrides -f option if present. Equivalent to: --rate-factor=17 --preset=slow{}".format( + USER_CONFIG.get_help_string("split-video", "high-quality") + ), ) @click.option( "--rate-factor", @@ -1202,8 +1242,9 @@ def list_scenes_command( CONFIG_MAP["split-video"]["rate-factor"].min_val, CONFIG_MAP["split-video"]["rate-factor"].max_val, ), - help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.%s" - % (USER_CONFIG.get_help_string("split-video", "rate-factor")), + help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.{}".format( + USER_CONFIG.get_help_string("split-video", "rate-factor") + ), ) @click.option( "--preset", @@ -1211,8 +1252,7 @@ def list_scenes_command( metavar="LEVEL", default=None, type=click.Choice(CHOICE_MAP["split-video"]["preset"]), - help="Video compression quality (x264 preset). Can be one of: %s. Faster modes take less time but output may be larger.%s" - % ( + help="Video compression quality (x264 preset). Can be one of: {}. Faster modes take less time but output may be larger.{}".format( ", ".join(CHOICE_MAP["split-video"]["preset"]), USER_CONFIG.get_help_string("split-video", "preset"), ), @@ -1223,28 +1263,30 @@ def list_scenes_command( metavar="ARGS", type=click.STRING, default=None, - help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.%s' - % (USER_CONFIG.get_help_string("split-video", "args")), + help='Override codec arguments passed to FFmpeg when splitting scenes. Use double quotes (") around arguments. Must specify at least audio/video codec.{}'.format( + USER_CONFIG.get_help_string("split-video", "args") + ), ) @click.option( "--mkvmerge", "-m", is_flag=True, flag_value=True, - help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.%s" - % (USER_CONFIG.get_help_string("split-video", "mkvmerge")), + help="Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than -f/--filename, -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix.{}".format( + USER_CONFIG.get_help_string("split-video", "mkvmerge") + ), ) @click.pass_context def split_video_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr], - filename: ty.Optional[ty.AnyStr], + output: ty.AnyStr | None, + filename: ty.AnyStr | None, quiet: bool, copy: bool, high_quality: bool, - rate_factor: ty.Optional[int], - preset: ty.Optional[str], - args: ty.Optional[str], + rate_factor: int | None, + preset: str | None, + args: str | None, mkvmerge: bool, ): ctx = ctx.obj @@ -1270,20 +1312,20 @@ def split_video_command( command = "mkvmerge (-m)" if mkvmerge else "copy (-c)" if high_quality: raise click.BadParameter( - "high-quality (-hq) cannot be used with %s" % (command), + f"high-quality (-hq) cannot be used with {command}", param_hint="split-video", ) if args: raise click.BadParameter( - "args (-a) cannot be used with %s" % (command), param_hint="split-video" + f"args (-a) cannot be used with {command}", param_hint="split-video" ) if rate_factor: raise click.BadParameter( - "rate-factor (crf) cannot be used with %s" % (command), param_hint="split-video" + f"rate-factor (crf) cannot be used with {command}", param_hint="split-video" ) if preset: raise click.BadParameter( - "preset (-p) cannot be used with %s" % (command), param_hint="split-video" + f"preset (-p) cannot be used with {command}", param_hint="split-video" ) # mkvmerge-Specific Options @@ -1333,8 +1375,9 @@ def split_video_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory for images. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-images", "output", show_default=False)), + help="Output directory for images. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-images", "output", show_default=False) + ), ) @click.option( "--filename", @@ -1342,8 +1385,9 @@ def split_video_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f \\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.%s" - % (USER_CONFIG.get_help_string("save-images", "filename")), + help="Filename format *without* extension to use when saving images. You can use the $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. You may have to use escape characters (e.g. -f \\$SCENE_NUMBER-Image-\\$IMAGE_NUMBER) or single quotes.{}".format( + USER_CONFIG.get_help_string("save-images", "filename") + ), ) @click.option( "--num-images", @@ -1351,16 +1395,18 @@ def split_video_command( metavar="N", default=None, type=click.INT, - help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene.%s" - % (USER_CONFIG.get_help_string("save-images", "num-images")), + help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene.{}".format( + USER_CONFIG.get_help_string("save-images", "num-images") + ), ) @click.option( "--jpeg", "-j", is_flag=True, flag_value=True, - help="Set output format to JPEG (default).%s" - % (USER_CONFIG.get_help_string("save-images", "format", show_default=False)), + help="Set output format to JPEG (default).{}".format( + USER_CONFIG.get_help_string("save-images", "format", show_default=False) + ), ) @click.option( "--webp", @@ -1375,8 +1421,9 @@ def split_video_command( metavar="Q", default=None, type=click.IntRange(0, 100), - help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s" - % (USER_CONFIG.get_help_string("save-images", "quality", show_default=False)), + help="JPEG/WebP encoding quality, from 0-100 (higher indicates better quality). For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]{}".format( + USER_CONFIG.get_help_string("save-images", "quality", show_default=False) + ), ) @click.option( "--png", @@ -1391,8 +1438,9 @@ def split_video_command( metavar="C", default=None, type=click.IntRange(0, 9), - help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.%s" - % (USER_CONFIG.get_help_string("save-images", "compression")), + help="PNG compression rate, from 0-9. Higher values produce smaller files but result in longer compression time. This setting does not affect image quality, only file size.{}".format( + USER_CONFIG.get_help_string("save-images", "compression") + ), ) @click.option( "-m", @@ -1400,8 +1448,9 @@ def split_video_command( metavar="DURATION", default=None, type=click.STRING, - help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).%s" - % (USER_CONFIG.get_help_string("save-images", "frame-margin")), + help="Padding around the beginning/end of each scene used when selecting which frames to extract. DURATION can be specified in frames (-m 1), in seconds with `s` suffix (-m 0.1s), or timecode (-m 00:00:00.100).{}".format( + USER_CONFIG.get_help_string("save-images", "frame-margin") + ), ) @click.option( "--scale", @@ -1409,8 +1458,9 @@ def split_video_command( metavar="S", default=None, type=click.FLOAT, - help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.%s" - % (USER_CONFIG.get_help_string("save-images", "scale", show_default=False)), + help="Factor to scale images by. Ignored if -W/--width or -H/--height is set.{}".format( + USER_CONFIG.get_help_string("save-images", "scale", show_default=False) + ), ) @click.option( "--height", @@ -1418,8 +1468,9 @@ def split_video_command( metavar="H", default=None, type=click.INT, - help="Height (pixels) of images.%s" - % (USER_CONFIG.get_help_string("save-images", "height", show_default=False)), + help="Height (pixels) of images.{}".format( + USER_CONFIG.get_help_string("save-images", "height", show_default=False) + ), ) @click.option( "--width", @@ -1427,24 +1478,25 @@ def split_video_command( metavar="W", default=None, type=click.INT, - help="Width (pixels) of images.%s" - % (USER_CONFIG.get_help_string("save-images", "width", show_default=False)), + help="Width (pixels) of images.{}".format( + USER_CONFIG.get_help_string("save-images", "width", show_default=False) + ), ) @click.pass_context def save_images_command( ctx: click.Context, - output: ty.Optional[ty.AnyStr] = None, - filename: ty.Optional[ty.AnyStr] = None, - num_images: ty.Optional[int] = None, + output: ty.AnyStr | None = None, + filename: ty.AnyStr | None = None, + num_images: int | None = None, jpeg: bool = False, webp: bool = False, - quality: ty.Optional[int] = None, + quality: int | None = None, png: bool = False, - compression: ty.Optional[int] = None, - frame_margin: ty.Optional[str] = None, - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, + compression: int | None = None, + frame_margin: str | None = None, + scale: float | None = None, + height: int | None = None, + width: int | None = None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1478,7 +1530,7 @@ def save_images_command( valid_params = get_cv2_imwrite_params() if image_extension not in valid_params or valid_params[image_extension] is None: error_strs = [ - "Image encoder type `%s` not supported." % image_extension.upper(), + f"Image encoder type `{image_extension.upper()}` not supported.", "The specified encoder type could not be found in the current OpenCV module.", "To enable this output format, please update the installed version of OpenCV.", "If you build OpenCV, ensure the the proper dependencies are enabled. ", @@ -1518,7 +1570,7 @@ def save_images_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-edl", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-edl", "filename")), ) @click.option( "--title", @@ -1526,7 +1578,7 @@ def save_images_command( metavar="NAME", default=None, type=click.STRING, - help="Title format to use.%s" % (USER_CONFIG.get_help_string("save-edl", "title")), + help="Title format to use.{}".format(USER_CONFIG.get_help_string("save-edl", "title")), ) @click.option( "--reel", @@ -1534,23 +1586,24 @@ def save_images_command( metavar="REEL", default=None, type=click.STRING, - help="Reel name to use.%s" % (USER_CONFIG.get_help_string("save-edl", "reel")), + help="Reel name to use.{}".format(USER_CONFIG.get_help_string("save-edl", "reel")), ) @click.option( "--output", "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save EDL file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-edl", "output", show_default=False)), + help="Output directory to save EDL file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-edl", "output", show_default=False) + ), ) @click.pass_context def save_edl_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - title: ty.Optional[ty.AnyStr], - reel: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, + title: ty.AnyStr | None, + reel: ty.AnyStr | None, + output: ty.AnyStr | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1577,15 +1630,16 @@ def save_edl_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-qp", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-qp", "filename")), ) @click.option( "--output", "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save QP file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-qp", "output", show_default=False)), + help="Output directory to save QP file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-qp", "output", show_default=False) + ), ) @click.option( "--disable-shift", @@ -1593,15 +1647,16 @@ def save_edl_command( is_flag=True, flag_value=True, default=None, - help="Disable shifting frame numbers by start time.%s" - % (USER_CONFIG.get_help_string("save-qp", "disable-shift")), + help="Disable shifting frame numbers by start time.{}".format( + USER_CONFIG.get_help_string("save-qp", "disable-shift") + ), ) @click.pass_context def save_qp_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], - disable_shift: ty.Optional[bool], + filename: ty.AnyStr | None, + output: ty.AnyStr | None, + disable_shift: bool | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1624,15 +1679,14 @@ def save_qp_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-fcp", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-fcp", "filename")), ) @click.option( "--format", metavar="TYPE", type=click.Choice(CHOICE_MAP["save-fcp"]["format"], False), default=None, - help="Format to export. TYPE must be one of: %s.%s" - % ( + help="Format to export. TYPE must be one of: {}.{}".format( ", ".join(CHOICE_MAP["save-fcp"]["format"]), USER_CONFIG.get_help_string("save-fcp", "format"), ), @@ -1642,15 +1696,16 @@ def save_qp_command( "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save XML file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-fcp", "output", show_default=False)), + help="Output directory to save XML file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-fcp", "output", show_default=False) + ), ) @click.pass_context def save_fcp_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - format: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, + format: ty.AnyStr | None, + output: ty.AnyStr | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1675,7 +1730,7 @@ def save_fcp_command( metavar="NAME", default=None, type=click.STRING, - help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-otio", "filename")), + help="Filename format to use.{}".format(USER_CONFIG.get_help_string("save-otio", "filename")), ) @click.option( "--name", @@ -1683,15 +1738,16 @@ def save_fcp_command( metavar="NAME", default=None, type=click.STRING, - help="Name of timeline to use.%s" % (USER_CONFIG.get_help_string("save-otio", "name")), + help="Name of timeline to use.{}".format(USER_CONFIG.get_help_string("save-otio", "name")), ) @click.option( "--output", "-o", metavar="DIR", type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help="Output directory to save OTIO file to. Overrides global option -o/--output.%s" - % (USER_CONFIG.get_help_string("save-otio", "output", show_default=False)), + help="Output directory to save OTIO file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-otio", "output", show_default=False) + ), ) @click.option( "--audio", @@ -1708,9 +1764,9 @@ def save_fcp_command( @click.pass_context def save_otio_command( ctx: click.Context, - filename: ty.Optional[ty.AnyStr], - name: ty.Optional[ty.AnyStr], - output: ty.Optional[ty.AnyStr], + filename: ty.AnyStr | None, + name: ty.AnyStr | None, + output: ty.AnyStr | None, audio: bool, no_audio: bool, ): diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 730ff277..ed4ad920 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -93,7 +93,7 @@ def save_qp( start_frame = context.start_time.frame_num if context.start_time else 0 shift_start = not disable_shift offset = start_frame if shift_start else 0 - with open(qp_path, "wt") as qp_file: + with open(qp_path, "w") as qp_file: qp_file.write(f"{0 if shift_start else start_frame} I -1\n") # Place another I frame at each detected cut. qp_file.writelines(f"{cut.frame_num - offset} I -1\n" for cut in cuts) @@ -151,14 +151,7 @@ def list_scenes( -----------------------------------------------------------------------""", "\n".join( [ - " | %5d | %11d | %s | %11d | %s |" - % ( - i + 1, - start_time.frame_num + 1, - start_time.get_timecode(), - end_time.frame_num, - end_time.get_timecode(), - ) + f" | {i + 1:5d} | {start_time.frame_num + 1:11d} | {start_time.get_timecode()} | {end_time.frame_num:11d} | {end_time.get_timecode()} |" for i, (start_time, end_time) in enumerate(scenes) ] ), @@ -180,7 +173,7 @@ def save_images( image_extension: str, encoder_param: int, filename: str, - output: ty.Optional[str], + output: str | None, show_progress: bool, scale: int, height: int, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 1bf8dad0..65171063 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -33,7 +33,7 @@ PYAV_THREADING_MODES = ["NONE", "SLICE", "FRAME", "AUTO"] -LogMessage = ty.Tuple[int, str] +LogMessage = tuple[int, str] class OptionParseFailure(Exception): @@ -75,13 +75,13 @@ class TimecodeValue(ValidatedValue): Stores value in original representation.""" - def __init__(self, value: ty.Union[int, float, str]): + def __init__(self, value: int | float | str): # Ensure value is a valid timecode. FrameTimecode(timecode=value, fps=100.0) self._value = value @property - def value(self) -> ty.Union[int, float, str]: + def value(self) -> int | float | str: return self._value @staticmethod @@ -99,9 +99,9 @@ class RangeValue(ValidatedValue): def __init__( self, - value: ty.Union[int, float], - min_val: ty.Union[int, float], - max_val: ty.Union[int, float], + value: int | float, + min_val: int | float, + max_val: int | float, ): if value < min_val or value > max_val: # min and max are inclusive. @@ -111,16 +111,16 @@ def __init__( self._max_val = max_val @property - def value(self) -> ty.Union[int, float]: + def value(self) -> int | float: return self._value @property - def min_val(self) -> ty.Union[int, float]: + def min_val(self) -> int | float: """Minimum value of the range.""" return self._min_val @property - def max_val(self) -> ty.Union[int, float]: + def max_val(self) -> int | float: """Maximum value of the range.""" return self._max_val @@ -134,7 +134,7 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": ) except ValueError as ex: raise OptionParseFailure( - "Value must be between %s and %s." % (default.min_val, default.max_val) + f"Value must be between {default.min_val} and {default.max_val}." ) from ex @@ -144,7 +144,7 @@ class CropValue(ValidatedValue): _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" - def __init__(self, value: ty.Optional[ty.Union[str, ty.Tuple[int, int, int, int]]] = None): + def __init__(self, value: str | tuple[int, int, int, int] | None = None): if isinstance(value, CropValue) or value is None: self._crop = value else: @@ -165,11 +165,12 @@ def __init__(self, value: ty.Optional[ty.Union[str, ty.Tuple[int, int, int, int] self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) @property - def value(self) -> ty.Tuple[int, int, int, int]: + def value(self) -> tuple[int, int, int, int]: return self._crop def __str__(self) -> str: - return "[%d, %d], [%d, %d]" % self.value + x0, y0, x1, y1 = self.value + return f"[{x0}, {y0}], [{x1}, {y1}]" @staticmethod def from_config(config_value: str, default: "CropValue") -> "CropValue": @@ -185,7 +186,7 @@ class ScoreWeightsValue(ValidatedValue): _IGNORE_CHARS = [",", "/", "(", ")"] """Characters to ignore.""" - def __init__(self, value: ty.Union[str, ContentDetector.Components]): + def __init__(self, value: str | ContentDetector.Components): if isinstance(value, ContentDetector.Components): self._value = value else: @@ -202,7 +203,7 @@ def value(self) -> ContentDetector.Components: return self._value def __str__(self) -> str: - return "%.3f, %.3f, %.3f, %.3f" % self.value + return "{:.3f}, {:.3f}, {:.3f}, {:.3f}".format(*self.value) @staticmethod def from_config(config_value: str, default: "ScoreWeightsValue") -> "ScoreWeightsValue": @@ -301,7 +302,7 @@ def format(self, timecode: FrameTimecode) -> str: if self == TimecodeFormat.TIMECODE: return timecode.get_timecode() if self == TimecodeFormat.SECONDS: - return "%.3f" % timecode.seconds + return f"{timecode.seconds:.3f}" raise RuntimeError("Unhandled format specifier.") @@ -314,8 +315,8 @@ class FcpFormat(Enum): """Final Cut Pro 7 XML Format""" -ConfigValue = ty.Union[bool, int, float, str] -ConfigDict = ty.Dict[str, ty.Dict[str, ConfigValue]] +ConfigValue = bool | int | float | str +ConfigDict = dict[str, dict[str, ConfigValue]] _CONFIG_FILE_NAME: ty.AnyStr = "scenedetect.cfg" _CONFIG_FILE_DIR: ty.AnyStr = user_config_dir("PySceneDetect", False) @@ -454,7 +455,7 @@ class FcpFormat(Enum): The types of these values are used when decoding the configuration file. Valid choices for certain string options are stored in `CHOICE_MAP`.""" -CHOICE_MAP: ty.Dict[str, ty.Dict[str, ty.List[str]]] = { +CHOICE_MAP: dict[str, dict[str, list[str]]] = { "backend-pyav": { "threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES], }, @@ -501,14 +502,14 @@ class FcpFormat(Enum): of a set to preserve order when generating error contexts. Values are case-insensitive, and must be in lowercase in this map.""" -DEPRECATED_COMMANDS: ty.Dict[str, str] = {"export-html": "save-html"} +DEPRECATED_COMMANDS: dict[str, str] = {"export-html": "save-html"} """Deprecated config file sections that have a 1:1 mapping to a new replacement.""" -def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessage]]: +def _validate_structure(parser: ConfigParser) -> tuple[bool, list[LogMessage]]: """Validates the layout of the section/option mapping. Returns a bool indicating if validation was successful, and a list of log messages for the init log.""" - logs: ty.List[LogMessage] = [] + logs: list[LogMessage] = [] success = True all_sections = set(parser.sections()) for section in all_sections: @@ -549,7 +550,7 @@ def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessa return (success, logs) -def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty.List[LogMessage]]: +def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMessage]]: """Process the given configuration into a key-value mapping. Returns a tuple of the config dict itself (or None on failure), and a list of log messages during parsing.""" (success, logs) = _validate_structure(parser) @@ -594,8 +595,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option %s': %s. Must be one of: %s." - % ( + "Invalid value for [{}] option {}': {}. Must be one of: {}.".format( command, option, parser.get(command, option), @@ -612,8 +612,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option '%s': %s is not a valid %s." - % (command, option, parser.get(command, option), value_type), + f"Invalid value for [{command}] option '{option}': {parser.get(command, option)} is not a valid {value_type}.", ) ) continue @@ -632,8 +631,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option '%s': %s\nError: %s" - % (command, option, config_value, ex.error), + f"Invalid value for [{command}] option '{option}': {config_value}\nError: {ex.error}", ) ) continue @@ -648,8 +646,7 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. logs.append( ( logging.ERROR, - "Invalid value for [%s] option '%s': %s. Must be one of: %s." - % ( + "Invalid value for [{}] option '{}': {}. Must be one of: {}.".format( command, option, parser.get(command, option), @@ -669,16 +666,16 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty. class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" - def __init__(self, init_log: ty.Tuple[int, str], reason: ty.Optional[Exception] = None): + def __init__(self, init_log: tuple[int, str], reason: Exception | None = None): super().__init__() self.init_log = init_log self.reason = reason class ConfigRegistry: - def __init__(self, path: ty.Optional[str] = None, throw_exception: bool = True): + def __init__(self, path: str | None = None, throw_exception: bool = True): self._config: ConfigDict = {} # Options set in the loaded config file. - self._init_log: ty.List[ty.Tuple[int, str]] = [] + self._init_log: list[tuple[int, str]] = [] self._initialized = False try: @@ -693,7 +690,7 @@ def __init__(self, path: ty.Optional[str] = None, throw_exception: bool = True): self._init_log = ex.init_log if ex.reason is not None: self._init_log += [ - (logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " ")), + (logging.ERROR, "Error: {}".format(str(ex.reason).replace("\t", " "))), ] self._initialized = False @@ -719,9 +716,9 @@ def _log(self, log_level, log_str): def _load_from_disk(self, path=None): # Validate `path`, or if not provided, use CONFIG_FILE_PATH if it exists. if path: - self._init_log.append((logging.INFO, "Loading config from file:\n %s" % path)) + self._init_log.append((logging.INFO, f"Loading config from file:\n {path}")) if not os.path.exists(path): - self._init_log.append((logging.ERROR, "File not found: %s" % (path))) + self._init_log.append((logging.ERROR, f"File not found: {path}")) raise ConfigLoadFailure(self._init_log) else: # Gracefully handle the case where there isn't a user config file. @@ -729,7 +726,7 @@ def _load_from_disk(self, path=None): self._init_log.append((logging.DEBUG, "User config file not found.")) return path = CONFIG_FILE_PATH - self._init_log.append((logging.INFO, "Loading user config file:\n %s" % path)) + self._init_log.append((logging.INFO, f"Loading user config file:\n {path}")) # Try to load and parse the config file at `path`. config = ConfigParser() try: @@ -757,7 +754,7 @@ def get_value( self, command: str, option: str, - override: ty.Optional[ConfigValue] = None, + override: ConfigValue | None = None, ) -> ConfigValue: """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] @@ -773,9 +770,7 @@ def get_value( return CONFIG_MAP[command][option].__class__[value.upper().strip()] return value - def get_help_string( - self, command: str, option: str, show_default: ty.Optional[bool] = None - ) -> str: + def get_help_string(self, command: str, option: str, show_default: bool | None = None) -> str: """Get a string to specify for the help text indicating the current command option value, if set, or the default. @@ -792,9 +787,9 @@ def get_help_string( value_str = "on" if self._config[command][option] else "off" else: value_str = str(self._config[command][option]) - return " [setting: %s]" % (value_str) + return f" [setting: {value_str}]" if show_default is False or ( show_default is None and is_flag and CONFIG_MAP[command][option] is False ): return "" - return " [default: %s]" % (str(CONFIG_MAP[command][option])) + return f" [default: {str(CONFIG_MAP[command][option])}]" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index b23287e3..771dc013 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -91,32 +91,32 @@ def __init__(self): self.video_stream: VideoStream = None self.load_scenes_input: str = None # load-scenes -i/--input self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name - self.start_time: ty.Optional[FrameTimecode] = None # time -s/--start - self.end_time: ty.Optional[FrameTimecode] = None # time -e/--end - self.duration: ty.Optional[FrameTimecode] = None # time -d/--duration + self.start_time: FrameTimecode | None = None # time -s/--start + self.end_time: FrameTimecode | None = None # time -e/--end + self.duration: FrameTimecode | None = None # time -d/--duration self.frame_skip: int = None # Options: self.drop_short_scenes: bool = None self.merge_last_scene: bool = None self.min_scene_len: FrameTimecode = None - self.default_detector: ty.Tuple[ty.Type[SceneDetector], ty.Dict[str, ty.Any]] = None + self.default_detector: tuple[type[SceneDetector], dict[str, ty.Any]] = None self.output: str = None self.stats_file_path: str = None # Output Commands (e.g. split-video, save-images): # Commands to run after the detection pipeline. Stored as (callback, args) and invoked with # the results of the detection pipeline by the controller. - self.commands: ty.List[ty.Tuple[ty.Callable, ty.Dict[str, ty.Any]]] = [] + self.commands: list[tuple[ty.Callable, dict[str, ty.Any]]] = [] - def add_command(self, command: ty.Callable, command_args: ty.Dict[str, ty.Any]): + def add_command(self, command: ty.Callable, command_args: dict[str, ty.Any]): """Add `command` to the processing pipeline. Will be called after processing the input.""" if "output" in command_args and command_args["output"] is None: command_args["output"] = self.output logger.debug("Adding command: %s(%s)", command.__name__, command_args) self.commands.append((command, command_args)) - def add_detector(self, detector: ty.Type[SceneDetector], detector_args: ty.Dict[str, ty.Any]): + def add_detector(self, detector: type[SceneDetector], detector_args: dict[str, ty.Any]): """Instantiate and add `detector` to the processing pipeline.""" if self.load_scenes_input: raise click.ClickException("The load-scenes command cannot be used with detectors.") @@ -130,7 +130,7 @@ def ensure_detector(self): (detector_type, detector_args) = self.default_detector self.add_detector(detector_type, detector_args) - def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> FrameTimecode: + def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode: """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` is None it will be passed through without processing. @@ -155,21 +155,21 @@ def parse_timecode(self, value: ty.Optional[str], correct_pts: bool = False) -> def handle_options( self, input_path: ty.AnyStr, - output: ty.Optional[ty.AnyStr], + output: ty.AnyStr | None, framerate: float, - stats_file: ty.Optional[ty.AnyStr], + stats_file: ty.AnyStr | None, frame_skip: int, min_scene_len: str, - drop_short_scenes: ty.Optional[bool], - merge_last_scene: ty.Optional[bool], - backend: ty.Optional[str], - crop: ty.Optional[ty.Tuple[int, int, int, int]], - downscale: ty.Optional[int], + drop_short_scenes: bool | None, + merge_last_scene: bool | None, + backend: str | None, + crop: tuple[int, int, int, int] | None, + downscale: int | None, quiet: bool, - logfile: ty.Optional[ty.AnyStr], - config: ty.Optional[ty.AnyStr], - stats: ty.Optional[ty.AnyStr], - verbosity: ty.Optional[str], + logfile: ty.AnyStr | None, + config: ty.AnyStr | None, + stats: ty.AnyStr | None, + verbosity: str | None, ): """Parse all global options/arguments passed to the main scenedetect command, before other sub-commands (e.g. this function processes the [options] when calling @@ -206,7 +206,9 @@ def handle_options( init_failure = True init_log += ex.init_log if ex.reason is not None: - init_log += [(logging.ERROR, "Error: %s" % str(ex.reason).replace("\t", " "))] + init_log += [ + (logging.ERROR, "Error: {}".format(str(ex.reason).replace("\t", " "))) + ] finally: # Make sure we print the version number even on any kind of init failure. logger.info("PySceneDetect %s", scenedetect.__version__) @@ -312,13 +314,13 @@ def handle_options( def get_detect_content_params( self, - threshold: ty.Optional[float] = None, + threshold: float | None = None, luma_only: bool = None, - min_scene_len: ty.Optional[str] = None, - weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, - kernel_size: ty.Optional[int] = None, - filter_mode: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + min_scene_len: str | None = None, + weights: tuple[float, float, float, float] | None = None, + kernel_size: int | None = None, + filter_mode: str | None = None, + ) -> dict[str, ty.Any]: """Get a dict containing user options to construct a ContentDetector with.""" if self.drop_short_scenes: min_scene_len = 0 @@ -350,14 +352,14 @@ def get_detect_content_params( def get_detect_adaptive_params( self, - threshold: ty.Optional[float] = None, - min_content_val: ty.Optional[float] = None, - frame_window: ty.Optional[int] = None, + threshold: float | None = None, + min_content_val: float | None = None, + frame_window: int | None = None, luma_only: bool = None, - min_scene_len: ty.Optional[str] = None, - weights: ty.Optional[ty.Tuple[float, float, float, float]] = None, - kernel_size: ty.Optional[int] = None, - ) -> ty.Dict[str, ty.Any]: + min_scene_len: str | None = None, + weights: tuple[float, float, float, float] | None = None, + kernel_size: int | None = None, + ) -> dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" if self.drop_short_scenes: @@ -392,11 +394,11 @@ def get_detect_adaptive_params( def get_detect_threshold_params( self, - threshold: ty.Optional[float] = None, - fade_bias: ty.Optional[float] = None, + threshold: float | None = None, + fade_bias: float | None = None, add_last_scene: bool = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + min_scene_len: str | None = None, + ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" if self.drop_short_scenes: @@ -419,10 +421,10 @@ def get_detect_threshold_params( def get_detect_hist_params( self, - threshold: ty.Optional[float] = None, - bins: ty.Optional[int] = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + threshold: float | None = None, + bins: int | None = None, + min_scene_len: str | None = None, + ) -> dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" if self.drop_short_scenes: @@ -442,11 +444,11 @@ def get_detect_hist_params( def get_detect_hash_params( self, - threshold: ty.Optional[float] = None, - size: ty.Optional[int] = None, - lowpass: ty.Optional[int] = None, - min_scene_len: ty.Optional[str] = None, - ) -> ty.Dict[str, ty.Any]: + threshold: float | None = None, + size: int | None = None, + lowpass: int | None = None, + min_scene_len: str | None = None, + ) -> dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" if self.drop_short_scenes: @@ -471,9 +473,9 @@ def get_detect_hash_params( def _initialize_logging( self, - quiet: ty.Optional[bool] = None, - verbosity: ty.Optional[str] = None, - logfile: ty.Optional[ty.AnyStr] = None, + quiet: bool | None = None, + verbosity: str | None = None, + logfile: ty.AnyStr | None = None, ): """Setup logging based on CLI args and user configuration settings.""" if quiet is not None: @@ -505,8 +507,8 @@ def _initialize_logging( def _open_video_stream( self, input_path: ty.AnyStr, - framerate: ty.Optional[float], - backend: ty.Optional[str], + framerate: float | None, + backend: str | None, ): if "%" in input_path and backend != "opencv": raise click.BadParameter( @@ -519,7 +521,7 @@ def _open_video_stream( backend = self.config.get_value("global", "backend", backend) if backend not in AVAILABLE_BACKENDS: raise click.BadParameter( - "Specified backend %s is not available on this system!" % backend, + f"Specified backend {backend} is not available on this system!", param_hint="-b/--backend", ) @@ -566,13 +568,14 @@ def _open_video_stream( if __debug__: raise raise click.BadParameter( - "Failed to open input video%s: %s" - % (" using %s backend" % backend if backend else "", str(ex)), + "Failed to open input video{}: {}".format( + f" using {backend} backend" if backend else "", str(ex) + ), param_hint="-i/--input", ) from ex except OSError as ex: if __debug__: raise raise click.BadParameter( - "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" + f"Input error:\n\n\t{str(ex)}\n", param_hint="-i/--input" ) from None diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index be7ac8d2..621d9217 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -94,7 +94,7 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene return scene_list -def _detect(context: CliContext) -> ty.Optional[ty.Tuple[SceneList, CutList]]: +def _detect(context: CliContext) -> tuple[SceneList, CutList] | None: perf_start_time = time.time() context.ensure_detector() @@ -162,7 +162,7 @@ def _save_stats(context: CliContext) -> None: logger.debug("No frame metrics updated, skipping update of the stats file.") -def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]: +def _load_scenes(context: CliContext) -> tuple[SceneList, CutList]: assert context.load_scenes_input assert os.path.exists(context.load_scenes_input) diff --git a/scenedetect/_thirdparty/simpletable.py b/scenedetect/_thirdparty/simpletable.py index 634c216f..f71c9cf5 100644 --- a/scenedetect/_thirdparty/simpletable.py +++ b/scenedetect/_thirdparty/simpletable.py @@ -173,8 +173,7 @@ def __str__(self): def __iter__(self): """Iterate through row cells""" - for cell in self.cells: - yield cell + yield from self.cells def add_cell(self, cell): """Add a SimpleTableCell object to the list of cells.""" @@ -249,8 +248,7 @@ def __str__(self): def __iter__(self): """Iterate through table rows""" - for row in self.rows: - yield row + yield from self.rows def add_row(self, row): """Add a SimpleTableRow object to the list of rows.""" @@ -298,8 +296,7 @@ def __str__(self): def __iter__(self): """Iterate through tables""" - for table in self.tables: - yield table + yield from self.tables def save(self, filename): """Save HTML page to a file using the proper encoding""" diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 6f5d9086..15a53f16 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -100,7 +100,7 @@ # TODO: Lazy-loading backends would improve startup performance. However, this requires removing # some of the re-exported types above from the public API. -AVAILABLE_BACKENDS: ty.Dict[str, ty.Type] = { +AVAILABLE_BACKENDS: dict[str, type] = { backend.BACKEND_NAME: backend for backend in filter( None, diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 8701c47d..efb735f8 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -41,7 +41,7 @@ def _retry_on_oserror(op_name: str, fn: ty.Callable): """Run ``fn``, retrying up to ``_FFMPEG_RETRY_COUNT`` times on ``OSError``.""" - last_exc: ty.Optional[OSError] = None + last_exc: OSError | None = None for attempt in range(_FFMPEG_RETRY_COUNT + 1): try: return fn() @@ -63,9 +63,7 @@ def _retry_on_oserror(op_name: str, fn: ty.Callable): class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__( - self, path: ty.AnyStr, framerate: ty.Optional[float] = None, print_infos: bool = False - ): + def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: bool = False): """Open a video or device. Arguments: @@ -97,8 +95,8 @@ def __init__( # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame # will always be the current frame, and self._reader.lastread will be the next. - self._last_frame: ty.Union[bool, np.ndarray] = False - self._last_frame_rgb: ty.Optional[np.ndarray] = None + self._last_frame: bool | np.ndarray = False + self._last_frame_rgb: np.ndarray | None = None # Older versions don't track the video position when calling read_frame so we need # to keep track of the current frame number. self._frame_number = 0 @@ -119,7 +117,7 @@ def frame_rate(self) -> Fraction: return framerate_to_fraction(self._reader.fps) @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: """Video path.""" return self._path @@ -134,12 +132,12 @@ def is_seekable(self) -> bool: return True @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return tuple(self._reader.infos["video_size"]) @property - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" assert isinstance(self._reader.infos["duration"], float) return self.base_timecode + self._reader.infos["duration"] @@ -191,7 +189,7 @@ def frame_number(self) -> int: """ return self._frame_number - def seek(self, target: ty.Union[FrameTimecode, float, int]): + def seek(self, target: FrameTimecode | float | int): """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -248,7 +246,7 @@ def reset(self, print_infos=False): "reset", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) ) - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: if not hasattr(self._reader, "lastread") or self._eof: return False has_last_read = hasattr(self._reader, "last_read") diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 5401119a..e3049759 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -66,9 +66,9 @@ class VideoStreamCv2(VideoStream): def __init__( self, path: ty.AnyStr = None, - framerate: ty.Optional[float] = None, + framerate: float | None = None, max_decode_attempts: int = 5, - path_or_device: ty.Union[bytes, str, int] = None, + path_or_device: bytes | str | int = None, ): """Open a video file, image sequence, or network stream. @@ -100,7 +100,7 @@ def __init__( if path is None: raise ValueError("Path must be specified!") if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") if max_decode_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") @@ -108,10 +108,10 @@ def __init__( self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: - self._cap: ty.Optional[cv2.VideoCapture] = ( + self._cap: cv2.VideoCapture | None = ( None # Reference to underlying cv2.VideoCapture object. ) - self._frame_rate: ty.Optional[Fraction] = None + self._frame_rate: Fraction | None = None # VideoCapture state self._has_grabbed = False @@ -149,10 +149,10 @@ def frame_rate(self) -> Fraction: return self._frame_rate @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: if self._is_device: assert isinstance(self._path_or_device, (int)) - return "Device %d" % self._path_or_device + return f"Device {self._path_or_device}" assert isinstance(self._path_or_device, (bytes, str)) return self._path_or_device @@ -175,7 +175,7 @@ def is_seekable(self) -> bool: return not self._is_device @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -183,7 +183,7 @@ def frame_size(self) -> ty.Tuple[int, int]: ) @property - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None @@ -224,7 +224,7 @@ def position_ms(self) -> float: def frame_number(self) -> int: return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) - def seek(self, target: ty.Union[FrameTimecode, float, int]): + def seek(self, target: FrameTimecode | float | int): if self._is_device: raise SeekError("Cannot seek if input is a device!") if target < 0: @@ -262,7 +262,7 @@ def reset(self): self._cap.release() self._open_capture(self._frame_rate) - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() @@ -293,7 +293,7 @@ def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: # Private Methods # - def _open_capture(self, framerate: ty.Optional[float] = None): + def _open_capture(self, framerate: float | None = None): """Opens capture referenced by this object and resets internal state.""" if self._is_device and self._path_or_device < 0: raise ValueError("Invalid/negative device ID specified.") @@ -346,7 +346,7 @@ class VideoCaptureAdapter(VideoStream): def __init__( self, cap: cv2.VideoCapture, - framerate: ty.Optional[float] = None, + framerate: float | None = None, max_read_attempts: int = 5, ): """Create from an existing OpenCV VideoCapture object. Used for webcams, live streams, @@ -368,7 +368,7 @@ def __init__( super().__init__() if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") if max_read_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") if not cap.isOpened(): @@ -430,7 +430,7 @@ def is_seekable(self) -> bool: return False @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Reported size of each video frame in pixels as a tuple of (width, height).""" return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), @@ -438,7 +438,7 @@ def frame_size(self) -> ty.Tuple[int, int]: ) @property - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" frame_count = math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) if frame_count > 0: @@ -471,7 +471,7 @@ def position_ms(self) -> float: def frame_number(self) -> int: return self._num_frames - def seek(self, target: ty.Union[FrameTimecode, float, int]): + def seek(self, target: FrameTimecode | float | int): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") @@ -479,7 +479,7 @@ def reset(self): """Not supported.""" raise NotImplementedError("Reset is not supported.") - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index a1ade9b4..72ce952b 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -35,10 +35,10 @@ class VideoStreamAv(VideoStream): # calculates the end time. def __init__( self, - path_or_io: ty.Union[ty.AnyStr, ty.BinaryIO], - framerate: ty.Optional[ty.Union[float, Fraction]] = None, - name: ty.Optional[str] = None, - threading_mode: ty.Optional[str] = None, + path_or_io: ty.AnyStr | ty.BinaryIO, + framerate: float | Fraction | None = None, + name: str | None = None, + threading_mode: str | None = None, suppress_output: bool = False, ): """Open a video by path. @@ -76,12 +76,12 @@ def __init__( # Ensure specified framerate is valid if set. if framerate is not None and framerate < MAX_FPS_DELTA: - raise ValueError("Specified framerate (%f) is invalid!" % framerate) + raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") self._name = "" if name is None else name self._path = "" - self._frame: ty.Optional[av.VideoFrame] = None - self._decoder: ty.Optional[ty.Generator] = None + self._frame: av.VideoFrame | None = None + self._decoder: ty.Generator | None = None self._decode_count: int = 0 self._reopened = True @@ -90,7 +90,7 @@ def __init__( threading_mode = av.codec.context.ThreadType[threading_mode.upper()] except KeyError as _: raise ValueError( - "Invalid threading mode! Must be one of: %s" % VALID_THREAD_MODES + f"Invalid threading mode! Must be one of: {VALID_THREAD_MODES}" ) from None if not suppress_output: @@ -149,12 +149,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: """Video path.""" return self._path @property - def name(self) -> ty.Union[bytes, str]: + def name(self) -> bytes | str: """Name of the video, without extension.""" return self._name @@ -164,7 +164,7 @@ def is_seekable(self) -> bool: return self._io.seekable() @property - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" return (self._codec_context.width, self._codec_context.height) @@ -233,7 +233,7 @@ def aspect_ratio(self) -> float: frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio - def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: + def seek(self, target: FrameTimecode | float | int) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -282,7 +282,7 @@ def reset(self): except Exception as ex: raise VideoOpenFailure() from ex - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: # Reuse a persistent decoder generator so the codec's internal frame buffer (used for # B-frame reordering) is never flushed prematurely. Creating a new generator each call # caused the last buffered frame to be lost at EOF. diff --git a/scenedetect/common.py b/scenedetect/common.py index 545ef7df..71b51c8d 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -74,18 +74,18 @@ ## Type Aliases ## -SceneList = ty.List[ty.Tuple["FrameTimecode", "FrameTimecode"]] +SceneList = list[tuple["FrameTimecode", "FrameTimecode"]] """Type hint for a list of scenes in the form (start time, end time).""" -CutList = ty.List["FrameTimecode"] +CutList = list["FrameTimecode"] """Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" -CropRegion = ty.Tuple[int, int, int, int] +CropRegion = tuple[int, int, int, int] """Type hint for rectangle of the form X0 Y0 X1 Y1 for cropping frames. Coordinates are relative to source frame without downscaling. """ -TimecodePair = ty.Tuple["FrameTimecode", "FrameTimecode"] +TimecodePair = tuple["FrameTimecode", "FrameTimecode"] """Named type for pairs of timecodes, which typically represents the start/end of a scene.""" MAX_FPS_DELTA: float = 1.0 / 1000000000.0 @@ -96,7 +96,7 @@ _MINUTES_PER_HOUR = 60.0 # Common framerates mapped from their float representation to exact rational values. -_COMMON_FRAMERATES: ty.Dict[Fraction, Fraction] = { +_COMMON_FRAMERATES: dict[Fraction, Fraction] = { Fraction(24000, 1001): Fraction(24000, 1001), # 23.976... Fraction(30000, 1001): Fraction(30000, 1001), # 29.97... Fraction(60000, 1001): Fraction(60000, 1001), # 59.94... @@ -192,7 +192,7 @@ def __init__( TypeError: Thrown if either `timecode` or `fps` are unsupported types. ValueError: Thrown when specifying a negative timecode or framerate. """ - self._time: ty.Union[_FrameNumber, _Seconds, Timecode] + self._time: _FrameNumber | _Seconds | Timecode """Internal time representation.""" self._rate: Fraction = None """Rate at which time passes between frames, measured in frames/sec.""" @@ -247,7 +247,7 @@ def __init__( raise TypeError("Timecode format/type unrecognized.") @property - def frame_num(self) -> ty.Optional[int]: + def frame_num(self) -> int | None: """The frame number. For VFR video or Timecode-backed objects, this is an approximation based on the average framerate. Prefer using `pts` and `time_base` for precise timing.""" if isinstance(self._time, Timecode): @@ -261,7 +261,7 @@ def frame_num(self) -> ty.Optional[int]: return self._time.value @property - def framerate(self) -> ty.Optional[float]: + def framerate(self) -> float | None: """The framerate to use for distance between frames and to calculate frame numbers. For a VFR video, this may just be the average framerate. Returns None if framerate is unknown (e.g. when working with pure Timecode representations).""" @@ -397,12 +397,12 @@ def get_timecode( mins = 0 hrs += 1 # We have to extend the precision by 1 here, since `format` will round up. - msec = format(secs, ".%df" % (precision + 1)) if precision else "" + msec = format(secs, f".{precision + 1}f") if precision else "" # Need to include decimal place in `msec_str`. msec_str = msec[-(2 + precision) : -1] secs_str = f"{int(secs):02d}{msec_str}" # Return hours, minutes, and seconds as a formatted timecode string. - return "%02d:%02d:%s" % (hrs, mins, secs_str) + return f"{hrs:02d}:{mins:02d}:{secs_str}" def _seconds_to_frames(self, seconds: float) -> int: """Convert `seconds` to the nearest number of frames using the current framerate. @@ -411,7 +411,7 @@ def _seconds_to_frames(self, seconds: float) -> int: """ return round(seconds * self._rate) - def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int: + def _parse_timecode_number(self, timecode: int | float) -> int: """Parse a timecode number, storing it as the exact number of frames. Can be passed as frame number (int), seconds (float) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 8afd0f71..2b581e3a 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -42,14 +42,14 @@ class SceneDetector(ABC): """ def __init__(self): - self._stats_manager: ty.Optional[StatsManager] = None + self._stats_manager: StatsManager | None = None # Required Methods @abstractmethod def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Process the next frame. `timecode` is assumed to be sequential. Args: @@ -62,7 +62,7 @@ def process_frame( # Optional Methods - def post_process(self, timecode: int) -> ty.List[FrameTimecode]: + def post_process(self, timecode: int) -> list[FrameTimecode]: """Called after there are no more frames to process. Args: @@ -82,7 +82,7 @@ def event_buffer_length(self) -> int: # Frame Stats/Metrics @property - def stats_manager(self) -> ty.Optional[StatsManager]: + def stats_manager(self) -> StatsManager | None: """Optional :class:`StatsManager ` to use for storing frame metrics. When this detector is added to a parent :class:`SceneManager `, then this is set to the @@ -91,10 +91,10 @@ def stats_manager(self) -> ty.Optional[StatsManager]: return self._stats_manager @stats_manager.setter - def stats_manager(self, value: ty.Optional[StatsManager]): + def stats_manager(self, value: StatsManager | None): self._stats_manager = value - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: """Returns a list of all metric names/keys used by this detector. Returns: @@ -115,7 +115,7 @@ class Mode(Enum): SUPPRESS = 1 """Suppress consecutive cuts until the filter length has passed.""" - def __init__(self, mode: Mode, length: ty.Union[int, float, str]): + def __init__(self, mode: Mode, length: int | float | str): """ Arguments: mode: The mode to use when enforcing `length`. @@ -128,7 +128,7 @@ def __init__(self, mode: Mode, length: ty.Union[int, float, str]): # known. Temporal inputs (float/non-digit str) populate `_filter_secs`; integer inputs # (int/digit str) populate `_filter_length`. self._filter_length: int = 0 - self._filter_secs: ty.Optional[float] = None + self._filter_secs: float | None = None if isinstance(length, float): self._filter_secs = length elif isinstance(length, str) and not length.strip().isdigit(): @@ -155,7 +155,7 @@ def _is_disabled(self) -> bool: return self._filter_secs <= 0.0 return self._filter_length <= 0 - def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[FrameTimecode]: + def filter(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTimecode]: if self._is_disabled: return [timecode] if above_threshold else [] if self._last_above is None: @@ -166,7 +166,7 @@ def filter(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[Fram return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") - def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: assert timecode.framerate >= 0 # Compute the threshold in seconds once from the first frame's framerate. This avoids # using an incorrect average fps (e.g. OpenCV on VFR video) on subsequent frames. @@ -180,7 +180,7 @@ def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> ty self._last_above = timecode return [timecode] - def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> ty.List[int]: + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: assert timecode.framerate >= 0 # Compute the threshold in seconds once from the first frame's framerate. if self._filter_secs is None: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index f1917d77..68a4115f 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -38,12 +38,12 @@ class AdaptiveDetector(ContentDetector): def __init__( self, adaptive_threshold: float = 3.0, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, window_width: int = 2, min_content_val: float = 15.0, weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: ty.Optional[int] = None, + kernel_size: int | None = None, ): """ Arguments: @@ -86,21 +86,19 @@ def __init__( self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( window_width=window_width, luma_only="" if not luma_only else "_lum" ) - self._buffer: ty.List[ty.Tuple[FrameTimecode, float]] = [] + self._buffer: list[tuple[FrameTimecode, float]] = [] # NOTE: The name of last cut is different from `self._last_scene_cut` from our base class, # and serves a different purpose! - self._last_cut: ty.Optional[FrameTimecode] = None + self._last_cut: FrameTimecode | None = None @property def event_buffer_length(self) -> int: return self.window_width - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return super().get_metrics() + [self._adaptive_ratio_key] - def process_frame( - self, timecode: FrameTimecode, frame_img: np.ndarray - ) -> ty.List[FrameTimecode]: + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: super().process_frame(timecode=timecode, frame_img=frame_img) # Initialize last scene cut point at the beginning of the frames of interest. diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 268233c3..b4060baa 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -98,16 +98,16 @@ class _FrameData: """Frame saturation map [2D 8-bit].""" lum: numpy.ndarray """Frame luma/brightness map [2D 8-bit].""" - edges: ty.Optional[numpy.ndarray] + edges: numpy.ndarray | None """Frame edge map [2D 8-bit, edges are 255, non edges 0]. Affected by `kernel_size`.""" def __init__( self, threshold: float = 27.0, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, weights: "ContentDetector.Components" = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, - kernel_size: ty.Optional[int] = None, + kernel_size: int | None = None, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): """ @@ -127,17 +127,17 @@ def __init__( """ super().__init__() self._threshold: float = threshold - self._last_above_threshold: ty.Optional[int] = None - self._last_frame: ty.Optional[ContentDetector._FrameData] = None + self._last_above_threshold: int | None = None + self._last_frame: ContentDetector._FrameData | None = None self._weights: ContentDetector.Components = weights if luma_only: self._weights = ContentDetector.LUMA_ONLY_WEIGHTS - self._kernel: ty.Optional[numpy.ndarray] = None + self._kernel: numpy.ndarray | None = None if kernel_size is not None: if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) - self._frame_score: ty.Optional[float] = None + self._frame_score: float | None = None # TODO(https://scenedetect.com/issue/168): Figure out a better long term plan for handling # `min_scene_len` which should be specified in seconds, not frames. self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) @@ -190,7 +190,7 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Process the next frame. `frame_num` is assumed to be sequential. Args: diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index af0994d1..16e9b543 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -51,9 +51,9 @@ def __init__( threshold: float = 0.395, size: int = 16, lowpass: int = 2, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, ): - super(HashDetector, self).__init__() + super().__init__() self._threshold = threshold self._min_scene_len = min_scene_len self._size = size @@ -69,7 +69,7 @@ def get_metrics(self): def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Similar to ContentDetector, but using a perceptual hashing algorithm to calculate a hash for each frame and then calculate a hash difference frame to frame.""" diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 8502e1e5..a450b089 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -34,7 +34,7 @@ def __init__( self, threshold: float = 0.05, bins: int = 256, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, ): """ Arguments: @@ -57,7 +57,7 @@ def __init__( self._last_cut = None self._metric_key = f"hist_diff [bins={self._bins}]" - def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]: + def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> list[int]: """Computes the histogram of the luma channel of the frame image and compares it with the histogram of the luma channel of the previous frame. If the difference between the histograms exceeds the threshold, a scene cut is detected. @@ -166,5 +166,5 @@ def calculate_histogram( return hist - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return [self._metric_key] diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index edb63024..2fa9114b 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -48,7 +48,7 @@ class Method(Enum): def __init__( self, threshold: float = 12, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, fade_bias: float = 0.0, add_final_scene: bool = False, method: Method = Method.FLOOR, @@ -95,12 +95,12 @@ def __init__( self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] self._time_base = None - def get_metrics(self) -> ty.List[str]: + def get_metrics(self) -> list[str]: return self._metric_keys def process_frame( self, timecode: FrameTimecode, frame_img: numpy.ndarray - ) -> ty.List[FrameTimecode]: + ) -> list[FrameTimecode]: """Process the next frame. Args: @@ -114,7 +114,7 @@ def process_frame( if self.last_scene_cut is None: self.last_scene_cut = timecode - cuts: ty.List[FrameTimecode] = [] + cuts: list[FrameTimecode] = [] # The metric used here to detect scene breaks is the percent of pixels # less than or equal to the threshold; however, since this differs on @@ -162,7 +162,7 @@ def process_frame( self.processed_frame = True return cuts - def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out. Only writes the scene cut if add_final_scene is true, and the last fade @@ -174,7 +174,7 @@ def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: # If the last fade detected was a fade out, we add a corresponding new # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. - cuts: ty.List[FrameTimecode] = [] + cuts: list[FrameTimecode] = [] if ( self.last_fade["type"] == "out" and self.add_final_scene diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index c559938e..020e5734 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -52,9 +52,9 @@ def push(self, ys: np.ndarray, ts: np.ndarray): class Predictor: def __init__( self, - model_path: ty.Union[str, Path], + model_path: str | Path, flash_filter: FlashFilter, - onnx_providers: ty.Union[ty.List[str], None], + onnx_providers: list[str] | None, threshold, ): import onnxruntime as ort @@ -132,10 +132,10 @@ def push(self, pixels: np.ndarray, time: np.ndarray): class TransnetV2Detector(SceneDetector): def __init__( self, - model_path: ty.Union[str, Path] = "tests/resources/transnetv2.onnx", - onnx_providers: ty.Union[ty.List[str], None] = None, + model_path: str | Path = "tests/resources/transnetv2.onnx", + onnx_providers: list[str] | None = None, threshold: float = 0.5, - min_scene_len: ty.Union[int, float, str] = 15, + min_scene_len: int | float | str = 15, filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): super().__init__() @@ -163,9 +163,7 @@ def mk_ft(self, pts: int): t = float(pts * self.time_base) return FrameTimecode(t, fps=self._fps) - def process_frame( - self, timecode: FrameTimecode, frame_img: np.ndarray - ) -> ty.List[FrameTimecode]: + def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: """Process the next frame.""" self.time_base = timecode.time_base @@ -189,7 +187,7 @@ def process_frame( else: return [] - def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Writes a final scene cut if the last detected fade was a fade-out.""" cuts = [] diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index b913933f..52df30b6 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -57,7 +57,7 @@ def write_scene_list( output_csv_file: ty.TextIO, scene_list: SceneList, include_cut_list: bool = True, - cut_list: ty.Optional[CutList] = None, + cut_list: CutList | None = None, col_separator: str = ",", row_separator: str = "\n", ): @@ -103,16 +103,16 @@ def write_scene_list( duration = end - start csv_writer.writerow( [ - "%d" % (i + 1), - "%d" % (start.frame_num + 1), + f"{i + 1:d}", + f"{start.frame_num + 1:d}", start.get_timecode(), - "%.3f" % start.seconds, - "%d" % end.frame_num, + f"{start.seconds:.3f}", + f"{end.frame_num:d}", end.get_timecode(), - "%.3f" % end.seconds, - "%d" % duration.frame_num, + f"{end.seconds:.3f}", + f"{duration.frame_num:d}", duration.get_timecode(), - "%.3f" % duration.seconds, + f"{duration.seconds:.3f}", ] ) @@ -120,12 +120,12 @@ def write_scene_list( def write_scene_list_html( output_html_filename: str, scene_list: SceneList, - cut_list: ty.Optional[CutList] = None, + cut_list: CutList | None = None, css: str = None, css_class: str = "mytable", - image_filenames: ty.Optional[ty.Dict[int, ty.List[str]]] = None, - image_width: ty.Optional[int] = None, - image_height: ty.Optional[int] = None, + image_filenames: dict[int, list[str]] | None = None, + image_width: int | None = None, + image_height: int | None = None, ): """Writes the given list of scenes to an output file handle in html format. @@ -209,16 +209,16 @@ def write_scene_list_html( row = SimpleTableRow( [ - "%d" % (i + 1), - "%d" % (start.frame_num + 1), + f"{i + 1:d}", + f"{start.frame_num + 1:d}", start.get_timecode(), - "%.3f" % start.seconds, - "%d" % end.frame_num, + f"{start.seconds:.3f}", + f"{end.frame_num:d}", end.get_timecode(), - "%.3f" % end.seconds, - "%d" % duration.frame_num, + f"{end.seconds:.3f}", + f"{duration.frame_num:d}", duration.get_timecode(), - "%.3f" % duration.seconds, + f"{duration.seconds:.3f}", ] ) @@ -252,7 +252,7 @@ def _edl_timecode(timecode: FrameTimecode) -> str: def write_scene_list_edl( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, title: str = "PySceneDetect", reel: str = "AX", @@ -298,12 +298,12 @@ def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: def write_scene_list_fcpx( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, - video_path: ty.Union[str, Path], + video_path: str | Path, frame_rate: Fraction, - frame_size: ty.Tuple[int, int], - video_name: ty.Optional[str] = None, + frame_size: tuple[int, int], + video_name: str | None = None, ): """Writes the given list of scenes to `output_path` in Final Cut Pro X XML format (FCPXML 1.9). @@ -397,13 +397,13 @@ def write_scene_list_fcpx( def write_scene_list_fcp7( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, - video_path: ty.Union[str, Path], + video_path: str | Path, frame_rate: Fraction, - frame_size: ty.Tuple[int, int], - video_name: ty.Optional[str] = None, - source_duration: ty.Optional[FrameTimecode] = None, + frame_size: tuple[int, int], + video_name: str | None = None, + source_duration: FrameTimecode | None = None, ): """Writes the given list of scenes to `output_path` in Final Cut Pro 7 XML (xmeml) format. @@ -517,11 +517,11 @@ def write_scene_list_fcp7( # fractional timecodes, we should export the framerate as a rational number instead. # https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190 def write_scene_list_otio( - output_path: ty.Union[str, Path], + output_path: str | Path, scene_list: SceneList, - video_path: ty.Union[str, Path], + video_path: str | Path, frame_rate: Fraction, - name: ty.Optional[str] = None, + name: str | None = None, audio: bool = True, ): """Writes the given list of scenes to `output_path` as an OTIO Timeline.1 JSON document. diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 842e1460..f3de6993 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -37,8 +37,8 @@ def _generate_timecode_list( scene_list: SceneList, num_images: int, - frame_margin: ty.Union[int, float, str], -) -> ty.List[ty.List[FrameTimecode]]: + frame_margin: int | float | str, +) -> list[list[FrameTimecode]]: """Generate per-scene image timecodes using PTS-accurate seconds-based timing. `frame_margin` accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``). @@ -72,9 +72,9 @@ def _generate_timecode_list( def _scale_image( image: np.ndarray, aspect_ratio: float, - height: ty.Optional[int], - width: ty.Optional[int], - scale: ty.Optional[float], + height: int | None, + width: int | None, + scale: float | None, interpolation: Interpolation, ) -> np.ndarray: # TODO: Combine this resize with the ones below. @@ -104,13 +104,13 @@ class _ImageExtractor: def __init__( self, num_images: int = 3, - frame_margin: ty.Union[int, float, str] = 1, + frame_margin: int | float | str = 1, image_extension: str = "jpg", - imwrite_param: ty.Dict[str, ty.Union[int, None]] = None, + imwrite_param: dict[str, int | None] = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, + scale: float | None = None, + height: int | None = None, + width: int | None = None, interpolation: Interpolation = Interpolation.CUBIC, ): """Multi-threaded implementation of save-images functionality. Uses background threads to @@ -159,9 +159,9 @@ def run( self, video: VideoStream, scene_list: SceneList, - output_dir: ty.Optional[str] = None, + output_dir: str | None = None, show_progress=False, - ) -> ty.Dict[int, ty.List[str]]: + ) -> dict[int, list[str]]: """Run image extraction on `video` using the current parameters. Thread-safe. Arguments: @@ -192,7 +192,7 @@ def run( image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d" def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode): - return "%s.%s" % ( + return "{}.{}".format( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (scene_number + 1), @@ -325,7 +325,7 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm): if progress_bar is not None: progress_bar.update(1) - def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.List[FrameTimecode]]: + def generate_timecode_list(self, scene_list: SceneList) -> list[list[FrameTimecode]]: """Generates a list of timecodes for each scene in `scene_list` based on the current config parameters. @@ -347,18 +347,18 @@ def save_images( scene_list: SceneList, video: VideoStream, num_images: int = 3, - frame_margin: ty.Union[int, float, str] = 1, + frame_margin: int | float | str = 1, image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: ty.Optional[str] = None, - show_progress: ty.Optional[bool] = False, - scale: ty.Optional[float] = None, - height: ty.Optional[int] = None, - width: ty.Optional[int] = None, + output_dir: str | None = None, + show_progress: bool | None = False, + scale: float | None = None, + height: int | None = None, + width: int | None = None, interpolation: Interpolation = Interpolation.CUBIC, threading: bool = True, -) -> ty.Dict[int, ty.List[str]]: +) -> dict[int, list[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -468,7 +468,7 @@ def save_images( if frame_im is not None and frame_im is not False: # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = "%s.%s" % ( + file_path = "{}.{}".format( filename_template.safe_substitute( VIDEO_NAME=video.name, SCENE_NUMBER=scene_num_format % (i + 1), diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index a503db6b..bcee16c8 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -52,7 +52,7 @@ for details. Sorry about that! """ -_FFMPEG_PATH: ty.Optional[str] = get_ffmpeg_path() +_FFMPEG_PATH: str | None = get_ffmpeg_path() """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" _DEFAULT_FFMPEG_ARGS = ( @@ -157,9 +157,9 @@ def formatter(video: VideoMetadata, scene: SceneMetadata) -> str: def split_video_mkvmerge( input_video_path: str, scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[ty.Union[str, Path]] = None, - output_file_template: ty.Optional[ty.Union[str, Path]] = "$VIDEO_NAME.mkv", - video_name: ty.Optional[str] = None, + output_dir: str | Path | None = None, + output_file_template: str | Path | None = "$VIDEO_NAME.mkv", + video_name: str | None = None, show_output: bool = False, suppress_output=None, ) -> int: @@ -217,12 +217,13 @@ def split_video_mkvmerge( "-o", str(output_path), "--split", - "parts:%s" - % ",".join( - [ - "%s-%s" % (start_time.get_timecode(), end_time.get_timecode()) - for start_time, end_time in scene_list - ] + "parts:{}".format( + ",".join( + [ + f"{start_time.get_timecode()}-{end_time.get_timecode()}" + for start_time, end_time in scene_list + ] + ) ), input_video_path, ] @@ -252,15 +253,15 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, scene_list: ty.Iterable[TimecodePair], - output_dir: ty.Optional[Path] = None, + output_dir: Path | None = None, output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", - video_name: ty.Optional[str] = None, + video_name: str | None = None, arg_override: str = _DEFAULT_FFMPEG_ARGS, show_progress: bool = False, show_output: bool = False, suppress_output=None, hide_progress=None, - formatter: ty.Optional[PathFormatter] = None, + formatter: PathFormatter | None = None, ) -> int: """Split `input_video_path` using `ffmpeg` based on the scenes in `scene_list`. diff --git a/scenedetect/platform.py b/scenedetect/platform.py index b832e250..85bc85b4 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -76,7 +76,7 @@ def __exit__(self, type, value, traceback): # TODO: Move this into scene_manager. -def get_cv2_imwrite_params() -> ty.Dict[str, ty.Union[int, None]]: +def get_cv2_imwrite_params() -> dict[str, int | None]: """Get OpenCV imwrite Params: Returns a dict of supported image formats and their associated quality/compression parameter index, or None if that format is not supported. @@ -88,7 +88,7 @@ def get_cv2_imwrite_params() -> ty.Dict[str, ty.Union[int, None]]: current system library (e.g. {'jpg': None}). """ - def _get_cv2_param(param_name: str) -> ty.Union[int, None]: + def _get_cv2_param(param_name: str) -> int | None: if param_name.startswith("CV_"): param_name = param_name[3:] try: @@ -124,7 +124,7 @@ def get_file_name(file_path: ty.AnyStr, include_extension=True) -> ty.AnyStr: def get_and_create_path( - file_path: ty.AnyStr, output_directory: ty.Optional[ty.AnyStr] = None + file_path: ty.AnyStr, output_directory: ty.AnyStr | None = None ) -> ty.AnyStr: """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories @@ -159,7 +159,7 @@ def get_and_create_path( def init_logger( - log_level: int = logging.INFO, show_stdout: bool = False, log_file: ty.Optional[str] = None + log_level: int = logging.INFO, show_stdout: bool = False, log_file: str | None = None ): """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'. By default the logger has no handlers to suppress output. All existing log handlers are replaced @@ -204,7 +204,7 @@ class CommandTooLong(Exception): """Raised if the length of a command line argument exceeds the limit allowed on Windows.""" -def invoke_command(args: ty.List[str]) -> int: +def invoke_command(args: list[str]) -> int: """Same as calling Python's subprocess.call() method, but explicitly raises a different exception when the command length is too long. @@ -233,7 +233,7 @@ def invoke_command(args: ty.List[str]) -> int: raise -def get_ffmpeg_path() -> ty.Optional[str]: +def get_ffmpeg_path() -> str | None: """Get path to ffmpeg if available on the current system. First looks at PATH, then checks if one is available from the `imageio_ffmpeg` package. Returns None if ffmpeg couldn't be found. """ @@ -263,7 +263,7 @@ def get_ffmpeg_path() -> ty.Optional[str]: return None -def get_ffmpeg_version() -> ty.Optional[str]: +def get_ffmpeg_version() -> str | None: """Get ffmpeg version identifier, or None if ffmpeg is not found. Uses `get_ffmpeg_path()`.""" ffmpeg_path = get_ffmpeg_path() if ffmpeg_path is None: @@ -277,7 +277,7 @@ def get_ffmpeg_version() -> ty.Optional[str]: return output.splitlines()[0] -def get_mkvmerge_version() -> ty.Optional[str]: +def get_mkvmerge_version() -> str | None: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" tool_name = "mkvmerge" try: @@ -309,8 +309,8 @@ def get_system_version_info() -> str: out_lines += [ output_template.format(name, version) for name, version in ( - ("OS", "%s" % platform.platform()), - ("Python", "%s %s" % (platform.python_implementation(), platform.python_version())), + ("OS", f"{platform.platform()}"), + ("Python", f"{platform.python_implementation()} {platform.python_version()}"), ("Architecture", " + ".join(platform.architecture())), ) ] diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 8b47b32b..e68508ee 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -141,8 +141,8 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI def get_scenes_from_cuts( cut_list: CutList, - start_pos: ty.Union[int, FrameTimecode], - end_pos: ty.Union[int, FrameTimecode], + start_pos: int | FrameTimecode, + end_pos: int | FrameTimecode, ) -> SceneList: """Returns a list of tuples of start/end FrameTimecodes for each scene based on a list of detected scene cuts/breaks. @@ -194,15 +194,15 @@ class SceneManager: def __init__( self, - stats_manager: ty.Optional[StatsManager] = None, + stats_manager: StatsManager | None = None, ): """ Arguments: stats_manager: :class:`StatsManager` to bind to this `SceneManager`. Can be accessed via the `stats_manager` property of the resulting object to save to disk. """ - self._cutting_list: ty.List[FrameTimecode] = [] - self._detector_list: ty.List[SceneDetector] = [] + self._cutting_list: list[FrameTimecode] = [] + self._detector_list: list[SceneDetector] = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the # `stats_manager` argument to to `store_stats: bool=False`, or lazy-init one. @@ -210,16 +210,16 @@ def __init__( # TODO(v1.0): This class should own a VideoStream as well, instead of passing one # to the detect_scenes method. If concatenation is required, it can be implemented as # a generic VideoStream wrapper. - self._stats_manager: ty.Optional[StatsManager] = stats_manager + self._stats_manager: StatsManager | None = stats_manager # Position of video that was first passed to detect_scenes. self._start_pos: FrameTimecode = None # Position of video on the last frame processed by detect_scenes. self._last_pos: FrameTimecode = None # Size of the decoded frames. - self._frame_size: ty.Tuple[int, int] = None + self._frame_size: tuple[int, int] = None self._frame_size_errors: int = 0 - self._base_timecode: ty.Optional[FrameTimecode] = None + self._base_timecode: FrameTimecode | None = None self._downscale: int = 1 self._auto_downscale: bool = True # Interpolation method to use when downscaling. Defaults to linear interpolation @@ -231,7 +231,7 @@ def __init__( self._exception_info = None self._stop = threading.Event() - self._frame_buffer: ty.List[ty.Tuple[FrameTimecode, np.ndarray]] = [] + self._frame_buffer: list[tuple[FrameTimecode, np.ndarray]] = [] self._frame_buffer_size = 0 self._crop = None @@ -245,12 +245,12 @@ def interpolation(self, value: Interpolation): self._interpolation = value @property - def stats_manager(self) -> ty.Optional[StatsManager]: + def stats_manager(self) -> StatsManager | None: """Getter for the StatsManager associated with this SceneManager, if any.""" return self._stats_manager @property - def crop(self) -> ty.Optional[CropRegion]: + def crop(self) -> CropRegion | None: """Portion of the frame to crop. Tuple of 4 ints in the form (X0, Y0, X1, Y1) where X0, Y0 describes one point and X1, Y1 is another which describe a rectangle inside of the frame. Coordinates start from 0 and are inclusive. For example, with a 100x100 pixel video, @@ -373,7 +373,7 @@ def get_scene_list(self, start_in_scene: bool = False) -> SceneList: scene_list = [] return sorted(scene_list) - def _get_cutting_list(self) -> ty.List[FrameTimecode]: + def _get_cutting_list(self) -> list[FrameTimecode]: """Return a sorted list of unique frame numbers of any detected scene cuts.""" if not self._cutting_list: return [] @@ -384,7 +384,7 @@ def _process_frame( self, position: FrameTimecode, frame_im: np.ndarray, - callback: ty.Optional[ty.Callable[[np.ndarray, FrameTimecode], None]] = None, + callback: ty.Callable[[np.ndarray, FrameTimecode], None] | None = None, ) -> bool: """Add any cuts detected with the current frame to the cutting list. Returns True if any new cuts were detected, False otherwise.""" @@ -419,12 +419,12 @@ def stop(self) -> None: def detect_scenes( self, video: VideoStream = None, - duration: ty.Optional[FrameTimecode] = None, - end_time: ty.Optional[FrameTimecode] = None, + duration: FrameTimecode | None = None, + end_time: FrameTimecode | None = None, frame_skip: int = 0, show_progress: bool = False, - callback: ty.Optional[ty.Callable[[np.ndarray, int], None]] = None, - frame_source: ty.Optional[VideoStream] = None, + callback: ty.Callable[[np.ndarray, int], None] | None = None, + frame_source: VideoStream | None = None, ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the number of frames processed. Results can be obtained by calling :meth:`get_scene_list` or diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 61e67970..8c2ea71c 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -103,12 +103,10 @@ def __init__(self, base_timecode: FrameTimecode = None): """ # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) # of each frame metric key and the value it represents (usually float). - self._frame_metrics: ty.Dict[FrameTimecode, ty.Dict[str, float]] = dict() - self._metric_keys: ty.Set[str] = set() + self._frame_metrics: dict[FrameTimecode, dict[str, float]] = dict() + self._metric_keys: set[str] = set() self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: ty.Optional[FrameTimecode] = ( - base_timecode # Used for timing calculations. - ) + self._base_timecode: FrameTimecode | None = base_timecode # Used for timing calculations. @property def metric_keys(self) -> ty.Iterable[str]: @@ -120,9 +118,7 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: # TODO(https://scenedetect.com/issues/507): We should support the dictionary protocol instead # of using this bespoke interface. It would be useful for Pandas compatibility as well. - def get_metrics( - self, timecode: FrameTimecode, metric_keys: ty.Iterable[str] - ) -> ty.List[ty.Any]: + def get_metrics(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> list[ty.Any]: """Return the requested statistics/metrics for a given timecode. Returns: @@ -131,7 +127,7 @@ def get_metrics( """ return [self._get_metric(timecode, metric_key) for metric_key in metric_keys] - def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: ty.Dict[str, ty.Any]) -> None: + def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: @@ -160,7 +156,7 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: ty.Union[str, bytes, Path, ty.TextIO], + csv_file: str | bytes | Path | ty.TextIO, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. @@ -195,7 +191,7 @@ def save_to_csv( ) @staticmethod - def valid_header(row: ty.List[str]) -> bool: + def valid_header(row: list[str]) -> bool: """Check that the given CSV row is a valid header for a statsfile. Arguments: @@ -212,7 +208,7 @@ def valid_header(row: ty.List[str]) -> bool: # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. - def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optional[int]: + def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: """[DEPRECATED] DO NOT USE Load all metrics stored in a CSV file into the StatsManager instance. Will be removed in a @@ -281,7 +277,7 @@ def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optiona self._set_metric(frame_number, loaded_metrics[i], float(metric)) except ValueError: raise StatsFileCorrupt( - "Corrupted value in stats file: %s" % metric + f"Corrupted value in stats file: {metric}" ) from ValueError num_frames += 1 self._metric_keys = self._metric_keys.union(set(loaded_metrics)) @@ -291,7 +287,7 @@ def load_from_csv(self, csv_file: ty.Union[str, bytes, ty.TextIO]) -> ty.Optiona # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Optional[ty.Any]: + def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Any | None: if self._metric_exists(timecode, metric_key): return self._frame_metrics[timecode][metric_key] return None diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 18a04b2c..987ff34b 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -106,13 +106,13 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> ty.Union[bytes, str]: + def path(self) -> bytes | str: """Video or device path.""" ... @property @abstractmethod - def name(self) -> ty.Union[bytes, str]: + def name(self) -> bytes | str: """Name of the video, without extension, or device.""" ... @@ -130,13 +130,13 @@ def frame_rate(self) -> Fraction: @property @abstractmethod - def duration(self) -> ty.Optional[FrameTimecode]: + def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" ... @property @abstractmethod - def frame_size(self) -> ty.Tuple[int, int]: + def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" ... @@ -175,7 +175,7 @@ def frame_number(self) -> int: # @abstractmethod - def read(self, decode: bool = True) -> ty.Union[np.ndarray, bool]: + def read(self, decode: bool = True) -> np.ndarray | bool: """Read and decode the next frame as a np.ndarray. Returns False when video ends. Arguments: @@ -195,7 +195,7 @@ def reset(self) -> None: ... @abstractmethod - def seek(self, target: ty.Union[FrameTimecode, float, int]) -> None: + def seek(self, target: FrameTimecode | float | int) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). diff --git a/tests/conftest.py b/tests/conftest.py index 8cef4b0e..21a43eb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,14 +45,13 @@ def check_exists(path: ty.AnyStr) -> ty.AnyStr: """ if not os.path.exists(path): raise FileNotFoundError( - """ -Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + f""" +Test video file ({path}) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset """ - % path ) return path diff --git a/tests/helpers.py b/tests/helpers.py index 4bdb6d7c..050b0620 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -20,7 +20,7 @@ from scenedetect._cli.controller import run_scenedetect -def invoke_cli(args: ty.List[str], catch_exceptions: bool = False) -> ty.Tuple[int, str]: +def invoke_cli(args: list[str], catch_exceptions: bool = False) -> tuple[int, str]: """Invoke the scenedetect CLI in-process using Click's CliRunner. Replicates the two-step execution of ``__main__.py``: diff --git a/tests/test_api.py b/tests/test_api.py index 853b7c32..4d355ab8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,7 +20,7 @@ def test_api_detect(test_video_file: str): scene_list = detect(test_video_file, ContentDetector()) for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_detect_start_end_time(test_video_file: str): @@ -31,7 +31,7 @@ def test_api_detect_start_end_time(test_video_file: str): # See test_api_timecode_types() for examples of each format. scene_list = detect(test_video_file, ContentDetector(), start_time=10.5, end_time=15.9) for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_detect_stats(test_video_file: str): @@ -51,7 +51,7 @@ def test_api_scene_manager(test_video_file: str): scene_manager.detect_scenes(video=video) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_scene_manager_start_end_time(test_video_file: str): @@ -69,7 +69,7 @@ def test_api_scene_manager_start_end_time(test_video_file: str): scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() for i, scene in enumerate(scene_list): - print("Scene %d: %s - %s" % (i + 1, scene[0].get_timecode(), scene[1].get_timecode())) + print(f"Scene {i + 1}: {scene[0].get_timecode()} - {scene[1].get_timecode()}") def test_api_timecode_types(): @@ -100,7 +100,7 @@ def test_api_stats_manager(test_video_file: str): scene_manager.add_detector(ContentDetector()) scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. - filename = "%s.stats.csv" % test_video_file + filename = f"{test_video_file}.stats.csv" scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -112,7 +112,7 @@ def test_api_scene_manager_callback(test_video_file: str): # Callback to invoke on the first frame of every new scene detection. def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print("New scene found at frame %d." % frame_num) + print(f"New scene found at frame {frame_num}.") video = open_video(test_video_file) scene_manager = SceneManager() @@ -131,7 +131,7 @@ def test_api_device_callback(test_video_file: str): # Callback to invoke on the first frame of every new scene detection. def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print("New scene found at frame %d." % frame_num) + print(f"New scene found at frame {frame_num}.") # We open a file just for test purposes, but we can also use a device or pipe here. cap = cv2.VideoCapture(test_video_file) diff --git a/tests/test_cli.py b/tests/test_cli.py index 78841978..740bce51 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,8 +67,8 @@ def invoke_scenedetect( args: str = "", - output_dir: ty.Optional[str] = None, - config_file: ty.Optional[str] = DEFAULT_CONFIG_FILE, + output_dir: str | None = None, + config_file: str | None = DEFAULT_CONFIG_FILE, **kwargs, ): """Invokes the scenedetect CLI with the specified arguments and returns the exit code. @@ -98,9 +98,9 @@ def invoke_scenedetect( value_dict.update(**kwargs) command = SCENEDETECT_CMD if output_dir: - command += " -o %s" % output_dir + command += f" -o {output_dir}" if config_file: - command += " -c %s" % config_file + command += f" -c {config_file}" command += " " + args.format(**value_dict) return subprocess.call(command.strip().split(" ")) @@ -512,8 +512,9 @@ def test_cli_save_images_path_handling(tmp_path: Path): """Test `save-images` ability to handle UTF-8 paths.""" assert ( invoke_scenedetect( - "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} save-images -f %s" - % ("電腦檔案-$SCENE_NUMBER-$IMAGE_NUMBER"), + "-i {{VIDEO}} -s {{STATS}} time {{TIME}} {{DETECTOR}} save-images -f {}".format( + "電腦檔案-$SCENE_NUMBER-$IMAGE_NUMBER" + ), output_dir=tmp_path, ) == 0 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 8db13213..26f8d6af 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -32,14 +32,14 @@ ThresholdDetector, ) -FAST_CUT_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( +FAST_CUT_DETECTORS: tuple[type[SceneDetector]] = ( AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ) -ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) +ALL_DETECTORS: tuple[type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) # TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively # consistent frame scores regardless of resolution. This will ensure that threshold values will hold @@ -57,14 +57,13 @@ def get_absolute_path(relative_path: str) -> str: abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): raise FileNotFoundError( - """ -Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + f""" +Test video file ({relative_path}) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset """ - % relative_path ) return abs_path @@ -81,7 +80,7 @@ class TestCase: """Start time as frames.""" end_time: int """End time as frames.""" - scene_boundaries: ty.List[int] + scene_boundaries: list[int] """Scene boundaries.""" def detect(self): @@ -107,7 +106,7 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], ), - id="%s/default" % detector_type.__name__, + id=f"{detector_type.__name__}/default", ) for detector_type in FAST_CUT_DETECTORS ] @@ -121,7 +120,7 @@ def get_fast_cut_test_cases(): end_time=1450, scene_boundaries=[1199, 1260, 1334, 1365], ), - id="%s/m=30" % detector_type.__name__, + id=f"{detector_type.__name__}/m=30", ) for detector_type in FAST_CUT_DETECTORS ] diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index b1e144d2..6c9b5817 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -89,7 +89,7 @@ class FakeCallback: """Fake callback used for testing. Tracks the frame numbers the callback was invoked with.""" def __init__(self): - self.scene_list: ty.List[int] = [] + self.scene_list: list[int] = [] def get_callback_lambda(self): """For testing using a lambda..""" diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 5b0ad57f..249a8cda 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -31,7 +31,7 @@ # Entries are (start_timecode, end_timecode). All backends should agree on cut timecodes since # CAP_PROP_POS_MSEC gives accurate PTS-derived timestamps. The last scene ends at the clip # boundary (end_time) which may vary slightly between backends based on frame counting. -EXPECTED_SCENES_VFR: ty.List[ty.Tuple[str, str]] = [ +EXPECTED_SCENES_VFR: list[tuple[str, str]] = [ ("00:00:00.000", "00:00:03.921"), ("00:00:03.921", "00:00:09.676"), ] @@ -40,7 +40,7 @@ # 10s of goldeneye.mp4 by dropping every 3rd frame (frames 2,5,8,...). PTS durations alternate # between 1001 and 2002 (time_base=1/24000), nominal fps=24000/1001, avg fps≈16. The last scene # ends at the clip boundary and may vary slightly between backends. -EXPECTED_SCENES_VFR_DROP3: ty.List[ty.Tuple[str, str]] = [ +EXPECTED_SCENES_VFR_DROP3: list[tuple[str, str]] = [ ("00:00:00.000", "00:00:03.754"), ("00:00:03.754", "00:00:08.759"), ] @@ -161,7 +161,7 @@ def test_vfr_csv_output(test_vfr_video: str, tmp_path): write_scene_list(f, scene_list) # Verify CSV contains valid data. - with open(csv_path, "r") as f: + with open(csv_path) as f: reader = csv.reader(f) rows = list(reader) assert len(rows) >= 3 # 2 header rows + data @@ -413,7 +413,7 @@ def test_vfr_csv_backend_conformance(test_vfr_video: str): Only the known interior scenes are compared; the last scene's end time may vary slightly between backends since it reflects the clip boundary rather than a detected cut. """ - timecodes: ty.Dict[str, ty.List[ty.Tuple[str, str]]] = {} + timecodes: dict[str, list[tuple[str, str]]] = {} for backend in ("pyav", "opencv"): video = open_video(test_vfr_video, backend=backend) sm = SceneManager() diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 115c16e4..a757c540 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -65,14 +65,13 @@ def get_absolute_path(relative_path: str) -> str: abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) if not os.path.exists(abs_path): raise FileNotFoundError( - """ -Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: + f""" +Test video file ({relative_path}) must be present to run test case. This file can be obtained by running the following commands from the root of the repository: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ git reset """ - % relative_path ) return abs_path @@ -91,7 +90,7 @@ class VideoParameters: # TODO: Save two "golden" frames from each video on a shot boundary, and use that to validate # that seeking works correctly for all backends (as well as that no frames are dropped). -def get_test_video_params() -> ty.List[VideoParameters]: +def get_test_video_params() -> list[VideoParameters]: """Fixture for parameters of all videos.""" return [ VideoParameters( @@ -140,7 +139,7 @@ def get_test_video_params() -> ty.List[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_properties(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) @@ -153,7 +152,7 @@ def test_properties(self, vs_type: ty.Type[VideoStream], test_video: VideoParame test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_read(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" stream = vs_type(test_video.path) frame = stream.read() @@ -161,13 +160,13 @@ def test_read(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_decode(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_read_no_decode(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_time_invariants(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -190,7 +189,7 @@ def test_time_invariants(self, vs_type: ty.Type[VideoStream], test_video: VideoP 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_reset(self, vs_type: type[VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" stream = vs_type(test_video.path) # Decode some frames, then reset the VideoStream and validate the time invariants. @@ -202,7 +201,7 @@ def test_reset(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters) assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate `seek()` functionality with different offset types.""" stream = vs_type(test_video.path) @@ -248,7 +247,7 @@ def test_seek(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek_start(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" stream = vs_type(test_video.path) # Here we check similar invariants to test_time_invariants, but using seek(). @@ -283,7 +282,7 @@ def test_seek_start(self, vs_type: ty.Type[VideoStream], test_video: VideoParame assert stream.frame_number == 2 stream = vs_type(test_video.path) - def test_read_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_read_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): """Ensure calling `read()` handles the end of the video correctly.""" stream = vs_type(test_video.path) # To make the test faster, we seek to the second last frame. @@ -296,7 +295,7 @@ def test_read_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParamete else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): """Validate calling `seek()` to offset past end of video.""" stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit @@ -315,7 +314,7 @@ def test_seek_past_eof(self, vs_type: ty.Type[VideoStream], test_video: VideoPar else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: ty.Type[VideoStream], test_video: VideoParameters): + def test_seek_invalid(self, vs_type: type[VideoStream], test_video: VideoParameters): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -331,13 +330,13 @@ def test_seek_invalid(self, vs_type: ty.Type[VideoStream], test_video: VideoPara # -def test_invalid_path(vs_type: ty.Type[VideoStream]): +def test_invalid_path(vs_type: type[VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): _ = vs_type("this_path_should_not_exist.mp4") -def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): +def test_corrupt_video(vs_type: type[VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. @@ -351,4 +350,4 @@ def test_corrupt_video(vs_type: ty.Type[VideoStream], corrupt_video_file: str): # OpenCV usually fails to read the video at frame 45, but the remaining frames all seem to # decode just fine. Make sure all backends can get to 60 without reporting a failure. for frame in range(60): - assert stream.read() is not False, "Failed on frame %d!" % frame + assert stream.read() is not False, f"Failed on frame {frame}!" From 4246791becec6aa264f6f551fd226d171f2b5c97 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:30:13 -0400 Subject: [PATCH 301/407] [lint] Enable ruff SIM (simplify) rules --- pyproject.toml | 3 +-- scenedetect/_cli/config.py | 35 ++++++++++++++++++---------------- scenedetect/_cli/controller.py | 13 +++++++++---- scenedetect/backends/pyav.py | 3 ++- scenedetect/output/video.py | 21 ++++++++++---------- scenedetect/platform.py | 10 ++++++++++ scenedetect/scene_manager.py | 2 +- scenedetect/stats_manager.py | 4 +--- 8 files changed, 54 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8fa5a4b7..546ef616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,8 @@ select = [ "I", # pyupgrade "UP", - # TODO - Add additional rule sets (https://docs.astral.sh/ruff/rules/): # flake8-simplify - #"SIM", + "SIM", ] ignore = [ # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 65171063..d63e611f 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -534,12 +534,12 @@ def _validate_structure(parser: ConfigParser) -> tuple[bool, list[LogMessage]]: ) ) continue - elif section not in CONFIG_MAP.keys(): + elif section not in CONFIG_MAP: success = False logs.append((logging.ERROR, f"Unsupported config section: [{section_name}]")) continue for option_name, _ in parser.items(section_name): - if option_name not in CONFIG_MAP[section].keys(): + if option_name not in CONFIG_MAP[section]: success = False logs.append( ( @@ -640,21 +640,24 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess # replace newlines with spaces, and strip any remaining leading/trailing whitespace. if value_type is None: config_value = parser.get(command, option).replace("\n", " ").strip() - if command in CHOICE_MAP and option in CHOICE_MAP[command]: - if config_value.lower() not in CHOICE_MAP[command][option]: - success = False - logs.append( - ( - logging.ERROR, - "Invalid value for [{}] option '{}': {}. Must be one of: {}.".format( - command, - option, - parser.get(command, option), - ", ".join(choice for choice in CHOICE_MAP[command][option]), - ), - ) + if ( + command in CHOICE_MAP + and option in CHOICE_MAP[command] + and config_value.lower() not in CHOICE_MAP[command][option] + ): + success = False + logs.append( + ( + logging.ERROR, + "Invalid value for [{}] option '{}': {}. Must be one of: {}.".format( + command, + option, + parser.get(command, option), + ", ".join(choice for choice in CHOICE_MAP[command][option]), + ), ) - continue + ) + continue config[command][option] = config_value continue diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 621d9217..95ccd8a9 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -82,10 +82,15 @@ def run_scenedetect(context: CliContext): def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> SceneList: # Handle --merge-last-scene. If set, when the last scene is shorter than --min-scene-len, # it will be merged with the previous one. - if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0: - if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len: - new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] + if ( + context.merge_last_scene + and context.min_scene_len is not None + and context.min_scene_len > 0 + and len(scene_list) > 1 + and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len + ): + new_last_scene = (scene_list[-2][0], scene_list[-1][1]) + scene_list = scene_list[:-2] + [new_last_scene] # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 72ce952b..59a50ada 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -100,7 +100,8 @@ def __init__( try: if isinstance(path_or_io, (str, bytes)): self._path = path_or_io - self._io = open(path_or_io, "rb") + # File handle is intentionally long-lived and tied to the VideoStream. + self._io = open(path_or_io, "rb") # noqa: SIM115 if not self._name: self._name = get_file_name(self.path, include_extension=False) else: diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index bcee16c8..9434d4de 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -32,14 +32,20 @@ import logging import math -import subprocess import time import typing as ty from dataclasses import dataclass from pathlib import Path from scenedetect.common import FrameTimecode, TimecodePair -from scenedetect.platform import CommandTooLong, Template, get_ffmpeg_path, invoke_command, tqdm +from scenedetect.platform import ( + CommandTooLong, + Template, + get_ffmpeg_path, + get_mkvmerge_path, + invoke_command, + tqdm, +) logger = logging.getLogger("pyscenedetect") @@ -52,6 +58,8 @@ for details. Sorry about that! """ +# TODO: Resolve this on first use (e.g., functools.cache on the getter) rather than at import +# time, so that importing this module doesn't spawn an ffmpeg subprocess. _FFMPEG_PATH: str | None = get_ffmpeg_path() """Relative path to the ffmpeg binary on this system, if any (will be None if not available).""" @@ -71,14 +79,7 @@ def is_mkvmerge_available() -> bool: Returns: True if `mkvmerge` can be invoked, False otherwise. """ - ret_val = None - try: - ret_val = subprocess.call(["mkvmerge", "--quiet"]) - except OSError: - return False - if ret_val is not None and ret_val != 2: - return False - return True + return get_mkvmerge_path() is not None def is_ffmpeg_available() -> bool: diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 85bc85b4..e26ce42b 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -277,6 +277,16 @@ def get_ffmpeg_version() -> str | None: return output.splitlines()[0] +def get_mkvmerge_path() -> str | None: + """Get path to mkvmerge if available on the current system by checking PATH. Returns None if + mkvmerge couldn't be found.""" + try: + subprocess.call(["mkvmerge", "--quiet"]) + return "mkvmerge" + except OSError: + return None + + def get_mkvmerge_version() -> str | None: """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH.""" tool_name = "mkvmerge" diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index e68508ee..94dad45a 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -399,7 +399,7 @@ def _process_frame( for detector in self._detector_list: cuts = detector.process_frame(position, frame_im) self._cutting_list += cuts - new_cuts = True if cuts else False + new_cuts = bool(cuts) if callback: for cut in cuts: for position, frame in self._frame_buffer: diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 8c2ea71c..3fa9a567 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -202,9 +202,7 @@ def valid_header(row: list[str]) -> bool: """ if not row or not len(row) >= 2: return False - if row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE: - return False - return True + return not (row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE) # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. From c4da726e76ec8a9837cca3778134fbffa117bd31 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:39:16 -0400 Subject: [PATCH 302/407] [lint] Enable ruff RUF rules --- pyproject.toml | 2 + scenedetect/_cli/config.py | 6 +- scenedetect/_cli/context.py | 8 +-- scenedetect/_cli/controller.py | 2 +- scenedetect/backends/opencv.py | 4 +- scenedetect/backends/pyav.py | 2 +- scenedetect/detectors/adaptive_detector.py | 2 +- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/histogram_detector.py | 2 +- scenedetect/output/__init__.py | 6 +- scenedetect/output/image.py | 2 +- scenedetect/scene_manager.py | 2 +- scenedetect/stats_manager.py | 2 +- tests/test_cli.py | 67 +++++++++++++++------ 14 files changed, 72 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 546ef616..e0702529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ select = [ "UP", # flake8-simplify "SIM", + # ruff-native checks + "RUF", ] ignore = [ # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index d63e611f..155ff3e1 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -141,7 +141,7 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": class CropValue(ValidatedValue): """Validator for crop region defined as X0 Y0 X1 Y1.""" - _IGNORE_CHARS = [",", "/", "(", ")"] + _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" def __init__(self, value: str | tuple[int, int, int, int] | None = None): @@ -183,7 +183,7 @@ def from_config(config_value: str, default: "CropValue") -> "CropValue": class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" - _IGNORE_CHARS = [",", "/", "(", ")"] + _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" def __init__(self, value: str | ContentDetector.Components): @@ -795,4 +795,4 @@ def get_help_string(self, command: str, option: str, show_default: bool | None = show_default is None and is_flag and CONFIG_MAP[command][option] is False ): return "" - return f" [default: {str(CONFIG_MAP[command][option])}]" + return f" [default: {CONFIG_MAP[command][option]!s}]" diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 771dc013..22502909 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -315,7 +315,7 @@ def handle_options( def get_detect_content_params( self, threshold: float | None = None, - luma_only: bool = None, + luma_only: bool | None = None, min_scene_len: str | None = None, weights: tuple[float, float, float, float] | None = None, kernel_size: int | None = None, @@ -355,7 +355,7 @@ def get_detect_adaptive_params( threshold: float | None = None, min_content_val: float | None = None, frame_window: int | None = None, - luma_only: bool = None, + luma_only: bool | None = None, min_scene_len: str | None = None, weights: tuple[float, float, float, float] | None = None, kernel_size: int | None = None, @@ -396,7 +396,7 @@ def get_detect_threshold_params( self, threshold: float | None = None, fade_bias: float | None = None, - add_last_scene: bool = None, + add_last_scene: bool | None = None, min_scene_len: str | None = None, ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" @@ -577,5 +577,5 @@ def _open_video_stream( if __debug__: raise raise click.BadParameter( - f"Input error:\n\n\t{str(ex)}\n", param_hint="-i/--input" + f"Input error:\n\n\t{ex!s}\n", param_hint="-i/--input" ) from None diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 95ccd8a9..a1cf3084 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -90,7 +90,7 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len ): new_last_scene = (scene_list[-2][0], scene_list[-1][1]) - scene_list = scene_list[:-2] + [new_last_scene] + scene_list = [*scene_list[:-2], new_last_scene] # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index e3049759..2873adc6 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -65,10 +65,10 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: ty.AnyStr = None, + path: ty.AnyStr | None = None, framerate: float | None = None, max_decode_attempts: int = 5, - path_or_device: bytes | str | int = None, + path_or_device: bytes | str | int | None = None, ): """Open a video file, image sequence, or network stream. diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 59a50ada..7e36ee47 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -203,7 +203,7 @@ def frame_number(self) -> int: """Current position within stream as the frame number (CFR-equivalent). Will return 0 until the first frame is `read`. For VFR video this is an approximation - derived from PTS × framerate; use `position` for accurate PTS-based timing.""" + derived from PTS * framerate; use `position` for accurate PTS-based timing.""" if self._frame is None: return 0 return round(self._frame.time * float(self.frame_rate)) + 1 diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 68a4115f..94e45577 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -96,7 +96,7 @@ def event_buffer_length(self) -> int: return self.window_width def get_metrics(self) -> list[str]: - return super().get_metrics() + [self._adaptive_ratio_key] + return [*super().get_metrics(), self._adaptive_ratio_key] def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: super().process_frame(timecode=timecode, frame_img=frame_img) diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index b4060baa..6bdc0eab 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -85,7 +85,7 @@ class Components(ty.NamedTuple): FRAME_SCORE_KEY = "content_val" """Key in statsfile representing the final frame score after weighed by specified components.""" - METRIC_KEYS = [FRAME_SCORE_KEY, *Components._fields] + METRIC_KEYS: ty.ClassVar[list[str]] = [FRAME_SCORE_KEY, *Components._fields] """All statsfile keys this detector produces.""" @dataclass diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index a450b089..5a7a2057 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -28,7 +28,7 @@ class HistogramDetector(SceneDetector): """Compares the difference in the Y channel of YUV histograms for adjacent frames. When the difference exceeds a given threshold, a cut is detected.""" - METRIC_KEYS = ["hist_diff"] + METRIC_KEYS: ty.ClassVar[list[str]] = ["hist_diff"] def __init__( self, diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 52df30b6..d2b28b4b 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -81,7 +81,7 @@ def write_scene_list( # If required, output the cutting list as the first row (i.e. before the header row). if include_cut_list: csv_writer.writerow( - ["Timecode List:"] + cut_list + ["Timecode List:", *cut_list] if cut_list else [start.get_timecode() for start, _ in scene_list[1:]] ) @@ -121,7 +121,7 @@ def write_scene_list_html( output_html_filename: str, scene_list: SceneList, cut_list: CutList | None = None, - css: str = None, + css: str | None = None, css_class: str = "mytable", image_filenames: dict[int, list[str]] | None = None, image_width: int | None = None, @@ -293,7 +293,7 @@ def _rational_seconds(value: Fraction) -> str: def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: - """Exact seconds for `tc` as a `Fraction`, derived from PTS × time base.""" + """Exact seconds for `tc` as a `Fraction`, derived from PTS * time base.""" return Fraction(tc.pts) * tc.time_base diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index f3de6993..3d21f3c5 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -106,7 +106,7 @@ def __init__( num_images: int = 3, frame_margin: int | float | str = 1, image_extension: str = "jpg", - imwrite_param: dict[str, int | None] = None, + imwrite_param: dict[str, int | None] | None = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", scale: float | None = None, height: int | None = None, diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 94dad45a..ca9e00a6 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -608,7 +608,7 @@ def _decode_thread( self._frame_size_errors += 1 if self._frame_size_errors <= MAX_FRAME_SIZE_ERRORS: logger.error( - f"ERROR: Frame at {str(video.position)} has incorrect size and " + f"ERROR: Frame at {video.position!s} has incorrect size and " f"cannot be processed: decoded size = {decoded_size}, " f"expected = {self._frame_size}. Video may be corrupt." ) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index 3fa9a567..d61ac789 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -181,7 +181,7 @@ def save_to_csv( csv_writer = csv.writer(csv_file, lineterminator="\n") metric_keys = sorted(list(self._metric_keys)) - csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) + csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE, *metric_keys]) frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: diff --git a/tests/test_cli.py b/tests/test_cli.py index 740bce51..f6fc82d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -167,9 +167,17 @@ def test_cli_time_end(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] - + test_case.split(), + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + *test_case.split(), + ], text=True, ) assert EXPECTED in output, test_case @@ -195,9 +203,17 @@ def test_cli_time_start(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] - + test_case.split(), + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + *test_case.split(), + ], text=True, ) assert EXPECTED in output, test_case @@ -240,9 +256,17 @@ def test_cli_time_scene_boundary(): ] for test_case in TEST_CASES: output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "-m", "0", "detect-content", "list-scenes", "-n"] - + test_case.split(), + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "-m", + "0", + "detect-content", + "list-scenes", + "-n", + *test_case.split(), + ], text=True, ) assert EXPECTED in output, test_case @@ -252,8 +276,17 @@ def test_cli_time_end_of_video(): """Validate frame number/timecode alignment at the end of the video. The end timecode includes presentation time and therefore should represent the full length of the video.""" output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + ["-i", DEFAULT_VIDEO_PATH, "detect-content", "list-scenes", "-n", "time", "-s", "1872"], + [ + *SCENEDETECT_CMD.split(" "), + "-i", + DEFAULT_VIDEO_PATH, + "detect-content", + "list-scenes", + "-n", + "time", + "-s", + "1872", + ], text=True, ) assert ( @@ -667,8 +700,8 @@ def test_cli_load_scenes_output(): with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) output = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ + [ + *SCENEDETECT_CMD.split(" "), "-i", DEFAULT_VIDEO_PATH, "load-scenes", @@ -709,8 +742,8 @@ def test_cli_load_scenes_round_trip(): with open("test_scene_list.csv", "w") as f: f.write(scenes_csv) ground_truth = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ + [ + *SCENEDETECT_CMD.split(" "), "-i", DEFAULT_VIDEO_PATH, "detect-content", @@ -726,8 +759,8 @@ def test_cli_load_scenes_round_trip(): text=True, ) loaded_first_pass = subprocess.check_output( - SCENEDETECT_CMD.split(" ") - + [ + [ + *SCENEDETECT_CMD.split(" "), "-i", DEFAULT_VIDEO_PATH, "load-scenes", From cc682ba2dc0f63c388a37a6b936fee8612cd0ed2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 19:54:25 -0400 Subject: [PATCH 303/407] [lint] Audit F401 and fix E501 --- benchmark/__main__.py | 1 - pyproject.toml | 12 +++++++++--- scenedetect/__init__.py | 3 +-- scenedetect/_cli/commands.py | 1 - scenedetect/_cli/context.py | 4 ++-- scenedetect/_cli/controller.py | 1 - scenedetect/backends/__init__.py | 2 -- scenedetect/backends/opencv.py | 3 ++- scenedetect/backends/pyav.py | 4 ++-- scenedetect/common.py | 3 ++- scenedetect/detector.py | 1 - scenedetect/detectors/adaptive_detector.py | 1 - scenedetect/detectors/hash_detector.py | 2 -- scenedetect/detectors/histogram_detector.py | 13 +++++++------ scenedetect/detectors/threshold_detector.py | 1 - scenedetect/detectors/transnet_v2.py | 5 +---- scenedetect/output/image.py | 6 ++++-- scenedetect/video_stream.py | 4 +--- tests/helpers.py | 2 -- tests/test_cli.py | 1 - tests/test_detectors.py | 1 - tests/test_scene_manager.py | 2 -- tests/test_vfr.py | 1 - tests/test_video_stream.py | 1 - 24 files changed, 31 insertions(+), 44 deletions(-) diff --git a/benchmark/__main__.py b/benchmark/__main__.py index 91169637..5925bdbc 100644 --- a/benchmark/__main__.py +++ b/benchmark/__main__.py @@ -1,7 +1,6 @@ import argparse import os import time -import typing as ty from tqdm import tqdm diff --git a/pyproject.toml b/pyproject.toml index e0702529..da7da0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,9 @@ select = [ "RUF", ] ignore = [ - # TODO: Determine if we should use __all__, a reudndant alias, or keep this suppressed. + # TODO: Audit re-exports in __init__.py files. Add `__all__` (note: must be kept in sync as + # new public symbols are added) or use redundant-alias form (`from x import Y as Y`). "F401", - # TODO: Line too long - "E501", ] fixable = ["ALL"] unfixable = [] @@ -52,6 +51,13 @@ unfixable = [] [tool.ruff.lint.per-file-ignores] # Vendored third-party code: don't rewrite/modernize upstream source. "scenedetect/_thirdparty/*" = ["UP"] +# CLI help text and validation messages are intentionally long. +"scenedetect/_cli/*" = ["E501"] +# Test data tables and golden output strings are clearer unwrapped. +"tests/*" = ["E501"] +# Doc generators and benchmark scripts mirror the CLI strings. +"docs/*" = ["E501"] +"benchmark/*" = ["E501"] [tool.pyright] include = ["scenedetect", "tests"] diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index e913d682..8db21640 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -15,14 +15,13 @@ :class:`SceneManager `. """ -import typing as ty from logging import getLogger # OpenCV is a required package, but we don't have it as an explicit dependency since we # need to support both opencv-python and opencv-python-headless. Include some additional # context with the exception if this is the case. try: - import cv2 as _ + import cv2 as _ # availability check; raise a friendlier error if missing except ModuleNotFoundError as ex: raise ModuleNotFoundError( "OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python", diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index ed4ad920..aaaef407 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -16,7 +16,6 @@ """ import logging -import typing as ty import webbrowser from string import Template diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 22502909..8e4ef849 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -25,7 +25,7 @@ CropValue, ) from scenedetect.common import MAX_FPS_DELTA, FrameTimecode -from scenedetect.detector import FlashFilter, SceneDetector +from scenedetect.detector import SceneDetector from scenedetect.detectors import ( AdaptiveDetector, ContentDetector, @@ -35,7 +35,7 @@ ) from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available from scenedetect.platform import init_logger -from scenedetect.scene_manager import Interpolation, SceneManager +from scenedetect.scene_manager import SceneManager from scenedetect.stats_manager import StatsManager from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index a1cf3084..77dc1eaa 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -15,7 +15,6 @@ import logging import os import time -import typing as ty import warnings from scenedetect._cli.context import CliContext diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 15a53f16..ae2059c5 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -83,8 +83,6 @@ # TODO: Future VideoStream implementations under consideration: # - Nvidia VPF: https://developer.nvidia.com/blog/vpf-hardware-accelerated-video-processing-framework-in-python/ -import typing as ty - # OpenCV must be available at minimum. from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 2873adc6..a06e03c5 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -92,7 +92,8 @@ def __init__( super().__init__() if path_or_device is not None: warnings.warn( - "The `path_or_device` argument is deprecated, use `path` or `VideoCaptureAdapter` instead.", + "The `path_or_device` argument is deprecated, use `path` or `VideoCaptureAdapter`" + " instead.", DeprecationWarning, stacklevel=2, ) diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 7e36ee47..27c37d42 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -70,8 +70,8 @@ def __init__( """ self._container = None - # TODO(https://scenedetect.com/issues/258): See what `self._container.discard_corrupt = True` - # does with corrupt videos. + # TODO(https://scenedetect.com/issues/258): See what + # `self._container.discard_corrupt = True` does with corrupt videos. super().__init__() # Ensure specified framerate is valid if set. diff --git a/scenedetect/common.py b/scenedetect/common.py index 71b51c8d..73fa50cc 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -488,7 +488,8 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] if isinstance(other, str): return self._seconds_to_frames(self._timecode_to_seconds(other)) if isinstance(other, FrameTimecode): - # If comparing two FrameTimecodes, they must have the same framerate for frame-based operations. + # If comparing two FrameTimecodes, they must have the same framerate for frame-based + # operations. if self._rate and other._rate and not self.equal_framerate(other._rate): raise ValueError( "FrameTimecode instances require equal framerate for frame-based arithmetic." diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 2b581e3a..3f7dcf0a 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -25,7 +25,6 @@ """ import math -import typing as ty from abc import ABC, abstractmethod from enum import Enum diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 94e45577..2e634a83 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -16,7 +16,6 @@ This detector is available from the command-line as the `detect-adaptive` command. """ -import typing as ty from logging import getLogger import numpy as np diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 16e9b543..86c15f57 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -16,8 +16,6 @@ This detector is available from the command-line interface by using the `detect-hash` command. """ -import typing as ty - import cv2 import numpy diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 5a7a2057..408a8312 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -59,8 +59,8 @@ def __init__( def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> list[int]: """Computes the histogram of the luma channel of the frame image and compares it with the - histogram of the luma channel of the previous frame. If the difference between the histograms - exceeds the threshold, a scene cut is detected. + histogram of the luma channel of the previous frame. If the difference between the + histograms exceeds the threshold, a scene cut is detected. Histogram difference is computed using the correlation metric. Arguments: @@ -98,11 +98,12 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> li # Check if a new scene should be triggered # Set a correlation threshold to determine scene changes. - # The threshold value should be between -1 (perfect negative correlation, not applicable here) - # and +1 (perfect positive correlation, identical histograms). + # The threshold value should be between -1 (perfect negative correlation, not + # applicable here) and +1 (perfect positive correlation, identical histograms). # Values close to 1 indicate very similar frames, while lower values suggest changes. - # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation - # less than 0.8 between histograms will be considered significant enough to denote a scene change. + # Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a + # correlation less than 0.8 between histograms will be considered significant enough to + # denote a scene change. if hist_diff <= self._threshold and ( (timecode - self._last_cut) >= self._min_scene_len ): diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 2fa9114b..1a5e4690 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -15,7 +15,6 @@ This detector is available from the command-line as the `detect-threshold` command. """ -import typing as ty import warnings from enum import Enum from logging import getLogger diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 020e5734..83c6260f 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -14,16 +14,13 @@ This detector is available from the command-line as the `detect-transnetv2` command. """ -import typing as ty -import warnings -from enum import Enum from logging import getLogger from pathlib import Path import cv2 import numpy as np -from scenedetect.common import FrameTimecode, Timecode +from scenedetect.common import FrameTimecode from scenedetect.detector import FlashFilter, SceneDetector logger = getLogger("pyscenedetect") diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 3d21f3c5..0d95981b 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -173,7 +173,8 @@ def run( # Setup flags and init progress bar if available. completed = True logger.info( - f"Saving {self._num_images} images per scene [format={self._image_extension}] {output_dir if output_dir else ''} " + f"Saving {self._num_images} images per scene [format={self._image_extension}]" + f" {output_dir if output_dir else ''} " ) progress_bar = None if show_progress: @@ -440,7 +441,8 @@ def save_images( # Setup flags and init progress bar if available. completed = True logger.info( - f"Saving {num_images} images per scene [format={image_extension}] {output_dir if output_dir else ''} " + f"Saving {num_images} images per scene [format={image_extension}]" + f" {output_dir if output_dir else ''} " ) progress_bar = None if show_progress: diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 987ff34b..142df605 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -31,14 +31,12 @@ tested by adding it to the test suite in `tests/test_video_stream.py`. """ -import typing as ty from abc import ABC, abstractmethod -from dataclasses import dataclass from fractions import Fraction import numpy as np -from scenedetect.common import FrameTimecode, Timecode +from scenedetect.common import FrameTimecode class SeekError(Exception): diff --git a/tests/helpers.py b/tests/helpers.py index 050b0620..87829cb9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,8 +11,6 @@ # """Shared test helpers.""" -import typing as ty - from click.testing import CliRunner from scenedetect._cli import scenedetect as _scenedetect_cli diff --git a/tests/test_cli.py b/tests/test_cli.py index f6fc82d2..05044ff0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,7 +29,6 @@ # logic by creating a CLI context with the desired parameters. # TODO: Missing tests for --min-scene-len and --drop-short-scenes. import sys -import typing as ty from pathlib import Path import cv2 diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 26f8d6af..638479b5 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -17,7 +17,6 @@ """ import os -import typing as ty from dataclasses import dataclass import pytest diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 6c9b5817..7a78813f 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -15,8 +15,6 @@ which applies SceneDetector algorithms on VideoStream backends. """ -import typing as ty - import pytest from scenedetect.backends.opencv import VideoStreamCv2 diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 249a8cda..3189ee5d 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -14,7 +14,6 @@ import csv import json import os -import typing as ty import cv2 import numpy as np diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index a757c540..e0903832 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -17,7 +17,6 @@ """ import os.path -import typing as ty from dataclasses import dataclass import numpy From 97666d4c57a2ed156f2b0f140aa913e5a3aeb6e5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 24 Apr 2026 21:08:14 -0400 Subject: [PATCH 304/407] [lint] Fix CLI None-default annotations --- scenedetect/_cli/__init__.py | 3 ++ scenedetect/_cli/commands.py | 8 ++++ scenedetect/_cli/context.py | 67 ++++++++++++++++++++++------------ scenedetect/_cli/controller.py | 17 +++++++-- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1e1abc22..1ea5e81f 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -979,6 +979,7 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s assert isinstance(ctx, CliContext) logger.debug("Will load scenes from %s (start_col_name = %s)", input, start_col_name) + assert ctx.scene_manager is not None if ctx.scene_manager.get_num_detectors() > 0: raise click.ClickException("The load-scenes command cannot be used with detectors.") if ctx.load_scenes_input: @@ -1293,6 +1294,7 @@ def split_video_command( assert isinstance(ctx, CliContext) check_split_video_requirements(use_mkvmerge=mkvmerge) + assert ctx.video_stream is not None if "%" in ctx.video_stream.path or "://" in ctx.video_stream.path: error = "The split-video command is incompatible with image sequences/URLs." raise click.BadParameter(error, param_hint="split-video") @@ -1500,6 +1502,7 @@ def save_images_command( ): ctx = ctx.obj assert isinstance(ctx, CliContext) + assert ctx.video_stream is not None if "://" in ctx.video_stream.path: error_str = "\nThe save-images command is incompatible with URLs." diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index aaaef407..f87d97be 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -53,6 +53,7 @@ def save_html( show: bool, ): """Handles the `save-html` command.""" + assert context.video_stream is not None (image_filenames, output) = ( context.save_images_result if context.save_images_result is not None @@ -85,6 +86,7 @@ def save_qp( ): """Handler for the `save-qp` command.""" del scenes # We only use cuts for this handler. + assert context.video_stream is not None qp_path = get_and_create_path( Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), output, @@ -115,6 +117,7 @@ def list_scenes( row_separator: str, ): """Handles the `list-scenes` command.""" + assert context.video_stream is not None # Write scene list CSV to if required. if not no_output_file: scene_list_filename = Template(filename).safe_substitute( @@ -182,6 +185,7 @@ def save_images( ): """Handles the `save-images` command.""" del cuts # save-images only uses scenes. + assert context.video_stream is not None images = save_images_impl( scene_list=scenes, @@ -215,6 +219,7 @@ def split_video( ): """Handles the `split-video` command.""" del cuts # split-video only uses scenes. + assert context.video_stream is not None if use_mkvmerge: name_format = name_format.removesuffix("-$SCENE_NUMBER") @@ -261,6 +266,7 @@ def save_edl( ): """Handles the `save-edl` command. Outputs in CMX 3600 format.""" del cuts # We only use scene information. + assert context.video_stream is not None video_name = context.video_stream.name edl_path = get_and_create_path( Template(filename).safe_substitute(VIDEO_NAME=video_name), @@ -286,6 +292,7 @@ def save_fcp( del cuts # We only use scene information. if not scenes: return + assert context.video_stream is not None video_stream = context.video_stream video_name = str(video_stream.name) @@ -328,6 +335,7 @@ def save_otio( ): """Handles the `save-otio` command.""" del cuts # We only use scene information + assert context.video_stream is not None video_stream = context.video_stream video_name = str(video_stream.name) otio_path = get_and_create_path( diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 8e4ef849..838552ff 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -81,28 +81,28 @@ class CliContext: def __init__(self): # State: self.config: ConfigRegistry = USER_CONFIG - self.quiet_mode: bool = None - self.scene_manager: SceneManager = None - self.stats_manager: StatsManager = None + self.quiet_mode: bool | None = None + self.scene_manager: SceneManager | None = None + self.stats_manager: StatsManager | None = None self.save_images: bool = False # True if the save-images command was specified self.save_images_result: ty.Any = (None, None) # Result of save-images used by save-html # Input: - self.video_stream: VideoStream = None - self.load_scenes_input: str = None # load-scenes -i/--input - self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name + self.video_stream: VideoStream | None = None + self.load_scenes_input: str | None = None # load-scenes -i/--input + self.load_scenes_column_name: str | None = None # load-scenes -c/--start-col-name self.start_time: FrameTimecode | None = None # time -s/--start self.end_time: FrameTimecode | None = None # time -e/--end self.duration: FrameTimecode | None = None # time -d/--duration - self.frame_skip: int = None + self.frame_skip: int | None = None # Options: - self.drop_short_scenes: bool = None - self.merge_last_scene: bool = None - self.min_scene_len: FrameTimecode = None - self.default_detector: tuple[type[SceneDetector], dict[str, ty.Any]] = None - self.output: str = None - self.stats_file_path: str = None + self.drop_short_scenes: bool | None = None + self.merge_last_scene: bool | None = None + self.min_scene_len: FrameTimecode | None = None + self.default_detector: tuple[type[SceneDetector], dict[str, ty.Any]] | None = None + self.output: str | None = None + self.stats_file_path: str | None = None # Output Commands (e.g. split-video, save-images): # Commands to run after the detection pipeline. Stored as (callback, args) and invoked with @@ -120,17 +120,20 @@ def add_detector(self, detector: type[SceneDetector], detector_args: dict[str, t """Instantiate and add `detector` to the processing pipeline.""" if self.load_scenes_input: raise click.ClickException("The load-scenes command cannot be used with detectors.") + assert self.scene_manager is not None logger.debug("Adding detector: %s(%s)", detector.__name__, detector_args) self.scene_manager.add_detector(detector(**detector_args)) def ensure_detector(self): """Ensures at least one detector has been instantiated, otherwise adds a default one.""" + assert self.scene_manager is not None + assert self.default_detector is not None if self.scene_manager.get_num_detectors() == 0: logger.debug("No detector specified, adding default detector.") (detector_type, detector_args) = self.default_detector self.add_detector(detector_type, detector_args) - def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode: + def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode | None: """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` is None it will be passed through without processing. @@ -142,11 +145,13 @@ def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameT try: if self.video_stream is None: raise click.ClickException("No input video (-i/--input) was specified.") + timecode: int | str if correct_pts and value.isdigit(): - value = int(value) - if value >= 1: - value -= 1 - return FrameTimecode(timecode=value, fps=self.video_stream.frame_rate) + int_value = int(value) + timecode = int_value - 1 if int_value >= 1 else int_value + else: + timecode = value + return FrameTimecode(timecode=timecode, fps=self.video_stream.frame_rate) except ValueError as ex: raise click.BadParameter( "timecode must be in seconds (100.0), frames (100), or HH:MM:SS" @@ -297,6 +302,7 @@ def handle_options( crop = self.config.get_value("global", "crop", CropValue(crop)) if crop is not None: (min_x, min_y) = crop[0:2] + assert self.video_stream is not None frame_size = self.video_stream.frame_size if min_x >= frame_size[0] or min_y >= frame_size[1]: region = CropValue(crop) @@ -327,10 +333,13 @@ def get_detect_content_params( else: if min_scene_len is None: if self.config.is_default("detect-content", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-content", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num if weights is not None: try: @@ -367,10 +376,13 @@ def get_detect_adaptive_params( else: if min_scene_len is None: if self.config.is_default("detect-adaptive", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num if weights is not None: try: @@ -406,10 +418,13 @@ def get_detect_threshold_params( else: if min_scene_len is None: if self.config.is_default("detect-threshold", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num # TODO(v1.0): add_last_scene cannot be disabled right now. return { "add_final_scene": add_last_scene @@ -432,10 +447,13 @@ def get_detect_hist_params( else: if min_scene_len is None: if self.config.is_default("detect-hist", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num return { "bins": self.config.get_value("detect-hist", "bins", bins), "min_scene_len": min_scene_len, @@ -456,10 +474,13 @@ def get_detect_hash_params( else: if min_scene_len is None: if self.config.is_default("detect-hash", "min-scene-len"): + assert self.min_scene_len is not None min_scene_len = self.min_scene_len.frame_num else: min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - min_scene_len = self.parse_timecode(min_scene_len).frame_num + parsed = self.parse_timecode(min_scene_len) + assert parsed is not None + min_scene_len = parsed.frame_num return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), "min_scene_len": min_scene_len, diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 77dc1eaa..a53ea77e 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -92,7 +92,11 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene scene_list = [*scene_list[:-2], new_last_scene] # Handle --drop-short-scenes. - if context.drop_short_scenes and context.min_scene_len > 0: + if ( + context.drop_short_scenes + and context.min_scene_len is not None + and context.min_scene_len > 0 + ): scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] return scene_list @@ -100,6 +104,9 @@ def _postprocess_scene_list(context: CliContext, scene_list: SceneList) -> Scene def _detect(context: CliContext) -> tuple[SceneList, CutList] | None: perf_start_time = time.time() + assert context.scene_manager is not None + assert context.video_stream is not None + assert context.frame_skip is not None context.ensure_detector() if context.start_time is not None: @@ -157,6 +164,7 @@ def _save_stats(context: CliContext) -> None: """Handles saving the statsfile if -s/--stats was specified.""" if not context.stats_file_path: return + assert context.stats_manager is not None if context.stats_manager.is_save_required(): path = get_and_create_path(context.stats_file_path, context.output) logger.info("Saving frame metrics to stats file: %s", path) @@ -168,8 +176,11 @@ def _save_stats(context: CliContext) -> None: def _load_scenes(context: CliContext) -> tuple[SceneList, CutList]: assert context.load_scenes_input + assert context.load_scenes_column_name is not None + assert context.video_stream is not None assert os.path.exists(context.load_scenes_input) + video_stream = context.video_stream with open(context.load_scenes_input) as input_file: file_reader = csv.reader(input_file) csv_headers = next(file_reader) @@ -184,8 +195,8 @@ def calculate_timecode(value: str) -> FrameTimecode: # Assume other columns are in seconds except frame numbers. if value.isdigit(): # Frame numbers start from index 1 in the CLI output so we correct for that. - return FrameTimecode(int(value) - 1, fps=context.video_stream.frame_rate) - return FrameTimecode(value, fps=context.video_stream.frame_rate) + return FrameTimecode(int(value) - 1, fps=video_stream.frame_rate) + return FrameTimecode(value, fps=video_stream.frame_rate) cut_list = sorted(calculate_timecode(row[col_idx]) for row in file_reader) # `SceneDetector` works on cuts, so we have to skip the first scene and place the first From 6d4c780bc4779346d5f1159e7d7b4b4045ca8a61 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 00:34:11 -0400 Subject: [PATCH 305/407] [lint] None checks for backends --- scenedetect/backends/moviepy.py | 9 ++++++--- scenedetect/backends/opencv.py | 16 ++++++++++++++-- scenedetect/backends/pyav.py | 11 +++++++++-- scenedetect/common.py | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index efb735f8..3711c133 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -102,7 +102,7 @@ def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: self._frame_number = 0 # We need to manually keep track of EOF as duration may not be accurate. self._eof = False - self._aspect_ratio: float = None + self._aspect_ratio: float | None = None # # VideoStream Methods/Properties @@ -211,11 +211,13 @@ def seek(self, target: FrameTimecode | float | int): success = False if not isinstance(target, FrameTimecode): target = FrameTimecode(target, self.frame_rate) + duration = self.duration + assert duration is not None try: self._last_frame = _retry_on_oserror( "seek", lambda: self._reader.get_frame(target.seconds) ) - if hasattr(self._reader, "last_read") and target >= self.duration: + if hasattr(self._reader, "last_read") and target >= duration: raise SeekError("MoviePy > 2.0 does not have proper EOF semantics (#461).") self._frame_number = min( target.frame_num, @@ -228,7 +230,7 @@ def seek(self, target: FrameTimecode | float | int): # # We need to ensure consistency for seeking past end of video with respect to errors and # behaviour, and should probably gracefully stop at the last frame instead of throwing. - if target >= self.duration: + if target >= duration: raise SeekError("Target frame is beyond end of video!") from ex raise finally: @@ -263,5 +265,6 @@ def read(self, decode: bool = True) -> np.ndarray | bool: last_frame_valid = self._last_frame is not None and self._last_frame is not False if last_frame_valid: self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) + assert self._last_frame_rgb is not None return self._last_frame_rgb return not self._eof diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index a06e03c5..90697805 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -178,6 +178,7 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" + assert self._cap is not None return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), @@ -188,11 +189,13 @@ def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None + assert self._cap is not None return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" + assert self._cap is not None return _get_aspect_ratio(self._cap) @property @@ -201,6 +204,7 @@ def timecode(self) -> Timecode: # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to # avoid frame-boundary rounding errors at common framerates like 24000/1001. + assert self._cap is not None ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) time_base = Fraction(1, 1000000) return Timecode(pts=round(ms * 1000), time_base=time_base) @@ -219,10 +223,12 @@ def position(self) -> FrameTimecode: @property def position_ms(self) -> float: + assert self._cap is not None return self._cap.get(cv2.CAP_PROP_POS_MSEC) @property def frame_number(self) -> int: + assert self._cap is not None return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) def seek(self, target: FrameTimecode | float | int): @@ -230,7 +236,9 @@ def seek(self, target: FrameTimecode | float | int): raise SeekError("Cannot seek if input is a device!") if target < 0: raise ValueError("Target seek position cannot be negative!") + assert self._cap is not None + assert self._frame_rate is not None target_secs = (self.base_timecode + target).seconds self._has_grabbed = False if target_secs > 0: @@ -260,16 +268,20 @@ def seek(self, target: FrameTimecode | float | int): def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + assert self._cap is not None + assert self._frame_rate is not None self._cap.release() - self._open_capture(self._frame_rate) + self._open_capture(float(self._frame_rate)) def read(self, decode: bool = True) -> np.ndarray | bool: + assert self._cap is not None if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() # If we failed to grab the frame, retry a few times if required. if not has_grabbed: - if self.duration > 0 and self.position < (self.duration - 1): + duration = self.duration + if duration is not None and duration > 0 and self.position < (duration - 1): for _ in range(self._max_decode_attempts): has_grabbed = self._cap.grab() if has_grabbed: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 27c37d42..067e935f 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -68,7 +68,7 @@ def __init__( VideoOpenFailure: video could not be opened (may be corrupted) ValueError: specified framerate is invalid """ - self._container = None + self._container: av.container.InputContainer | None = None # TODO(https://scenedetect.com/issues/258): See what # `self._container.discard_corrupt = True` does with corrupt videos. @@ -213,7 +213,7 @@ def rate(self) -> Fraction: return self._video_stream.guessed_rate @property - def time_base(self) -> Fraction: + def time_base(self) -> Fraction | None: if self._frame: return self._frame.time_base return None @@ -265,6 +265,7 @@ def seek(self, target: FrameTimecode | float | int) -> None: self._frame = None self._decoder = None self._decode_count = 0 + assert self._container is not None self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -274,6 +275,7 @@ def seek(self, target: FrameTimecode | float | int) -> None: def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" + assert self._container is not None self._container.close() self._frame = None self._decoder = None @@ -288,9 +290,11 @@ def read(self, decode: bool = True) -> np.ndarray | bool: # B-frame reordering) is never flushed prematurely. Creating a new generator each call # caused the last buffered frame to be lost at EOF. if self._decoder is None: + assert self._container is not None self._decoder = self._container.decode(video=0) try: last_frame = self._frame + assert self._decoder is not None self._frame = next(self._decoder) self._decode_count += 1 except av.error.EOFError: @@ -300,6 +304,7 @@ def read(self, decode: bool = True) -> np.ndarray | bool: return False except StopIteration: return False + assert self._frame is not None return self._frame.to_ndarray(format="bgr24") if decode else True # @@ -309,6 +314,7 @@ def read(self, decode: bool = True) -> np.ndarray | bool: @property def _video_stream(self): """PyAV `av.video.stream.VideoStream` being used.""" + assert self._container is not None return self._container.streams.video[0] @property @@ -365,6 +371,7 @@ def _handle_eof(self): except: self._io.seek(orig_pos) raise + assert self._container is not None self._container.close() self._container = container self._decoder = None diff --git a/scenedetect/common.py b/scenedetect/common.py index 73fa50cc..a045298b 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -247,7 +247,7 @@ def __init__( raise TypeError("Timecode format/type unrecognized.") @property - def frame_num(self) -> int | None: + def frame_num(self) -> int: """The frame number. For VFR video or Timecode-backed objects, this is an approximation based on the average framerate. Prefer using `pts` and `time_base` for precise timing.""" if isinstance(self._time, Timecode): From 9377cf171db738e23c85bb2cbbbb1d881d81033a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 00:50:11 -0400 Subject: [PATCH 306/407] [common] Add TimecodeLike type hint --- scenedetect/__init__.py | 13 ++++++------- scenedetect/backends/moviepy.py | 4 ++-- scenedetect/backends/opencv.py | 14 +++++++++++--- scenedetect/backends/pyav.py | 6 ++++-- scenedetect/common.py | 7 +++++++ scenedetect/video_stream.py | 4 ++-- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 8db21640..6a27e72d 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -37,6 +37,7 @@ CutList, CropRegion, TimecodePair, + TimecodeLike, Interpolation, ) from scenedetect.video_stream import VideoStream, VideoOpenFailure @@ -136,8 +137,8 @@ def detect( detector: SceneDetector, stats_file_path: str | None = None, show_progress: bool = False, - start_time: str | float | int | None = None, - end_time: str | float | int | None = None, + start_time: TimecodeLike | None = None, + end_time: TimecodeLike | None = None, start_in_scene: bool = False, ) -> SceneList: """Perform scene detection on a given video `path` using the specified `detector`. @@ -170,10 +171,8 @@ def detect( """ video = open_video(video_path) if start_time is not None: - start_time = video.base_timecode + start_time - video.seek(start_time) - if end_time is not None: - end_time = video.base_timecode + end_time + video.seek(FrameTimecode(start_time, video.frame_rate)) + end_timecode = FrameTimecode(end_time, video.frame_rate) if end_time is not None else None # To reduce memory consumption when not required, we only add a StatsManager if we # need to save frame metrics to disk. scene_manager = SceneManager(StatsManager() if stats_file_path else None) @@ -181,7 +180,7 @@ def detect( scene_manager.detect_scenes( video=video, show_progress=show_progress, - end_time=end_time, + end_time=end_timecode, ) if scene_manager.stats_manager is not None: scene_manager.stats_manager.save_to_csv(csv_file=stats_file_path) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 3711c133..8c86534b 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -26,7 +26,7 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import FrameTimecode, Timecode, framerate_to_fraction +from scenedetect.common import FrameTimecode, Timecode, TimecodeLike, framerate_to_fraction from scenedetect.platform import get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream @@ -189,7 +189,7 @@ def frame_number(self) -> int: """ return self._frame_number - def seek(self, target: FrameTimecode | float | int): + def seek(self, target: TimecodeLike): """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 90697805..a7db82c8 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -27,7 +27,13 @@ import cv2 import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) from scenedetect.platform import get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, @@ -231,9 +237,11 @@ def frame_number(self) -> int: assert self._cap is not None return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) - def seek(self, target: FrameTimecode | float | int): + def seek(self, target: TimecodeLike): if self._is_device: raise SeekError("Cannot seek if input is a device!") + if not isinstance(target, FrameTimecode): + target = FrameTimecode(target, self.frame_rate) if target < 0: raise ValueError("Target seek position cannot be negative!") assert self._cap is not None @@ -484,7 +492,7 @@ def position_ms(self) -> float: def frame_number(self) -> int: return self._num_frames - def seek(self, target: FrameTimecode | float | int): + def seek(self, target: TimecodeLike): """The underlying VideoCapture is assumed to not support seeking.""" raise NotImplementedError("Seeking is not supported.") diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 067e935f..366a4000 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -18,7 +18,7 @@ import av import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode +from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, TimecodeLike from scenedetect.platform import get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream @@ -234,7 +234,7 @@ def aspect_ratio(self) -> float: frame_aspect_ratio = self.frame_size[0] / self.frame_size[1] return display_aspect_ratio / frame_aspect_ratio - def seek(self, target: FrameTimecode | float | int) -> None: + def seek(self, target: TimecodeLike) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). @@ -252,6 +252,8 @@ def seek(self, target: FrameTimecode | float | int) -> None: Raises: ValueError: `target` is not a valid value (i.e. it is negative). """ + if not isinstance(target, FrameTimecode): + target = FrameTimecode(target, self.frame_rate) if target < 0: raise ValueError("Target cannot be negative!") beginning = target == 0 diff --git a/scenedetect/common.py b/scenedetect/common.py index a045298b..cd178d9b 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -715,3 +715,10 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None + + +TimecodeLike = int | float | str | Timecode | FrameTimecode +"""Type hint for values that can be converted to a :class:`FrameTimecode`. Accepts a frame number +(`int`), number of seconds (`float`), timecode string (`str` of the form ``HH:MM:SS[.nnn]``), a +:class:`Timecode`, or an existing :class:`FrameTimecode`. +""" diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 142df605..740a876d 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -36,7 +36,7 @@ import numpy as np -from scenedetect.common import FrameTimecode +from scenedetect.common import FrameTimecode, TimecodeLike class SeekError(Exception): @@ -193,7 +193,7 @@ def reset(self) -> None: ... @abstractmethod - def seek(self, target: FrameTimecode | float | int) -> None: + def seek(self, target: TimecodeLike) -> None: """Seek to the given timecode. If given as a frame number, represents the current seek pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video). From 268e2590b4160caf079b25c5010f8bcfc658db10 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 01:06:45 -0400 Subject: [PATCH 307/407] [lint] Use proper Path type hints rather than str | bytes --- scenedetect/__init__.py | 7 ++--- scenedetect/_cli/__init__.py | 48 ++++++++++++++++----------------- scenedetect/_cli/config.py | 6 ++--- scenedetect/_cli/context.py | 22 +++++++-------- scenedetect/backends/moviepy.py | 18 ++++++++----- scenedetect/backends/opencv.py | 47 +++++++++++++++++++------------- scenedetect/backends/pyav.py | 24 ++++++++++------- scenedetect/output/video.py | 2 +- scenedetect/platform.py | 18 ++++++------- scenedetect/video_stream.py | 4 +-- 10 files changed, 110 insertions(+), 86 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 6a27e72d..826f2f5b 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -40,6 +40,7 @@ TimecodeLike, Interpolation, ) +from scenedetect.platform import StrPath from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.output import ( save_images, @@ -80,7 +81,7 @@ def open_video( - path: str, + path: StrPath, framerate: float | None = None, backend: str = "opencv", **kwargs, @@ -133,9 +134,9 @@ def open_video( def detect( - video_path: str, + video_path: StrPath, detector: SceneDetector, - stats_file_path: str | None = None, + stats_file_path: StrPath | None = None, show_progress: bool = False, start_time: TimecodeLike | None = None, end_time: TimecodeLike | None = None, diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1ea5e81f..ca6f6cfa 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -319,10 +319,10 @@ def print_command_help(ctx: click.Context, command: click.Command): @click.pass_context def scenedetect( ctx: click.Context, - input: ty.AnyStr | None, - output: ty.AnyStr | None, - stats: ty.AnyStr | None, - config: ty.AnyStr | None, + input: str | None, + output: str | None, + stats: str | None, + config: str | None, framerate: float | None, min_scene_len: str | None, drop_short_scenes: bool | None, @@ -332,7 +332,7 @@ def scenedetect( downscale: int | None, frame_skip: int | None, verbosity: str | None, - logfile: ty.AnyStr | None, + logfile: str | None, quiet: bool, ): ctx = ctx.obj @@ -1052,7 +1052,7 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s @click.pass_context def save_html_command( ctx: click.Context, - filename: ty.AnyStr | None, + filename: str | None, no_images: bool, image_width: int | None, image_height: int | None, @@ -1144,8 +1144,8 @@ def save_html_command( @click.pass_context def list_scenes_command( ctx: click.Context, - output: ty.AnyStr | None, - filename: ty.AnyStr | None, + output: str | None, + filename: str | None, no_output_file: bool | None, quiet: bool | None, skip_cuts: bool | None, @@ -1280,8 +1280,8 @@ def list_scenes_command( @click.pass_context def split_video_command( ctx: click.Context, - output: ty.AnyStr | None, - filename: ty.AnyStr | None, + output: str | None, + filename: str | None, quiet: bool, copy: bool, high_quality: bool, @@ -1487,8 +1487,8 @@ def split_video_command( @click.pass_context def save_images_command( ctx: click.Context, - output: ty.AnyStr | None = None, - filename: ty.AnyStr | None = None, + output: str | None = None, + filename: str | None = None, num_images: int | None = None, jpeg: bool = False, webp: bool = False, @@ -1603,10 +1603,10 @@ def save_images_command( @click.pass_context def save_edl_command( ctx: click.Context, - filename: ty.AnyStr | None, - title: ty.AnyStr | None, - reel: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + title: str | None, + reel: str | None, + output: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1657,8 +1657,8 @@ def save_edl_command( @click.pass_context def save_qp_command( ctx: click.Context, - filename: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + output: str | None, disable_shift: bool | None, ): ctx = ctx.obj @@ -1706,9 +1706,9 @@ def save_qp_command( @click.pass_context def save_fcp_command( ctx: click.Context, - filename: ty.AnyStr | None, - format: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + format: str | None, + output: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1767,9 +1767,9 @@ def save_fcp_command( @click.pass_context def save_otio_command( ctx: click.Context, - filename: ty.AnyStr | None, - name: ty.AnyStr | None, - output: ty.AnyStr | None, + filename: str | None, + name: str | None, + output: str | None, audio: bool, no_audio: bool, ): diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 155ff3e1..ca95559c 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -318,11 +318,11 @@ class FcpFormat(Enum): ConfigValue = bool | int | float | str ConfigDict = dict[str, dict[str, ConfigValue]] -_CONFIG_FILE_NAME: ty.AnyStr = "scenedetect.cfg" -_CONFIG_FILE_DIR: ty.AnyStr = user_config_dir("PySceneDetect", False) +_CONFIG_FILE_NAME: str = "scenedetect.cfg" +_CONFIG_FILE_DIR: str = user_config_dir("PySceneDetect", False) _PLACEHOLDER = 0 # Placeholder for image quality default, as the value depends on output format -CONFIG_FILE_PATH: ty.AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) +CONFIG_FILE_PATH: str = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME) DEFAULT_JPG_QUALITY = 95 DEFAULT_WEBP_QUALITY = 100 diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 838552ff..895409a4 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -159,21 +159,21 @@ def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameT def handle_options( self, - input_path: ty.AnyStr, - output: ty.AnyStr | None, - framerate: float, - stats_file: ty.AnyStr | None, - frame_skip: int, - min_scene_len: str, + input_path: str | None, + output: str | None, + framerate: float | None, + stats_file: str | None, + frame_skip: int | None, + min_scene_len: str | None, drop_short_scenes: bool | None, merge_last_scene: bool | None, backend: str | None, crop: tuple[int, int, int, int] | None, downscale: int | None, quiet: bool, - logfile: ty.AnyStr | None, - config: ty.AnyStr | None, - stats: ty.AnyStr | None, + logfile: str | None, + config: str | None, + stats: str | None, verbosity: str | None, ): """Parse all global options/arguments passed to the main scenedetect command, @@ -496,7 +496,7 @@ def _initialize_logging( self, quiet: bool | None = None, verbosity: str | None = None, - logfile: ty.AnyStr | None = None, + logfile: str | None = None, ): """Setup logging based on CLI args and user configuration settings.""" if quiet is not None: @@ -527,7 +527,7 @@ def _initialize_logging( def _open_video_stream( self, - input_path: ty.AnyStr, + input_path: str, framerate: float | None, backend: str | None, ): diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 8c86534b..826f4f3b 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -16,6 +16,7 @@ image sequences or AviSynth scripts are supported as inputs. """ +import os import time import typing as ty from fractions import Fraction @@ -26,8 +27,13 @@ from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader from scenedetect.backends.opencv import VideoStreamCv2 -from scenedetect.common import FrameTimecode, Timecode, TimecodeLike, framerate_to_fraction -from scenedetect.platform import get_file_name +from scenedetect.common import ( + FrameTimecode, + Timecode, + TimecodeLike, + framerate_to_fraction, +) +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import SeekError, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") @@ -63,7 +69,7 @@ def _retry_on_oserror(op_name: str, fn: ty.Callable): class VideoStreamMoviePy(VideoStream): """MoviePy `FFMPEG_VideoReader` backend.""" - def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: bool = False): + def __init__(self, path: StrPath, framerate: float | None = None, print_infos: bool = False): """Open a video or device. Arguments: @@ -84,13 +90,13 @@ def __init__(self, path: ty.AnyStr, framerate: float | None = None, print_infos: "VideoStreamMoviePy does not support the `framerate` argument yet." ) - self._path = path + self._path: str = os.fspath(path) # TODO: Need to map errors based on the strings, since several failure # cases return IOErrors (e.g. could not read duration/video resolution). These # should be mapped to specific errors, e.g. write a function to map MoviePy # exceptions to a new set of equivalents. self._reader = _retry_on_oserror( - "open", lambda: FFMPEG_VideoReader(path, print_infos=print_infos) + "open", lambda: FFMPEG_VideoReader(self._path, print_infos=print_infos) ) # This will always be one behind self._reader.lastread when we finally call read() # as MoviePy caches the first frame when opening the video. Thus self._last_frame @@ -117,7 +123,7 @@ def frame_rate(self) -> Fraction: return framerate_to_fraction(self._reader.fps) @property - def path(self) -> bytes | str: + def path(self) -> str: """Video path.""" return self._path diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index a7db82c8..e0985535 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -18,6 +18,7 @@ """ import math +import os import os.path import typing as ty import warnings @@ -34,7 +35,7 @@ TimecodeLike, framerate_to_fraction, ) -from scenedetect.platform import get_file_name +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import ( FrameRateUnavailable, SeekError, @@ -71,10 +72,10 @@ class VideoStreamCv2(VideoStream): def __init__( self, - path: ty.AnyStr | None = None, + path: StrPath | None = None, framerate: float | None = None, max_decode_attempts: int = 5, - path_or_device: bytes | str | int | None = None, + path_or_device: StrPath | int | None = None, ): """Open a video file, image sequence, or network stream. @@ -103,15 +104,19 @@ def __init__( DeprecationWarning, stacklevel=2, ) - path = path_or_device - if path is None: + resolved: str | int = ( + path_or_device if isinstance(path_or_device, int) else os.fspath(path_or_device) + ) + elif path is None: raise ValueError("Path must be specified!") + else: + resolved = os.fspath(path) if framerate is not None and framerate < MAX_FPS_DELTA: raise ValueError(f"Specified framerate ({framerate:f}) is invalid!") if max_decode_attempts < 0: raise ValueError("Maximum decode attempts must be >= 0!") - self._path_or_device = path + self._path_or_device: str | int = resolved self._is_device = isinstance(self._path_or_device, int) # Initialized in _open_capture: @@ -156,11 +161,11 @@ def frame_rate(self) -> Fraction: return self._frame_rate @property - def path(self) -> bytes | str: + def path(self) -> str: if self._is_device: - assert isinstance(self._path_or_device, (int)) + assert isinstance(self._path_or_device, int) return f"Device {self._path_or_device}" - assert isinstance(self._path_or_device, (bytes, str)) + assert isinstance(self._path_or_device, str) return self._path_or_device @property @@ -316,15 +321,21 @@ def read(self, decode: bool = True) -> np.ndarray | bool: def _open_capture(self, framerate: float | None = None): """Opens capture referenced by this object and resets internal state.""" - if self._is_device and self._path_or_device < 0: - raise ValueError("Invalid/negative device ID specified.") - input_is_video_file = not self._is_device and not any( - identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS - ) - # We don't have a way of querying why opening a video fails (errors are logged at least), - # so provide a better error message if we try to open a file that doesn't exist. - if input_is_video_file and not os.path.exists(self._path_or_device): - raise OSError("Video file not found.") + if self._is_device: + assert isinstance(self._path_or_device, int) + if self._path_or_device < 0: + raise ValueError("Invalid/negative device ID specified.") + input_is_video_file = False + else: + assert isinstance(self._path_or_device, str) + input_is_video_file = not any( + identifier in self._path_or_device + for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS + ) + # We don't have a way of querying why opening a video fails (errors are logged at + # least), so provide a better error message if we try to open a missing file. + if input_is_video_file and not os.path.exists(self._path_or_device): + raise OSError("Video file not found.") cap = cv2.VideoCapture(self._path_or_device) if not cap.isOpened(): diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 366a4000..911f3697 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -11,6 +11,7 @@ # """:class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.""" +import os import typing as ty from fractions import Fraction from logging import getLogger @@ -18,8 +19,13 @@ import av import numpy as np -from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, TimecodeLike -from scenedetect.platform import get_file_name +from scenedetect.common import ( + MAX_FPS_DELTA, + FrameTimecode, + Timecode, + TimecodeLike, +) +from scenedetect.platform import StrPath, get_file_name from scenedetect.video_stream import FrameRateUnavailable, VideoOpenFailure, VideoStream logger = getLogger("pyscenedetect") @@ -35,7 +41,7 @@ class VideoStreamAv(VideoStream): # calculates the end time. def __init__( self, - path_or_io: ty.AnyStr | ty.BinaryIO, + path_or_io: StrPath | ty.BinaryIO, framerate: float | Fraction | None = None, name: str | None = None, threading_mode: str | None = None, @@ -98,12 +104,12 @@ def __init__( av.logging.restore_default_callback() try: - if isinstance(path_or_io, (str, bytes)): - self._path = path_or_io + if isinstance(path_or_io, (str, os.PathLike)): + self._path: str = os.fspath(path_or_io) # File handle is intentionally long-lived and tied to the VideoStream. - self._io = open(path_or_io, "rb") # noqa: SIM115 + self._io = open(self._path, "rb") # noqa: SIM115 if not self._name: - self._name = get_file_name(self.path, include_extension=False) + self._name = get_file_name(self._path, include_extension=False) else: self._io = path_or_io @@ -150,12 +156,12 @@ def __del__(self): """Unique name used to identify this backend.""" @property - def path(self) -> bytes | str: + def path(self) -> str: """Video path.""" return self._path @property - def name(self) -> bytes | str: + def name(self) -> str: """Name of the video, without extension.""" return self._name diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 9434d4de..54bfb91e 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -120,7 +120,7 @@ class SceneMetadata: """Last frame.""" -PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], ty.AnyStr] +PathFormatter = ty.Callable[[VideoMetadata, SceneMetadata], str] def default_formatter(template: str) -> PathFormatter: diff --git a/scenedetect/platform.py b/scenedetect/platform.py index e26ce42b..0c41cd50 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -28,6 +28,10 @@ import cv2 +StrPath = str | os.PathLike[str] +"""Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike` +(e.g. :class:`pathlib.Path`).""" + ## ## tqdm Library ## @@ -108,24 +112,19 @@ def _get_cv2_param(param_name: str) -> int | None: ## -def get_file_name(file_path: ty.AnyStr, include_extension=True) -> ty.AnyStr: +def get_file_name(file_path: StrPath, include_extension: bool = True) -> str: """Return the file name that `file_path` refers to, optionally removing the extension. - If `include_extension` is False, the result will always be a str. - E.g. /tmp/foo.bar -> foo""" - file_name = os.path.basename(file_path) + file_name = os.path.basename(os.fspath(file_path)) if not include_extension: - file_name = str(file_name) last_dot_pos = file_name.rfind(".") if last_dot_pos >= 0: file_name = file_name[:last_dot_pos] return file_name -def get_and_create_path( - file_path: ty.AnyStr, output_directory: ty.AnyStr | None = None -) -> ty.AnyStr: +def get_and_create_path(file_path: StrPath, output_directory: StrPath | None = None) -> str: """Get & Create Path: Gets and returns the full/absolute path to file_path in the specified output_directory if set, creating any required directories along the way. @@ -143,10 +142,11 @@ def get_and_create_path( Full path to output file suitable for writing. """ + file_path = os.fspath(file_path) # If an output directory is defined and the file path is a relative path, open # the file handle in the output directory instead of the working directory. if output_directory is not None and not os.path.isabs(file_path): - file_path = os.path.join(output_directory, file_path) + file_path = os.path.join(os.fspath(output_directory), file_path) # Now that file_path is an absolute path, let's make sure all the directories # exist for us to start writing files there. os.makedirs(os.path.split(os.path.abspath(file_path))[0], exist_ok=True) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 740a876d..502390c7 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -104,13 +104,13 @@ def BACKEND_NAME() -> str: @property @abstractmethod - def path(self) -> bytes | str: + def path(self) -> str: """Video or device path.""" ... @property @abstractmethod - def name(self) -> bytes | str: + def name(self) -> str: """Name of the video, without extension, or device.""" ... From c5084417aff3ced0ffe6caea96dd94507c1b894e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 01:22:48 -0400 Subject: [PATCH 308/407] [cli] Strengthen type guards --- scenedetect/_cli/__init__.py | 72 ++++++++++++++-------------- scenedetect/_cli/config.py | 19 ++++++-- scenedetect/_cli/context.py | 91 +++++++++++------------------------- scenedetect/common.py | 18 +++---- 4 files changed, 86 insertions(+), 114 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ca6f6cfa..f4ab2898 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -27,7 +27,7 @@ import click -import scenedetect +import scenedetect as scenedetect_pkg import scenedetect._cli.commands as cli_commands from scenedetect._cli.config import ( CHOICE_MAP, @@ -35,6 +35,7 @@ CONFIG_MAP, DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, + RangeValue, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS @@ -47,13 +48,24 @@ ) from scenedetect.platform import get_cv2_imwrite_params, get_system_version_info -PROGRAM_VERSION = scenedetect.__version__ +PROGRAM_VERSION = scenedetect_pkg.__version__ """Used to avoid name conflict with named `scenedetect` command below.""" logger = logging.getLogger("pyscenedetect") LINE_SEPARATOR = "-" * 72 + +def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": + """Return a `click` parameter type matching the `RangeValue` at `CONFIG_MAP[section][key]`. + + Used in `@click.option(... type=...)` decorators so each option's bounds and value type are + sourced from the canonical `CONFIG_MAP` entry. + """ + val = CONFIG_MAP[section][key] + assert isinstance(val, RangeValue), f"Expected RangeValue at {section}/{key}, got {type(val)}" + return val.click_range + # About & copyright message string shown for the 'about' CLI command (scenedetect about). ABOUT_STRING = """ Site: http://scenedetect.com/ @@ -375,7 +387,8 @@ def add_hidden_alias(command: click.Command, alias: str): def help_command(ctx: click.Context, command_name: str): """Print full help reference.""" # TODO: Other commands still seem to run if this is specified. - assert isinstance(ctx.parent.command, click.MultiCommand) + assert ctx.parent is not None + assert isinstance(ctx.parent.command, click.Group) parent_command = ctx.parent.command all_commands = set(parent_command.list_commands(ctx)) if command_name is not None: @@ -386,11 +399,15 @@ def help_command(ctx: click.Context, command_name: str): ] raise click.BadParameter("\n".join(error_strs), param_hint="command") click.echo("") - print_command_help(ctx, parent_command.get_command(ctx, command_name)) + target = parent_command.get_command(ctx, command_name) + assert target is not None + print_command_help(ctx, target) else: click.echo(ctx.parent.get_help()) for command in sorted(all_commands): - print_command_help(ctx, parent_command.get_command(ctx, command)) + target = parent_command.get_command(ctx, command) + assert target is not None + print_command_help(ctx, target) ctx.exit() @@ -509,10 +526,7 @@ def time_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-content"]["threshold"].min_val, - CONFIG_MAP["detect-content"]["threshold"].max_val, - ), + type=_click_range("detect-content", "threshold"), default=None, help='The max difference (0.0 to 255.0) that adjacent frames score must exceed to trigger a cut. Lower values are more sensitive to shot changes. Refers to "content_val" in stats file.{}'.format( USER_CONFIG.get_help_string("detect-content", "threshold") @@ -720,10 +734,7 @@ def detect_adaptive_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-threshold"]["threshold"].min_val, - CONFIG_MAP["detect-threshold"]["threshold"].max_val, - ), + type=_click_range("detect-threshold", "threshold"), default=None, help='Threshold (integer) that frame score must exceed to start a new scene. Refers to "delta_rgb" in stats file.{}'.format( USER_CONFIG.get_help_string("detect-threshold", "threshold") @@ -733,10 +744,7 @@ def detect_adaptive_command( "--fade-bias", "-f", metavar="PERCENT", - type=click.FloatRange( - CONFIG_MAP["detect-threshold"]["fade-bias"].min_val, - CONFIG_MAP["detect-threshold"]["fade-bias"].max_val, - ), + type=_click_range("detect-threshold", "fade-bias"), default=None, help="Percent (%) from -100 to 100 of timecode skew of cut placement. -100 indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.{}".format( USER_CONFIG.get_help_string("detect-threshold", "fade-bias") @@ -802,10 +810,7 @@ def detect_threshold_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-hist"]["threshold"].min_val, - CONFIG_MAP["detect-hist"]["threshold"].max_val, - ), + type=_click_range("detect-hist", "threshold"), default=None, help="Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower " "values are more sensitive to changes.{}".format( @@ -816,9 +821,7 @@ def detect_threshold_command( "--bins", "-b", metavar="NUM", - type=click.IntRange( - CONFIG_MAP["detect-hist"]["bins"].min_val, CONFIG_MAP["detect-hist"]["bins"].max_val - ), + type=_click_range("detect-hist", "bins"), default=None, help="The number of bins to use for the histogram calculation.{}".format( USER_CONFIG.get_help_string("detect-hist", "bins") @@ -873,10 +876,7 @@ def detect_hist_command( "--threshold", "-t", metavar="VAL", - type=click.FloatRange( - CONFIG_MAP["detect-hash"]["threshold"].min_val, - CONFIG_MAP["detect-hash"]["threshold"].max_val, - ), + type=_click_range("detect-hash", "threshold"), default=None, help=( "Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are " @@ -889,9 +889,7 @@ def detect_hist_command( "--size", "-s", metavar="SIZE", - type=click.IntRange( - CONFIG_MAP["detect-hash"]["size"].min_val, CONFIG_MAP["detect-hash"]["size"].max_val - ), + type=_click_range("detect-hash", "size"), default=None, help="Size of square of low frequency data to include from the discrete cosine transform.{}".format( USER_CONFIG.get_help_string("detect-hash", "size") @@ -901,9 +899,7 @@ def detect_hist_command( "--lowpass", "-l", metavar="FRAC", - type=click.IntRange( - CONFIG_MAP["detect-hash"]["lowpass"].min_val, CONFIG_MAP["detect-hash"]["lowpass"].max_val - ), + type=_click_range("detect-hash", "lowpass"), default=None, help=( "How much high frequency information to filter from the DCT. 2 means keep lower 1/2 of " @@ -984,6 +980,8 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s raise click.ClickException("The load-scenes command cannot be used with detectors.") if ctx.load_scenes_input: raise click.ClickException("The load-scenes command must only be specified once.") + if input is None: + raise click.BadParameter("Input file is required.", param_hint="-i/--input") input = os.path.abspath(input) if not os.path.exists(input): raise click.BadParameter( @@ -1066,6 +1064,7 @@ def save_html_command( # to include images. include_images = not ctx.config.get_value("save-html", "no-images", no_images) if include_images and not ctx.save_images: + assert save_images_command.callback is not None save_images_command.callback() save_html_args = { "filename": ctx.config.get_value("save-html", "filename", filename), @@ -1239,10 +1238,7 @@ def list_scenes_command( "-crf", metavar="RATE", default=None, - type=click.IntRange( - CONFIG_MAP["split-video"]["rate-factor"].min_val, - CONFIG_MAP["split-video"]["rate-factor"].max_val, - ), + type=_click_range("split-video", "rate-factor"), help="Video encoding quality (x264 constant rate factor), from 0-100, where lower is higher quality (larger output). 0 indicates lossless.{}".format( USER_CONFIG.get_help_string("split-video", "rate-factor") ), diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index ca95559c..60d4a57a 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -23,6 +23,7 @@ from configparser import Error as ConfigParserError from enum import Enum +import click from platformdirs import user_config_dir from scenedetect.common import FrameTimecode @@ -124,6 +125,13 @@ def max_val(self) -> int | float: """Maximum value of the range.""" return self._max_val + @property + def click_range(self) -> "click.IntRange | click.FloatRange": + """A `click` parameter type matching this range's bounds and value type.""" + if isinstance(self._value, int): + return click.IntRange(int(self._min_val), int(self._max_val)) + return click.FloatRange(float(self._min_val), float(self._max_val)) + @staticmethod def from_config(config_value: str, default: "RangeValue") -> "RangeValue": try: @@ -757,9 +765,14 @@ def get_value( self, command: str, option: str, - override: ConfigValue | None = None, - ) -> ConfigValue: - """Get the current setting or default value of the specified command option.""" + override: ty.Any = None, + ) -> ty.Any: + """Get the current setting or default value of the specified command option. + + Returns ``ty.Any`` because each (command, option) pair has a known concrete type at + the call site, but the union across all options is too wide to be useful as a return + annotation. Callers should know the expected type for the option they are reading. + """ assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: value = override diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 895409a4..cc6eb458 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -133,6 +133,22 @@ def ensure_detector(self): (detector_type, detector_args) = self.default_detector self.add_detector(detector_type, detector_args) + def _resolve_min_scene_len(self, command: str, override: str | None) -> int: + """Resolve the minimum scene length (in frames) for a `detect-*` command, honoring + the `--drop-short-scenes` flag, command-specific config, and global default.""" + if self.drop_short_scenes: + return 0 + if override is not None: + parsed = self.parse_timecode(override) + assert parsed is not None + return parsed.frame_num + if self.config.is_default(command, "min-scene-len"): + assert self.min_scene_len is not None + return self.min_scene_len.frame_num + parsed = self.parse_timecode(self.config.get_value(command, "min-scene-len")) + assert parsed is not None + return parsed.frame_num + def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameTimecode | None: """Parses a user input string into a FrameTimecode assuming the given framerate. If `value` is None it will be passed through without processing. @@ -328,18 +344,7 @@ def get_detect_content_params( filter_mode: str | None = None, ) -> dict[str, ty.Any]: """Get a dict containing user options to construct a ContentDetector with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-content", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-content", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-content", min_scene_len) if weights is not None: try: @@ -354,7 +359,7 @@ def get_detect_content_params( "weights": self.config.get_value("detect-content", "weights", weights), "kernel_size": self.config.get_value("detect-content", "kernel-size", kernel_size), "luma_only": luma_only or self.config.get_value("detect-content", "luma-only"), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "threshold": self.config.get_value("detect-content", "threshold", threshold), "filter_mode": self.config.get_value("detect-content", "filter-mode", filter_mode), } @@ -371,18 +376,7 @@ def get_detect_adaptive_params( ) -> dict[str, ty.Any]: """Handle detect-adaptive command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-adaptive", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-adaptive", min_scene_len) if weights is not None: try: @@ -400,7 +394,7 @@ def get_detect_adaptive_params( "min_content_val": self.config.get_value( "detect-adaptive", "min-content-val", min_content_val ), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "window_width": self.config.get_value("detect-adaptive", "frame-window", frame_window), } @@ -413,24 +407,13 @@ def get_detect_threshold_params( ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-threshold", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-threshold", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-threshold", min_scene_len) # TODO(v1.0): add_last_scene cannot be disabled right now. return { "add_final_scene": add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"), "fade_bias": self.config.get_value("detect-threshold", "fade-bias", fade_bias), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "threshold": self.config.get_value("detect-threshold", "threshold", threshold), } @@ -442,21 +425,10 @@ def get_detect_hist_params( ) -> dict[str, ty.Any]: """Handle detect-hist command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hist", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hist", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-hist", min_scene_len) return { "bins": self.config.get_value("detect-hist", "bins", bins), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "threshold": self.config.get_value("detect-hist", "threshold", threshold), } @@ -469,21 +441,10 @@ def get_detect_hash_params( ) -> dict[str, ty.Any]: """Handle detect-hash command options and return args to construct one with.""" - if self.drop_short_scenes: - min_scene_len = 0 - else: - if min_scene_len is None: - if self.config.is_default("detect-hash", "min-scene-len"): - assert self.min_scene_len is not None - min_scene_len = self.min_scene_len.frame_num - else: - min_scene_len = self.config.get_value("detect-hash", "min-scene-len") - parsed = self.parse_timecode(min_scene_len) - assert parsed is not None - min_scene_len = parsed.frame_num + min_scene_len_frames = self._resolve_min_scene_len("detect-hash", min_scene_len) return { "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass), - "min_scene_len": min_scene_len, + "min_scene_len": min_scene_len_frames, "size": self.config.get_value("detect-hash", "size", size), "threshold": self.config.get_value("detect-hash", "threshold", threshold), } diff --git a/scenedetect/common.py b/scenedetect/common.py index cd178d9b..bf40d34f 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -503,7 +503,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"] def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return False - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num == other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -515,7 +515,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: if other is None: return True - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num != other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -525,7 +525,7 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num != self._get_other_as_frames(other) def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num < other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -535,7 +535,7 @@ def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num < self._get_other_as_frames(other) def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num <= other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -545,7 +545,7 @@ def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num <= self._get_other_as_frames(other) def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num > other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -555,7 +555,7 @@ def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.frame_num > self._get_other_as_frames(other) def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: - if _compare_as_fixed(self, other): + if _compare_as_fixed(other, self): return self.frame_num >= other.frame_num # For integer comparison, use frame numbers to avoid floating point precision issues. if isinstance(other, int): @@ -713,8 +713,10 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode" raise TypeError("Unsupported type for performing arithmetic with FrameTimecode.") -def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool: - return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None +def _compare_as_fixed(other: ty.Any, base: FrameTimecode) -> ty.TypeGuard[FrameTimecode]: + """Type guard: True (and narrows `other` to `FrameTimecode`) iff both timecodes have a known + framerate, in which case frame-based comparison is exact and preferred over float seconds.""" + return base._rate is not None and isinstance(other, FrameTimecode) and other._rate is not None TimecodeLike = int | float | str | Timecode | FrameTimecode From 7f2b25b99d9dd2824e6e0ff50236d73bf78ea5e8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 01:38:16 -0400 Subject: [PATCH 309/407] [lint] Fix more type hint errors --- scenedetect/__init__.py | 4 ++-- scenedetect/_cli/__init__.py | 1 + scenedetect/_cli/controller.py | 8 ++++--- scenedetect/backends/moviepy.py | 10 ++++----- scenedetect/backends/pyav.py | 8 +++---- scenedetect/detector.py | 23 +++++++++++++------- scenedetect/detectors/hash_detector.py | 6 +++--- scenedetect/detectors/threshold_detector.py | 11 +++++----- scenedetect/output/__init__.py | 4 +++- scenedetect/output/image.py | 21 +++++++++++------- scenedetect/output/video.py | 17 ++++++++------- scenedetect/scene_manager.py | 24 +++++++++++++-------- scenedetect/stats_manager.py | 9 +++++--- scenedetect/video_stream.py | 11 ++++------ 14 files changed, 89 insertions(+), 68 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 826f2f5b..19460d7c 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -105,7 +105,7 @@ def open_video( :class:`VideoOpenFailure`: Constructing the VideoStream fails. If multiple backends have been attempted, the error from the first backend will be returned. """ - last_error: Exception = None + last_error: Exception | None = None # If `backend` is available, try to open the video at `path` using it. if backend in AVAILABLE_BACKENDS: backend_type = AVAILABLE_BACKENDS[backend] @@ -183,6 +183,6 @@ def detect( show_progress=show_progress, end_time=end_timecode, ) - if scene_manager.stats_manager is not None: + if scene_manager.stats_manager is not None and stats_file_path is not None: scene_manager.stats_manager.save_to_csv(csv_file=stats_file_path) return scene_manager.get_scene_list(start_in_scene=start_in_scene) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index f4ab2898..16dfce5b 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -66,6 +66,7 @@ def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": assert isinstance(val, RangeValue), f"Expected RangeValue at {section}/{key}, got {type(val)}" return val.click_range + # About & copyright message string shown for the 'about' CLI command (scenedetect about). ABOUT_STRING = """ Site: http://scenedetect.com/ diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index a53ea77e..aa43a45e 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -209,11 +209,13 @@ def calculate_timecode(value: str) -> FrameTimecode: start_time = context.start_time cut_list = [cut for cut in cut_list if cut > context.start_time] - end_time = context.video_stream.duration + video_duration = context.video_stream.duration + assert video_duration is not None + end_time = video_duration if context.end_time is not None: - end_time = min(context.end_time, context.video_stream.duration) + end_time = min(context.end_time, video_duration) elif context.duration is not None: - end_time = min(start_time + context.duration, context.video_stream.duration) + end_time = min(start_time + context.duration, video_duration) cut_list = [cut for cut in cut_list if cut < end_time] scene_list = get_scenes_from_cuts(cut_list=cut_list, start_pos=start_time, end_pos=end_time) diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index 826f4f3b..ad8a849a 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -267,10 +267,8 @@ def read(self, decode: bool = True) -> np.ndarray | bool: return False self._eof = True self._frame_number += 1 - if decode: - last_frame_valid = self._last_frame is not None and self._last_frame is not False - if last_frame_valid: - self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) - assert self._last_frame_rgb is not None - return self._last_frame_rgb + if decode and isinstance(self._last_frame, np.ndarray): + self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB) + assert self._last_frame_rgb is not None + return self._last_frame_rgb return not self._eof diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 911f3697..e297600b 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -74,7 +74,7 @@ def __init__( VideoOpenFailure: video could not be opened (may be corrupted) ValueError: specified framerate is invalid """ - self._container: av.container.InputContainer | None = None + self._container: av.container.InputContainer | None = None # type: ignore[name-defined] # TODO(https://scenedetect.com/issues/258): See what # `self._container.discard_corrupt = True` does with corrupt videos. @@ -93,7 +93,7 @@ def __init__( if threading_mode: try: - threading_mode = av.codec.context.ThreadType[threading_mode.upper()] + threading_mode = av.codec.context.ThreadType[threading_mode.upper()] # type: ignore[attr-defined] except KeyError as _: raise ValueError( f"Invalid threading mode! Must be one of: {VALID_THREAD_MODES}" @@ -101,7 +101,7 @@ def __init__( if not suppress_output: logger.debug("Restoring default ffmpeg log callbacks.") - av.logging.restore_default_callback() + av.logging.restore_default_callback() # type: ignore[attr-defined] try: if isinstance(path_or_io, (str, os.PathLike)): @@ -305,7 +305,7 @@ def read(self, decode: bool = True) -> np.ndarray | bool: assert self._decoder is not None self._frame = next(self._decoder) self._decode_count += 1 - except av.error.EOFError: + except av.error.EOFError: # type: ignore[attr-defined] self._frame = last_frame if self._handle_eof(): return self.read(decode) diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 3f7dcf0a..ba65b2a6 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -134,10 +134,10 @@ def __init__(self, mode: Mode, length: int | float | str): self._filter_secs = FrameTimecode(timecode=length, fps=100.0).seconds else: self._filter_length = int(length) - self._last_above = None # Last frame above threshold. + self._last_above: FrameTimecode | None = None # Last frame above threshold. self._merge_enabled = False # Used to disable merging until at least one cut was found. self._merge_triggered = False # True when the merge filter is active. - self._merge_start = None # Frame number where we started the merge filter. + self._merge_start: FrameTimecode | None = None # Frame where we started merging. @property def max_behind(self) -> int: @@ -165,12 +165,16 @@ def filter(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTi return self._filter_suppress(timecode=timecode, above_threshold=above_threshold) raise RuntimeError("Unhandled FlashFilter mode.") - def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: - assert timecode.framerate >= 0 + def _filter_suppress( + self, timecode: FrameTimecode, above_threshold: bool + ) -> list[FrameTimecode]: + framerate = timecode.framerate + assert framerate is not None and framerate >= 0 + assert self._last_above is not None # Compute the threshold in seconds once from the first frame's framerate. This avoids # using an incorrect average fps (e.g. OpenCV on VFR video) on subsequent frames. if self._filter_secs is None: - self._filter_secs = self._filter_length / timecode.framerate + self._filter_secs = self._filter_length / framerate min_length_met: bool = (timecode - self._last_above) >= self._filter_secs if not (above_threshold and min_length_met): return [] @@ -179,17 +183,20 @@ def _filter_suppress(self, timecode: FrameTimecode, above_threshold: bool) -> li self._last_above = timecode return [timecode] - def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[int]: - assert timecode.framerate >= 0 + def _filter_merge(self, timecode: FrameTimecode, above_threshold: bool) -> list[FrameTimecode]: + framerate = timecode.framerate + assert framerate is not None and framerate >= 0 + assert self._last_above is not None # Compute the threshold in seconds once from the first frame's framerate. if self._filter_secs is None: - self._filter_secs = self._filter_length / timecode.framerate + self._filter_secs = self._filter_length / framerate min_length_met: bool = (timecode - self._last_above) >= self._filter_secs # Ensure last frame is always advanced to the most recent one that was above the threshold. if above_threshold: self._last_above = timecode if self._merge_triggered: # This frame was under the threshold, see if enough frames passed to disable the filter. + assert self._merge_start is not None if ( min_length_met and not above_threshold diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 86c15f57..40b4e3c8 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -57,8 +57,8 @@ def __init__( self._size = size self._size_sq = float(size * size) self._factor = lowpass - self._last_frame: numpy.ndarray = None - self._last_scene_cut: FrameTimecode = None + self._last_frame: numpy.ndarray | None = None + self._last_scene_cut: FrameTimecode | None = None self._last_hash = numpy.array([]) self._metric_key = f"hash_dist [size={self._size} lowpass={self._factor}]" @@ -136,7 +136,7 @@ def hash_frame(frame_img, hash_size, factor) -> numpy.ndarray: max_value = 1 # Calculate discrete cosine tranformation of the image - resized_img = numpy.float32(resized_img) / max_value + resized_img = numpy.asarray(numpy.float32(resized_img) / max_value) dct_complete = cv2.dct(resized_img) # Only keep the low frequency information diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 1a5e4690..dd8d2a8a 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -15,6 +15,7 @@ This detector is available from the command-line as the `detect-threshold` command. """ +import typing as ty import warnings from enum import Enum from logging import getLogger @@ -82,12 +83,12 @@ def __init__( self.fade_bias = fade_bias self.min_scene_len = min_scene_len self.processed_frame = False - self.last_scene_cut = None + self.last_scene_cut: FrameTimecode | None = None # Whether to add an additional scene or not when ending on a fade out # (as cuts are only added on fade ins; see post_process() for details). self.add_final_scene = add_final_scene # Where the last fade (threshold crossing) was detected. - self.last_fade = { + self.last_fade: dict[str, ty.Any] = { "frame": None, # FrameTimecode where the last detected fade is "type": None, # type of fade, can be either 'in' or 'out' } @@ -174,14 +175,12 @@ def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: # scene break to indicate the end of the scene. This is only done for # fade-outs, as a scene cut is already added when a fade-in is found. cuts: list[FrameTimecode] = [] + elapsed = timecode if self.last_scene_cut is None else timecode - self.last_scene_cut if ( self.last_fade["type"] == "out" and self.add_final_scene and self.last_fade["frame"] is not None - and ( - (self.last_scene_cut is None and timecode >= self.min_scene_len) - or (timecode - self.last_scene_cut) >= self.min_scene_len - ) + and elapsed >= self.min_scene_len ): cuts.append(self.last_fade["frame"]) return cuts diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index d2b28b4b..ceff96c3 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -244,10 +244,12 @@ def write_scene_list_html( def _edl_timecode(timecode: FrameTimecode) -> str: """Format `timecode` as ``HH:MM:SS:FF`` for a CMX 3600 EDL entry.""" total_seconds = timecode.seconds + framerate = timecode.framerate + assert framerate is not None hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) seconds = int(total_seconds % 60) - frames_part = int((total_seconds * timecode.framerate) % timecode.framerate) + frames_part = int((total_seconds * framerate) % framerate) return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 0d95981b..902bcaeb 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -44,6 +44,7 @@ def _generate_timecode_list( `frame_margin` accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``). """ framerate = scene_list[0][0].framerate + assert framerate is not None margin_secs = FrameTimecode(timecode=frame_margin, fps=framerate).seconds result = [] for start, end in scene_list: @@ -71,7 +72,7 @@ def _generate_timecode_list( def _scale_image( image: np.ndarray, - aspect_ratio: float, + aspect_ratio: float | None, height: int | None, width: int | None, scale: float | None, @@ -90,9 +91,11 @@ def _scale_image( if height and not width: factor = height / float(image_height) width = int(factor * image_width) - if width and not height: + elif width and not height: factor = width / float(image_width) height = int(factor * image_height) + assert height is not None + assert width is not None assert height > 0 and width > 0 image = cv2.resize(image, (width, height), interpolation=interpolation.value) elif scale: @@ -106,7 +109,7 @@ def __init__( num_images: int = 3, frame_margin: int | float | str = 1, image_extension: str = "jpg", - imwrite_param: dict[str, int | None] | None = None, + imwrite_param: list[int] | None = None, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", scale: float | None = None, height: int | None = None, @@ -153,7 +156,7 @@ def __init__( self._height = height self._width = width self._interpolation = interpolation - self._imwrite_param = imwrite_param if imwrite_param else {} + self._imwrite_param: list[int] = imwrite_param if imwrite_param is not None else [] def run( self, @@ -337,7 +340,7 @@ def generate_timecode_list(self, scene_list: SceneList) -> list[list[FrameTimeco def resize_image( self, image: np.ndarray, - aspect_ratio: float, + aspect_ratio: float | None, ) -> np.ndarray: return _scale_image( image, aspect_ratio, self._height, self._width, self._scale, self._interpolation @@ -436,7 +439,7 @@ def save_images( width, interpolation, ) - return extractor.run(video, scene_list, output_dir, show_progress) + return extractor.run(video, scene_list, output_dir, bool(show_progress)) # Setup flags and init progress bar if available. completed = True @@ -467,7 +470,7 @@ def save_images( for j, image_timecode in enumerate(scene_timecodes): video.seek(image_timecode) frame_im = video.read() - if frame_im is not None and frame_im is not False: + if isinstance(frame_im, np.ndarray): # TODO: Add extension to template. # TODO: Allow NUM to be a valid suffix in addition to NUMBER. file_path = "{}.{}".format( @@ -495,9 +498,11 @@ def save_images( if height and not width: factor = height / float(frame_height) width = int(factor * frame_width) - if width and not height: + elif width and not height: factor = width / float(frame_width) height = int(factor * frame_height) + assert height is not None + assert width is not None assert height > 0 and width > 0 frame_im = cv2.resize( frame_im, (width, height), interpolation=interpolation.value diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 54bfb91e..35f8841e 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -34,6 +34,7 @@ import math import time import typing as ty +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -157,9 +158,9 @@ def formatter(video: VideoMetadata, scene: SceneMetadata) -> str: def split_video_mkvmerge( input_video_path: str, - scene_list: ty.Iterable[TimecodePair], + scene_list: Sequence[TimecodePair], output_dir: str | Path | None = None, - output_file_template: str | Path | None = "$VIDEO_NAME.mkv", + output_file_template: str = "$VIDEO_NAME.mkv", video_name: str | None = None, show_output: bool = False, suppress_output=None, @@ -253,8 +254,8 @@ def split_video_mkvmerge( def split_video_ffmpeg( input_video_path: str, - scene_list: ty.Iterable[TimecodePair], - output_dir: Path | None = None, + scene_list: Sequence[TimecodePair], + output_dir: str | Path | None = None, output_file_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4", video_name: str | None = None, arg_override: str = _DEFAULT_FFMPEG_ARGS, @@ -312,14 +313,14 @@ def split_video_ffmpeg( arg_override = arg_override.replace('\\"', '"') ret_val = 0 - arg_override = arg_override.split(" ") + ffmpeg_args = arg_override.split(" ") scene_num_format = "%0" scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" if formatter is None: formatter = default_formatter(output_file_template) video_metadata = VideoMetadata( - name=video_name, path=input_video_path, total_scenes=len(scene_list) + name=video_name, path=Path(input_video_path), total_scenes=len(scene_list) ) try: @@ -331,7 +332,7 @@ def split_video_ffmpeg( for i, (start_time, end_time) in enumerate(scene_list): duration = end_time - start_time scene_metadata = SceneMetadata(index=i, start=start_time, end=end_time) - output_path = Path(formatter(scene=scene_metadata, video=video_metadata)) + output_path = Path(formatter(video_metadata, scene_metadata)) if output_dir: output_path = Path(output_dir) / output_path output_path.parent.mkdir(parents=True, exist_ok=True) @@ -355,7 +356,7 @@ def split_video_ffmpeg( "-t", str(duration.seconds), ] - call_list += arg_override + call_list += ffmpeg_args call_list += ["-sn"] call_list += [str(output_path)] ret_val = invoke_command(call_list) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index ca9e00a6..b1034e0d 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -213,11 +213,11 @@ def __init__( self._stats_manager: StatsManager | None = stats_manager # Position of video that was first passed to detect_scenes. - self._start_pos: FrameTimecode = None + self._start_pos: FrameTimecode | None = None # Position of video on the last frame processed by detect_scenes. - self._last_pos: FrameTimecode = None + self._last_pos: FrameTimecode | None = None # Size of the decoded frames. - self._frame_size: tuple[int, int] = None + self._frame_size: tuple[int, int] | None = None self._frame_size_errors: int = 0 self._base_timecode: FrameTimecode | None = None self._downscale: int = 1 @@ -361,7 +361,7 @@ def get_scene_list(self, start_in_scene: bool = False) -> SceneList: end_time are FrameTimecode objects representing the exact time/frame where each detected scene in the video begins and ends. """ - if self._base_timecode is None: + if self._base_timecode is None or self._start_pos is None or self._last_pos is None: return [] cut_list = self._get_cutting_list() scene_list = get_scenes_from_cuts( @@ -404,7 +404,7 @@ def _process_frame( for cut in cuts: for position, frame in self._frame_buffer: if cut == position: - callback(frame, int(position)) + callback(frame, position) return new_cuts def _post_process(self, timecode: FrameTimecode) -> None: @@ -418,12 +418,12 @@ def stop(self) -> None: def detect_scenes( self, - video: VideoStream = None, + video: VideoStream | None = None, duration: FrameTimecode | None = None, end_time: FrameTimecode | None = None, frame_skip: int = 0, show_progress: bool = False, - callback: ty.Callable[[np.ndarray, int], None] | None = None, + callback: ty.Callable[[np.ndarray, FrameTimecode], None] | None = None, frame_source: VideoStream | None = None, ) -> int: """Perform scene detection on the given video using the added SceneDetectors, returning the @@ -480,7 +480,9 @@ def detect_scenes( effective_frame_size = video.frame_size if self._crop: - logger.debug(f"Crop set: top left = {self.crop[0:2]}, bottom right = {self.crop[2:4]}") + logger.debug( + f"Crop set: top left = {self._crop[0:2]}, bottom right = {self._crop[2:4]}" + ) x0, y0, x1, y1 = self._crop min_x, min_y = (min(x0, x1), min(y0, y1)) max_x, max_y = (max(x0, x1), max(y0, y1)) @@ -550,6 +552,7 @@ def detect_scenes( break if next_frame is not None: frame_im = next_frame + assert frame_im is not None new_cuts = self._process_frame(position, frame_im, callback) if progress_bar is not None: if new_cuts: @@ -570,7 +573,9 @@ def detect_scenes( decode_thread.join() if self._exception_info is not None: - raise self._exception_info[1].with_traceback(self._exception_info[2]) + exc = self._exception_info[1] + assert exc is not None + raise exc.with_traceback(self._exception_info[2]) self._last_pos = video.position self._post_process(video.position) @@ -594,6 +599,7 @@ def _decode_thread( frame_im = video.read() if frame_im is False: break + assert isinstance(frame_im, np.ndarray) # Verify the decoded frame size against the video container's reported # resolution, and also verify that consecutive frames have the correct size. decoded_size = (frame_im.shape[1], frame_im.shape[0]) diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index d61ac789..d2292f67 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -22,12 +22,14 @@ """ import csv +import os import os.path import typing as ty from logging import getLogger from pathlib import Path from scenedetect.common import FrameTimecode +from scenedetect.platform import StrPath logger = getLogger("pyscenedetect") @@ -94,7 +96,7 @@ class StatsManager: Only metrics consisting of `float` or `int` should be used currently. """ - def __init__(self, base_timecode: FrameTimecode = None): + def __init__(self, base_timecode: FrameTimecode | None = None): """Initialize a new StatsManager. Arguments: @@ -156,7 +158,7 @@ def is_save_required(self) -> bool: def save_to_csv( self, - csv_file: str | bytes | Path | ty.TextIO, + csv_file: StrPath | ty.TextIO, force_save=True, ) -> None: """Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. @@ -174,10 +176,11 @@ def save_to_csv( # If we get a path instead of an open file handle, recursively call ourselves # again but with file handle instead of path. - if isinstance(csv_file, (str, bytes, Path)): + if isinstance(csv_file, (str, bytes, Path, os.PathLike)): with open(csv_file, "w") as file: self.save_to_csv(csv_file=file, force_save=force_save) return + # csv_file is now narrowed to ty.TextIO (the path branch returned above). csv_writer = csv.writer(csv_file, lineterminator="\n") metric_keys = sorted(list(self._metric_keys)) diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 502390c7..3c3687a1 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -31,6 +31,7 @@ tested by adding it to the test suite in `tests/test_video_stream.py`. """ +import typing as ty from abc import ABC, abstractmethod from fractions import Fraction @@ -88,15 +89,11 @@ def base_timecode(self) -> FrameTimecode: return FrameTimecode(timecode=0, fps=self.frame_rate) # - # Abstract Static Methods + # Backend Identification # - @staticmethod - @abstractmethod - def BACKEND_NAME() -> str: - """Unique name used to identify this backend. Should be a static property in derived - classes (`BACKEND_NAME = 'backend_identifier'`).""" - ... + BACKEND_NAME: ty.ClassVar[str] + """Unique name used to identify this backend. Each subclass must set this to a unique str.""" # # Abstract Properties From ed6a725b873cdbec7dcf652bfccbb925d3aabd89 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 12:59:51 -0400 Subject: [PATCH 310/407] [backends] Tighten None safety, reduce unnecessary None checks --- pyproject.toml | 26 +++++++++++++++++++++++--- scenedetect/backends/opencv.py | 27 +++------------------------ scenedetect/backends/pyav.py | 15 +++++---------- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da7da0d9..35b9e85b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,15 +61,35 @@ unfixable = [] [tool.pyright] include = ["scenedetect", "tests"] +# Run pyright from inside an activated venv (or pass `--pythonpath` / +# configure your editor's interpreter) so cv2 / av / numpy / moviepy +# resolve. Without this, pyright uses its bundled Python and the report +# fills with cascading "import could not be resolved" noise. +# Per-developer venv paths are deliberately NOT pinned here. +# +# Modes: "off" | "basic" | "standard" | "strict". +# We're at "basic" for 0.7. Bumping requires the cleanup in TODO(0.8) below. typeCheckingMode = "basic" -# cv2, av, and moviepy ship without type stubs; these reports generate -# unactionable noise without catching real issues in this codebase. + +# Third-party noise: cv2, av, moviepy ship without (or with partial) type +# stubs; these reports are unactionable in this codebase. reportMissingTypeStubs = "none" reportUnknownMemberType = "none" reportUnknownArgumentType = "none" reportUnknownVariableType = "none" reportUnknownParameterType = "none" + +# Click + pytest decorators are conventionally untyped. +reportUntypedFunctionDecorator = "none" + +# TODO(0.8): Audit the rules below. They were added globally with a +# third-party justification, but they affect the whole codebase. After the +# remaining first-party type-debt is cleared (FrameTimecode/TimecodeLike in +# common.py, _cli/config.py CropValue overloads, Click CLI typing in +# _cli/__init__.py), re-enable each in isolation and decide keep/drop: +# reportMissingParameterType — first-party coverage looks complete +# reportMissingTypeArgument — modern Python defaults absorb most +# reportPrivateUsage — no cross-module _private access seen reportMissingParameterType = "none" reportMissingTypeArgument = "none" -reportUntypedFunctionDecorator = "none" reportPrivateUsage = "none" diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index e0985535..fb64585e 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -119,18 +119,13 @@ def __init__( self._path_or_device: str | int = resolved self._is_device = isinstance(self._path_or_device, int) - # Initialized in _open_capture: - self._cap: cv2.VideoCapture | None = ( - None # Reference to underlying cv2.VideoCapture object. - ) - self._frame_rate: Fraction | None = None - # VideoCapture state self._has_grabbed = False self._max_decode_attempts = max_decode_attempts self._decode_failures = 0 self._warning_displayed = False + # `_open_capture` populates `_cap` and `_frame_rate`. self._open_capture(framerate) # @@ -145,7 +140,6 @@ def capture(self) -> cv2.VideoCapture: backing this object. Seeking or using the read/grab methods through this property are unsupported and will leave this object in an inconsistent state. """ - assert self._cap return self._cap # @@ -157,7 +151,6 @@ def capture(self) -> cv2.VideoCapture: @property def frame_rate(self) -> Fraction: - assert self._frame_rate return self._frame_rate @property @@ -189,7 +182,6 @@ def is_seekable(self) -> bool: @property def frame_size(self) -> tuple[int, int]: """Size of each video frame in pixels as a tuple of (width, height).""" - assert self._cap is not None return ( math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)), math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), @@ -200,13 +192,11 @@ def duration(self) -> FrameTimecode | None: """Duration of the stream as a FrameTimecode, or None if non terminating.""" if self._is_device: return None - assert self._cap is not None return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)) @property def aspect_ratio(self) -> float: """Display/pixel aspect ratio as a float (1.0 represents square pixels).""" - assert self._cap is not None return _get_aspect_ratio(self._cap) @property @@ -215,7 +205,6 @@ def timecode(self) -> Timecode: # *NOTE*: Although OpenCV has `CAP_PROP_PTS`, it doesn't seem to be reliable. For now, we # use `CAP_PROP_POS_MSEC` instead, converting to microseconds for sufficient precision to # avoid frame-boundary rounding errors at common framerates like 24000/1001. - assert self._cap is not None ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) time_base = Fraction(1, 1000000) return Timecode(pts=round(ms * 1000), time_base=time_base) @@ -234,12 +223,10 @@ def position(self) -> FrameTimecode: @property def position_ms(self) -> float: - assert self._cap is not None return self._cap.get(cv2.CAP_PROP_POS_MSEC) @property def frame_number(self) -> int: - assert self._cap is not None return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES)) def seek(self, target: TimecodeLike): @@ -249,9 +236,6 @@ def seek(self, target: TimecodeLike): target = FrameTimecode(target, self.frame_rate) if target < 0: raise ValueError("Target seek position cannot be negative!") - assert self._cap is not None - - assert self._frame_rate is not None target_secs = (self.base_timecode + target).seconds self._has_grabbed = False if target_secs > 0: @@ -281,13 +265,10 @@ def seek(self, target: TimecodeLike): def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - assert self._cap is not None - assert self._frame_rate is not None self._cap.release() self._open_capture(float(self._frame_rate)) def read(self, decode: bool = True) -> np.ndarray | bool: - assert self._cap is not None if not self._cap.isOpened(): return False has_grabbed = self._cap.grab() @@ -364,8 +345,8 @@ def _open_capture(self, framerate: float | None = None): if framerate < MAX_FPS_DELTA: raise FrameRateUnavailable() - self._cap = cap - self._frame_rate = framerate_to_fraction(framerate) + self._cap: cv2.VideoCapture = cap + self._frame_rate: Fraction = framerate_to_fraction(framerate) self._has_grabbed = False cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 1.0) # https://github.com/opencv/opencv/issues/26795 @@ -430,7 +411,6 @@ def capture(self) -> cv2.VideoCapture: backing this object. Using the read/grab methods through this property are unsupported and will leave this object in an inconsistent state. """ - assert self._cap return self._cap # @@ -443,7 +423,6 @@ def capture(self) -> cv2.VideoCapture: @property def frame_rate(self) -> Fraction: """Framerate in frames/sec.""" - assert self._frame_rate return self._frame_rate @property diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index e297600b..057897a6 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -74,8 +74,6 @@ def __init__( VideoOpenFailure: video could not be opened (may be corrupted) ValueError: specified framerate is invalid """ - self._container: av.container.InputContainer | None = None # type: ignore[name-defined] - # TODO(https://scenedetect.com/issues/258): See what # `self._container.discard_corrupt = True` does with corrupt videos. super().__init__() @@ -113,7 +111,7 @@ def __init__( else: self._io = path_or_io - self._container = av.open(self._io) + self._container: av.container.InputContainer = av.open(self._io) # type: ignore[attr-defined] if threading_mode is not None: self._video_stream.thread_type = threading_mode self._reopened = False @@ -145,8 +143,10 @@ def __init__( self._duration_frames = self._get_duration() def __del__(self): - if self._container is not None: - self._container.close() + # `_container` is unset if `__init__` raised before `av.open()` succeeded. + container = getattr(self, "_container", None) + if container is not None: + container.close() # # VideoStream Methods/Properties @@ -273,7 +273,6 @@ def seek(self, target: TimecodeLike) -> None: self._frame = None self._decoder = None self._decode_count = 0 - assert self._container is not None self._container.seek(target_pts, stream=self._video_stream) if not beginning: self.read(decode=False) @@ -283,7 +282,6 @@ def seek(self, target: TimecodeLike) -> None: def reset(self): """Close and re-open the VideoStream (should be equivalent to calling `seek(0)`).""" - assert self._container is not None self._container.close() self._frame = None self._decoder = None @@ -298,7 +296,6 @@ def read(self, decode: bool = True) -> np.ndarray | bool: # B-frame reordering) is never flushed prematurely. Creating a new generator each call # caused the last buffered frame to be lost at EOF. if self._decoder is None: - assert self._container is not None self._decoder = self._container.decode(video=0) try: last_frame = self._frame @@ -322,7 +319,6 @@ def read(self, decode: bool = True) -> np.ndarray | bool: @property def _video_stream(self): """PyAV `av.video.stream.VideoStream` being used.""" - assert self._container is not None return self._container.streams.video[0] @property @@ -379,7 +375,6 @@ def _handle_eof(self): except: self._io.seek(orig_pos) raise - assert self._container is not None self._container.close() self._container = container self._decoder = None From 504046cc2429ed469b8e8b355b332841fa992700 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 13:14:39 -0400 Subject: [PATCH 311/407] [lint] Fix type checking for config parser --- scenedetect/_cli/config.py | 72 ++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 60d4a57a..1154adff 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -152,11 +152,14 @@ class CropValue(ValidatedValue): _IGNORE_CHARS = (",", "/", "(", ")") """Characters to ignore.""" - def __init__(self, value: str | tuple[int, int, int, int] | None = None): - if isinstance(value, CropValue) or value is None: - self._crop = value + def __init__(self, value: "str | tuple[int, int, int, int] | CropValue | None" = None): + self._crop: tuple[int, int, int, int] | None = None + if isinstance(value, CropValue): + self._crop = value._crop + elif value is None: + return else: - crop = () + crop: tuple[int, ...] = () if isinstance(value, str): translation_table = str.maketrans( {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} @@ -173,11 +176,13 @@ def __init__(self, value: str | tuple[int, int, int, int] | None = None): self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) @property - def value(self) -> tuple[int, int, int, int]: + def value(self) -> tuple[int, int, int, int] | None: return self._crop def __str__(self) -> str: - x0, y0, x1, y1 = self.value + if self._crop is None: + return "(none)" + x0, y0, x1, y1 = self._crop return f"[{x0}, {y0}], [{x1}, {y1}]" @staticmethod @@ -228,25 +233,27 @@ class KernelSizeValue(ValidatedValue): """Validator for kernel sizes (odd integer > 1, or -1 for auto size).""" def __init__(self, value: int): + self._value: int | None if value == -1: - # Downscale factor of -1 maps to None internally for auto downscale. - value = None + # Kernel size of -1 maps to None internally for auto-sized kernel. + self._value = None elif value < 0: # Disallow other negative values. raise ValueError() elif value % 2 == 0: # Disallow even values. raise ValueError() - self._value = value + else: + self._value = value @property - def value(self) -> int: + def value(self) -> int | None: return self._value def __str__(self) -> str: - if self.value is None: + if self._value is None: return "auto" - return str(self.value) + return str(self._value) @staticmethod def from_config(config_value: str, default: "KernelSizeValue") -> "KernelSizeValue": @@ -291,7 +298,12 @@ def __init__(self, value: str): @staticmethod def from_config(config_value: str, default: "EscapedString") -> "EscapedChar": - return EscapedString.from_config(config_value, default, length_limit=1) + try: + return EscapedChar(config_value) + except (UnicodeDecodeError, UnicodeEncodeError) as ex: + raise OptionParseFailure( + "Value must be valid UTF-8 string with escape characters." + ) from ex class TimecodeFormat(Enum): @@ -323,7 +335,11 @@ class FcpFormat(Enum): """Final Cut Pro 7 XML Format""" -ConfigValue = bool | int | float | str +# `ConfigValue` covers every concrete type that can appear as a default in +# `CONFIG_MAP` or as a parsed value in `ConfigRegistry._config`. Custom +# validators (`ValidatedValue` subclasses) and `Enum` defaults are included +# because they appear directly in `CONFIG_MAP`. +ConfigValue = bool | int | float | str | None | ValidatedValue | Enum ConfigDict = dict[str, dict[str, ConfigValue]] _CONFIG_FILE_NAME: str = "scenedetect.cfg" @@ -577,26 +593,28 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess config[command] = {} for option in CONFIG_MAP[command]: if command in parser and option in parser[command]: + # Bind to a local so pyright can narrow inside the isinstance branches. + default_value = CONFIG_MAP[command][option] try: value_type = None - if isinstance(CONFIG_MAP[command][option], bool): + if isinstance(default_value, bool): value_type = "yes/no value" config[command][option] = parser.getboolean(command, option) continue - elif isinstance(CONFIG_MAP[command][option], int): + elif isinstance(default_value, int): value_type = "integer" config[command][option] = parser.getint(command, option) continue - elif isinstance(CONFIG_MAP[command][option], float): + elif isinstance(default_value, float): value_type = "number" config[command][option] = parser.getfloat(command, option) continue - elif isinstance(CONFIG_MAP[command][option], Enum): + elif isinstance(default_value, Enum): config_value = ( parser.get(command, option).replace("\n", " ").strip().upper() ) try: - parsed = CONFIG_MAP[command][option].__class__[config_value] + parsed = default_value.__class__[config_value] config[command][option] = parsed except TypeError: success = False @@ -627,12 +645,11 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess # Handle custom validation types. config_value = parser.get(command, option) - default = CONFIG_MAP[command][option] - option_type = type(default) - if issubclass(option_type, ValidatedValue): + if isinstance(default_value, ValidatedValue): + option_type = type(default_value) try: config[command][option] = option_type.from_config( - config_value=config_value, default=default + config_value=config_value, default=default_value ) except OptionParseFailure as ex: success = False @@ -677,7 +694,7 @@ def _parse_config(parser: ConfigParser) -> tuple[ConfigDict | None, list[LogMess class ConfigLoadFailure(Exception): """Raised when a user-specified configuration file fails to be loaded or validated.""" - def __init__(self, init_log: tuple[int, str], reason: Exception | None = None): + def __init__(self, init_log: list[LogMessage], reason: Exception | None = None): super().__init__() self.init_log = init_log self.reason = reason @@ -774,16 +791,17 @@ def get_value( annotation. Callers should know the expected type for the option they are reading. """ assert command in CONFIG_MAP and option in CONFIG_MAP[command] + default_value = CONFIG_MAP[command][option] if override is not None: value = override elif command in self._config and option in self._config[command]: value = self._config[command][option] else: - value = CONFIG_MAP[command][option] + value = default_value if isinstance(value, ValidatedValue): return value.value - if isinstance(CONFIG_MAP[command][option], Enum) and isinstance(override, str): - return CONFIG_MAP[command][option].__class__[value.upper().strip()] + if isinstance(default_value, Enum) and isinstance(override, str): + return default_value.__class__[override.upper().strip()] return value def get_help_string(self, command: str, option: str, show_default: bool | None = None) -> str: From 23fc9c5e9ea3287ecede7e5c7e17a6953ed34157 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 13:32:34 -0400 Subject: [PATCH 312/407] [api] Modernize FrameTimecode type hints --- scenedetect/common.py | 140 +++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 64 deletions(-) diff --git a/scenedetect/common.py b/scenedetect/common.py index bf40d34f..a4cc31ab 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -179,8 +179,8 @@ class FrameTimecode: def __init__( self, - timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None, - fps: ty.Union[float, "FrameTimecode", Fraction] = None, + timecode: "int | float | str | Timecode | FrameTimecode", + fps: "float | FrameTimecode | Fraction | None" = None, ): """ Arguments: @@ -194,35 +194,19 @@ def __init__( """ self._time: _FrameNumber | _Seconds | Timecode """Internal time representation.""" - self._rate: Fraction = None + self._rate: Fraction | None = None """Rate at which time passes between frames, measured in frames/sec.""" # Copy constructor. if isinstance(timecode, FrameTimecode): - self._rate = timecode._rate if fps is None else fps self._time = timecode._time + self._rate = timecode._rate if fps is None else self._ensure_fractional(fps) return - if not isinstance(fps, (float, Fraction, FrameTimecode)): - raise TypeError("fps must be of type float, Fraction, or FrameTimecode.") - # Ensure args are consistent with API. if fps is None: raise TypeError("fps is a required argument.") - if isinstance(fps, FrameTimecode): - self._rate = fps._rate - elif isinstance(fps, float): - if fps <= MAX_FPS_DELTA: - raise ValueError("Framerate must be positive and greater than zero.") - self._rate = Fraction.from_float(fps) - elif isinstance(fps, Fraction): - if float(fps) <= MAX_FPS_DELTA: - raise ValueError("Framerate must be positive and greater than zero.") - self._rate = fps - else: - raise TypeError( - f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" - ) + self._rate = self._ensure_fractional(fps) # Timecode with a time base. if isinstance(timecode, Timecode): @@ -239,12 +223,12 @@ def __init__( if timecode < 0.0: raise ValueError("Timecode frame number must be positive and greater than zero.") self._time = _Seconds(timecode) - elif isinstance(timecode, int): + else: + # Only `int` remains: `Timecode`/`FrameTimecode` returned earlier and `str`/`float` + # were just handled above. if timecode < 0: raise ValueError("Timecode frame number must be positive and greater than zero.") self._time = _FrameNumber(timecode) - else: - raise TypeError("Timecode format/type unrecognized.") @property def frame_num(self) -> int: @@ -274,6 +258,8 @@ def time_base(self) -> Fraction: """The time base in which presentation time is calculated.""" if isinstance(self._time, Timecode): return self._time.time_base + # `_FrameNumber` / `_Seconds` are only assigned after `_rate` is set. + assert self._rate is not None return 1 / self._rate @property @@ -297,7 +283,7 @@ def get_frames(self) -> int: ) return self.frame_num - def get_framerate(self) -> float: + def get_framerate(self) -> float | None: """[DEPRECATED] Get Framerate: Returns the framerate used by the FrameTimecode object. Use the `framerate` property instead. @@ -332,6 +318,8 @@ def seconds(self) -> float: return self._time.seconds if isinstance(self._time, _Seconds): return self._time.value + # `_FrameNumber` is only assigned after `_rate` is set. + assert self._rate is not None return float(self._time.value / self._rate) def get_seconds(self) -> float: @@ -404,11 +392,31 @@ def get_timecode( # Return hours, minutes, and seconds as a formatted timecode string. return f"{hrs:02d}:{mins:02d}:{secs_str}" + @staticmethod + def _ensure_fractional(fps: "float | FrameTimecode | Fraction") -> Fraction: + """Validate and convert an `fps` argument into a positive `Fraction`.""" + if isinstance(fps, FrameTimecode): + if fps._rate is None: + raise TypeError("FrameTimecode passed as fps must have a known rate.") + return fps._rate + if isinstance(fps, float): + if fps <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + return Fraction.from_float(fps) + if isinstance(fps, Fraction): + if float(fps) <= MAX_FPS_DELTA: + raise ValueError("Framerate must be positive and greater than zero.") + return fps + raise TypeError( + f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode" + ) + def _seconds_to_frames(self, seconds: float) -> int: """Convert `seconds` to the nearest number of frames using the current framerate. *NOTE*: This will not be correct for variable framerate videos. """ + assert self._rate is not None return round(seconds * self._rate) def _parse_timecode_number(self, timecode: int | float) -> int: @@ -450,7 +458,7 @@ def _timecode_to_seconds(self, input: str) -> float: timecode = int(input) if timecode < 0: raise ValueError("Timecode frame number must be positive.") - return timecode / self.framerate + return timecode / float(self._rate) # Timecode in string format 'HH:MM:SS[.nnn]' or 'MM:SS[.nnn]' elif input.find(":") >= 0: values = input.split(":") @@ -564,44 +572,46 @@ def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool: return self.seconds >= self._get_other_as_seconds(other) return self.frame_num >= self._get_other_as_frames(other) - def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) + def __iadd__(self, other: "int | float | str | FrameTimecode") -> "FrameTimecode": + # Narrow `other`'s internal time once so pyright can track it through the dispatch below. + other_inner = other._time if isinstance(other, FrameTimecode) else None - if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base == other._time.time_base: + if isinstance(self._time, Timecode) and isinstance(other_inner, Timecode): + if self._time.time_base == other_inner.time_base: self._time = Timecode( - pts=max(0, self._time.pts + other._time.pts), + pts=max(0, self._time.pts + other_inner.pts), time_base=self._time.time_base, ) return self # Different time bases: use the finer (smaller) one for better precision. - time_base = min(self._time.time_base, other._time.time_base) + time_base = min(self._time.time_base, other_inner.time_base) self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) - other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + other_pts = round(Fraction(other_inner.pts) * other_inner.time_base / time_base) self._time = Timecode(pts=max(0, self_pts + other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if isinstance(self._time, Timecode) or other_is_timecode: - timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time - seconds: float = ( - self._get_other_as_seconds(other) - if isinstance(self._time, Timecode) - else self.seconds + if isinstance(self._time, Timecode): + seconds = self._get_other_as_seconds(other) + self._time = Timecode( + pts=max(0, self._time.pts + round(seconds / self._time.time_base)), + time_base=self._time.time_base, ) + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate + return self + if isinstance(other_inner, Timecode): self._time = Timecode( - pts=max(0, timecode.pts + round(seconds / timecode.time_base)), - time_base=timecode.time_base, + pts=max(0, other_inner.pts + round(self.seconds / other_inner.time_base)), + time_base=other_inner.time_base, ) - # Preserve rate if available from self or other. if self._rate is None and isinstance(other, FrameTimecode): self._rate = other._rate return self - other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) - if isinstance(self._time, _Seconds) and other_is_seconds: - self._time = _Seconds(max(0, self._time.value + other._time.value)) + if isinstance(self._time, _Seconds) and isinstance(other_inner, _Seconds): + self._time = _Seconds(max(0.0, self._time.value + other_inner.value)) return self if isinstance(self._time, _Seconds): @@ -616,44 +626,46 @@ def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTi to_return += other return to_return - def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode": - other_is_timecode = isinstance(other, FrameTimecode) and isinstance(other._time, Timecode) + def __isub__(self, other: "int | float | str | FrameTimecode") -> "FrameTimecode": + # Narrow `other`'s internal time once so pyright can track it through the dispatch below. + other_inner = other._time if isinstance(other, FrameTimecode) else None - if isinstance(self._time, Timecode) and other_is_timecode: - if self._time.time_base == other._time.time_base: + if isinstance(self._time, Timecode) and isinstance(other_inner, Timecode): + if self._time.time_base == other_inner.time_base: self._time = Timecode( - pts=max(0, self._time.pts - other._time.pts), + pts=max(0, self._time.pts - other_inner.pts), time_base=self._time.time_base, ) return self # Different time bases: use the finer (smaller) one for better precision. - time_base = min(self._time.time_base, other._time.time_base) + time_base = min(self._time.time_base, other_inner.time_base) self_pts = round(Fraction(self._time.pts) * self._time.time_base / time_base) - other_pts = round(Fraction(other._time.pts) * other._time.time_base / time_base) + other_pts = round(Fraction(other_inner.pts) * other_inner.time_base / time_base) self._time = Timecode(pts=max(0, self_pts - other_pts), time_base=time_base) return self # If either input is a timecode, the output shall also be one. The input which isn't a # timecode is converted into seconds, after which the equivalent timecode is computed. - if isinstance(self._time, Timecode) or other_is_timecode: - timecode: Timecode = self._time if isinstance(self._time, Timecode) else other._time - seconds: float = ( - self._get_other_as_seconds(other) - if isinstance(self._time, Timecode) - else self.seconds + if isinstance(self._time, Timecode): + seconds = self._get_other_as_seconds(other) + self._time = Timecode( + pts=max(0, self._time.pts - round(seconds / self._time.time_base)), + time_base=self._time.time_base, ) + if self._rate is None and isinstance(other, FrameTimecode): + self._rate = other._rate + return self + if isinstance(other_inner, Timecode): self._time = Timecode( - pts=max(0, timecode.pts - round(seconds / timecode.time_base)), - time_base=timecode.time_base, + pts=max(0, other_inner.pts - round(self.seconds / other_inner.time_base)), + time_base=other_inner.time_base, ) - # Preserve rate if available from self or other. if self._rate is None and isinstance(other, FrameTimecode): self._rate = other._rate return self - other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds) - if isinstance(self._time, _Seconds) and other_is_seconds: - self._time = _Seconds(max(0, self._time.value - other._time.value)) + if isinstance(self._time, _Seconds) and isinstance(other_inner, _Seconds): + self._time = _Seconds(max(0.0, self._time.value - other_inner.value)) return self if isinstance(self._time, _Seconds): From 006fe37b48fa99fc9b8f2f18cefaca5a1e80a202 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 13:56:37 -0400 Subject: [PATCH 313/407] [api] Strengthen type hints for detectors --- docs/api/migration_guide.rst | 32 ++++++++++++++++++- pyproject.toml | 3 ++ scenedetect/_cli/context.py | 9 ++++-- scenedetect/detector.py | 2 +- scenedetect/detectors/adaptive_detector.py | 4 +++ scenedetect/detectors/content_detector.py | 4 ++- scenedetect/detectors/transnet_v2.py | 2 ++ scenedetect/output/image.py | 6 ++-- scenedetect/scene_manager.py | 4 +-- scenedetect/stats_manager.py | 37 ++++++++++++++-------- tests/test_api.py | 13 ++++---- tests/test_backend_opencv.py | 1 + tests/test_cli.py | 7 ++-- tests/test_detectors.py | 6 ++-- tests/test_platform.py | 2 +- tests/test_scene_manager.py | 8 ++--- tests/test_timecode.py | 7 ++-- tests/test_video_stream.py | 35 +++++++++++++------- website/pages/changelog.md | 6 ++-- 19 files changed, 132 insertions(+), 56 deletions(-) diff --git a/docs/api/migration_guide.rst b/docs/api/migration_guide.rst index e361a3a6..83c8b7d4 100644 --- a/docs/api/migration_guide.rst +++ b/docs/api/migration_guide.rst @@ -160,7 +160,37 @@ All backends now return presentation timestamp (PTS) backed values from ``VideoS ``StatsManager`` Changes ======================================================================= -The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now take a ``FrameTimecode`` instead of ``int`` for the frame identifier, matching the detector interface change. +The ``StatsManager`` methods ``get_metrics()``, ``set_metrics()``, and ``metrics_exist()`` now formally accept either a ``FrameTimecode`` or a plain ``int`` frame number for the timecode argument. Passing a ``FrameTimecode`` is preferred and matches the detector interface; the ``int`` form is retained for compatibility with the deprecated ``load_from_csv()`` path, which keys metrics by integer frame number. + +``StatsManager.load_from_csv()`` also accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str`` / ``bytes`` / file handles. + + +======================================================================= +``SceneDetector`` Annotation Fixes +======================================================================= + +``SceneDetector.post_process()`` now declares its parameter as ``timecode: FrameTimecode`` (previously typed as ``int``). The method already received a ``FrameTimecode`` at runtime and concrete detectors (e.g. ``ThresholdDetector``, ``ContentDetector``) already used the ``FrameTimecode`` type — only the abstract-base-class annotation was inconsistent. No call-site changes are needed; this just brings the signature into agreement with the documented and actual behavior. + + +======================================================================= +``SceneManager.detect_scenes()`` Time Arguments +======================================================================= + +The ``duration`` and ``end_time`` arguments now formally accept ``int`` (frames), ``float`` (seconds), ``str`` (timecode string, e.g. ``"00:00:05.000"``), or ``FrameTimecode``. The internal code already validated these forms; the annotation was previously narrower than the documented behavior. + +.. code:: python + + # All of these were always supported at runtime; now they type-check too: + scene_manager.detect_scenes(video=video, end_time=15.0) # seconds + scene_manager.detect_scenes(video=video, end_time=1500) # frames + scene_manager.detect_scenes(video=video, end_time="00:01:00") # timecode + + +======================================================================= +``save_images()`` Path Handling +======================================================================= + +The ``output_dir`` argument of :func:`scenedetect.output.save_images` now accepts ``os.PathLike`` (e.g. ``pathlib.Path``) in addition to ``str``. No changes are required for existing string-based callers. ======================================================================= diff --git a/pyproject.toml b/pyproject.toml index 35b9e85b..ffec4e3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,9 @@ unfixable = [] [tool.pyright] include = ["scenedetect", "tests"] +# Vendored third-party code: don't lint upstream source (mirrors the ruff +# per-file-ignores convention). +exclude = ["scenedetect/_thirdparty"] # Run pyright from inside an activated venv (or pass `--pythonpath` / # configure your editor's interpreter) so cv2 / av / numpy / moviepy # resolve. Without this, pyright uses its bundled Python and the report diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index cc6eb458..d24df723 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -206,6 +206,7 @@ def handle_options( # The `scenedetect` command was just started, let's initialize logging and try to load any # config files that were specified. + init_log: list = [] try: init_failure = not self.config.initialized init_log = self.config.get_init_log() @@ -305,9 +306,9 @@ def handle_options( scene_manager.auto_downscale = True else: scene_manager.auto_downscale = False - downscale = self.config.get_value("global", "downscale", downscale) + downscale_value: int = self.config.get_value("global", "downscale", downscale) try: - scene_manager.downscale = downscale + scene_manager.downscale = downscale_value except ValueError as ex: logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="downscale factor") from ex @@ -532,11 +533,13 @@ def _open_video_stream( framerate=framerate, backend=backend, ) + duration = self.video_stream.duration + duration_str = f"{duration} ({duration.frame_num} frames)" if duration else "unknown" logger.debug(f"""Video information: Backend: {type(self.video_stream).__name__} Resolution: {self.video_stream.frame_size} Framerate: {self.video_stream.frame_rate} - Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""") + Duration: {duration_str}""") except FrameRateUnavailable as ex: if __debug__: diff --git a/scenedetect/detector.py b/scenedetect/detector.py index ba65b2a6..1d453534 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -61,7 +61,7 @@ def process_frame( # Optional Methods - def post_process(self, timecode: int) -> list[FrameTimecode]: + def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: """Called after there are no more frames to process. Args: diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 2e634a83..bb4bbd2b 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -100,6 +100,10 @@ def get_metrics(self) -> list[str]: def process_frame(self, timecode: FrameTimecode, frame_img: np.ndarray) -> list[FrameTimecode]: super().process_frame(timecode=timecode, frame_img=frame_img) + # If the parent could not calculate a frame score, there's nothing to buffer. + if self._frame_score is None: + return [] + # Initialize last scene cut point at the beginning of the frames of interest. if self._last_cut is None: self._last_cut = timecode diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 6bdc0eab..730e1ab1 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -169,7 +169,9 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr delta_sat=_mean_pixel_distance(sat, self._last_frame.sat), delta_lum=_mean_pixel_distance(lum, self._last_frame.lum), delta_edges=( - 0.0 if edges is None else _mean_pixel_distance(edges, self._last_frame.edges) + 0.0 + if edges is None or self._last_frame.edges is None + else _mean_pixel_distance(edges, self._last_frame.edges) ), ) diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 83c6260f..088272db 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -107,6 +107,8 @@ def push(self, pixels: np.ndarray, time: np.ndarray): ), ) else: + # `self.time` is set in lockstep with `self.pixels` above, so it is non-None here. + assert self.time is not None c1 = self.pixels c2 = pixels diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index 902bcaeb..d38c6fc9 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -28,7 +28,7 @@ Interpolation, SceneList, ) -from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, tqdm +from scenedetect.platform import StrPath, get_and_create_path, get_cv2_imwrite_params, tqdm from scenedetect.video_stream import VideoStream logger = logging.getLogger("pyscenedetect") @@ -162,7 +162,7 @@ def run( self, video: VideoStream, scene_list: SceneList, - output_dir: str | None = None, + output_dir: StrPath | None = None, show_progress=False, ) -> dict[int, list[str]]: """Run image extraction on `video` using the current parameters. Thread-safe. @@ -355,7 +355,7 @@ def save_images( image_extension: str = "jpg", encoder_param: int = 95, image_name_template: str = "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER", - output_dir: str | None = None, + output_dir: StrPath | None = None, show_progress: bool | None = False, scale: float | None = None, height: int | None = None, diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index b1034e0d..67cd2a8b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -419,8 +419,8 @@ def stop(self) -> None: def detect_scenes( self, video: VideoStream | None = None, - duration: FrameTimecode | None = None, - end_time: FrameTimecode | None = None, + duration: "int | float | str | FrameTimecode | None" = None, + end_time: "int | float | str | FrameTimecode | None" = None, frame_skip: int = 0, show_progress: bool = False, callback: ty.Callable[[np.ndarray, FrameTimecode], None] | None = None, diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index d2292f67..cbdb8602 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -96,19 +96,22 @@ class StatsManager: Only metrics consisting of `float` or `int` should be used currently. """ - def __init__(self, base_timecode: FrameTimecode | None = None): + def __init__(self, base_timecode: int | FrameTimecode | None = None): """Initialize a new StatsManager. Arguments: base_timecode: Timecode associated with this object. Must not be None (default value will be removed in a future release). """ - # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) - # of each frame metric key and the value it represents (usually float). - self._frame_metrics: dict[FrameTimecode, dict[str, float]] = dict() + # Frame metrics keyed by either an `int` frame number or a `FrameTimecode`. Both forms + # hash/compare to the same dict slot (`FrameTimecode.__hash__` returns `frame_num`), so + # public methods accept both interchangeably for the same frame. + self._frame_metrics: dict[int | FrameTimecode, dict[str, float]] = dict() self._metric_keys: set[str] = set() self._metrics_updated: bool = False # Flag indicating if metrics require saving. - self._base_timecode: FrameTimecode | None = base_timecode # Used for timing calculations. + self._base_timecode: int | FrameTimecode | None = ( + base_timecode # Used for timing calculations. + ) @property def metric_keys(self) -> ty.Iterable[str]: @@ -120,7 +123,9 @@ def register_metrics(self, metric_keys: ty.Iterable[str]) -> None: # TODO(https://scenedetect.com/issues/507): We should support the dictionary protocol instead # of using this bespoke interface. It would be useful for Pandas compatibility as well. - def get_metrics(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> list[ty.Any]: + def get_metrics( + self, timecode: int | FrameTimecode, metric_keys: ty.Iterable[str] + ) -> list[ty.Any]: """Return the requested statistics/metrics for a given timecode. Returns: @@ -129,7 +134,7 @@ def get_metrics(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> """ return [self._get_metric(timecode, metric_key) for metric_key in metric_keys] - def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: + def set_metrics(self, timecode: int | FrameTimecode, metric_kv_dict: dict[str, ty.Any]) -> None: """Set Metrics: Sets the provided statistics/metrics for a given frame. Arguments: @@ -139,7 +144,7 @@ def set_metrics(self, timecode: FrameTimecode, metric_kv_dict: dict[str, ty.Any] for metric_key in metric_kv_dict: self._set_metric(timecode, metric_key, metric_kv_dict[metric_key]) - def metrics_exist(self, timecode: FrameTimecode, metric_keys: ty.Iterable[str]) -> bool: + def metrics_exist(self, timecode: int | FrameTimecode, metric_keys: ty.Iterable[str]) -> bool: """Metrics Exist: Checks if the given metrics/stats exist for the given frame. Returns: @@ -188,6 +193,10 @@ def save_to_csv( frame_keys = sorted(self._frame_metrics.keys()) logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: + # `frame_key` may be a bare `int` if the deprecated `load_from_csv` populated the dict. + # Skip such rows since we cannot recover a timecode without a base framerate. + if not isinstance(frame_key, FrameTimecode): + continue csv_writer.writerow( [frame_key.frame_num + 1, frame_key.get_timecode()] + [str(metric) for metric in self.get_metrics(frame_key, metric_keys)] @@ -209,7 +218,7 @@ def valid_header(row: list[str]) -> bool: # TODO(v1.0): Create a replacement for a calculation cache that functions like load_from_csv # did, but is better integrated with detectors for cached calculations instead of statistics. - def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: + def load_from_csv(self, csv_file: StrPath | bytes | ty.TextIO) -> int | None: """[DEPRECATED] DO NOT USE Load all metrics stored in a CSV file into the StatsManager instance. Will be removed in a @@ -233,7 +242,7 @@ def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: # If we get a path instead of an open file handle, check that it exists, and if so, # recursively call ourselves again but with file set instead of path. - if isinstance(csv_file, (str, bytes, Path)): + if isinstance(csv_file, (str, bytes, os.PathLike)): if os.path.exists(csv_file): with open(csv_file) as file: return self.load_from_csv(csv_file=file) @@ -288,16 +297,18 @@ def load_from_csv(self, csv_file: str | bytes | ty.TextIO) -> int | None: # TODO: Get rid of these functions and simplify the implementation of this class. - def _get_metric(self, timecode: FrameTimecode, metric_key: str) -> ty.Any | None: + def _get_metric(self, timecode: int | FrameTimecode, metric_key: str) -> ty.Any | None: if self._metric_exists(timecode, metric_key): return self._frame_metrics[timecode][metric_key] return None - def _set_metric(self, timecode: FrameTimecode, metric_key: str, metric_value: ty.Any) -> None: + def _set_metric( + self, timecode: int | FrameTimecode, metric_key: str, metric_value: ty.Any + ) -> None: self._metrics_updated = True if timecode not in self._frame_metrics: self._frame_metrics[timecode] = dict() self._frame_metrics[timecode][metric_key] = metric_value - def _metric_exists(self, timecode: FrameTimecode, metric_key: str) -> bool: + def _metric_exists(self, timecode: int | FrameTimecode, metric_key: str) -> bool: return timecode in self._frame_metrics and metric_key in self._frame_metrics[timecode] diff --git a/tests/test_api.py b/tests/test_api.py index 4d355ab8..7a3369cc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -101,6 +101,7 @@ def test_api_stats_manager(test_video_file: str): scene_manager.detect_scenes(video=video) # Save per-frame statistics to disk. filename = f"{test_video_file}.stats.csv" + assert scene_manager.stats_manager is not None scene_manager.stats_manager.save_to_csv(csv_file=filename) @@ -108,11 +109,11 @@ def test_api_scene_manager_callback(test_video_file: str): """Demonstrate how to use a callback with the SceneManager detect_scenes method.""" import numpy - from scenedetect import ContentDetector, SceneManager, open_video + from scenedetect import ContentDetector, FrameTimecode, SceneManager, open_video # Callback to invoke on the first frame of every new scene detection. - def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print(f"New scene found at frame {frame_num}.") + def on_new_scene(frame_img: numpy.ndarray, position: FrameTimecode): + print(f"New scene found at frame {position.frame_num}.") video = open_video(test_video_file) scene_manager = SceneManager() @@ -127,11 +128,11 @@ def test_api_device_callback(test_video_file: str): import cv2 import numpy - from scenedetect import ContentDetector, SceneManager, VideoCaptureAdapter + from scenedetect import ContentDetector, FrameTimecode, SceneManager, VideoCaptureAdapter # Callback to invoke on the first frame of every new scene detection. - def on_new_scene(frame_img: numpy.ndarray, frame_num: int): - print(f"New scene found at frame {frame_num}.") + def on_new_scene(frame_img: numpy.ndarray, position: FrameTimecode): + print(f"New scene found at frame {position.frame_num}.") # We open a file just for test purposes, but we can also use a device or pipe here. cap = cv2.VideoCapture(test_video_file) diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index d1d66020..557de35a 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -31,6 +31,7 @@ def test_open_image_sequence(test_image_sequence: str): sequence = VideoStreamCv2(test_image_sequence, framerate=25.0) assert sequence.is_seekable assert sequence.frame_size[0] > 0 and sequence.frame_size[1] > 0 + assert sequence.duration is not None assert sequence.duration.frame_num == 30 assert sequence.read() is not False sequence.seek(100) diff --git a/tests/test_cli.py b/tests/test_cli.py index 05044ff0..9e77af70 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,6 +37,7 @@ import scenedetect from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available +from scenedetect.platform import StrPath from tests.helpers import invoke_cli SCENEDETECT_CMD = sys.executable + " -m scenedetect" @@ -66,7 +67,7 @@ def invoke_scenedetect( args: str = "", - output_dir: str | None = None, + output_dir: StrPath | None = None, config_file: str | None = DEFAULT_CONFIG_FILE, **kwargs, ): @@ -536,7 +537,7 @@ def test_cli_save_images(tmp_path: Path): # Should detect two scenes and generate 3 images per scene with above params. assert len(images) == 6 # Open one of the created images and make sure it has the correct resolution. - image = cv2.imread(images[0]) + image = cv2.imread(str(images[0])) assert image.shape == (544, 1280, 3) @@ -575,7 +576,7 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): images = [image for image in tmp_path.glob("*.jpg")] # Should detect two scenes and generate 3 images per scene with above params. assert len(images) == 6 - image = cv2.imread(images[0]) + image = cv2.imread(str(images[0])) # Note same resolution as in test_cli_save_images but rotated 90 degrees. assert image.shape == (1280, 544, 3) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 638479b5..e6c62a72 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -31,14 +31,16 @@ ThresholdDetector, ) -FAST_CUT_DETECTORS: tuple[type[SceneDetector]] = ( +# Untyped so each entry retains its concrete `type[…]` for parameterized construction +# (calls below pass detector-specific kwargs like `min_scene_len`). +FAST_CUT_DETECTORS = ( AdaptiveDetector, ContentDetector, HashDetector, HistogramDetector, ) -ALL_DETECTORS: tuple[type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) +ALL_DETECTORS = (*FAST_CUT_DETECTORS, ThresholdDetector) # TODO(https://scenedetect.com/issues/53): Add a test that verifies algorithms output relatively # consistent frame scores regardless of resolution. This will ensure that threshold values will hold diff --git a/tests/test_platform.py b/tests/test_platform.py index 319a54ea..50130518 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -37,4 +37,4 @@ def test_long_command(): """ if platform.system() == "Windows": with pytest.raises(CommandTooLong): - invoke_command("x" * 2**15) + invoke_command(["x" * 2**15]) diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 7a78813f..0ba91fc2 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -198,15 +198,15 @@ def test_detect_scenes_crop(test_video_file): def test_crop_invalid(): sm = SceneManager() - sm.crop = None + sm.crop = None # type: ignore[assignment] sm.crop = (0, 0, 0, 0) sm.crop = (1, 1, 0, 0) sm.crop = (0, 0, 1, 1) with pytest.raises(TypeError): - sm.crop = 1 + sm.crop = 1 # type: ignore[assignment] with pytest.raises(TypeError): - sm.crop = (1, 1) + sm.crop = (1, 1) # type: ignore[assignment] with pytest.raises(TypeError): - sm.crop = (1, 1, 1) + sm.crop = (1, 1, 1) # type: ignore[assignment] with pytest.raises(ValueError): sm.crop = (1, 1, 1, -1) diff --git a/tests/test_timecode.py b/tests/test_timecode.py index f4296923..0ad4be52 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -33,11 +33,14 @@ def test_framerate(): """Test FrameTimecode constructor argument "fps".""" # Not passing fps results in TypeError. with pytest.raises(TypeError): - FrameTimecode() + FrameTimecode() # type: ignore[call-arg] with pytest.raises(TypeError): FrameTimecode(timecode=0, fps=None) with pytest.raises(TypeError): - FrameTimecode(timecode=None, fps=FrameTimecode(timecode=0, fps=None)) + FrameTimecode( + timecode=None, # type: ignore[arg-type] + fps=FrameTimecode(timecode=0, fps=None), + ) # Test zero FPS/negative. with pytest.raises(ValueError): FrameTimecode(timecode=0, fps=0.0) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index e0903832..0930c155 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -17,6 +17,7 @@ """ import os.path +import typing as ty from dataclasses import dataclass import numpy @@ -138,11 +139,12 @@ def get_test_video_params() -> list[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_properties(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" stream = vs_type(test_video.path) assert stream.frame_size == (test_video.width, test_video.height) assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) + assert stream.duration is not None assert stream.duration.frame_num == test_video.total_frames file_name = os.path.basename(test_video.path) last_dot_pos = file_name.rfind(".") @@ -151,21 +153,26 @@ def test_properties(self, vs_type: type[VideoStream], test_video: VideoParameter test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_read(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate basic `read` functionality.""" stream = vs_type(test_video.path) frame = stream.read() + assert isinstance(frame, numpy.ndarray) # For now hard-code 3 channels/pixel for each test video assert frame.shape == (test_video.height, test_video.width, 3) assert stream.frame_number == 1 - def test_read_no_decode(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_read_no_decode( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Validate invoking `read` with `decode` set to False.""" stream = vs_type(test_video.path) assert stream.read(decode=False) is True assert stream.frame_number == 1 - def test_time_invariants(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_time_invariants( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Validate the `frame_number`, `position`, and `position_ms` properties.""" stream = vs_type(test_video.path) # The video starts "before" the first frame, with everything set to zero. @@ -188,7 +195,7 @@ def test_time_invariants(self, vs_type: type[VideoStream], test_video: VideoPara 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_reset(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Test `reset()` functions as expected.""" stream = vs_type(test_video.path) # Decode some frames, then reset the VideoStream and validate the time invariants. @@ -200,7 +207,7 @@ def test_reset(self, vs_type: type[VideoStream], test_video: VideoParameters): assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate `seek()` functionality with different offset types.""" stream = vs_type(test_video.path) @@ -246,7 +253,7 @@ def test_seek(self, vs_type: type[VideoStream], test_video: VideoParameters): assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek_start(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Validate behaviour of `seek()` at the start of a video.""" stream = vs_type(test_video.path) # Here we check similar invariants to test_time_invariants, but using seek(). @@ -281,7 +288,7 @@ def test_seek_start(self, vs_type: type[VideoStream], test_video: VideoParameter assert stream.frame_number == 2 stream = vs_type(test_video.path) - def test_read_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_read_eof(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): """Ensure calling `read()` handles the end of the video correctly.""" stream = vs_type(test_video.path) # To make the test faster, we seek to the second last frame. @@ -294,7 +301,9 @@ def test_read_eof(self, vs_type: type[VideoStream], test_video: VideoParameters) else: assert stream.frame_number == test_video.total_frames - def test_seek_past_eof(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek_past_eof( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Validate calling `seek()` to offset past end of video.""" stream = vs_type(test_video.path) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit @@ -313,7 +322,9 @@ def test_seek_past_eof(self, vs_type: type[VideoStream], test_video: VideoParame else: assert stream.frame_number == test_video.total_frames - def test_seek_invalid(self, vs_type: type[VideoStream], test_video: VideoParameters): + def test_seek_invalid( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + ): """Test `seek()` throws correct exception when specifying in invalid seek value.""" stream = vs_type(test_video.path) @@ -329,13 +340,13 @@ def test_seek_invalid(self, vs_type: type[VideoStream], test_video: VideoParamet # -def test_invalid_path(vs_type: type[VideoStream]): +def test_invalid_path(vs_type: ty.Callable[..., VideoStream]): """Ensure correct exception is thrown if the path does not exist.""" with pytest.raises(OSError): _ = vs_type("this_path_should_not_exist.mp4") -def test_corrupt_video(vs_type: type[VideoStream], corrupt_video_file: str): +def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_file: str): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 6f6acf7f..e44697de 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -698,8 +698,10 @@ Although there have been minimal changes to most API examples, there are several **Detector Interface:** * Replace `frame_num` parameter (`int`) with `timecode` (`FrameTimecode`) in `SceneDetector` interface [#168](https://github.com/Breakthrough/PySceneDetect/issues/168): - * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` - * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` + * The detector interface: `SceneDetector.process_frame()` and `SceneDetector.post_process()` (the `post_process` signature on the abstract base is now consistently typed as `FrameTimecode` to match its concrete-detector overrides; the prior `int` annotation did not reflect the actual runtime value) + * Statistics: `StatsManager.get_metrics()`, `StatsManager.set_metrics()`, and `StatsManager.metrics_exist()` formally accept either `FrameTimecode` or `int` (the `int` form is retained for compatibility with the deprecated `load_from_csv()` path, which keys metrics by integer frame number) + * `StatsManager.load_from_csv()` and `save_images()` `output_dir` now accept `os.PathLike` (e.g. `pathlib.Path`) in addition to `str` + * `SceneManager.detect_scenes()` `duration` and `end_time` formally accept `int` (frames), `float` (seconds), `str` (timecode), or `FrameTimecode` — matching the documented and runtime-supported behavior * `SceneDetector` is now a [Python abstract class](https://docs.python.org/3/library/abc.html) * `SceneDetector` instances can now assume they always have frame data to process when `process_frame` is called * Remove `SceneDetector.is_processing_required()` method From 2c1273688ce951710e5e5695e000b49467c69e65 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 14:21:36 -0400 Subject: [PATCH 314/407] [lint] Remove pyright suppressions, code is now clean at basic --- pyproject.toml | 48 ++++++++++++++---------------------------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffec4e3d..90b895ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,38 +61,18 @@ unfixable = [] [tool.pyright] include = ["scenedetect", "tests"] -# Vendored third-party code: don't lint upstream source (mirrors the ruff -# per-file-ignores convention). -exclude = ["scenedetect/_thirdparty"] -# Run pyright from inside an activated venv (or pass `--pythonpath` / -# configure your editor's interpreter) so cv2 / av / numpy / moviepy -# resolve. Without this, pyright uses its bundled Python and the report -# fills with cascading "import could not be resolved" noise. -# Per-developer venv paths are deliberately NOT pinned here. -# -# Modes: "off" | "basic" | "standard" | "strict". -# We're at "basic" for 0.7. Bumping requires the cleanup in TODO(0.8) below. -typeCheckingMode = "basic" - -# Third-party noise: cv2, av, moviepy ship without (or with partial) type -# stubs; these reports are unactionable in this codebase. -reportMissingTypeStubs = "none" -reportUnknownMemberType = "none" -reportUnknownArgumentType = "none" -reportUnknownVariableType = "none" -reportUnknownParameterType = "none" - -# Click + pytest decorators are conventionally untyped. -reportUntypedFunctionDecorator = "none" +exclude = [ + # Pyright built-in defaults + "**/node_modules", + "**/__pycache__", + "**/.*", + ".venv", + # Vendored third-party code + "scenedetect/_thirdparty", +] -# TODO(0.8): Audit the rules below. They were added globally with a -# third-party justification, but they affect the whole codebase. After the -# remaining first-party type-debt is cleared (FrameTimecode/TimecodeLike in -# common.py, _cli/config.py CropValue overloads, Click CLI typing in -# _cli/__init__.py), re-enable each in isolation and decide keep/drop: -# reportMissingParameterType — first-party coverage looks complete -# reportMissingTypeArgument — modern Python defaults absorb most -# reportPrivateUsage — no cross-module _private access seen -reportMissingParameterType = "none" -reportMissingTypeArgument = "none" -reportPrivateUsage = "none" +# Modes: "off" | "basic" | "standard" | "strict". The 0.7 codebase is clean +# at "basic". When tightening to "standard" or "strict" in the future, the +# cv2 / av / numpy / moviepy noise rules (reportUnknown*, reportMissingTypeStubs) +# will likely need to be re-added with "none" or scoped per-file. +typeCheckingMode = "basic" From 1691c6d2ba0a90494290730e3d1ea8e0533304af Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 18:04:14 -0400 Subject: [PATCH 315/407] [build] Extend action paths to cover project/lint config changes --- .github/workflows/build-windows.yml | 6 ++++++ .github/workflows/build.yml | 4 ++++ .github/workflows/check-code-format.yml | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2f788f99..86603d0a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -10,11 +10,17 @@ on: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt + - .github/workflows/build-windows.yml push: paths: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt + - .github/workflows/build-windows.yml branches: - main - 'releases/**' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8ce7bdb..e7c717b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,8 @@ on: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt - .github/workflows/build.yml - .github/actions/setup-ffmpeg/** push: @@ -16,6 +18,8 @@ on: - dist/** - scenedetect/** - tests/** + - pyproject.toml + - requirements_headless.txt - .github/workflows/build.yml - .github/actions/setup-ffmpeg/** branches: diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index 1cb694c2..d0dbac6e 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -6,10 +6,16 @@ on: paths: - scenedetect/** - tests/** + - pyproject.toml + - .style.yapf + - .github/workflows/check-code-format.yml push: paths: - scenedetect/** - tests/** + - pyproject.toml + - .style.yapf + - .github/workflows/check-code-format.yml jobs: check_format: From dab8b2651768af9fc96754658c86dcc5de9ffc7e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 18:23:35 -0400 Subject: [PATCH 316/407] [feat] Add start TC shift to save-edl functionality #515 --- scenedetect/_cli/__init__.py | 14 ++++++ scenedetect/_cli/commands.py | 2 + scenedetect/_cli/config.py | 1 + scenedetect/output/__init__.py | 38 ++++++++++++++++- tests/test_output.py | 78 ++++++++++++++++++++++++++++++++++ website/pages/changelog.md | 2 + 6 files changed, 133 insertions(+), 2 deletions(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 16dfce5b..5613c0ee 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1597,6 +1597,18 @@ def save_images_command( USER_CONFIG.get_help_string("save-edl", "output", show_default=False) ), ) +@click.option( + "--start-timecode", + "-s", + metavar="TIMECODE", + default=None, + type=click.STRING, + help=( + "Start timecode added to every event so the EDL aligns with the source media's " + "on-screen timecode. Accepts SMPTE HH:MM:SS:FF or 8 digits (HHMMSSFF, e.g. 01000000)." + "{}" + ).format(USER_CONFIG.get_help_string("save-edl", "start-timecode", show_default=False)), +) @click.pass_context def save_edl_command( ctx: click.Context, @@ -1604,6 +1616,7 @@ def save_edl_command( title: str | None, reel: str | None, output: str | None, + start_timecode: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1613,6 +1626,7 @@ def save_edl_command( "title": ctx.config.get_value("save-edl", "title", title), "reel": ctx.config.get_value("save-edl", "reel", reel), "output": ctx.config.get_value("save-edl", "output", output), + "start_timecode": ctx.config.get_value("save-edl", "start-timecode", start_timecode), } ctx.add_command(cli_commands.save_edl, save_edl_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index f87d97be..35400003 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -263,6 +263,7 @@ def save_edl( output: str, title: str, reel: str, + start_timecode: str | None, ): """Handles the `save-edl` command. Outputs in CMX 3600 format.""" del cuts # We only use scene information. @@ -277,6 +278,7 @@ def save_edl( scene_list=scenes, title=Template(title).safe_substitute(VIDEO_NAME=video_name), reel=reel, + start_timecode=start_timecode, ) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 1154adff..81acc236 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -424,6 +424,7 @@ class FcpFormat(Enum): "filename": "$VIDEO_NAME.edl", "output": None, "reel": "AX", + "start-timecode": None, "title": "$VIDEO_NAME", }, "save-html": { diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index ceff96c3..e4c38a2e 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -18,6 +18,7 @@ import csv import json import logging +import math import typing as ty from fractions import Fraction from pathlib import Path @@ -253,11 +254,36 @@ def _edl_timecode(timecode: FrameTimecode) -> str: return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}" +def _parse_edl_start_timecode(value: str, framerate: Fraction | float) -> int: + """Parse a SMPTE ``HH:MM:SS:FF`` (or 8-digit ``HHMMSSFF``) start timecode into a frame count.""" + stripped = value.strip() + if ":" in stripped: + parts = stripped.split(":") + elif stripped.isdigit() and len(stripped) == 8: + parts = [stripped[0:2], stripped[2:4], stripped[4:6], stripped[6:8]] + else: + raise ValueError( + f"Invalid start timecode {value!r}: expected HH:MM:SS:FF or 8 digits (HHMMSSFF)." + ) + if len(parts) != 4 or not all(p.isdigit() for p in parts): + raise ValueError( + f"Invalid start timecode {value!r}: expected HH:MM:SS:FF or 8 digits (HHMMSSFF)." + ) + hours, minutes, seconds, frames = (int(p) for p in parts) + max_frames = math.ceil(float(framerate)) + if minutes >= 60 or seconds >= 60 or frames >= max_frames: + raise ValueError( + f"Invalid start timecode {value!r}: MM<60, SS<60, FF<{max_frames} required." + ) + return round((hours * 3600 + minutes * 60 + seconds) * float(framerate)) + frames + + def write_scene_list_edl( output_path: str | Path, scene_list: SceneList, title: str = "PySceneDetect", reel: str = "AX", + start_timecode: str | None = None, ): """Writes the given list of scenes to `output_path` in CMX 3600 EDL format. @@ -266,12 +292,20 @@ def write_scene_list_edl( scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end. title: Title header written as ``TITLE:`` in the EDL. reel: Reel name used for each event. Typically 2-8 uppercase characters. + start_timecode: Optional SMPTE timecode (``HH:MM:SS:FF`` or 8-digit ``HHMMSSFF``) added to + every event so the EDL aligns with the source media's on-screen timecode. Applied to + both source and record columns. """ output_path = Path(output_path) + offset_frames = 0 + if start_timecode is not None and start_timecode.strip() and scene_list: + framerate = scene_list[0][0].framerate + assert framerate is not None + offset_frames = _parse_edl_start_timecode(start_timecode, framerate) lines = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""] for i, (start, end) in enumerate(scene_list): - in_tc = _edl_timecode(start) - out_tc = _edl_timecode(end) + in_tc = _edl_timecode(start + offset_frames) + out_tc = _edl_timecode(end + offset_frames) lines.append(f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}") logger.info("Writing scenes in EDL format to %s", output_path) with open(output_path, "w") as f: diff --git a/tests/test_output.py b/tests/test_output.py index d3efe182..42b93c18 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -269,6 +269,84 @@ def test_write_scene_list_edl_accepts_str_path(tmp_path: Path): assert output_path.exists() +def test_write_scene_list_edl_with_start_timecode_smpte(tmp_path: Path): + """`start_timecode` shifts every event by the supplied SMPTE offset (source + record).""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, start_timecode="01:00:00:00") + + content = output_path.read_text() + assert "001 AX V C 01:00:00:00 01:00:01:00 01:00:00:00 01:00:01:00" in content + assert "002 AX V C 01:00:01:00 01:00:02:00 01:00:01:00 01:00:02:00" in content + + +def test_write_scene_list_edl_with_start_timecode_digits(tmp_path: Path): + """8-digit form (numpad-friendly) yields the same output as the colon-separated form.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + smpte_path = tmp_path / "smpte.edl" + digits_path = tmp_path / "digits.edl" + write_scene_list_edl(smpte_path, scenes, start_timecode="01:00:00:00") + write_scene_list_edl(digits_path, scenes, start_timecode="01000000") + + assert smpte_path.read_text() == digits_path.read_text() + + +def test_write_scene_list_edl_with_start_timecode_subsecond(tmp_path: Path): + """A sub-second frame offset (FF component) is added to every event.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "scenes.edl" + write_scene_list_edl(output_path, scenes, start_timecode="00:00:00:15") + + content = output_path.read_text() + assert "001 AX V C 00:00:00:15 00:00:01:15 00:00:00:15 00:00:01:15" in content + + +def test_write_scene_list_edl_default_no_offset(tmp_path: Path): + """Omitting `start_timecode` (or passing ``None``/empty) preserves the existing baseline.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + baseline = tmp_path / "baseline.edl" + explicit_none = tmp_path / "none.edl" + explicit_empty = tmp_path / "empty.edl" + write_scene_list_edl(baseline, scenes) + write_scene_list_edl(explicit_none, scenes, start_timecode=None) + write_scene_list_edl(explicit_empty, scenes, start_timecode=" ") + + assert baseline.read_text() == explicit_none.read_text() == explicit_empty.read_text() + + +@pytest.mark.parametrize( + "bad_value", + [ + "bogus", + "00:00:00", # 3 segments, not 4 + "00:00:00:00:00", # 5 segments + "1234567", # 7 digits + "123456789", # 9 digits + "ab:cd:ef:gh", # non-numeric + ], +) +def test_write_scene_list_edl_with_start_timecode_invalid_format(tmp_path: Path, bad_value: str): + """Malformed start timecodes raise ValueError before writing.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + with pytest.raises(ValueError): + write_scene_list_edl(tmp_path / "scenes.edl", scenes, start_timecode=bad_value) + + +@pytest.mark.parametrize( + "bad_value", + [ + "00:60:00:00", # MM=60 + "00:00:60:00", # SS=60 + "00:00:00:99", # FF beyond ceil(30 fps) + ], +) +def test_write_scene_list_edl_with_start_timecode_out_of_range(tmp_path: Path, bad_value: str): + """Out-of-range SMPTE components raise ValueError.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + with pytest.raises(ValueError): + write_scene_list_edl(tmp_path / "scenes.edl", scenes, start_timecode=bad_value) + + def test_write_scene_list_fcpx(tmp_path: Path): """FCPXML output declares version 1.9, rational time strings, and an asset-clip per scene.""" scenes = _fake_scenes(_FPS_NTSC, [(48, 96), (96, 144)]) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e44697de..f38e16ce 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -678,6 +678,7 @@ Although there have been minimal changes to most API examples, there are several - [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters - [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156) - [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531) +- [feature] `save-edl` accepts a new `--start-timecode`/`-s` flag (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) to stamp every event with a custom start timecode so generated EDLs align with the source media's on-screen timecode [#515](https://github.com/Breakthrough/PySceneDetect/issues/515) - [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision - [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496) - [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command @@ -687,6 +688,7 @@ Although there have been minimal changes to most API examples, there are several **VFR & Timestamp Overhaul:** * Add `write_scene_list_edl`, `write_scene_list_fcpx`, `write_scene_list_fcp7`, and `write_scene_list_otio` to the `scenedetect.output` module so `save-edl`, `save-fcp`, and `save-otio` can be invoked directly from Python (previously CLI-only) + * `write_scene_list_edl` accepts an optional `start_timecode` parameter (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) that is added to every event's source and record columns [#515](https://github.com/Breakthrough/PySceneDetect/issues/515) * Add new `Timecode` type to represent frame timings in terms of the video's source timebase * Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information * All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position` From 97f1e4b3b87a0d2c1102613c116b3dca2f97ca00 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 18:30:43 -0400 Subject: [PATCH 317/407] [general] Change copyright style to single year, files use first date of creation now --- LICENSE | 2 +- README.md | 2 +- dist/package-info.rst | 2 +- dist/pre_release.py | 2 +- docs/conf.py | 2 +- docs/generate_cli_docs.py | 2 +- docs/index.rst | 2 +- pyproject.toml | 2 +- scenedetect/__init__.py | 2 +- scenedetect/__main__.py | 2 +- scenedetect/_cli/__init__.py | 4 ++-- scenedetect/_cli/commands.py | 2 +- scenedetect/_cli/config.py | 2 +- scenedetect/_cli/context.py | 2 +- scenedetect/_cli/controller.py | 2 +- scenedetect/_thirdparty/__init__.py | 2 +- scenedetect/backends/__init__.py | 2 +- scenedetect/backends/moviepy.py | 2 +- scenedetect/backends/opencv.py | 2 +- scenedetect/backends/pyav.py | 2 +- scenedetect/common.py | 2 +- scenedetect/detector.py | 2 +- scenedetect/detectors/__init__.py | 2 +- scenedetect/detectors/adaptive_detector.py | 2 +- scenedetect/detectors/content_detector.py | 2 +- scenedetect/detectors/hash_detector.py | 2 +- scenedetect/detectors/histogram_detector.py | 2 +- scenedetect/detectors/threshold_detector.py | 2 +- scenedetect/detectors/transnet_v2.py | 2 +- scenedetect/frame_timecode.py | 2 +- scenedetect/output/__init__.py | 2 +- scenedetect/output/image.py | 2 +- scenedetect/output/video.py | 2 +- scenedetect/platform.py | 2 +- scenedetect/scene_detector.py | 2 +- scenedetect/scene_manager.py | 2 +- scenedetect/stats_manager.py | 2 +- scenedetect/video_splitter.py | 2 +- scenedetect/video_stream.py | 2 +- setup.py | 2 +- tests/__init__.py | 2 +- tests/conftest.py | 2 +- tests/helpers.py | 2 +- tests/test_api.py | 2 +- tests/test_backend_opencv.py | 2 +- tests/test_backend_pyav.py | 2 +- tests/test_cli.py | 2 +- tests/test_detectors.py | 2 +- tests/test_output.py | 2 +- tests/test_platform.py | 2 +- tests/test_stats_manager.py | 2 +- tests/test_timecode.py | 2 +- tests/test_vfr.py | 2 +- tests/test_video_stream.py | 2 +- website/mkdocs.yml | 4 ++-- website/pages/copyright.md | 2 +- 56 files changed, 58 insertions(+), 58 deletions(-) diff --git a/LICENSE b/LICENSE index b8110e2f..c03985e6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (C) 2024, Brandon Castellano +Copyright (C) 2014, Brandon Castellano Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 2855638a..6f1a5a18 100644 --- a/README.md +++ b/README.md @@ -125,5 +125,5 @@ BSD-3-Clause; see [`LICENSE`](LICENSE) and [`THIRD-PARTY.md`](THIRD-PARTY.md) fo ---------------------------------------------------------- -Copyright (C) 2014-2024 Brandon Castellano. +Copyright (C) 2014 Brandon Castellano. All rights reserved. diff --git a/dist/package-info.rst b/dist/package-info.rst index 94fdfc8e..09a56570 100644 --- a/dist/package-info.rst +++ b/dist/package-info.rst @@ -43,6 +43,6 @@ You can also use the Python API (`docs . +# Copyright (C) 2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/docs/conf.py b/docs/conf.py index a7935b56..954c2552 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,7 @@ # -- Project information ----------------------------------------------------- project = "PySceneDetect" -copyright = "2014-2024, Brandon Castellano" +copyright = "2014, Brandon Castellano" author = "Brandon Castellano" # The short X.Y version diff --git a/docs/generate_cli_docs.py b/docs/generate_cli_docs.py index f26ae114..7e1cd4dd 100644 --- a/docs/generate_cli_docs.py +++ b/docs/generate_cli_docs.py @@ -2,7 +2,7 @@ # # Inspired by sphinx-click: https://github.com/click-contrib/sphinx-click # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. """Generates CLI reference documentation file docs/cli.rst. diff --git a/docs/index.rst b/docs/index.rst index f3de773a..b258fb21 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ .. PySceneDetect documentation index file (contains toctree directive). - Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. + Copyright (C) 2014 Brandon Castellano. All rights reserved. ####################################################################### PySceneDetect Documentation diff --git a/pyproject.toml b/pyproject.toml index 90b895ee..32d1d102 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # [ Documentation: http://www.scenedetect.com/docs/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . # [build-system] diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 19460d7c..afd43ab0 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2016 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 5a8f7e4c..bd4754ab 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2016 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 5613c0ee..84e2d388 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # @@ -73,7 +73,7 @@ def _click_range(section: str, key: str) -> "click.IntRange | click.FloatRange": Docs: https://www.scenedetect.com/docs/ Code: https://github.com/Breakthrough/PySceneDetect/ -Copyright (C) 2014-2024 Brandon Castellano. All rights reserved. +Copyright (C) 2014 Brandon Castellano. All rights reserved. PySceneDetect is released under the BSD 3-Clause license. See the LICENSE file or visit [ https://www.scenedetect.com/copyright/ ]. diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 35400003..b3eca646 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 81acc236..3e5cfdda 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index d24df723..73c30afc 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index aa43a45e..dc8008f2 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/_thirdparty/__init__.py b/scenedetect/_thirdparty/__init__.py index 5442893f..c7e79af1 100644 --- a/scenedetect/_thirdparty/__init__.py +++ b/scenedetect/_thirdparty/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2023 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index ae2059c5..dfe8ff7a 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index ad8a849a..84039e3a 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index fb64585e..b2369064 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 057897a6..edb77628 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/common.py b/scenedetect/common.py index a4cc31ab..795c42bc 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detector.py b/scenedetect/detector.py index 1d453534..f5499f46 100644 --- a/scenedetect/detector.py +++ b/scenedetect/detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 70eb5eb7..565ac354 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index bb4bbd2b..71c518b1 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2021 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 730e1ab1..06754669 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index 40b4e3c8..9fb7b25c 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2022 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 408a8312..2d2439e6 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -5,7 +5,7 @@ # [ Docs: http://manual.scenedetect.scenedetect.com/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2022 Brandon Castellano . +# Copyright (C) 2024 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index dd8d2a8a..3a7db315 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/detectors/transnet_v2.py b/scenedetect/detectors/transnet_v2.py index 088272db..e5cce4c4 100644 --- a/scenedetect/detectors/transnet_v2.py +++ b/scenedetect/detectors/transnet_v2.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2026 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/frame_timecode.py b/scenedetect/frame_timecode.py index 8411cef0..8fcb5e15 100644 --- a/scenedetect/frame_timecode.py +++ b/scenedetect/frame_timecode.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index e4c38a2e..7fb6f17a 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index d38c6fc9..102298c5 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/output/video.py b/scenedetect/output/video.py index 35f8841e..c3a0b4cf 100644 --- a/scenedetect/output/video.py +++ b/scenedetect/output/video.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 0c41cd50..89c0d81a 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2016 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index 5de13977..fed33b97 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 67cd2a8b..17eaaeb8 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index cbdb8602..dc415f3c 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index 2b8499da..563ea269 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 3c3687a1..95da5d5e 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/setup.py b/setup.py index ec281380..a91bbda1 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # [ Documentation: http://www.scenedetect.com/docs/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . # """PySceneDetect setup.py - DEPRECATED. diff --git a/tests/__init__.py b/tests/__init__.py index 981ec4b7..c616990f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/conftest.py b/tests/conftest.py index 21a43eb8..a8823249 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2020 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/helpers.py b/tests/helpers.py index 87829cb9..dc2d6362 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2026 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_api.py b/tests/test_api.py index 7a3369cc..5a0e7056 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index 557de35a..3778d811 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index 8e27a495..5f658ac6 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_cli.py b/tests/test_cli.py index 9e77af70..504ac1dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_detectors.py b/tests/test_detectors.py index e6c62a72..6ba9d263 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2021 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_output.py b/tests/test_output.py index 42b93c18..c60cc353 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_platform.py b/tests/test_platform.py index 50130518..e4b3fe18 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2020 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_stats_manager.py b/tests/test_stats_manager.py index 03bef2c7..5e7360ae 100644 --- a/tests/test_stats_manager.py +++ b/tests/test_stats_manager.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2018 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 0ad4be52..42d4a082 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2025 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 3189ee5d..6ec0c2e8 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2025 Brandon Castellano . +# Copyright (C) 2026 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index 0930c155..9b8e3610 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -5,7 +5,7 @@ # [ Docs: https://scenedetect.com/docs/ ] # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] # -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2022 Brandon Castellano . # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 71ed0b0b..4429a64e 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -1,5 +1,5 @@ # PySceneDetect Website (https://www.scenedetect.com) -# Copyright (C) 2014-2024 Brandon Castellano . +# Copyright (C) 2014 Brandon Castellano . site_name: PySceneDetect site_description: "Website and documentation for PySceneDetect, a program to automatically detect scene cuts and split videos. Written in Python, and also provides Python API in addition to command-line interface for use within other programs." site_author: "Brandon Castellano" @@ -8,7 +8,7 @@ site_dir: "build" repo_url: https://github.com/Breakthrough/PySceneDetect edit_uri: 'blob/main/website/pages/' repo_name: "PySceneDetect on Github" -copyright: 'Copyright © 2014-2024 Brandon Castellano. All rights reserved.
    Licensed under BSD 3-Clause (see the LICENSE file for details).' +copyright: 'Copyright © 2014 Brandon Castellano. All rights reserved.
    Licensed under BSD 3-Clause (see the LICENSE file for details).' theme: name: readthedocs logo: img/pyscenedetect_logo_small.png diff --git a/website/pages/copyright.md b/website/pages/copyright.md index b88f7f22..b849e501 100644 --- a/website/pages/copyright.md +++ b/website/pages/copyright.md @@ -6,7 +6,7 @@ PySceneDetect License (BSD 3-Clause) < http://www.bcastell.com/projects/PySceneDetect > -Copyright (C) 2014-2024, Brandon Castellano. +Copyright (C) 2014, Brandon Castellano. All rights reserved. Redistribution and use in source and binary forms, with or without From 63fbf8b76d1c1bdb405c62a5353b2ba3395afa47 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 19:02:27 -0400 Subject: [PATCH 318/407] [build] Update jinja2 --- website/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/requirements.txt b/website/requirements.txt index 8455efc2..f2cfb9c8 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1,2 +1,2 @@ mkdocs==1.5.2 -jinja2==3.1.5 +jinja2>=3.1.6 From 2726346cb9982c4d082b6aa09a281c0b7c9bec83 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 25 Apr 2026 19:11:46 -0400 Subject: [PATCH 319/407] [release] Add new release test suite, workflow runner, and release plan template --- .github/workflows/release-test.yml | 144 ++++++++++++++++++++++ RELEASE-PLAN.md | 75 ++++++++++++ pyproject.toml | 6 + scripts/generate_goldens.py | 90 ++++++++++++++ scripts/update_copyright.py | 155 ++++++++++++++++++++++++ tests/release/__init__.py | 0 tests/release/conftest.py | 81 +++++++++++++ tests/release/synthetic.py | 100 +++++++++++++++ tests/release/test_cli_permutations.py | 147 ++++++++++++++++++++++ tests/release/test_cross_backend.py | 91 ++++++++++++++ tests/release/test_golden_regression.py | 81 +++++++++++++ tests/release/test_install_matrix.py | 60 +++++++++ tests/release/test_long_video_stress.py | 85 +++++++++++++ tests/release/test_output_validation.py | 153 +++++++++++++++++++++++ tests/release/test_synthetic_matrix.py | 50 ++++++++ tests/release/test_vfr_accuracy.py | 71 +++++++++++ 16 files changed, 1389 insertions(+) create mode 100644 .github/workflows/release-test.yml create mode 100644 RELEASE-PLAN.md create mode 100644 scripts/generate_goldens.py create mode 100644 scripts/update_copyright.py create mode 100644 tests/release/__init__.py create mode 100644 tests/release/conftest.py create mode 100644 tests/release/synthetic.py create mode 100644 tests/release/test_cli_permutations.py create mode 100644 tests/release/test_cross_backend.py create mode 100644 tests/release/test_golden_regression.py create mode 100644 tests/release/test_install_matrix.py create mode 100644 tests/release/test_long_video_stress.py create mode 100644 tests/release/test_output_validation.py create mode 100644 tests/release/test_synthetic_matrix.py create mode 100644 tests/release/test_vfr_accuracy.py diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml new file mode 100644 index 00000000..49018800 --- /dev/null +++ b/.github/workflows/release-test.yml @@ -0,0 +1,144 @@ +name: Release Test Suite + +on: + workflow_dispatch: + push: + tags: + - 'v*-release' + +jobs: + static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine pip-audit + - name: Version consistency check + run: | + VERSION=$(python -c "import scenedetect; print(scenedetect.__version__)") + echo "scenedetect.__version__ = $VERSION" + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + TAG_VERSION=${GITHUB_REF#refs/tags/v} + TAG_VERSION=${TAG_VERSION%-release} + if [[ "$VERSION" != "$TAG_VERSION" ]]; then + echo "Version mismatch: scenedetect=$VERSION, tag=$TAG_VERSION" + exit 1 + fi + if ! grep -q "^## PySceneDetect $TAG_VERSION" website/pages/changelog.md; then + echo "Changelog is missing a '## PySceneDetect $TAG_VERSION' heading" + exit 1 + fi + fi + - name: Build and Check + run: | + python -m build + twine check dist/* + - name: pip-audit + run: pip-audit + + release-tests: + needs: static + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.10', '3.13'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Checkout resources branch + run: | + git fetch --depth=1 origin refs/heads/resources:refs/remotes/origin/resources + git checkout refs/remotes/origin/resources -- tests/resources/ + git reset + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install ffmpeg + uses: ./.github/actions/setup-ffmpeg + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[opencv,pyav,moviepy] + pip install opentimelineio pillow psutil pytest + - name: Run release tests + run: pytest -m release -vv --ignore=tests/release/test_long_video_stress.py --ignore=tests/release/test_install_matrix.py + + long-stress: + needs: static + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Checkout resources branch + run: | + git fetch --depth=1 origin refs/heads/resources:refs/remotes/origin/resources + git checkout refs/remotes/origin/resources -- tests/resources/ + git reset + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install ffmpeg + uses: ./.github/actions/setup-ffmpeg + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[opencv,pyav] + pip install psutil pytest + - name: Run long stress test + run: pytest -m release -k long_video -vv + + install-matrix: + needs: static + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Checkout resources branch + run: | + git fetch --depth=1 origin refs/heads/resources:refs/remotes/origin/resources + git checkout refs/remotes/origin/resources -- tests/resources/ + git reset + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build wheel + run: | + pip install build + python -m build --wheel + - name: Test Bare Install + shell: bash + run: | + WHEEL=$(ls dist/*.whl) + PY=${{ matrix.os == 'windows-latest' && 'venv_bare/Scripts/python' || 'venv_bare/bin/python' }} + PYTEST=${{ matrix.os == 'windows-latest' && 'venv_bare/Scripts/pytest' || 'venv_bare/bin/pytest' }} + python -m venv venv_bare + $PY -m pip install pytest "$WHEEL" + $PYTEST -m release -k test_install_bare + - name: Test OpenCV Install + shell: bash + run: | + WHEEL=$(ls dist/*.whl) + PY=${{ matrix.os == 'windows-latest' && 'venv_opencv/Scripts/python' || 'venv_opencv/bin/python' }} + PYTEST=${{ matrix.os == 'windows-latest' && 'venv_opencv/Scripts/pytest' || 'venv_opencv/bin/pytest' }} + python -m venv venv_opencv + $PY -m pip install pytest "${WHEEL}[opencv]" + $PYTEST -m release -k test_opencv_only + - name: Test PyAV Install + shell: bash + run: | + WHEEL=$(ls dist/*.whl) + PY=${{ matrix.os == 'windows-latest' && 'venv_pyav/Scripts/python' || 'venv_pyav/bin/python' }} + PYTEST=${{ matrix.os == 'windows-latest' && 'venv_pyav/Scripts/pytest' || 'venv_pyav/bin/pytest' }} + python -m venv venv_pyav + $PY -m pip install pytest "${WHEEL}[pyav]" + $PYTEST -m release -k test_pyav_only diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md new file mode 100644 index 00000000..30ae6ea4 --- /dev/null +++ b/RELEASE-PLAN.md @@ -0,0 +1,75 @@ +# PySceneDetect Release Checklist + +Use one copy per release (e.g. tick the boxes in a tracking issue or draft PR). +Version referenced below as `X.Y[.Z]` — replace with the real version throughout. + +## 0. Branch setup + +- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. +- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). + +## 1. Code & version + +- [ ] Bump `__version__` in `scenedetect/__init__.py`. +- [ ] Bump `ProductVersion` in `dist/installer/PySceneDetect.aip` (must match `__version__` — `dist/pre_release.py --release` asserts this). +- [ ] No `-dev` / pre-release suffix on the version string for a final release. + +> **Note:** `setup.cfg` reads the package version dynamically via `version = attr: scenedetect.__version__`, and `pyproject.toml` does not declare a `version` field. The single source of truth is `scenedetect/__init__.py`; the `.aip` is the only other place to keep in sync. + +## 2. Docs + +- [ ] Docstrings / API docs reflect any signature changes (`cd docs/ && make html` builds clean). +- [ ] `docs/api/migration_guide.rst` updated if any public API changed. +- [ ] Docstring examples still run (nothing references removed symbols). + +## 3. Website & changelog + +- [ ] `website/pages/changelog.md`: rename the bottom **Development** section to `X.Y (YYYY-MM-DD)` and add a fresh empty **Development** section below it for post-release work. +- [ ] Changelog entry covers: new features, breaking changes, bug fixes, known issues. +- [ ] `website/pages/download.md` updated with the new version / installer link. +- [ ] Any other version-stamped pages updated (`supporting.md`, `cli.md` if commands changed). + +## 4. Tests + +- [ ] Unit tests green locally and in CI: `pytest -vv` (should collect `-m 'not release'` by default). +- [ ] `ruff check scenedetect/ tests/` and `ruff format --check scenedetect/ tests/` pass. +- [ ] Release test suite green: tag a disposable `vX.Y.Z-release-rc` or use `workflow_dispatch` on `.github/workflows/release-test.yml` — all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the 3-OS × 2-Python matrix. See `RELEASE-TEST-PLAN.md` for what the suite covers. +- [ ] `resources` branch has the artifacts the release tests need (goldens under `tests/resources/goldens/`, `tests/resources/stress_15min.mp4`). Re-push if any golden was regenerated. +- [ ] Manual smoke: fresh venv, `pip install .` then `pip install .[opencv]` then `pip install .[pyav]`; run `scenedetect -i

    h3Y0Mu<51_*#2J( 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 QjyrNQxrD>(|M!;s8(P!R%K!iX From e115d91d5afd592a69c9c6f36a7e3d51c0c99980 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 22 Feb 2026 21:47:11 -0500 Subject: [PATCH 281/407] [dist] Add new verison of full logo --- dist/logo/pyscenedetect-logo.svg | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 dist/logo/pyscenedetect-logo.svg diff --git a/dist/logo/pyscenedetect-logo.svg b/dist/logo/pyscenedetect-logo.svg new file mode 100644 index 00000000..f4dea3c1 --- /dev/null +++ b/dist/logo/pyscenedetect-logo.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + PySceneDetect + From d4a55ca632c9de42641268aac8d726a2ac01d96b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 22 Feb 2026 22:00:47 -0500 Subject: [PATCH 282/407] [docs] Update assets for site and documentation --- dist/{generate_ico.py => generate_assets.py} | 41 ++- dist/logo/pyscenedetect-logo-bg.svg | 246 ++++++++++++++++++ dist/logo/pyscenedetect-logo.svg | 229 +++++++++++----- dist/pyscenedetect-logo.svg | 65 ----- docs/_static/pyscenedetect_logo.png | Bin 3337 -> 30832 bytes docs/_static/pyscenedetect_logo_small.png | Bin 4344 -> 12190 bytes icons/icon_128.png | Bin 0 -> 4554 bytes icons/icon_16.png | Bin 0 -> 148 bytes icons/icon_24.png | Bin 0 -> 581 bytes icons/icon_256.png | Bin 0 -> 8796 bytes icons/icon_32.png | Bin 0 -> 1603 bytes icons/icon_48.png | Bin 0 -> 2029 bytes icons/icon_64.png | Bin 0 -> 2632 bytes website/pages/img/pyscenedetect_logo.png | Bin 4857 -> 25121 bytes .../pages/img/pyscenedetect_logo_small.png | Bin 3128 -> 15496 bytes 15 files changed, 449 insertions(+), 132 deletions(-) rename dist/{generate_ico.py => generate_assets.py} (70%) create mode 100644 dist/logo/pyscenedetect-logo-bg.svg delete mode 100644 dist/pyscenedetect-logo.svg create mode 100644 icons/icon_128.png create mode 100644 icons/icon_16.png create mode 100644 icons/icon_24.png create mode 100644 icons/icon_256.png create mode 100644 icons/icon_32.png create mode 100644 icons/icon_48.png create mode 100644 icons/icon_64.png diff --git a/dist/generate_ico.py b/dist/generate_assets.py similarity index 70% rename from dist/generate_ico.py rename to dist/generate_assets.py index 9dd535c1..699b1b8b 100644 --- a/dist/generate_ico.py +++ b/dist/generate_assets.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""Generate pyscenedetect.ico from pyscenedetect.svg. +"""Generate pyscenedetect.ico and logo PNGs from SVG sources. Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). """ @@ -10,9 +10,17 @@ import sys import tempfile from pathlib import Path +from typing import NamedTuple from PIL import Image, ImageFilter + +class LogoOutput(NamedTuple): + path: Path + width: int + height: int + source: Path + # Colors matching the SVG design BG = (224, 232, 240, 255) # #e0e8f0 FG = (42, 53, 69, 255) # #2a3545 @@ -31,9 +39,22 @@ SHARPEN_RADIUS = 0.5 DIST_DIR = Path(__file__).resolve().parent +REPO_DIR = DIST_DIR.parent LOGO_DIR = DIST_DIR / "logo" ICO_PATH = DIST_DIR / "pyscenedetect.ico" +LOGO_SVG = LOGO_DIR / "pyscenedetect-logo.svg" +LOGO_BG_SVG = LOGO_DIR / "pyscenedetect-logo-bg.svg" + +# Heights match the natural SVG aspect ratio (1024x480). +# _small outputs use the -bg variant (background included). +LOGO_OUTPUTS: list[LogoOutput] = [ + LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo.png", 900, 422, LOGO_SVG), + LogoOutput(REPO_DIR / "docs" / "_static" / "pyscenedetect_logo_small.png", 300, 141, LOGO_BG_SVG), + LogoOutput(REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo.png", 640, 300, LOGO_BG_SVG), + LogoOutput(REPO_DIR / "website" / "pages" / "img" / "pyscenedetect_logo_small.png", 462, 217, LOGO_SVG), +] + SVG_FOR_SIZE: dict[int, Path] = { 24: LOGO_DIR / "pyscenedetect-24.svg", 32: LOGO_DIR / "pyscenedetect-32.svg", @@ -88,15 +109,24 @@ def find_inkscape() -> str: sys.exit(1) -def render_svg(inkscape: str, svg: Path, output: Path, size: int): - """Render an SVG to a PNG at the given size using Inkscape.""" +def render_svg(inkscape: str, svg: Path, output: Path, width: int, height: int): + """Render an SVG to a PNG at the given dimensions using Inkscape.""" subprocess.run( - [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(size), "-h", str(size)], + [inkscape, str(svg), "--export-type=png", f"--export-filename={output}", "-w", str(width), "-h", str(height)], check=True, capture_output=True, ) +def render_logos(inkscape: str): + """Render the logo SVG to all required PNG outputs.""" + print("Rendering logo PNGs...") + for entry in LOGO_OUTPUTS: + print(f" {entry.path.relative_to(REPO_DIR)} ({entry.width}x{entry.height}) [source: {entry.source.name}]...") + render_svg(inkscape, entry.source, entry.path, entry.width, entry.height) + print(f" Done ({len(LOGO_OUTPUTS)} files).") + + def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: """Render the SVG at all icon sizes, applying sharpening where configured.""" images = [] @@ -109,7 +139,7 @@ def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: else: svg_path = SVG_FOR_SIZE[size] print(f" Rendering {size}x{size} using {svg_path.name}...") - render_svg(inkscape, svg_path, png_path, size) + render_svg(inkscape, svg_path, png_path, size, size) img = Image.open(png_path).copy() if size in SHARPEN_AMOUNT: img = img.filter(ImageFilter.UnsharpMask(radius=SHARPEN_RADIUS, percent=SHARPEN_AMOUNT[size], threshold=0)) @@ -135,6 +165,7 @@ def main(): images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) print(f"Output ICO: {ICO_PATH}") + render_logos(inkscape) if __name__ == "__main__": diff --git a/dist/logo/pyscenedetect-logo-bg.svg b/dist/logo/pyscenedetect-logo-bg.svg new file mode 100644 index 00000000..a554ac8d --- /dev/null +++ b/dist/logo/pyscenedetect-logo-bg.svg @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + + + diff --git a/dist/logo/pyscenedetect-logo.svg b/dist/logo/pyscenedetect-logo.svg index f4dea3c1..22cbc48c 100644 --- a/dist/logo/pyscenedetect-logo.svg +++ b/dist/logo/pyscenedetect-logo.svg @@ -15,14 +15,75 @@ included LICENSE file, or visit one of the above pages for details. viewBox="127.91564 143.58975 147.2796 69.037314" version="1.1" id="svg2" - sodipodi:docname="pyscenedetect-logo.svg" + sodipodi:docname="pyscenedetect-logo-nobg.svg" inkscape:version="1.4.3 (0d15f75, 2025-12-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> + id="defs2"> + + + + + + + + + + + + + - - - - - - + + PySceneDetect + y="202.9969" + id="text1" + inkscape:label="pyscenedetect-text">PySceneDetect + + diff --git a/dist/pyscenedetect-logo.svg b/dist/pyscenedetect-logo.svg deleted file mode 100644 index 7f09ad70..00000000 --- a/dist/pyscenedetect-logo.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - PySceneDetect - diff --git a/docs/_static/pyscenedetect_logo.png b/docs/_static/pyscenedetect_logo.png index 63471a6aeb0a3047a741f206af589585960e5da7..26cb1b24e38dadbc1e13e7b3e07e666c1a3ee67a 100644 GIT binary patch literal 30832 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 literal 0 HcmV?d00001 diff --git a/icons/icon_16.png b/icons/icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..8a847435d017c0e924028b538d4763ad6a700610 GIT binary patch literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(Vi}jAr*6yYd$My=%R2|cAF?uH4{lCvBe1VuVRc$tG3g_&C$SZ6lN?E1lQ v%|S)nfVY-8VuIz9CG1_X41S$cW-u_Mq#3W3;(xXfXbpp>tDnm{r-UW|hQ}^? literal 0 HcmV?d00001 diff --git a/icons/icon_24.png b/icons/icon_24.png new file mode 100644 index 0000000000000000000000000000000000000000..43b9f60de72c328162f5232f545683e2dd5c7107 GIT binary patch literal 581 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 LNkvXXu0mjfg+TOk literal 0 HcmV?d00001 diff --git a/icons/icon_64.png b/icons/icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..d9fb3d7b964712fbf43383936f99798580dcacc2 GIT binary patch literal 2632 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 diff --git a/website/pages/img/pyscenedetect_logo_small.png b/website/pages/img/pyscenedetect_logo_small.png index 5214519621782c522a66d8d052f9529a9def5643..0634b34c4893b6daaf8a4a138d0e9c3de975d4a5 100644 GIT binary patch literal 15496 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 literal 3128 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 VwoSsT! Date: Thu, 26 Feb 2026 21:33:57 -0500 Subject: [PATCH 283/407] [docs] Improve spacing of elements --- icons/icon_128.png | Bin 4554 -> 0 bytes icons/icon_16.png | Bin 148 -> 0 bytes icons/icon_24.png | Bin 581 -> 0 bytes icons/icon_256.png | Bin 8796 -> 0 bytes icons/icon_32.png | Bin 1603 -> 0 bytes icons/icon_48.png | Bin 2029 -> 0 bytes icons/icon_64.png | Bin 2632 -> 0 bytes website/overrides/main.html | 6 +----- website/pages/index.md | 3 +-- website/pages/style.css | 5 +++++ 10 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 icons/icon_128.png delete mode 100644 icons/icon_16.png delete mode 100644 icons/icon_24.png delete mode 100644 icons/icon_256.png delete mode 100644 icons/icon_32.png delete mode 100644 icons/icon_48.png delete mode 100644 icons/icon_64.png diff --git a/icons/icon_128.png b/icons/icon_128.png deleted file mode 100644 index 219b080b0fac1013ee63162969fbc85a533219db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4554 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 diff --git a/icons/icon_16.png b/icons/icon_16.png deleted file mode 100644 index 8a847435d017c0e924028b538d4763ad6a700610..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(Vi}jAr*6yYd$My=%R2|cAF?uH4{lCvBe1VuVRc$tG3g_&C$SZ6lN?E1lQ v%|S)nfVY-8VuIz9CG1_X41S$cW-u_Mq#3W3;(xXfXbpp>tDnm{r-UW|hQ}^? diff --git a/icons/icon_24.png b/icons/icon_24.png deleted file mode 100644 index 43b9f60de72c328162f5232f545683e2dd5c7107..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 581 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 LNkvXXu0mjfg+TOk diff --git a/icons/icon_64.png b/icons/icon_64.png deleted file mode 100644 index d9fb3d7b964712fbf43383936f99798580dcacc2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2632 zcmV-O3b*x%P)1 zeQXow9e&=s_wH<;^96Jpg?8g(u)zkUKtk*|Njt5BY}49povKYM>jrIsPTE#Y)wI9X zb`zD*)@|L&R0+{(ld4sv(%L~F3HdMyUn$=ZC^SRrBw95jaXuXT?tC}>UdK-2#EyZG zIOLH?a=yoR_x!%z-}}A?a~uaBTmcks*A_78M-DI>fQ@Mi#+dj`y2gOSfKJD;cUgT! zI~Oem*#AEK5SHR;2jm5qrh+9&f~qP|xB{+72uC6?qZVSZScY%L+7SsY;+B&4D*)3< zyd*Jju0T;ZG)+NQR|xy68`02k2z5UfKEdU3q2=&VELpM$ne+p=mkf|w zUgqRwP2}>$OIHvKHlwDt5vNX_hN7w>&ne%F3JXVw9u7*4mcz%edDBJ!0~znjkxO3g z=|`~nFzV`>P*Zad;ei2YnkEDl70nX4pA9(YIDY&DDErB%wr%Hz0oa&c{s6(?p}3b% zpN68SP!$yldD*3<^>QZAJ-~q0))V;B@?{z80KY{|*?XCDxLgW)LPK8OzrPLq9`H_R^7KEqXF2}kGj*16BFv?+S+>5*3{#?sMt!}%L*6VoAL5^AkhmJ zd=_uM@wyllZfgWcqrBWZ%F8ZI73XKCRP5UYih#3c&LI+wLY5@N($oPy5#5)Z!OK+K z+#c7Yh)#V*Sy6DL5*EvO=O&a9Kj@iBo83Y(ZT^BQC#xMGR4zriw0%nvbax z9T#lDG%ZA z)CqPRSf&Xp7K3S;kQ|0J#rMPSD@Iwsk7Y|2!!m7{mI=c!Gfs+l;t%qoM|b1W`&Up~ z+aPi|-eW6JH4Q2mrJw*)p*a!OB!)zh=;ce73b7=*xa5AoX5w=)N>^8=NzfR>psK1k zbM_pzYAIeHg;Ph4tHSzav7Ws3?7pJ*jz|mxqdg4?bTp0s%kno_iOJXxt!Vn68Xd-R!{=e^5b_ zWqH!X)5DuLkjI^!6C*f%5o8i3Gmoi=Et{Y`zI6jQ6R$s+$FadYjz%$W-rZQg-Uq+0 z1b*K-^Hd+my!>5&an%-QB(7 zJHtg!Rr3^wgH-!-`oX~9hc|j3l3n}Ku=HKh|Z8vBUDh3k0*Yz4d>2Z5Ms$=mSy6x$G(TQ3vGDs#A&$Qu2B(S z8YJ6>O~R9w&8-kmz_A@T3~0Z20^OmZRODjXC^NMG_p0av2>tzGyzu<5@!fA%L6d;M zS06=ZXQw!y_~(K)C^|OJ2liv>K;$> z##wO;1O0s|994s^4{>P7?3(~iIxwmM6w>tN$_F+;bH!WYv5FvpCQHR@SL2OcFC$E~ zgaIB2*x%HOr?>tLZnqneNCazEufo4}|0yG9)vA?v>+i25Ic^E4Yd(q1PyIB>S-Elr z_Wa{77#QTZB%r?KG&ViCZHTjCIrjW>=Lk;2ku&)5rme!@i3sVq-ifQ1-$Tdsp&Gyv zi=uR~ZZ$zl~^$l|JJ$P!{FJU_|$nsF+rh_#6z%bJH;uQK7 zm1U@^dN3*8>(%k*TmQk4qpe~|j67CR?#DO2{*|P7;_TT|jpoD0lAQ9uIz0T%hmzt+ zz`x%)fV%o%l0!>b4}a@xN%2WeU1O57X7zn|u^Q5_G;?elyH zWxFiKhaZfwb0jQUke`RSi`P!O>2Nj?%d@#8S(Dx#o3RTbgz1p~Hv {% endif %} -{% if page.is_index %} - 🎥 {{ config.site_name }} -{% else %} - -{% endif %} + {% endblock %} diff --git a/website/pages/index.md b/website/pages/index.md index 5dadd243..916b2281 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -1,11 +1,10 @@ + PySceneDetect

    -PySceneDetect is available via `pip` as either [`scenedetect`](https://pypi.org/project/scenedetect/) (depends on `opencv-python`) or [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/) (depends on `opencv-python-headless`). Both ship the same `scenedetect` Python module — install whichever OpenCV variant suits your environment. +PySceneDetect is available via `pip` as either [`scenedetect`](https://pypi.org/project/scenedetect/) (depends on `opencv-python`) or [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/) (depends on `opencv-python-headless`). Both ship the same `scenedetect` Python module -- install whichever OpenCV variant suits your environment. ## Windows Build (64-bit Only)   diff --git a/website/pages/faq.md b/website/pages/faq.md index 18b1f72d..02e4e855 100644 --- a/website/pages/faq.md +++ b/website/pages/faq.md @@ -16,7 +16,7 @@ For server environments without GUI libraries, install the headless variant inst pip install scenedetect-headless ``` -Both packages ship the same `scenedetect` Python module — you only need one. +Both packages ship the same `scenedetect` Python module -- you only need one. #### How can I enable video splitting support? From c8b3a3dff2783eb61897f439089067cc065137f2 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 28 Apr 2026 23:30:18 -0400 Subject: [PATCH 334/407] [build] Cache installer & ffmpeg on signed builder --- RELEASE-PLAN.md | 10 ++--- appveyor.yml | 19 ++++++---- scripts/generate_assets.py | 17 ++++++--- scripts/generate_goldens.py | 5 +-- scripts/generate_manifest.py | 2 +- scripts/pre_release.py | 18 +++++---- scripts/stage_windows_dist.py | 38 ++++++++----------- ...{bump_installer.py => update_installer.py} | 19 +++------- 8 files changed, 63 insertions(+), 65 deletions(-) rename scripts/{bump_installer.py => update_installer.py} (85%) diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index bdf31c68..618bc868 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -11,7 +11,7 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout ## 1. Code & version - [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Bump the installer project: `python scripts/bump_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. +- [ ] Bump the installer project: `python scripts/update_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. - [ ] No `-dev` / pre-release suffix on the version string for a final release. > **Note:** `pyproject.toml` does not declare a `version` field - the single source of truth is `scenedetect/__init__.py`; the Windows installer `.aip` is the only other place to keep in sync. @@ -42,19 +42,19 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout - [ ] `python scripts/pre_release.py --release` passes (enforces `.aip` <-> `__version__` parity, writes `packaging/windows/.version_info`). - [ ] `pyinstaller packaging/windows/scenedetect.spec` produces a working `scenedetect.exe` - run it against a sample video. -- [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`). -- [ ] `python scripts/bump_installer.py --sync-files` and commit the .aip diff (refreshes the APPDIR baseline so CI's per-build `--sync-only` diff stays small). +- [ ] `python scripts/stage_windows_dist.py --ffmpeg-dir ` populates `dist/scenedetect/` with ffmpeg, third-party licenses, sphinx docs, and emits the portable `.zip`. Pass `--ffmpeg-dir` pointing at a recent extracted [GyanD codexffmpeg](https://github.com/GyanD/codexffmpeg/releases) build; omit it only for offline builds (uses the bundled `packaging/windows/thirdparty.7z` with a stub `LICENSE-FFMPEG`). +- [ ] `python scripts/update_installer.py --sync-files` and commit the .aip diff (refreshes the APPDIR baseline so CI's per-build `--sync-only` diff stays small). - [ ] Build the MSI via Advanced Installer (`packaging/windows/installer/PySceneDetect.aip`); install into a clean Windows VM and run the CLI. - [ ] After both `pyinstaller` and the MSI build are done (and the portable `.zip` is staged at `dist/PySceneDetect-X.Y.Z-portable.zip`), run `python scripts/generate_manifest.py` to produce `dist/PySceneDetect-X.Y.Z.manifest.json` (per-file SHA256 audit of every artifact) and `dist/SHA256SUMS` (flat `sha256sum -c` compatible). Both are attached to the GitHub release in step 7. -> **GUI required for structural changes.** `scripts/bump_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: +> **GUI required for structural changes.** `scripts/update_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: > > - Moving the .aip or its source tree (the build's `SourcePath` references are stored relative to the .aip and aren't rewritten by `/NewSync` - cf. the `dist/installer/` -> `packaging/windows/installer/` move that broke the relative paths until they were edited in the GUI). > - Adding/removing build configurations, features, or prerequisites. > - Editing dialog layouts, branding bitmaps, install sequences, custom actions, file associations, or shortcuts. > - Changing `UpgradeCode`, install directory layout (`APPDIR` location), or per-component attributes. > -> When in doubt, open the .aip in AdvancedInstaller, make the change, save, and commit the resulting diff. Re-run `bump_installer.py` afterwards if the version-identity fields need refreshing. +> When in doubt, open the .aip in AdvancedInstaller, make the change, save, and commit the resulting diff. Re-run `update_installer.py` afterwards if the version-identity fields need refreshing. ## 6. Cut the release diff --git a/appveyor.yml b/appveyor.yml index b04b84d7..0bebe2e3 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,6 +2,11 @@ build: false +cache: + - 'ffmpeg-%ffmpeg_version%-full_build.7z -> appveyor.yml' + - 'packaging\windows\installer\advinst.msi -> appveyor.yml' + - '%LOCALAPPDATA%\uv\cache -> pyproject.toml' + # Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release branches: only: @@ -37,10 +42,10 @@ install: - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - 'SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%' - python --version - - python -m pip install --upgrade pip build wheel virtualenv setuptools - - python -m pip install .[docs] - - python -m pip install --upgrade -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg - - appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z + - python -m pip install uv + - uv pip install --system .[docs] + - uv pip install --system -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg + - if not exist ffmpeg-%ffmpeg_version%-full_build.7z appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r # moviepy.config reads FFMPEG_BINARY (which routes through imageio_ffmpeg) at import time. # `--no-binary imageio-ffmpeg` strips the bundled ffmpeg, so point it at the GyanD copy @@ -57,7 +62,7 @@ install: # portable .zip - keeps CI and local builds in sync (see scripts/stage_windows_dist.py). - python scripts/pre_release.py --release - pyinstaller packaging/windows/scenedetect.spec - - python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg --portable-zip + - python scripts/stage_windows_dist.py --ffmpeg-dir dist/ffmpeg - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING MSI INSTALLER * * @@ -66,7 +71,7 @@ install: - cd packaging/windows/installer - ps: iex ((New-Object Net.WebClient).DownloadString('https://raw.githubusercontent.com/appveyor/secure-file/master/install.ps1')) - appveyor-tools\secure-file -decrypt license65.dat.enc -secret %ai_license_secret% -salt %ai_license_salt% - - appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi + - if not exist advinst.msi appveyor DownloadFile https://www.advancedinstaller.com/downloads/advinst.msi - msiexec /i advinst.msi /qn # Resolve the installed Advanced Installer bin path dynamically - the upstream # MSI is unversioned so the directory name (Advanced Installer X.Y.Z) drifts. @@ -82,7 +87,7 @@ install: # release tag and must stay stable across rebuilds for upgrade-chain integrity. # On non-tag builds, also pass --dev so the MSI is named PySceneDetect-{ver}-dev-win64.msi # (keeps dev artifacts distinguishable from signed releases). - - if "%APPVEYOR_REPO_TAG%"=="true" (python scripts/bump_installer.py --sync-only) else (python scripts/bump_installer.py --sync-only --dev) + - if "%APPVEYOR_REPO_TAG%"=="true" (python scripts/update_installer.py --sync-only) else (python scripts/update_installer.py --sync-only --dev) # Snapshot the post-sync .aip and the actual payload tree as build artifacts. # The committed .aip is a baseline; CI adapts it to its own pyinstaller output # and we never write back to git, so these snapshots are the authoritative diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py index 14e64654..083a0a88 100644 --- a/scripts/generate_assets.py +++ b/scripts/generate_assets.py @@ -1,8 +1,15 @@ -#!/usr/bin/env python -"""Generate pyscenedetect.ico and logo PNGs from SVG sources. - -Requires Inkscape (for SVG rasterization) and Pillow (for ICO generation). -""" +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +"""Generate pyscenedetect.ico and logo PNGs from SVG sources. Requires Inkscape and Pillow.""" import contextlib import shutil diff --git a/scripts/generate_goldens.py b/scripts/generate_goldens.py index 6c0c24d5..26bdf107 100644 --- a/scripts/generate_goldens.py +++ b/scripts/generate_goldens.py @@ -9,10 +9,7 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Golden Cut-List Generator - -This script generates golden cut-lists in JSON format for the release test suite. -""" +"""Generates golden cut-lists in JSON format for the release test suite.""" import argparse import json diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py index acfd0776..0626de16 100644 --- a/scripts/generate_manifest.py +++ b/scripts/generate_manifest.py @@ -38,7 +38,7 @@ def msi_version(raw: str) -> str: - # Mirror scripts/bump_installer.py - the artifact filename uses the + # Mirror scripts/update_installer.py - the artifact filename uses the # normalized X.Y.Z form, not the Python __version__ string. parts = [re.split(r"[^\d]", p, maxsplit=1)[0] for p in raw.split(".")] while len(parts) < 3: diff --git a/scripts/pre_release.py b/scripts/pre_release.py index 1335fe09..a38a385b 100644 --- a/scripts/pre_release.py +++ b/scripts/pre_release.py @@ -10,11 +10,13 @@ # included LICENSE file, or visit one of the above pages for details. # -# Pre-release script to run before invoking `pyinstaller`: -# -# python scripts/pre_release.py -# pyinstaller packaging/windows/scenedetect.spec -# +""" +Pre-release script to run before invoking `pyinstaller` when building the Windows distribution: +```bash +python scripts/pre_release.py +pyinstaller packaging/windows/scenedetect.spec +``` +""" import sys from pathlib import Path @@ -23,7 +25,7 @@ sys.path.insert(0, str(REPO_DIR)) sys.path.insert(0, str(SCRIPTS_DIR)) -from bump_installer import msi_version # noqa: E402 +from update_installer import msi_version # noqa: E402 import scenedetect # noqa: E402 @@ -40,12 +42,12 @@ installer_aip = INSTALLER_AIP.read_text() # The .aip stores the numeric MSI form (e.g. "0.7.0"), not the Python __version__ # (which may be "0.7-dev0", "0.7", "0.7.1", ...). Normalize through the same - # function bump_installer.py uses to write the .aip so the comparison is apples-to-apples. + # function update_installer.py uses to write the .aip so the comparison is apples-to-apples. expected = msi_version(VERSION) aip_row = f'' assert aip_row in installer_aip, ( f"Installer ProductVersion does not match normalized {VERSION!r} ({expected!r}). " - f"Run `python scripts/bump_installer.py` to refresh the .aip." + f"Run `python scripts/update_installer.py` to refresh the .aip." ) with VERSION_INFO.open("wb") as f: diff --git a/scripts/stage_windows_dist.py b/scripts/stage_windows_dist.py index 469b567a..690497d6 100644 --- a/scripts/stage_windows_dist.py +++ b/scripts/stage_windows_dist.py @@ -9,35 +9,36 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Stage non-pyinstaller assets into dist/scenedetect/. +"""Stages Windows distribution assets into dist/scenedetect/. -Pyinstaller produces only scenedetect.exe + _internal/. This script adds -the rest of what both the AdvancedInstaller MSI and the portable ZIP need: -ffmpeg.exe + its LICENSE, the project LICENSE / README.txt, third-party -licenses, and sphinx-built docs. Mirrors the inline staging steps that -appveyor.yml used to do, so CI and local builds stay in sync. - -Sequence in a release: +Sequence in a release to generate the installer: +```bash python scripts/pre_release.py pyinstaller packaging/windows/scenedetect.spec - python scripts/stage_windows_dist.py --ffmpeg-dir --portable-zip - python scripts/bump_installer.py --sync-files + python scripts/stage_windows_dist.py --ffmpeg-dir + python scripts/update_installer.py --sync-files AdvancedInstaller.com /build packaging/windows/installer/PySceneDetect.aip python scripts/generate_manifest.py +``` ---ffmpeg-dir points at a directory containing ffmpeg.exe and its LICENSE -(e.g. the extracted GyanD codexffmpeg release). If omitted, the script -falls back to extracting ffmpeg.exe from packaging/windows/thirdparty.7z; -LICENSE-FFMPEG is then a stub since the bundled archive doesn't carry it. +This script assumes it is run on a Windows machine. """ +# TODO: This should be called from the Github Actions workflow as well, right now it's only +# done from the appveyor one. When that's done it should be merged with update_installer.py +# into a combined "prepare_windows_dist.py". + import argparse import re import shutil import subprocess import sys import zipfile + +if sys.platform != "win32": + print("Error: stage_windows_dist.py must be run on Windows.", file=sys.stderr) + sys.exit(1) from pathlib import Path REPO_DIR = Path(__file__).resolve().parent.parent @@ -174,11 +175,6 @@ def main() -> None: help="Directory containing ffmpeg.exe and its LICENSE. " "If omitted, ffmpeg is extracted from packaging/windows/thirdparty.7z.", ) - parser.add_argument( - "--portable-zip", - action="store_true", - help="Also produce dist/PySceneDetect--portable.zip.", - ) args = parser.parse_args() if not DIST_TREE.exists(): @@ -191,9 +187,7 @@ def main() -> None: copy_file(PACKAGING_WIN / "README.txt", DIST_TREE / "README.txt") stage_thirdparty_licenses() build_docs() - - if args.portable_zip: - make_portable_zip(msi_version(scenedetect.__version__)) + make_portable_zip(msi_version(scenedetect.__version__)) if __name__ == "__main__": diff --git a/scripts/bump_installer.py b/scripts/update_installer.py similarity index 85% rename from scripts/bump_installer.py rename to scripts/update_installer.py index 2f9af18d..5b57ad4a 100644 --- a/scripts/bump_installer.py +++ b/scripts/update_installer.py @@ -9,21 +9,14 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Bump the AdvancedInstaller .aip project for a release. +"""Update the AdvancedInstaller .aip project for a release. Usage: - python scripts/bump_installer.py # version bump only - python scripts/bump_installer.py --sync-files # bump + re-sync APPDIR - python scripts/bump_installer.py --sync-only # re-sync APPDIR only (CI) - python scripts/bump_installer.py --sync-only --dev # CI dev build (renames MSI) - python scripts/bump_installer.py --version 0.7.0 # explicit version override - -The committed .aip is a baseline; CI's --sync-only adapts it per build and is -never written back to git. Refresh locally with --sync-files before each release. - -All paths shell out to AdvancedInstaller.com to preserve .aip invariants -(line endings, attribute ordering, GUID casing). Override CLI discovery with -the ADVINST environment variable. + python scripts/update_installer.py # version bump only + python scripts/update_installer.py --sync-files # bump + re-sync APPDIR + python scripts/update_installer.py --sync-only # re-sync APPDIR only (CI) + python scripts/update_installer.py --sync-only --dev # CI dev build (renames MSI) + python scripts/update_installer.py --version 0.7.0 # explicit version override """ import argparse From 523a83f720b5cc62aee1258c9f4fe54fc724a495 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 1 May 2026 21:43:43 -0400 Subject: [PATCH 335/407] [dist] Generate installer artifacts dynamically rather than persisting in git --- .gitignore | 1 + RELEASE-PLAN.md | 9 +- appveyor.yml | 7 + docs/_static/favicon.ico | Bin 27826 -> 28910 bytes .../Generated Images/installer_banner.jpg | Bin 1771 -> 0 bytes .../installer_banner.scale-125.jpg | Bin 2348 -> 0 bytes .../installer_banner.scale-150.jpg | Bin 2870 -> 0 bytes .../installer_banner.scale-200.jpg | Bin 4214 -> 0 bytes .../Generated Images/installer_banner.svg | 394 ++++++++++------- .../Generated Images/installer_logo.jpg | Bin 5347 -> 0 bytes .../installer_logo.scale-125.jpg | Bin 7371 -> 0 bytes .../installer_logo.scale-150.jpg | Bin 9631 -> 0 bytes .../installer_logo.scale-200.jpg | Bin 15187 -> 0 bytes .../Generated Images/installer_logo.svg | 379 +++++++++++------ packaging/windows/installer/PySceneDetect.aip | 2 +- .../windows/installer/installer_banner.png | Bin 8260 -> 8303 bytes .../windows/installer/installer_banner.svg | 395 +++++++++++------- .../windows/installer/installer_logo.png | Bin 7373 -> 6809 bytes .../windows/installer/installer_logo.svg | 380 ++++++++++------- .../windows/installer/psd_square_small.ico | Bin 173989 -> 28910 bytes packaging/windows/pyscenedetect.ico | Bin 27826 -> 28910 bytes pyproject.toml | 4 +- scripts/generate_assets.py | 214 +++++++++- scripts/pre_release.py | 11 +- website/pages/img/favicon.ico | Bin 27826 -> 28910 bytes 25 files changed, 1192 insertions(+), 604 deletions(-) delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.scale-125.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.scale-150.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_banner.scale-200.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.scale-125.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.scale-150.jpg delete mode 100644 packaging/windows/installer/Generated Images/installer_logo.scale-200.jpg diff --git a/.gitignore b/.gitignore index 1d488715..ec426043 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ docs/_build/ website/build/ +scripts/local/ tests/resources/* *.mp4 *.jpg diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index 618bc868..2e968bc8 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -5,16 +5,13 @@ Version referenced below as `X.Y[.Z]` - replace with the real version throughout ## 0. Branch setup -- [X] Create / fast-forward release branch: `releases/X.Y` off `main`. -- [X] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). +- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. +- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). ## 1. Code & version - [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Bump the installer project: `python scripts/update_installer.py` (rewrites `ProductVersion`, regenerates `ProductCode`, updates the MSI filename via the AdvancedInstaller CLI). Add `--sync-files` after `pyinstaller` if any bundled dependency versions changed since the last release - this re-syncs APPDIR from `dist/scenedetect/` and replaces the manual "delete install dir + re-add files" GUI step. `scripts/pre_release.py --release` asserts the resulting `ProductVersion` matches `__version__`. -- [ ] No `-dev` / pre-release suffix on the version string for a final release. - -> **Note:** `pyproject.toml` does not declare a `version` field - the single source of truth is `scenedetect/__init__.py`; the Windows installer `.aip` is the only other place to keep in sync. +- [ ] Regular release: No `-dev` suffix or other, pre-release: has `-dev0`, `-dev1`, ... ## 2. Docs diff --git a/appveyor.yml b/appveyor.yml index 0bebe2e3..689716e4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,6 +6,7 @@ cache: - 'ffmpeg-%ffmpeg_version%-full_build.7z -> appveyor.yml' - 'packaging\windows\installer\advinst.msi -> appveyor.yml' - '%LOCALAPPDATA%\uv\cache -> pyproject.toml' + - 'C:\Program Files\Inkscape -> appveyor.yml' # Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release branches: @@ -52,6 +53,12 @@ install: # we just extracted; otherwise pre_release.py and pyinstaller analysis crash on # `import scenedetect`. The runtime hook (pyi_rth_scenedetect.py) does the same at exe runtime. - 'SET IMAGEIO_FFMPEG_EXE=%APPVEYOR_BUILD_FOLDER%\\dist\\ffmpeg\\ffmpeg.exe' + # Inkscape is required by scripts/pre_release.py --release (regenerates installer JPGs + # from the master SVG). Not preinstalled on the AppVeyor VS2019 image; cached + # in `C:\Program Files\Inkscape` (see cache: section) so we only re-install when + # the cache is busted (appveyor.yml changes). + - if not exist "C:\Program Files\Inkscape\bin\inkscape.exe" choco install inkscape -y --no-progress + - 'SET PATH=%PATH%;C:\Program Files\Inkscape\bin' - echo * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - echo * * BUILDING WINDOWS EXE * * diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico index 019c86150820b2975616d560c6cd01bc7cdd0aeb..bf8cbf10a2938375fc07a27300dd44f57df47bba 100644 GIT binary patch literal 28910 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%0DsthoEYoDtMM%n}KQ1z& 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni diff --git a/packaging/windows/installer/Generated Images/installer_banner.jpg b/packaging/windows/installer/Generated Images/installer_banner.jpg deleted file mode 100644 index 2652dd54bcbe775b91d3001fcdfa3a21f9fdf506..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1771 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 LRm>kog1i3%Vs3C> diff --git a/packaging/windows/installer/Generated Images/installer_banner.svg b/packaging/windows/installer/Generated Images/installer_banner.svg index 2a9ab287..a554ac8d 100644 --- a/packaging/windows/installer/Generated Images/installer_banner.svg +++ b/packaging/windows/installer/Generated Images/installer_banner.svg @@ -1,150 +1,246 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + - \ No newline at end of file + diff --git a/packaging/windows/installer/Generated Images/installer_logo.jpg b/packaging/windows/installer/Generated Images/installer_logo.jpg deleted file mode 100644 index 210bc2b2f353d99c1d1596193196bfad12c7e2f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5347 zcmeH}do#PAfx+P-qHqL4 zR8-hIRQMf;q7gDG>n+4qkPahNPhkwg;?u>|wiegR?rvkN8@l<0qa;?Yl3OjWp{a$} z)*)=zXk@&}WZTb{R@U2XZ1*_q-RHR9>43Y(k)xi+ypH<^1fC8GJ`)mgF)}LpPcf9M z35nOPC*8Q2Ov}j3x|5wl|Ml*@lG3vKvyuRxX?l_5m8aNC~}?)CK4nxI9e2;vR+Kaf`mML zYK5vnm^fx@e0p&`O3iRLTh`65O=6`wp-*Fc9__DW{~TEOzeV;t*gv>l14%ecxOi|h zAc8MVM=s0Vc5-o##oVh(MZoD=MR{Y>HUYRp1d?JO zzKhEs_Gz?|M~v=3p!Wbd7RSCiLgXlPklAa9k8AvCxmTQ@v{4s2;}%AA-PC5S?2oe| zH~f+YfmUBT2-KB8Kz?!1bHkR3Va=hdA%N14fPh7{5d=g!c*h`cG06e~Y41}Y5auP= zFnJ5(hg{F`q#z>1SAfW#X1)j@F%}Nm^naLD9)n#}yYh=g_u{l9a?G$N` zS+r8iRvJZDwc%Uv|AAk!Rt5r$+&nTfv1b-mn93Sy`O2|C z2eH&xU7B-SJ&kZl7IP09W@YCy`u?Xb?%JDcJxi)*^lTn;GYev%)To~??JzD7O#>O3w|_sU1ZRrc&d@l!2#dswGz0>tbd zNMiT~XJ6vvfg06gQ<T5PzKQAah<3BIwv@KZp)kPE?bX4 zz~zq}8yWmFO`{|W>FBkkbiESP3Kst=DV{&gy|8byU5mnLi6JL?4hh2XN`hrbWvGLO2~xlPP0uzDcRfls%;O_ z-CO(PcHDo#>h?IYZ&o76Qd^^S^0;oho*Mh*yV94uWX037Csr>zrB*a)Ps_JYA5F$q zz+aprDwS5r6jd>TY&e+Dd+0vVH%JyNOG9&-u=Euknzn&}vclNxECjkO&qyuYxjLRj z0~I3{;S##~D~Fee2Zm(nq#`VENO!it-8YEd zj9GuVEfwB_Bv=fz`3}huHp@Ne;iee(aO;L>Y~uSaGOM1Iprn0p&lZQl&!^W-e>&NJ z6;DtQ&q(6CDcAWHAPLq3kD|Ii(OOT)Am_$&8t!+xClBP%80X?QqX+8NNTs#rA&LhZ zkPNbCq&d07;J+`{c$Ud2ExVN!Ud(THG?qf!q zItS(-fB&5G>{ywV;9#}`2*8FvXs`4I!iv-_&G$=~+0|ZbaM8i(IX0VRV;e%v*D&1m zT`huBFS$Ao0?Y`erir6duU%mf{UDO0H{}x_W&Ac&2RCiL0Rl4vNWK75dGnq2=G5o1 z9S~@M7eb&#jyT575G+?B3xew*K#p8x>HnmnndCy`DVsomHy$B?fCRA*#)QD|CES;3 zQh?(4XMfp_V5Go`L1raVpC&CBeInSsQT}>lzJ7X+xM~?i>%HKZF(bXy?FhbvYrLY9 z?}azB!#o%He(D@T-=3aHyRb^Dvn}DH7d{Qq#3gvlCfnj+exY`n_W29W`9sz01cA9x z50?vpyOare?$|}@1gr)Eg@yb?hnB-n{9AKq6h_g!w3gWZ->x=~__X8lfiDv;JxxNJ z?s(QICaQQou#w!5*-ji5%uU)0Gz-?K9;g_^I0_}az#BL1kZjF^K+kzE2t0i0pYC^$ zfiHB0K%`A3xrP5_h|06WvFU`k83@$ygnJ++;}%laG@K$_vPDDNgrZN_4gu=i*mL4^ z@}}1BHqfH$1g3Tod_hX%A^9@)qtvdhdv-d{v~KJuXBfkau!&jft?fY@M`)Dhv?W&R aQY-&Qyrr)uKj!quoc_?je@_E&sQWk7IFwWX diff --git a/packaging/windows/installer/Generated Images/installer_logo.scale-125.jpg b/packaging/windows/installer/Generated Images/installer_logo.scale-125.jpg deleted file mode 100644 index bb74d9dc55dfdbe0f60aa156d637580495083399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7371 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 YI55Y7IS$NmV2%TG9Qgm?010aS2jkco+W-In diff --git a/packaging/windows/installer/Generated Images/installer_logo.scale-200.jpg b/packaging/windows/installer/Generated Images/installer_logo.scale-200.jpg deleted file mode 100644 index 2a8f1db8c2c8ae1626220c6b99d0e58188da4389..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15187 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 - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PySceneDetect + - \ No newline at end of file + diff --git a/packaging/windows/installer/PySceneDetect.aip b/packaging/windows/installer/PySceneDetect.aip index 0abcfc68..f245c562 100644 --- a/packaging/windows/installer/PySceneDetect.aip +++ b/packaging/windows/installer/PySceneDetect.aip @@ -23,7 +23,7 @@ - + diff --git a/packaging/windows/installer/installer_banner.png b/packaging/windows/installer/installer_banner.png index 3c4b542f8d1f5cf345d3a779c94e00bb45d70a7b..192c26e6d7210e4c473b1bec055c6bbb27786c37 100644 GIT binary patch literal 8303 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 literal 8260 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 ozjXvLo4@?e-?^ - + + id="defs2"> + + + + + + + + + + + + + + + + + + + + - + id="g5" + inkscape:label="logo" + mask="url(#mask-powermask-path-effect8)" + inkscape:path-effect="#path-effect8"> + + + + + + + + + PySceneDetect + diff --git a/packaging/windows/installer/installer_logo.png b/packaging/windows/installer/installer_logo.png index 11eef793072deacbfaacd38c9ccb3c18197221b2..e1d32d2b035d8f8763c99a35d44813f485bb88c4 100644 GIT binary patch literal 6809 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 IDaY&o0}{P+3IG5A diff --git a/packaging/windows/installer/installer_logo.svg b/packaging/windows/installer/installer_logo.svg index e4107d7c..a554ac8d 100644 --- a/packaging/windows/installer/installer_logo.svg +++ b/packaging/windows/installer/installer_logo.svg @@ -1,154 +1,246 @@ - + + id="defs2"> + + + + + + + + + + + + + + + + + + + + - + id="g5" + inkscape:label="logo" + mask="url(#mask-powermask-path-effect8)" + inkscape:path-effect="#path-effect8"> + + + + + + + + + PySceneDetect + diff --git a/packaging/windows/installer/psd_square_small.ico b/packaging/windows/installer/psd_square_small.ico index f42aecb878ba4253ebda03e91f13358b9d1f0c80..bf8cbf10a2938375fc07a27300dd44f57df47bba 100644 GIT binary patch literal 28910 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%0DsthoEYoDtMM%n}KQ1z& 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%0DsthoEYoDtMM%n}KQ1z& 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni diff --git a/pyproject.toml b/pyproject.toml index 22b837e3..ea4fc619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,7 @@ unfixable = [] "benchmark/*" = ["E501"] [tool.pyright] -include = ["scenedetect", "tests"] +include = ["scenedetect", "tests", "scripts", "packaging"] exclude = [ # Pyright built-in defaults "**/node_modules", @@ -141,6 +141,8 @@ exclude = [ "scenedetect/_thirdparty", # Release tests pull in extra deps (opentimelineio, psutil) that aren't in [dev] "tests/release", + # User-local scripts (gitignored) + "scripts/local", ] # Modes: "off" | "basic" | "standard" | "strict". The 0.7 codebase is clean diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py index 083a0a88..3a04f012 100644 --- a/scripts/generate_assets.py +++ b/scripts/generate_assets.py @@ -9,8 +9,22 @@ # PySceneDetect is licensed under the BSD 3-Clause License; see the # included LICENSE file, or visit one of the above pages for details. # -"""Generate pyscenedetect.ico and logo PNGs from SVG sources. Requires Inkscape and Pillow.""" +"""Generate pyscenedetect.ico, logo PNGs, and Windows installer branding from SVG sources. +Outputs: + - icons: packaging/windows/pyscenedetect.ico, docs/_static/favicon.ico, + website/pages/img/favicon.ico + - logos: docs/_static/, website/pages/img/ + - installer: psd_square_small.ico, installer_banner.{svg,png}, installer_logo.{svg,png} and + scale variants for .msi creation + +Usage: + python scripts/generate_assets.py + +Requires Inkscape and Pillow. +""" + +import argparse import contextlib import shutil import subprocess @@ -19,7 +33,7 @@ from pathlib import Path from typing import NamedTuple -from PIL import Image, ImageFilter +from PIL import Image, ImageDraw, ImageFilter class LogoOutput(NamedTuple): @@ -53,6 +67,29 @@ class LogoOutput(NamedTuple): LOGO_SVG = LOGO_DIR / "pyscenedetect-logo.svg" LOGO_BG_SVG = LOGO_DIR / "pyscenedetect-logo-bg.svg" +SLATE_SVG = LOGO_DIR / "pyscenedetect.svg" # slate-only icon (256x256) + +INSTALLER_DIR = PACKAGING_DIR / "windows" / "installer" +GENERATED_IMAGES_DIR = INSTALLER_DIR / "Generated Images" +ARP_ICO_PATH = INSTALLER_DIR / "psd_square_small.ico" + +# Classic AdvancedInstaller theme: brand mark on a colored panel. +# Banner is full-bleed light blue (BG) with the FG-bodied slate on the right; +# dialog is white with a dark (FG) strip on the left holding the inverted +# (BG-bodied) slate. +BANNER_BASE = (493, 58) +DIALOG_BASE = (493, 312) +DIALOG_STRIP_FRAC = 1.0 / 3.0 # left strip width as fraction of dialog width +BANNER_ICON_FRAC = 0.75 # icon side as fraction of banner height +DIALOG_ICON_FRAC = 0.55 # icon side as fraction of dialog strip width +SCALES: list[tuple[float, str]] = [ + (1.00, ""), + (1.25, ".scale-125"), + (1.50, ".scale-150"), + (2.00, ".scale-200"), +] +TOP_LEVEL_BANNER_PNG_SIZE = (1634, 211) +TOP_LEVEL_DIALOG_PNG_SIZE = (647, 407) # Heights match the natural SVG aspect ratio (1024x480). # _small outputs use the -bg variant (background included). @@ -88,6 +125,7 @@ def make_icon_16() -> Image.Image: """Create a hand-crafted 16x16 clapperboard icon.""" img = Image.new("RGBA", (16, 16), FG) px = img.load() + assert px is not None # Clear 1px padding on all sides for i in range(16): @@ -156,6 +194,138 @@ def render_logos(inkscape: str): print(f" Done ({len(LOGO_OUTPUTS)} files).") +def _render_slate(inkscape: str, work_dir: Path, side: int, *, inverted: bool) -> Image.Image: + """Render the slate icon at exact size with Inkscape. + + With inverted=False, the slate renders with its native FG body / BG stripes + (right for placing on the white banner). With inverted=True, the SVG color + codes are swapped before rendering so the body becomes BG and the stripes + FG — needed for the dialog's dark FG strip, where a non-inverted slate + would blend into the background. + """ + if inverted: + sentinel = "__SWAP_FG__" + svg_text = SLATE_SVG.read_text(encoding="utf-8") + svg_text = ( + svg_text.replace("#2a3545", sentinel) + .replace("#e0e8f0", "#2a3545") + .replace(sentinel, "#e0e8f0") + ) + svg_path = work_dir / f"slate_inv_{side}.svg" + svg_path.write_text(svg_text, encoding="utf-8") + else: + svg_path = SLATE_SVG + out = work_dir / f"slate_{'inv_' if inverted else ''}{side}.png" + render_svg(inkscape, svg_path, out, side, side) + return Image.open(out).convert("RGBA") + + +def _save_baseline_jpeg(img: Image.Image, path: Path) -> None: + """Save as baseline (non-progressive) sRGB JPEG. Required by Windows Installer's + dialog renderer; progressive JPEGs decode as solid black at install time.""" + img.convert("RGB").save( + path, "JPEG", quality=92, optimize=True, progressive=False, subsampling=0 + ) + + +def _compose_banner(slate_fg: Image.Image, size: tuple[int, int]) -> Image.Image: + """Banner = full-bleed BG (light blue) canvas with the FG slate on the right.""" + width, height = size + canvas = Image.new("RGBA", size, BG) + pad = max(2, round(height * 0.10)) + icon_x = width - slate_fg.width - pad + icon_y = (height - slate_fg.height) // 2 + canvas.paste(slate_fg, (icon_x, icon_y), slate_fg) + return canvas + + +def _compose_dialog(slate_bg: Image.Image, size: tuple[int, int]) -> Image.Image: + """Dialog = white canvas with FG strip on the left holding a BG-tinted slate.""" + width, height = size + strip_w = round(width * DIALOG_STRIP_FRAC) + canvas = Image.new("RGBA", size, (255, 255, 255, 255)) + draw = ImageDraw.Draw(canvas) + draw.rectangle([(0, 0), (strip_w, height)], fill=FG) + icon_x = (strip_w - slate_bg.width) // 2 + icon_y = round(height * 0.20) + canvas.paste(slate_bg, (icon_x, icon_y), slate_bg) + return canvas + + +def render_installer_jpegs(inkscape: str, work_dir: Path) -> None: + """Render the per-scale baseline JPEGs that ship inside the MSI. + + Outputs `Generated Images/installer_{banner,logo}{,.scale-125,.scale-150,.scale-200}.jpg` + from the master SVG. These are gitignored — pre_release.py --release rebuilds + them before each MSI build, so they always match the current logo without + being re-committed every time. + """ + GENERATED_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + # Render the slate at the exact target size each iteration — sharper than + # rendering once big and downsampling, and avoids Pillow's resize stub mismatch. + for scale, suffix in SCALES: + bw, bh = round(BANNER_BASE[0] * scale), round(BANNER_BASE[1] * scale) + dw, dh = round(DIALOG_BASE[0] * scale), round(DIALOG_BASE[1] * scale) + + # Banner icon sized off height (the limiting dim — banner is wide & short). + # Strip is wider than the icon, so the icon centers within it. + banner_icon_side = round(bh * BANNER_ICON_FRAC) + slate_fg = _render_slate(inkscape, work_dir, banner_icon_side, inverted=False) + + dialog_strip_w = round(dw * DIALOG_STRIP_FRAC) + dialog_icon_side = round(dialog_strip_w * DIALOG_ICON_FRAC) + slate_bg = _render_slate(inkscape, work_dir, dialog_icon_side, inverted=True) + + banner_path = GENERATED_IMAGES_DIR / f"installer_banner{suffix}.jpg" + dialog_path = GENERATED_IMAGES_DIR / f"installer_logo{suffix}.jpg" + print(f" {banner_path.relative_to(REPO_DIR)} ({bw}x{bh})") + _save_baseline_jpeg(_compose_banner(slate_fg, (bw, bh)), banner_path) + print(f" {dialog_path.relative_to(REPO_DIR)} ({dw}x{dh})") + _save_baseline_jpeg(_compose_dialog(slate_bg, (dw, dh)), dialog_path) + + +def render_installer_static(inkscape: str, work_dir: Path) -> None: + """Render the stable, committed installer assets — only re-run when the logo changes. + + Outputs: + - psd_square_small.ico (copy of pyscenedetect.ico) + - installer_banner.png, installer_logo.png (top-level audit masters) + - installer_banner.svg, installer_logo.svg (top-level + Generated Images/, master SVG copies) + """ + GENERATED_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + + top_banner = INSTALLER_DIR / "installer_banner.png" + top_dialog = INSTALLER_DIR / "installer_logo.png" + tbw, tbh = TOP_LEVEL_BANNER_PNG_SIZE + tdw, tdh = TOP_LEVEL_DIALOG_PNG_SIZE + top_slate_fg = _render_slate(inkscape, work_dir, round(tbh * BANNER_ICON_FRAC), inverted=False) + top_dialog_strip = round(tdw * DIALOG_STRIP_FRAC) + top_slate_bg = _render_slate( + inkscape, work_dir, round(top_dialog_strip * DIALOG_ICON_FRAC), inverted=True + ) + print(f" {top_banner.relative_to(REPO_DIR)} ({tbw}x{tbh})") + _compose_banner(top_slate_fg, TOP_LEVEL_BANNER_PNG_SIZE).save(top_banner, "PNG") + print(f" {top_dialog.relative_to(REPO_DIR)} ({tdw}x{tdh})") + _compose_dialog(top_slate_bg, TOP_LEVEL_DIALOG_PNG_SIZE).save(top_dialog, "PNG") + + # SVG references: drop a copy of the master logo+bg SVG at every spot the + # repo previously kept a reference rendering. These aren't read at MSI build + # time (the JPGs are what ship); they exist as audit artifacts. + for dest in ( + INSTALLER_DIR / "installer_banner.svg", + INSTALLER_DIR / "installer_logo.svg", + GENERATED_IMAGES_DIR / "installer_banner.svg", + GENERATED_IMAGES_DIR / "installer_logo.svg", + ): + shutil.copy2(LOGO_BG_SVG, dest) + print(f" {dest.relative_to(REPO_DIR)} <- {LOGO_BG_SVG.name}") + + # ARP product icon: reuse pyscenedetect.ico under the filename the .aip + # references (line 17: ARPPRODUCTICON psd_square_small). + shutil.copy2(ICO_PATH, ARP_ICO_PATH) + print(f" {ARP_ICO_PATH.relative_to(REPO_DIR)} <- {ICO_PATH.name}") + + def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: """Render the SVG at all icon sizes, applying sharpening where configured.""" images = [] @@ -183,7 +353,24 @@ def render_all_sizes(inkscape: str, work_dir: Path) -> list[Image.Image]: def main(): - persist_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else None + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument( + "persist_dir", + nargs="?", + type=Path, + help="Optional directory to persist intermediate PNGs (default: tempdir).", + ) + parser.add_argument( + "--installer-jpegs", + action="store_true", + help=( + "Only regenerate the per-build installer JPGs (Generated Images/*.jpg). " + "Used by pre_release.py --release before the MSI build." + ), + ) + args = parser.parse_args() + + persist_dir = args.persist_dir if persist_dir: persist_dir.mkdir(parents=True, exist_ok=True) print(f"Persisting PNGs to: {persist_dir}") @@ -194,15 +381,24 @@ def main(): ctx = contextlib.nullcontext(str(persist_dir)) if persist_dir else tempfile.TemporaryDirectory() with ctx as work: + if args.installer_jpegs: + print("Rendering installer JPGs...") + render_installer_jpegs(inkscape, Path(work)) + return + images = render_all_sizes(inkscape, Path(work)) images[-1].save(ICO_PATH, format="ICO", append_images=images[:-1]) - print(f"Output ICO: {ICO_PATH}") - print("Copying favicons...") - for dest in FAVICON_OUTPUTS: - shutil.copy2(ICO_PATH, dest) - print(f" {dest.relative_to(REPO_DIR)}") - render_logos(inkscape) + print(f"Output ICO: {ICO_PATH}") + print("Copying favicons...") + for dest in FAVICON_OUTPUTS: + shutil.copy2(ICO_PATH, dest) + print(f" {dest.relative_to(REPO_DIR)}") + render_logos(inkscape) + print("Rendering installer branding (static assets)...") + render_installer_static(inkscape, Path(work)) + print("Rendering installer JPGs...") + render_installer_jpegs(inkscape, Path(work)) if __name__ == "__main__": diff --git a/scripts/pre_release.py b/scripts/pre_release.py index a38a385b..67f58b98 100644 --- a/scripts/pre_release.py +++ b/scripts/pre_release.py @@ -17,7 +17,9 @@ pyinstaller packaging/windows/scenedetect.spec ``` """ + import sys +import tempfile from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent @@ -25,6 +27,7 @@ sys.path.insert(0, str(REPO_DIR)) sys.path.insert(0, str(SCRIPTS_DIR)) +from generate_assets import find_inkscape, render_installer_jpegs # noqa: E402 from update_installer import msi_version # noqa: E402 import scenedetect # noqa: E402 @@ -42,7 +45,7 @@ installer_aip = INSTALLER_AIP.read_text() # The .aip stores the numeric MSI form (e.g. "0.7.0"), not the Python __version__ # (which may be "0.7-dev0", "0.7", "0.7.1", ...). Normalize through the same - # function update_installer.py uses to write the .aip so the comparison is apples-to-apples. + # function update_installer.py uses to write the .aip so the comparison is correct. expected = msi_version(VERSION) aip_row = f'' assert aip_row in installer_aip, ( @@ -50,6 +53,12 @@ f"Run `python scripts/update_installer.py` to refresh the .aip." ) + # Refresh installer JPGs from the master SVG. + print("Regenerating installer JPGs...") + inkscape = find_inkscape() + with tempfile.TemporaryDirectory() as work: + render_installer_jpegs(inkscape, Path(work)) + with VERSION_INFO.open("wb") as f: v = VERSION.split(".") assert 2 <= len(v) <= 4, f"Unrecognized version format: {VERSION}" diff --git a/website/pages/img/favicon.ico b/website/pages/img/favicon.ico index 019c86150820b2975616d560c6cd01bc7cdd0aeb..bf8cbf10a2938375fc07a27300dd44f57df47bba 100644 GIT binary patch literal 28910 zcmcG#WmFwO(*}5P3GVJ1JlGAc!QI^n4i|TKx8Sb9-Q7b7Zo%Dxb8+4Ke(&y{{jq1i zUpwdY%yjomPfb^K)l*e7000yK0f2!4fNT_i49MOcQV0nCTNi`{0MMWyJrw_~!@~mr za&Q0uJNtj@5}yD76-W^g`EPv%6#)2w2LJ>H{ zbh6s)4JZ3U6`a-%7bU=%Gu+f&kr?D70B0^vcE~2=dA%d7F%+mS(g4N$i)4Rh1fd?D zs>7IoAP_5ZzXT!LpkLbB9UfqA$ysBcV>AhQ6hKB&QKDANIQajCuK&yg!T->;?IHLG z0Kmfjm#*Bj2^V!qoY8=`7a_|?aredobIhdBG4n4n7*2b?g@-<~D9eF@bW1g9L84eI zI$@CsvOlqElEXe=Z+?d$!oi9LRn|*eXj{=`j{jMgdH``yMrF!Vw5!{08jyGBFB?mwMSc@%o ztdczFdd}i7M$*5>DK9==P)VkmQ;k8EQ)CP@qr2D1+Iys`&%p83Ew|yC(Z9>{$6yMy zX1^Qv+MG4u{`_=%Y_^Fx?KhzPRJ3Kus^(fBpE_-;+`-x2H+e#f&hu)UEw+K7My3=R zjr7do*ql5H5e8;xz3YRX*J1r|baIVJqgp*tvHVIPK?q1xD{ias zIhV@A_ygHE;Dg@ra>(ZaJGy@urZC8ERUQs|uYJcs-({6@7@5i-IFF>rjbfiVx~ z&`B^s{j)-^i*N}23wt(T^Jz~=hJ>1`y>?W0=Q7~X>-CDK)VV`(W;JW)N4$d}|Lfm4-kM;jrkFrrKHQ%T$|fCxfT$uVkNs+ z5gFiC&V+8B1U8^BsXX%=j_V=?u$C<8BjTo3rh zJ=~I~Elw`R_iVyA@KP2;YY-#r4UJ12nFwZ&n}BUDFO~Nn=N;&=hahWH?Z+T8u0rn0~kt$OlG8?h!15uYB{( zzdrb)Z-E(pq-S_3livjN8bf{lfqY5k6Vnlq;(LFBG{~^KhdstPrN}7&6iKG>y_t=` z`Ck6yYz1hd`zZQwQ9a?x{PT&S2Sreg;kWPN?kI{8pGWEXfe4p~?Z|smCVlPogu2{4o#$D!8 zt-vuM;Hh@>M33ZhyDFPK`x6cuzh~FnZOz3$KT|}3_{$tRxPJ{R=&~vtYKVw!HJ|^_ zy?i)BG*U4}>nhzCX(EtO(D2DQr4NMk2HL!&2I8CL#GqNw!dC>>sFN%0ofZ=^(Z#*AjO zyg#ibLBGo@?|<8;a5QO-yrS)=!*k15r8UVsw$8vUkq6` zPikb@Hhqd>-!LgZH-81CBNdC0R+cd}S-P~{`;O@7BaJd8clT8ye1enw>gLja(zr7} z`f@o6<7vg~r4NfQUYAlmRSvf?Ma{fXO}`sB`4ojBS*On5vj^0+m7&4P@w&s=bViy` zkS!<7#3*wAQi@Ycy!(6?aD5>WzW$Q_%bYH=H&t93L5h(?DhLA@FJ8cn=R|@~iUUz9 z+rv@mUXT6$0@GdN4NF0lKN?$QP6Nj)V$8qjG^XJHGH=95O9O5Eb77<%B?zVf^t&18 zVzIn#Cq)pha`rD)Edy_dr+=nSaJNl$dq;Z;#B-Dx2C7CM6palJ`z&_*PrrJs)iac%N|^v{Pj}hJPigYx-+Svu5-R6JNw+ zdwU%TD2*R#U!KO5(7{J8j#qJx{xP%P^GcMp>9FFcN<}`WyVUhXopuIlU5e=GG4+gZ zG`F;vvOJ24G`3|=O`YfPxe@X1J8k>CJnfhv%n0p&c*TW8>Reu4{^k)|Nk#j!a;~mXH6!%*)KZ`9V(FYeSZxbZuif+(J)hUD4W^G!I#)DN2j>e?)#8DoO4+TjiDx zFiPHJ^Pn!JK&%Y)FIAn~tR>|oF0KHWp+F|eq@XYV{at7dMy6A!4;9AhlWqu7zp08W zCUV%)+h8TE%8Ns+g;$5?DTx)%KxJe<0dx&gu<^*;x9@sN?zWTAVPZkA2ji!`j}xgF=MK26QLJ_oP-jOM7(rfx)=JDPsW|Yy8fS zgji7$W<{@O-ILQ(YhS1T+Vh9oEl>H~_wK{oKa@}FZs}%EOs}%hMA(0y*0OEMwEbu> zk`*@R_wF_QaC&Y*we5JV-bf;9HIf&$>hic~+Ted!fsApM^Zo4eWw?U&=O5Q^$YRo_ z4Gdz^zYp%XMLYC6>=k}z%djysJ5%yc4Gx-u5vh{ccbx8Yqih~9HYMz-xp0hQE4r$( zLqudn1<0ygOvRy=bkkxaZ{{7<82q}emvYE-se-321Zj4!*FFK*g9?>{!$zQ&92CX4 zgEi%6{!XGE1&%mmkyOU>fI;F=(4aN=XCMY@__QuptjK2>IoN^&v^#*?URwo#682g< zcB)p;;}}nUt99>MTU_cCw)&8@lqRXY#f%q*Qew#eC(?f&p&eWnWskPlw^!OptU^3e z#UsPG{lQ@J_9+VH58#2Y>0dv6G+|VtkCue*V0}8v?!X6hygDs?z$qu%|6&FH1D4tU z#|mgcKScllpFaK93Y=&8IHfLojRqX$To(0MNz%dV1k=F(nG6Gn6?}5|axxr6uVA-4 z&lLr7$^!X~)!MViIa)JN>H89nl?0yB4b=K5Y%Fmrp2@7Wb9YiQVwg}PB`$U%j-ZLn z)w;KAJ>*{ACj1Dc31uH`J(kZ4C|LoYcU^+>j(T#Mm|{6xUGM!38Ne8kPl&7}h>2oY z;X4(}D8|E-*x>>0GS{hE!?R`^edVdX{&P7bFpRWm7AA~gjm+__ZJcJ_9C=6i_thM` zM6%|TjEEz2c(PcJxhnzLz~8xvkZ?I zJjgxRgAw#3l(4dl%yK4cJbvReZg;2)OWevmZE0p>T~2ny6Rj$$rm>VNiQ5N)AJRrf z&edPd{0OG>(!x}~S|78QRDgut?&22Imq9;kP)nIhN)tu6h(=VADHS{1x|?FsmUx8T z8wg>8$Z*k5L!3OT|A>xHl}xJESpilx4fe#PII=RW z4x7?mPtLJR(xsjz(@KA}TX3<7NX95p#EWJbgngcjj!#ed1EBX#+RNO@-Q40978abV zn?8*-sQeymbauY-%hzU}`u!U>HJ$CKEu%rLl3r&Py{t^Av)04AZ!(1pZ2Q&vMBOp>b zk>Z<)`5$rSTfP);m9dfTdg+jiS&XR(8z0q@Z+D_)20>~1-Az>MdEnRDgKj6=tZZy{ zebzj7yo7o$UCz0+K$n7TDdQ+Px&2+UAP0jY_!Pwm$|g&{U|d>S^;+g3&1jzspTx~g z8j1|BaTKxuJ8XP>yd5vHYtNkcc(e#m!G~L-^+kRndRMy&?=hqUq-@zjW(L0D6^qo+@ zrW3HkDMc|btl}=cQke~6IlfDqmeSC7D&73y3>dQYu?;_MfQSyQv+{#z0VAW%aJi>h5{X6zrVknd--*hb_+wIMk2W(iPtp}vI0fLk@ z$q6y!C=N>92-u*6qnAG!hVqgYgt$zyjCv}v0(iy3xuXOwd_kT9Q{ROjXuV%lfwduk zMx6wTD;>`jXn`7Q;njG1e&5?-a+3)U-g>JYXw?e++b*Shr>#x|pyy9GM%?&Q<(3`M zxi{-d+tZ3FN2(vPs9Hso^vz4KqJc*UIQvIxD!n<4jTY+ascQP_k(UUty$NVTvz)Wq z&{40zPwBPhvZBE=1Vvt60!d!1-2XMp9JXJp$;L-C*D zBx9p)ux(pz!afg6RHY;iZynJV2R9O@0}KcZjEpP>gEx(;nrZ2rju)lJqT{fl?6pJR zX_OPC>z!n1;ozPCu)3T+hw@}AH>9jte7Mu#$o&J*Awf1OuZ^tOOP;l(+Kb5i&%5m{BDO zcz61=?|xdwN*D{r_Y>}a@Lk&Sz&CpNcWD+|*hW9TnMG8|BM3iO;1;DeJZ3d}9WB?I))xkve(m5pi`dpA>!r_cem%d61Ej!`B`#2H9yi z+56{c^J!L=rR3Hy^~Xj)&IgkOoFpc~S2DwUsWsY)UOJ3Feptw}u?~X$T(_~0uibnN zVHIy9EDy@-`a|Il!+|rFe{+h!x-5Qx+Fr!-8b}Y6RE$#IArX(}bP$Mpa z|FvB2wUh@%sp-`E*Vl8GfS`2LND1cF%fAYKLl9rX>~6VkZ?nsB;o(8x3FKT(- zefNRP7$K{W%l#^YGf#w)o5UI{4pLEm;MhRDGB z*?iKTcWy143Xx0c?O>~+t`KSKq2u`+6e( zikfEhH(RS^8tO}6z#=kQ+gqDaWJ@9rNc25kv5>JbAnohk6LIU_;N|P?amsCAFlkqw#n@E& zwDfK@f1*>s8I_nckA0_+BBWlq2=!Xw9 z_B~XodTo%rDx;f$$6y029YsNEQOA>;yYS2M<;m#TTcCvw7jK`?wH^0t_5(MUie8&z zBM*R5aib~^30v0c_}`1z=uaB5&Za(PlUPcUy{!uBQnZGWqVhU)FVQ#ST^uo#H1*W5M(XDdFzJk@EOG1y(= zEDj=0o{gm5%NQg}qt$tCpn;|j1ePR^r|&R{2LB6&7J>AO{11k%v<7WL!o~&vH-=73 zUCe}F=&t)^K|w*zKUNY;xU*qDs}O%#cv}XZ!DF^`(-6nuCpShq#O~5w>xP}$1gBLI zFQn?n9y^q_axEq67EAyAiQOC__b-xz#nq0r96;umcol#{6Lmz{%x3519Ok?Jx|6=wOrM*|isO zu5OMLxs#?@@Lv4We^ZopiPH__h4RVm`iY0*H+p{Tf3+-NK+Y;FJS8eCOc`H+TwYM+ zLw(BMxzYiqmY*MHN8!?tRKW?wXs?~H-W$Dnb(yaBaI~4ZZYd^^(_nbkmQvGjHK4~y zp3H8YS?r0Lif7&QzZB#$kXq()H7GCaC6iQvk1amsb9}q#d)!ayAqeNNVj%!QeSUg8 ze`uXunU&OfpyRP$hmK~BQq|Hz5FrTn%tMz(84}T0gxb^93{BUnJGgN);Q5If-w6i~ zzJ88>7n;K4Ky@_`pKqQ+Mi3P#+*W45A@JKp`jfZuTPrJmuVE}5Gb$@1T<2b|_SAV! z#_X|8qpzU6yb(1%8ZdO%fB_9)v>2Zk#ZbnM0gaKiIZds6=kRu=j>o15-7Yrh(s6oI zI9u)=S`%Or;|F4UU{DR8Qc}Fg<*JIbe<)$^72g=TG>N}LZc;9g=Ij?W-EVIUfr)+yac355Z zP|+hsDB%5Jxw-yT;AZCSxGWk!)Nb<@kC?~4tjY&F;BhG8XJvHi%>4Y&XxVrdV6EKd6z9nqr%Wps=h11?O7peH`bSDc)F_oz*0(}sy$Cd4JeT=(uonJRCy*kl?bx zgoQe(o@C)Q92+FYPi!(!*Pz6j{k29`<$p(oF8mBE)2V)hxC5}FD-N>}%Cf%GL#<$W zffu%?qwnb{la}Z#ft8iony^DNXeVX{%M-7sKs2~9TCx}pA5CISu0e30z9f}OOcBH1 z*2!TPx|`wTh$vn7Rs#wYag-qnL$mAS-@*h=olHw1E*1MX$hBXDWwb|+b7Wq~zcR$1CgC7baUx|0F^m>U7-p@gnp zwsj^)oVha}x5}rnI|iJb!nT3VPS|vPcigqjEk$NLmbH)<|6wu+AwoYIQ?lDnleJlRLZuuW3r{1Q)b+H_0N;*;NB^vk8Ao8Rh^|8J*Dr< z@H}?DDPjWy?6=WOC}-$@$H2ZMV}(3q<+NXO4-YGvTj>b>#IS4Ca;nH-Lro%6L()5v z0b+>XxEH=Syy6fM{hOHMH_ejI{1TP6k7C)vaR@_U-pVer@tK!&z3JHBoB>k|63MKrOdMZ5&I&gVh+WF+M%>U|jxy)NJOq0GD7>D)|&B3BK3?$jMs$PnB62?On!H@&sxfxgxISa&iG zhlA`@0z1+88K*wcm2P4ZB6uvH`XWwc)HSXyd@jL<*?QXG?3P#m(|gs#ktpP6i#q`M z%ZuOoTv|z~*XN@=AGFXS23chs3m_D_9)OXy!Zn;RW%@8`jBF&D00$Yaq+}t8#mg6t zNmD>vK|qlJ9wK>!<@r5g-Vk>6L`WcvtG%x}cD*`8TIkV+?63VQ#(-b8_=mXwK5`-I2=D7k* z52baO(-0nDrB=rxSVRO2hagfvQY@EN#86=SLup(TBT=?Alh+Y#nXNs|jr3G!YjY&+ z8pErtvUZDX={67b!>C5$wMmE2y5eE~WC4wPhlsrz@+@e@asoR`{{)i=pq$x2H zt$wj9FA*Jw9F(K7XYDbN#OaVLm}@ERvkqcj7EV@ARWV-%Cv~VVy|Q^;zZWKjLDF5as7-@Z!?^+ds#j zCQpzJbI6-Ez>qAvjgvu587m{hMaZ%B1gbk$s*|W|&|*cYqX^|sb6VU;xZAJ&iLY^N zFk90*+icCml_RIlpQbLQf%Kl_G==Zy_$_5x6bST>bP0oz>#*U%SqGx|Mn6O(~1m4crv|C@TLyJfnF`Y>gYVQ{*Qg>U@$}oQ? z?pK4Oi0BGaJ!_?2PODTFVMu6b)((wzG0hnmJy}WXYQCv0`jf~^`~h>wlOjQu1>aH) zzYrc8 z`N8RZn2~16Th2f)Wy?Z5vxh=r%L*?Unj+@zCq;D;hG18|$&i>2vZrpbnquJW7r^Ox&1`8Y$|2JZqFw(*Jj z53^W%Tv=Dnpr+)ij1JoR{75G%YquEqQF-Dk5BBHj=7E)k)t-bGh+{xKt#P<`;Y6 zOYtf3hu9JDE(e&E7rO?6_pZ)qnXh4$k6aQ%(^&~v1yxf2n)BN!~^c$ijJM_`UKu8Zv<)4GK6%=&# zQYKb_wQA24JCV+&4RZ?hsmVF^d%%MnD`uYU*g4^FrCca6L>n8M$Psu)p6DC`4nje` z9PLrCk}gL;jyk$;$Y0fU2D4(kBTP2AU$hfqcdOP z9cusLZb#aCG6uU#3ETC}TQ~nJY?lc0{Un7@ZEes8jPENT0*s@;{l7SNfsp>d|8eYw zgiIy?0K(V*)3M9-%D_43xDQy=FxXh!;M{0J=R|3TO_IdGRZm>u0LA#r;5dl_F(0fJ z_K&~H{Q17%8?_eoH3KXOY5E6 z&ym;xk|(Q`mV=do@#% z29I7|l@NoyT%Hw&ippJ6-6TP~sgfjEcCY66OlgY^&Ud<-x?r>-gZ|k(oW}VmR4XL- zub1mCkMk)XqTpe>?iT5S*M>r+!mWlESdAoijwouAz0E>S?!D$N#9>Dsnim zf!<+vkQ~@aOtK(U!RZ>|Kna1lLp^&krB;Xt_Pd9n^JRt#dGRvDlfjV0)JX#Y&DYVa zRLrXNTf!j&n63~r{e3$~Z#Q7YksLU1Px_G?MuAvi#&_2J`p?e!1DZa0M|Dc`E zP&*Z0Yiu4wqFA1SZ{!>IsgE;Eg#B+hP!sw#Flgr5b=#Sd+3*G{J>Zfb_L=8h8w(>s zku)8W6ebQ%gkT<(v-v>cvM6f;zjvQ`u9dC_IialvOpUl(8Te{s5opqn$4{EfHN_5# zx=Xcw4~V2}EG#GBK*DfQnxevQ8eH7J|8kBtdd<^0`+oi9e2HfOCn^GB?7n)H)4%VL zL%c0SF*k>kUiR&#CkPKJvz90(Dpbx`UnjAtWa`)sCaSFZeD&3MXo;=e_ZcPF1lLhh zv#wW8LnGYpRIj^npYeX_p_eh018IhsH2wTuDE(jvDMCY?dw2z#PTA0(b#YN6mRKmX z0L?~Q+}3`CO98@`J-iNZ`NQ??t7K{_4yBAB(^Q#tz(8yMM!B$Z5qe6!x&Yf9Qw_k!6t6hF3i zGRwiu&Fyl@=Kp(PqECqe<9HF|Z-!M+70*xm=ma?2D?Ra+n>WR(rXSK0Q4#Rs~g7qI@-Bm@xD%TmIs-PA4Kj zy4A8ZePiLaP~#QT*|`X-!7a$=kY$yGm{nUGu70MN3AU|bU}e}&aQVm%4yGcBe}rcx zK$*v~+G&U&H$QBadu-%zoBx(f1Aox*G%=Xo{#NVrYhqkfB{dYuHjA#yZZ{qM*txH+;`Nx$F4-VUBT3Fq}Qrb`x0?;I} zuf=v+jb}Fb<`wl>a_UU~N$1r28>;?$h+HQ45hipHzEJrmr~9%kgn#kU4&ree>zD_#zUmz-B2MRSOvvJ&z#%oFYUwe8pP z^ZUkX*z~A^Nh2}d4%-4`=&D^odT)n($XEoxA$BBKy~Hzjytg~X(Ky0y>~<7uBMyR5 zr4yQ1i(LAwaBX@rHi+5TZ?BK7*%^KtD6j8%I{$~YCoB;vj8uh?;qh0lS&M+BSs8a>Dv0x zsy`>)*84n=Z3eWVC9KU;6H5C)59cP3(~>T`_DDQ?i;M1${osEt6-uh8e6^zPt=Y`{uNJdR z>mU8%z#N#qhq8u-s5Qr~sBFvW!L5)Lkv{y8-IK@KH-E#&F*(rD-kSl0y__b4(#ga0 zn4$}i9R10+_nSnivA@Z4BQj~^~%=KmhpEDqh;jMKY_fub0{e*YHJa;@!s=40(T zGkt%2*}|Gb5gXuv@n~;mL_?G^Il0lOHtb1j+w#X(k^RUEtUWR#fM;Rxmz9h5=~k*& z!iB8Uau*^0M^jlEEJ+;uUy2BMH1M@M#@O`vT&W||@1wo}WL2*VQVd8cUBHXtv)y2kg}5 zuS@%(mj??f0%Vevao{WhU;dR@7g^U>>8484h}qi4Mjgo)bRG(nEi8Sp)Mo5^og^Xi zdqslBMHNfiQ;Je=+2|L%p=c@#Xw!mscrViG*faWwV@@Yd7{h*O>a1;YG!k5%&KuwK zd(m#SqvaTqP+5JAKXauj^NLsV5oBC{#2=S(<><_9E19o< zUbcds&|Y-KGFFsL1?u_=_TyUq5mh7|3yUD9Q&8aVW~T4gC*SjTz)!pWOYA5`S>b@c z=gvO64=-O_=Tw$7G-!*gt!z>`%)UW}7qu9nnaX4j*G4HyBFL)6jIxrMW+7w5F8J9$ z5%9hnCa&~ErT{{w9cXa4oQceH+*K@S6^8e z{5Y#Wxeq}6rJVGcex%?)yEbZmlKoCQz`D~in@+DCE!tQ=ar;z7)sm2##!wEsIeQED zqPl#Zw1vjkJRs@#dmFe=@H-&6pH&>i@oK8HIdj`? z>c>o+kCqNQ_LYUy5`SxK5>VGlS5x80T+Nb|y%GFbgQ3Z-tEZRZPV*u*2g|^E^QB_9 zi;MTg=kC)nvjHY5OC=VX6al{jPB`7w)0EP+cMlT2LKYh7?mo7X`E?V-B6|5O26Y{- zw$d&H2gAZoaUr$BHpuYg8C=m%3HkvQ3JD-_YjdxS&uJ`rGD8C)?E(%s5inf_-(;J7 zE-UvqhRkpFn=tOzge> zll@#ZZ6>QgE0hq6_`3D+0GsgYuV@M*<%3W=K3$w3(BG%;Wu1VkZ=IK2j^Fn?A>i53 zyrc2TQy(N;b;=hP7Z(e3qahYv{cE{Sf`;?`nK#_GbkCkC29ta6uS)UHJXJ}J5ed3H zLA+wMF>FTyLWX2I5gijAuMnkTPi4?tOc6vo8b@AZkE$7Xk#R5nhy_+;B%;k)F(pZ7 zYep6Qb=VgAzItnmRHXvps7#u2Hqdr3llgHXMY?Fl45qx2X!%cPkdn}5P;!B<2g)B2 z)W;n@p%OcG?iLMog*=Hxz_Dk5bQWMFY-Jxep{Sbqn)PpM2(M1Z8E&)-4s=aHIF&^6 zV|?>azt;v;+ol)WCp;l38PG26f-yrVQ8c^b#h$+F`+ht8#Z_7~{-)z{f)N!Ut*g$Ew|JtlnXW6Vrihv0sDzNESAz@d z#E0A1Xl@s1-u58$lSV~dy}va<_;6tsJLARZ0}?P)Co$PZGvZ0Jz?2MT;lFS6p44k! zcd+vDUksepBi-^VI3VWOHaO9QLPqZPZNE12zX!kk?Y8(NWTe;^s~!>5brlTGRR0UG6R4Dyi2gg;mtEIA;7?uu78_~jT>L-C??m(sUtq`=^uJ7 z1WiD`z>weAUk6W>H9|0Gi$*LV&whI*2ku-q5cZQo`AeHw`FmVlUB0ZdHCRCE0qX8AR2L%BMM@I@CUUf(f>DzH zJ;u9FM>S(I<64a!h5CZ6vtwm?|q* z_igQB2*H#tgH#wywgFCFvC<7~*c+|)Uim}8AerOX+ zbQ0zlw)esMQDzM|e^~lT=siauE1P_Ye~g|MbxIs=a&@rj*Sz4UusU+q5IFYYL(Ov) zVrK2V1@V*BFr?k?>}E8T{l0#c;c;ru=-aNtIc(m@{VanO_7!vN+93co&VJucRf%XJ|&13UkR!*FnhWkzn)t3B!4?<{As2T;0tJFy_M$D?O-nPylv87!fE{JOTu>xY(q55o(0 z5~|g}6Vc#S*44H?gCVm%ALzQamkGFp*PGse%*skGZtg;QFJLlFt0U2!H+G(t(r#o? zAiV-e-+}k*Nn~;R__z#iIpIcRl2zqe3b02MI#Pm4o`63mSu|vZOYp+~?HvmdmO#*( ztBy4<`=)7P>y>_asu$wIKA5Ax2FC*La14za=`38d-Y)Via z(|H+iug%~oZh9tFWG6Tb#g4dVnBph)xO$tYlO-r`t}c-RZLYn3P4{yo>IH(lCom@l zE!{3QHip%{wX+?DoAe-X_~+Lv@XFupUF_+{{dW&KUXVP9ns^&L|^%=o8CNsrtSdI(8nP9f*n= z_j)nAwX2%LxTpUWJUXSf*fsa}@9+@1VC34KEK&Kdqx~#G-tFXrc63iGJG))pzTR|_ ze0qzV72eJ8A?pNwD+Pq?_7ANOMF$5Sn`mHDZ5K=zbJ?UXdBgQZ{{V$>-;risfP)Zy z4lvbmGMg=YdQIiN^dagX8t;w;V8)YAX21=(~LpaDbvbj%?tLll5q(kOt&yh}Y8{ zyqsPeOH5CI!s+|y&vTghdtH!_>0TYQVn6scWaw)aSdFqhoQc<SG=>@X;&F=VHBii&=D zx#yM`{_YOp(dkV$r;(JKNC87?kdi5uK#;OFbhub+zP~o$XXWQ4=}^kBmE#&eBcr-H zN8=0ffCvLDB)B~z8qq+pe6jNptzAv5^!KlQKCtEUNw>>Jsl`qM=>Cyo=NCMW0MxiR zf%m`S5YPr;DVG~hG|9)s_UI@bng64xZ|^<`0z_DI-31j#Fg<1N(tmG=01fq-iS9Zirpnqu(bxIwSHt&HrI<%6@KR`Lz^jf~=5x;i))&Wj z7a7|9*aoxq!{|?HY3~ph_G?S1{Ap%r_tVlE60P~;)y_H>Q%C5duVJ1X73CZ4NgSm( za?>?Vg1NNL<)%F(e(`Jy3)~ z#LXTq-9R~hE5AhI(DUgnCBWkJUI{Dh>{jMi`<|wlCVgU}Ymz3?j9V^VKq4u5pBx2- zjCNygqdZq7$6>4t4le#E6#CQ7CL}TC|m+7hR%Dr80Mp$DVp3kVjAGgBAKbfp+0A<$B$1{|JCfwr= zlu$CwN!h1z&yQ*15wD?(%5!Zt(nTT;4h}a!f8ibFkG;n?|6uEAY~(Oi*2shX!~4?{ zb0ax8jL_Cki7E4aw3DqsA?H5}F{F%pkG}i2#B_$9+*d`ktvGs!@6as+ciL# z>Hf`7N6;TLT9NSoXGyu&33Ovx?oYlTl+eX4*b7n~cW=?X{m}}qtD+*RiyuCRH_oN- zj~H#P`=Qu1tT{5I&JKlkMAEtn_M`?-V=vd|g9aZpV#+9FKTB-KLmUDY%v}Z=ph(lR z`(M zp#Ak6t~3U0H1f^$k37o_5{$V zxiT*RI*JTP?7!qrqY)1#0TO{L%WNcPg5ua!hlc$4k>T|3c*pl~;G6l^>opeUctI|f z!M`B0$P5|sO+xJ6zgyiCp^cs&S~!xp zn{|qCQSp#1n@h6t+iexU)6m>5^d-~uG_p9DMqq2bjCWl9I@ebV#}sRntF;Q|cn+G7 z5PoMkJ??^0f@?SOgppZVtepkUX$Oywq2JDJ6@cCmK_2I|RgmWAD`U$q3dk@|taeYjyP|(OV1etK? z8}t5ruU?=h5qboCe5zm<`zC=Smhk_bG=lt*4uAsuPtr)=`KKiS@CEDtN*aClB{^7Z zt6Ry<-B`S^S){GXFrBwEGh-joNM<1!!iI(lJ|RH7gAzmHra_|-K`cz~4uqNyQIPRW zPTV%E=hz6e?>CO7S!?WU{galN`w&92M}>brWuvwF^8EHX(8$G?cKAO1H2w9VgLa7h zn8j#;XSaSWK5biMJWq(A1cl#w-QOC~R<613?St{+VcQ6S=<=RY(#f zaXC=0PdlMn*Ow$hANZ;3TBLxr8t(7P7YnP898y?b<$R5zIs}9^zs=n#beVM{jEVxHGyYd zg*p^DyYm2^w-z>8sz$hWD=~y^Hom>3e8KIeA`Bnky<>sWX|*inq6(sjJ2AY9o(!L_ z*lrwX%U;b84xp4v>lxaGn^304%u(cb?+Pwr(5?}-T7B}C=_b&oEXjF_-(6>JqKg;o~5ta8sZJRi6(c?(u^NR?JN!!-a&{w2) z?-El1y}Ju-qL}NH)z-#y8!fA8Cq@t{7QXNhFv5hW;RlH%>fi70+44FJ|M(F$e=-b7 z=$%h0J~H;v3mCGw7V9+*ym(>3@ZVw=$ zsa%ot))g)-E#gek@V5%4rJn8@$owzoti9LTRW4|lD*rDkI7kYM%oT1?<=aNVK1Y zAEyQ~gI7+?&Q=%TkE4|8OrUNgjQcD@_#OO;40ZtVIw_-J0jd$DXmHQ97S`Gq^vDF) zn1?#?D*QFQqu%}46tG_KC_H6S`rNI?Q7Kslj{r%%-4tHP+&qKXXSy&SY1Dke{QSAQCOWdq$+mc_Y)hHGn|C+%MD};S?JD9p z&AN5#9>5+4;JH8A|s$jvz{U(t`@qSty&>l5G{<$$r*+EFP^8UA2NZ^UE^%Qs_D@ zMD2Uv_$m|OsFpwwgkzoun>$aLAHeC46UhKxmS&(n_6He_XquLIN_CE)x#Ij%sfB%H zXSeC@vWzAzpufre?i*MvNBpIj;ew65eZg6aijCu;2qi6wNl-1M)kjXg;}HtYvw3xT zO7*97c+hmE7^a8Q{fqpi(i>tdSSf&MBjilQPMsO{5M1wfo9K|Tv60JdrM0(r+^dc_ z`|p4uUG3E`q(EroBKKgKaFqrBkq}e`oz@*Ht-pTwOJ5HcU|^-0MXwG2yPk6=)81d` zd7n>G=9#0}M~L@Ybn5Q>zZ&=n3zWsKoGKqp$ZKl$Q@b&!hzHPo|L$*&916O2L(7lf zpj`GBFPc-_n~FH6C5U~AgQldC$tc%->SgNHK?FeogOXQJ==9l@X>$3)X`dCj*d^#yvir$T_K}pmmb;bbvHhBn%ARAFE!U=` zOI`;@$Gs=tbBm+uWGLcWk#V3ba0X=H41zd4DdhZ0za7nydB=snqo1eCm2t^)TR(MI z>oJPv*h_6;6e~%@|2v*fOii`&5dgr=va#Z^`(GtSe~MQD6q-&5K7e@i(AJEt_{3Or znN=rcpDKf29yTktOvsuVak?3M2iMd{Ew4OUXTVwO@&}1`n;UPptQsHVI=*P6S=nAC zn3CJCHb&93BU)9)35byr&~T(_Hr7Iam(ocka=+vsy)Z>rRp$*WEp^eLaU-7?hYNNz z?|pK0YqPLq4B#s)4PUA$Ow@O5|Cw3&#Q5F8R;}N0M2l_7NDxB1q8;7LD(w zISYtIQ2V8QVEm?7jZ#|r&To<1$bZ*GfEiApN(b_n4ouVD(Dzh4W{l}AfI@_ynE7WF zvlV52MMx5B@JlhgI<3YWNDsvm?cx5S`D1=w>#X?--@s>m$X;<9yF0{>j3%HpZf4?% zamt!+Ymcsn`%h(=RnI~$Ay!zjEx=n0QNhg;?QXGi<2H{4fHaAoanF*6O8K5Oulzj2 zYlRsvT*e&o9voN;G8WGBeJ?5^jT803;hG3Q`iRuA;dGyaC?ET%t5wmmC6MDAfLB=9 zhiQq-!Qt=_u&4B}osxV3_hb6hA|?e=G%JhOZuVZ zM!cmgrq^;w&hN8#eC$`j$7`ZI#}x7k3Pj2!xILZcw?uRNUnhKGkK|;T{u$^bottf4 z#y4KqlqEX2u0qR7ho2CqBZ2FFIrj|9fF8!IN{+|x)oBE?D*R+Oe+ucFby%J~_j{-S zR884j_en9${@_c*40Y_5_FP-Q@HxjCD{Oj~#_cRtp=#-Gk^81iM*rSerm+8giKy%I zsXK~0Qg9G!wUaN6#mcWY_7}H;&pi$e8-V?bJO--nU$nIcw2l9MTF95H1(@8Y3*eEq z4>NqPUS$fMExyX}ITDw$Ww&AV@Iqhr-Z$>wW*lK{&TCMVbXs}!-4#dc)M@BAAnh5*;~!aD5`c* zN8Zoze}%uwn;a*}$68*N#E^LENfc$96)+F~`{a&`iW=p$2?(>VuL#^5-YRe_JUkrT zaI>L6s8jwic(lW5%(sFc}}kxpBTNH-Go5-V@{g96k886?DS4?+CgB_i#r_ z=S$O@O8p|3vIbI>@!R{%R1*^Gela9$8@x_0;eNzBdpuU-yeH}ck@Er)dW5!T(d_Ce z=i5&MIRI?)S@D+~6}ZbJZSN;(z%zof>1MkYD$`~2EeI`UkIQmvdYAQd#M;hof7#f$ zE6gfa#+T4uvLCZkGSdxRB)%Q%?%Qj>$uCSQB?U#LNpi{k1iZ9_FDj{??q|zZ-H#o1 zb~ERKi)#u?er&9}atIoDzxYl?r@?RE{cP_GSn|q+vAwaeEZ1dSdmDTDm17x!-v*1* z!&By;cs-6Ro}S_x?sgz^EXuya=g9o8e^t@OlVBUEMB|cQAaPIbt$Nf#4$AB+GfSh0 zN;3FzAn}>Zx?@t%Gs2tPI;v)ZA$oMse*(fOAW1Dbjcqth?&H;GdFK7pcMj*-k;kYt2JH z*L~9;+K2zJ9qR@~y?6A}HlCSZAODeRJPMRZKl`>c>2?Afv_4vP&L!SX*%K5O=eg`3 z@OD_C;bxxZHd!9jZR_{Da&rK$wX&lEGJCkcb&I%2tBSmDMV_$SZ9aB_gpuukQA^T6 zH+r9M^UAX&8(*uvZ;#_N4Q_stiZls%x(hgic*)S_xlXJ#xD2Z>o4qkAh!0=8ZpGh>%NhL)Q<)E#INZ<1=dqEr zL;|w)rwtpbEn99OGaj>SZ7q1mfSs2bh9k>%0DsthoEYoDtMM%n}KQ1z& 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 Xq;A(vpU`ZF3o+hnU`+krX$b!V$Z0ni From b959971178adcf78f6d72545f9ce73ed1f0974ff Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 2 May 2026 16:25:22 -0400 Subject: [PATCH 336/407] [build] Add docs/LATEST_VERSION to avoid needing to make actions updates when new releases happen --- .github/workflows/generate-docs.yml | 8 +++++--- docs/LATEST_VERSION | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/LATEST_VERSION diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 10cefe3f..d5d608a9 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -14,14 +14,16 @@ jobs: update_docs: runs-on: ubuntu-latest env: - # TODO: Figure out a better way to handle figuring out what version /latest should be, - # e.g. add a latest version file in main. - scenedetect_docs_latest: '0.6.7' scenedetect_docs_dest: '' steps: - uses: actions/checkout@v5 + - name: Get Latest Version + run: | + git fetch origin main --depth=1 + echo "scenedetect_docs_latest=$(git show origin/main:docs/LATEST_VERSION | tr -d '[:space:]')" >> "$GITHUB_ENV" + - name: Set up Python 3.12 uses: actions/setup-python@v6 with: diff --git a/docs/LATEST_VERSION b/docs/LATEST_VERSION new file mode 100644 index 00000000..2228cad4 --- /dev/null +++ b/docs/LATEST_VERSION @@ -0,0 +1 @@ +0.6.7 From 4ece0acf886c77d09059c6b49612cb5975df3879 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 2 May 2026 16:25:32 -0400 Subject: [PATCH 337/407] [docs] Update release plan --- RELEASE-PLAN.md | 86 +++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index 2e968bc8..693a0500 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -1,79 +1,65 @@ # PySceneDetect Release Checklist -Use one copy per release (e.g. tick the boxes in a tracking issue or draft PR). -Version referenced below as `X.Y[.Z]` - replace with the real version throughout. +Use one copy per release, copy into a pull request and check each box as steps are completed. +Optional: version referenced below as `X.Y[.Z]` - replace with the real version throughout. -## 0. Branch setup +## 1. Version Identifiers, Branch Prep -- [ ] Create / fast-forward release branch: `releases/X.Y` off `main`. -- [ ] All release-prep commits land on `releases/X.Y` (never directly on `main` during the freeze - commits are usually halted to `main` until the release branch is cut, after which the release branch is merged back into `main` and development resumes). +- [ ] Create / fast-forward release branch: `releases/X.Y` off `main` if major/minor release. If patch release, fast-forward current `releases/X.Y` branch. +- [ ] Bump `__version__` in `scenedetect/__init__.py` +- [ ] Bump `docs/LATEST_VERSION` if needed (stable major/minor releases only) +- [ ] Regular release: No `-dev` suffix or other, pre-release: has suffix `-dev0`, `-dev1`, ... -## 1. Code & version - -- [ ] Bump `__version__` in `scenedetect/__init__.py`. -- [ ] Regular release: No `-dev` suffix or other, pre-release: has `-dev0`, `-dev1`, ... - -## 2. Docs +## 2. Documentation, Website, Changelog - [ ] Docstrings / API docs reflect any signature changes (`cd docs/ && make html` builds clean). - [ ] `docs/api/migration_guide.rst` updated if any public API changed. -- [ ] Docstring examples still run (nothing references removed symbols). - -## 3. Website & changelog +- [ ] Docstring examples all run correctly, nothing references removed or deprecated symbols. +- [ ] Changelog has release notes for major/minor release, all features, breaking changes, bug fixes, and known issues are documented. +- [ ] `website/pages/download.md` updated with the new version / installer link / release date. +- [ ] `website/pages/changelog.md`: move the release changes from the **Development** section at the bottom to the top. +- [ ] `website/pages/index.md`: Latest release version and date updated. -- [ ] `website/pages/changelog.md`: rename the bottom **Development** section to `X.Y (YYYY-MM-DD)` and add a fresh empty **Development** section below it for post-release work. -- [ ] Changelog entry covers: new features, breaking changes, bug fixes, known issues. -- [ ] `website/pages/download.md` updated with the new version / installer link. -- [ ] Any other version-stamped pages updated (`supporting.md`, `cli.md` if commands changed). - -## 4. Tests +## 3. Tests +- [ ] Static analysis passing (ruff + pyright). - [ ] Unit tests green locally and in CI: `pytest -vv` (should collect `-m 'not release'` by default). -- [ ] `ruff check scenedetect/ tests/` and `ruff format --check scenedetect/ tests/` pass. -- [ ] Release test suite green: tag a disposable `vX.Y.Z-release-rc` or use `workflow_dispatch` on `.github/workflows/release-test.yml` - all 4 jobs (`static`, `release-tests`, `install-matrix`, `long-stress`) green across the 3-OS x 2-Python matrix. See `RELEASE-TEST-PLAN.md` for what the suite covers. -- [ ] `resources` branch has the artifacts the release tests need (goldens under `tests/resources/goldens/`, `tests/resources/stress_15min.mp4`). Re-push if any golden was regenerated. -- [ ] Manual smoke: fresh venv, `pip install .` (pulls opencv-python automatically) then `pip install .[pyav]`; run `scenedetect -i
    +## Docker Image   + +Official container images are published at [ghcr.io/breakthrough/pyscenedetect](https://github.com/breakthrough/PySceneDetect/pkgs/container/pyscenedetect). The image includes the full CLI, all optional backends (PyAV, MoviePy), and the external tools used for video splitting (`ffmpeg`, `mkvmerge`) -- no other setup is required: + +```bash +docker pull ghcr.io/breakthrough/pyscenedetect +docker run --rm ghcr.io/breakthrough/pyscenedetect version +``` + +To process videos, mount the folder containing them into the container (the image runs as a non-root user, so output files are written with regular permissions): + +```bash +docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect \ + -i /files/video.mp4 detect-adaptive split-video -o /files +``` + +The `latest` tag (the default when no tag is given) points to the most recent recommended build, and the `main` tag tracks the development branch. Starting with the next release, version tags (e.g. `0.7.1`) will also be published. `podman` can be used in place of `docker` in the commands above. + ## Post Installation After installation, you can call PySceneDetect from any terminal/command prompt by typing `scenedetect` (try running `scenedetect --help`, or `scenedetect version`). If you encounter any runtime errors while running PySceneDetect, ensure that you have all the required dependencies listed in the System Requirements section above (you should be able to `import numpy` and `import cv2`). If you encounter any issues or want to make a feature request, feel free to [report any bugs or share some feature requests/ideas](contributing.md) on the [issue tracker](https://github.com/Breakthrough/PySceneDetect/issues) and help make PySceneDetect even better. From 925bf702342c41d6a975c9e0e78bd1ca3d168111 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 18 Jul 2026 20:42:28 -0400 Subject: [PATCH 391/407] [build] Accept release tags without the -release suffix --- .github/workflows/build-windows.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/check-docs.yml | 2 +- .github/workflows/release-test.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 220b8a11..495d68c3 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -25,7 +25,7 @@ on: - main - 'releases/**' tags: - - v*-release + - 'v*' workflow_dispatch: jobs: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2bedd3a4..2a8ef826 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ on: - main - 'releases/**' tags: - - v*-release + - 'v*' workflow_dispatch: jobs: diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index 78f27de0..199c5ef3 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -17,7 +17,7 @@ on: - main - 'releases/**' tags: - - v*-release + - 'v*' workflow_dispatch: jobs: diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 7333dc0e..fec1c15b 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: tags: - - 'v*-release' + - 'v*' jobs: static: From 3ec99f8fa924a418f47bf511b8c3739e92a4ca21 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 18 Jul 2026 20:47:47 -0400 Subject: [PATCH 392/407] [tests] Fix fanout behavior for testing --- scenedetect/_fan_out.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scenedetect/_fan_out.py b/scenedetect/_fan_out.py index d49d18f5..0d8883a3 100644 --- a/scenedetect/_fan_out.py +++ b/scenedetect/_fan_out.py @@ -139,17 +139,19 @@ def _read_loop(self) -> None: except BaseException as e: self._reader_exc = e finally: - # Sentinel must reach every consumer or its blocking read() deadlocks. - # Drop the oldest frame whenever the queue is full; we are the sole writer, - # so after a successful get_nowait() the queue has room for the EOF. + # Sentinel must reach every consumer or its blocking read() deadlocks. On normal + # EOF the put must respect back-pressure (a full queue still holds undelivered + # frames); only once an abort is in progress may pending frames be dropped to + # force the sentinel through. for q in self._queues: while True: try: - q.put_nowait(_EOF) + q.put(_EOF, timeout=0.1) break except queue.Full: - with contextlib.suppress(queue.Empty): - q.get_nowait() + if self._stop.is_set(): + with contextlib.suppress(queue.Empty): + q.get_nowait() class _FanOutConsumer(VideoStream): From ec3aca09dc8cb693bd09c9252fc346694d59731a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sat, 18 Jul 2026 21:32:23 -0400 Subject: [PATCH 393/407] [docs] Add copy buttons to download snippets --- README.md | 6 ++++ website/mkdocs.yml | 3 ++ website/pages/download.md | 4 +-- website/pages/js/helper.js | 28 +++++++++++++++++++ website/pages/style.css | 57 +++++++++++++++++++++++++++++++++++++- 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 website/pages/js/helper.js diff --git a/README.md b/README.md index e576c390..8d9b2aa8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ Skip the first 10 seconds of the input video: More examples can be found throughout [the documentation](https://www.scenedetect.com/docs/latest/cli.html). +**Quick Start (Docker)**: + +The same commands work without installing anything using [the official Docker image](https://github.com/Breakthrough/PySceneDetect/pkgs/container/pyscenedetect), which includes all dependencies (`ffmpeg`/`mkvmerge` included). Mount the folder containing your videos and use it for input/output paths: + + docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect -i /files/video.mp4 split-video -o /files + **Quick Start (Python API)**: To get started, there is a high level function in the library that performs content-aware scene detection on a video (try it from a Python prompt): diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 508155d3..3c97a978 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -41,3 +41,6 @@ markdown_extensions: [fenced_code] extra_css: - style.css + +extra_javascript: + - js/helper.js diff --git a/website/pages/download.md b/website/pages/download.md index dabd79b9..17b80fd8 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -10,9 +10,9 @@ PySceneDetect requires at least Python 3.10 or higher.

    Standard install (recommended):

    -

    pip install --upgrade scenedetect

    +
    pip install --upgrade scenedetect

    Headless install (servers, no GUI libs):

    -

    pip install --upgrade scenedetect-headless

    +
    pip install --upgrade scenedetect-headless
    PySceneDetect is available via `pip` as three packages: diff --git a/website/pages/js/helper.js b/website/pages/js/helper.js new file mode 100644 index 00000000..72e366b1 --- /dev/null +++ b/website/pages/js/helper.js @@ -0,0 +1,28 @@ +// Adds a copy-to-clipboard button to code blocks (the readthedocs theme has no +// built-in equivalent of mkdocs-material's `content.code.copy` feature). +document.addEventListener("DOMContentLoaded", function () { + var blocks = document.querySelectorAll(".rst-content pre"); + blocks.forEach(function (pre) { + var code = pre.querySelector("code"); + if (!code) { + return; + } + var button = document.createElement("button"); + button.className = "copy-btn"; + button.type = "button"; + button.title = "Copy to clipboard"; + button.setAttribute("aria-label", "Copy to clipboard"); + button.innerHTML = ''; + button.addEventListener("click", function () { + navigator.clipboard.writeText(code.innerText.trim()).then(function () { + button.innerHTML = ''; + button.classList.add("copied"); + setTimeout(function () { + button.innerHTML = ''; + button.classList.remove("copied"); + }, 600); + }); + }); + pre.appendChild(button); + }); +}); diff --git a/website/pages/style.css b/website/pages/style.css index ad683ab9..8c59567a 100644 --- a/website/pages/style.css +++ b/website/pages/style.css @@ -41,4 +41,59 @@ } .bm-t1 { background-color: #6da7ec; } /* F1 >= 80 */ .bm-t2 { background-color: #9ec5f4; } /* F1 60-79 */ -.bm-t3 { background-color: #cde2fb; } /* F1 40-59 */ \ No newline at end of file +.bm-t3 { background-color: #cde2fb; } /* F1 40-59 */ + +/* Copy-to-clipboard button injected into code blocks by js/helper.js. */ +.rst-content pre { + position: relative; +} +.rst-content pre .copy-btn { + position: absolute; + top: 4px; + right: 4px; + padding: 2px 8px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: #9a9a9a; + cursor: pointer; + font-size: 14px; + line-height: 1.5; +} +.rst-content pre:hover .copy-btn, +.rst-content pre .copy-btn:focus { + border-color: #c4c4c4; + background: rgba(255, 255, 255, 0.8); + color: #404040; +} +.rst-content pre .copy-btn.copied, +.rst-content pre .copy-btn.copied:focus { + color: #27ae60; +} + +/* Prominent pip install commands inside the download page "important" divs: + full-width like regular code blocks, but white with larger bold text. */ +.rst-content .important h4:has(+ pre.command) { + margin-bottom: 6px; +} +.rst-content .important pre.command { + margin: 0 0 28px 0; + padding: 6px 42px 6px 12px; /* right padding leaves room for the copy button */ + background: #fff; + border: 1px solid #e1e4e5; +} +.rst-content .important pre.command code { + font-size: 120%; + font-weight: 700; + color: #404040; + background: transparent; + border: none; + padding: 0; +} +.rst-content .important pre.command:last-child { + margin-bottom: 4px; /* tighten space at the bottom of the box */ +} +.rst-content .important pre.command .copy-btn { + top: 50%; + transform: translateY(-50%); +} \ No newline at end of file From fb5d53eedcd56a85c9660e2e980f02fdbacf95ea Mon Sep 17 00:00:00 2001 From: Spacey <85053722+Mubashir78@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:03:08 +0500 Subject: [PATCH 394/407] Remove unused imports and fix re-exports (F401) (#556) * Remove unused imports and fix re-exports (F401) * fix: apply ruff auto-fix for I001 and break long lines (E501) --------- Co-authored-by: Brandon Castellano --- scenedetect/__init__.py | 72 ++++++++++++++++--------------- scenedetect/_cli/__init__.py | 1 - scenedetect/backends/__init__.py | 10 +++-- scenedetect/backends/opencv.py | 1 - scenedetect/detectors/__init__.py | 10 ++--- scenedetect/output/__init__.py | 32 ++++++++++---- scenedetect/platform.py | 1 - 7 files changed, 71 insertions(+), 56 deletions(-) diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index e0977176..1e36405a 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -30,49 +30,51 @@ # Commonly used classes/functions exported under the `scenedetect` namespace for brevity. # Note that order of importants is important! -from scenedetect.platform import init_logger # noqa: I001 +from scenedetect.platform import init_logger as init_logger # noqa: I001 from scenedetect.common import ( - FrameTimecode, - FrameRate, - SceneList, - CutList, - CropRegion, - TimecodePair, - TimecodeLike, - Interpolation, + FrameTimecode as FrameTimecode, + FrameRate as FrameRate, + SceneList as SceneList, + CutList as CutList, + CropRegion as CropRegion, + TimecodePair as TimecodePair, + TimecodeLike as TimecodeLike, + Interpolation as Interpolation, ) -from scenedetect.platform import StrPath -from scenedetect.video_stream import VideoStream, VideoOpenFailure +from scenedetect.platform import StrPath as StrPath +from scenedetect.video_stream import VideoStream as VideoStream +from scenedetect.video_stream import VideoOpenFailure as VideoOpenFailure from scenedetect.output import ( - save_images, - split_video_ffmpeg, - split_video_mkvmerge, - is_ffmpeg_available, - is_mkvmerge_available, - write_scene_list, - write_scene_list_html, - PathFormatter, - VideoMetadata, - SceneMetadata, + save_images as save_images, + split_video_ffmpeg as split_video_ffmpeg, + split_video_mkvmerge as split_video_mkvmerge, + is_ffmpeg_available as is_ffmpeg_available, + is_mkvmerge_available as is_mkvmerge_available, + write_scene_list as write_scene_list, + write_scene_list_html as write_scene_list_html, + PathFormatter as PathFormatter, + VideoMetadata as VideoMetadata, + SceneMetadata as SceneMetadata, ) -from scenedetect.detector import SceneDetector +from scenedetect.detector import SceneDetector as SceneDetector from scenedetect.detectors import ( - ContentDetector, - AdaptiveDetector, - ThresholdDetector, - HistogramDetector, - HashDetector, + ContentDetector as ContentDetector, + AdaptiveDetector as AdaptiveDetector, + ThresholdDetector as ThresholdDetector, + HistogramDetector as HistogramDetector, + HashDetector as HashDetector, ) from scenedetect.backends import ( - AVAILABLE_BACKENDS, - VideoStreamCv2, - VideoStreamAv, - VideoStreamMoviePy, - VideoCaptureAdapter, - VideoStreamConcat, - SourceSpan, + AVAILABLE_BACKENDS as AVAILABLE_BACKENDS, + VideoStreamCv2 as VideoStreamCv2, + VideoStreamAv as VideoStreamAv, + VideoStreamMoviePy as VideoStreamMoviePy, + VideoCaptureAdapter as VideoCaptureAdapter, + VideoStreamConcat as VideoStreamConcat, + SourceSpan as SourceSpan, ) -from scenedetect.stats_manager import StatsManager, StatsFileCorrupt +from scenedetect.stats_manager import StatsManager as StatsManager +from scenedetect.stats_manager import StatsFileCorrupt as StatsFileCorrupt from scenedetect.scene_manager import SceneManager # Used for module identification and when printing version & about info diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 6062b1b1..dd21768c 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -22,7 +22,6 @@ import logging import os import os.path -import typing as ty from copy import copy import click diff --git a/scenedetect/backends/__init__.py b/scenedetect/backends/__init__.py index 3af38bf9..bad4b972 100644 --- a/scenedetect/backends/__init__.py +++ b/scenedetect/backends/__init__.py @@ -92,16 +92,18 @@ # - Nvidia VPF: https://developer.nvidia.com/blog/vpf-hardware-accelerated-video-processing-framework-in-python/ # OpenCV must be available at minimum. -from scenedetect.backends.concat import SourceSpan, VideoStreamConcat -from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 +from scenedetect.backends.concat import SourceSpan as SourceSpan +from scenedetect.backends.concat import VideoStreamConcat as VideoStreamConcat +from scenedetect.backends.opencv import VideoCaptureAdapter as VideoCaptureAdapter +from scenedetect.backends.opencv import VideoStreamCv2 as VideoStreamCv2 try: - from scenedetect.backends.pyav import VideoStreamAv + from scenedetect.backends.pyav import VideoStreamAv as VideoStreamAv except ImportError: VideoStreamAv = None try: - from scenedetect.backends.moviepy import VideoStreamMoviePy + from scenedetect.backends.moviepy import VideoStreamMoviePy as VideoStreamMoviePy except ImportError: VideoStreamMoviePy = None diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index d7bc9a29..12294664 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -20,7 +20,6 @@ import math import os import os.path -import typing as ty import warnings from fractions import Fraction from logging import getLogger diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 565ac354..16238025 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -35,11 +35,11 @@ processing videos, however they can also be used to process frames directly. """ -from scenedetect.detectors.content_detector import ContentDetector # noqa: I001 -from scenedetect.detectors.threshold_detector import ThresholdDetector -from scenedetect.detectors.adaptive_detector import AdaptiveDetector -from scenedetect.detectors.hash_detector import HashDetector -from scenedetect.detectors.histogram_detector import HistogramDetector +from scenedetect.detectors.content_detector import ContentDetector as ContentDetector # noqa: I001 +from scenedetect.detectors.threshold_detector import ThresholdDetector as ThresholdDetector +from scenedetect.detectors.adaptive_detector import AdaptiveDetector as AdaptiveDetector +from scenedetect.detectors.hash_detector import HashDetector as HashDetector +from scenedetect.detectors.histogram_detector import HistogramDetector as HistogramDetector # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index eef360af..6fa26585 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -39,16 +39,30 @@ ) # Commonly used classes/functions exported under the `scenedetect.output` namespace for brevity. -from scenedetect.output.image import save_images +from scenedetect.output.image import save_images as save_images from scenedetect.output.video import ( - PathFormatter, - SceneMetadata, - VideoMetadata, - default_formatter, - is_ffmpeg_available, - is_mkvmerge_available, - split_video_ffmpeg, - split_video_mkvmerge, + PathFormatter as PathFormatter, +) +from scenedetect.output.video import ( + SceneMetadata as SceneMetadata, +) +from scenedetect.output.video import ( + VideoMetadata as VideoMetadata, +) +from scenedetect.output.video import ( + default_formatter as default_formatter, +) +from scenedetect.output.video import ( + is_ffmpeg_available as is_ffmpeg_available, +) +from scenedetect.output.video import ( + is_mkvmerge_available as is_mkvmerge_available, +) +from scenedetect.output.video import ( + split_video_ffmpeg as split_video_ffmpeg, +) +from scenedetect.output.video import ( + split_video_mkvmerge as split_video_mkvmerge, ) logger = logging.getLogger("pyscenedetect") diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 3ee5f2d4..536638fe 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -24,7 +24,6 @@ import string import subprocess import sys -import typing as ty import cv2 From 365d9786cfcc48265f4b78e84c89d9719b0c6fc8 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 21 Jul 2026 20:50:54 -0400 Subject: [PATCH 395/407] [build] Add release orchestrator and Windows installer test workflows --- .github/workflows/docker-publish.yml | 4 +- .github/workflows/release-test.yml | 6 +- .github/workflows/release.yml | 202 +++++++++ .github/workflows/test-installer.yml | 654 +++++++++++++++++++++++++++ RELEASE-PLAN.md | 14 +- 5 files changed, 869 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test-installer.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c2adeab6..0ce991b7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,6 +1,8 @@ # Build and publish PySceneDetect Docker image to GitHub Container Registry (GHCR). name: Publish Docker Image +# Publishing a release build is driven by release.yml, which dispatches this workflow after +# artifact verification passes. on: workflow_dispatch: inputs: @@ -10,8 +12,6 @@ on: default: false push: branches: [ "main" ] - release: - types: [published] env: REGISTRY: ghcr.io diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index fec1c15b..4a0cc9b4 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -37,8 +37,10 @@ jobs: # so only enforce the heading on stable releases. if [[ "$TAG_VERSION" == *-dev* ]]; then echo "Pre-release ($TAG_VERSION); skipping changelog heading check." - elif ! grep -q "^## PySceneDetect $TAG_VERSION" website/pages/changelog.md; then - echo "Changelog is missing a '## PySceneDetect $TAG_VERSION' heading" + # Major/minor releases use a '## PySceneDetect X.Y' heading; patch + # releases nest under it as '### PySceneDetect X.Y.Z (date)'. + elif ! grep -Eq "^#{2,3} (PySceneDetect )?$TAG_VERSION( |$)" website/pages/changelog.md; then + echo "Changelog is missing a heading for $TAG_VERSION (e.g. '### PySceneDetect $TAG_VERSION (...)')" exit 1 fi fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8751f890 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,202 @@ +# Release orchestrator: verifies the artifacts attached to a published GitHub +# release actually work, then publishes them stage by stage, verifying each +# stage before starting the next: +# +# MSI install/upgrade test (test-installer.yml) +# -> TestPyPI publish -> pip smoke install from TestPyPI +# -> PyPI publish -> pip smoke install from PyPI +# -> Docker publish -> docker pull + smoke run from GHCR +# +# Publishing is intentionally NOT triggered automatically by the release event - +# this workflow is dispatched manually once the GitHub release is published, and +# the publish steps only run after every verification step succeeds. Each stage +# is driven through the existing workflows via `gh workflow run` (rather than +# workflow_call) so they keep working standalone and the PyPI trusted-publisher +# configuration (which is bound to publish-pypi.yml as the top-level workflow) +# is unaffected. + +name: Release Orchestrator + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to verify and publish (e.g. v0.7.1)' + required: true + verify-only: + description: 'Stop after verification (no PyPI/Docker publish)' + type: boolean + default: false + +permissions: + contents: read + actions: write # `gh workflow run` on the workflows this one orchestrates + +# The run-id lookup after each dispatch assumes this is the only orchestrator +# running; never allow two concurrent releases. +concurrency: + group: release-orchestrator + +jobs: + orchestrate: + name: ${{ inputs.verify-only && 'Verify' || 'Verify + Publish' }} ${{ inputs.tag }} + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + steps: + - name: Validate release + run: | + set -euo pipefail + state=$(gh release view "$TAG" --json isDraft,isPrerelease \ + -q 'if .isDraft then "draft" elif .isPrerelease then "prerelease" else "published" end') + if [[ "$state" == "draft" ]]; then + echo "::error::Release $TAG is still a draft; publish it before running the orchestrator." + exit 1 + fi + echo "Release $TAG is $state." + # Display version used by the pip smoke test; mirrors the tag + # normalization in release-test.yml (both vX.Y[.Z] and the legacy + # vX.Y[.Z]-release tag styles are accepted). + VERSION="${TAG#v}" + VERSION="${VERSION%-release}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Write dispatch helper + # `gh workflow run` returns no run id, so the helper polls for the + # newest workflow_dispatch run created at/after dispatch time (the + # concurrency group above guarantees it is ours), then watches it to + # completion, propagating failure. + run: | + cat > "$RUNNER_TEMP/dispatch.sh" <<'EOF' + dispatch_and_watch() { + local workflow="$1" ref="$2" + shift 2 + local start_epoch + start_epoch=$(date -u +%s) + echo "::group::Dispatch $workflow (ref $ref) $*" + gh workflow run "$workflow" --ref "$ref" "$@" + local run_id="" + for _ in $(seq 1 24); do + sleep 5 + run_id=$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 5 \ + --json databaseId,createdAt \ + -q "[.[] | select((.createdAt | fromdateiso8601) >= $((start_epoch - 5)))] | first | .databaseId // \"\"") + [[ -n "$run_id" ]] && break + done + if [[ -z "$run_id" ]]; then + echo "::error::Dispatched $workflow but its run never appeared." + return 1 + fi + echo "Watching run $run_id: https://github.com/$GH_REPO/actions/runs/$run_id" + echo "::endgroup::" + gh run watch "$run_id" --exit-status --interval 30 + } + EOF + + - name: Write pip smoke-install helper + # Same smoke test for TestPyPI and production PyPI: fresh venv, install + # the exact release version, and check `scenedetect version` reports it. + # Both indexes can lag a fresh upload, so installs are retried briefly. + run: | + cat > "$RUNNER_TEMP/smoke.sh" <<'EOF' + pip_smoke_install() { + local venv="$1" + shift + python3 -m venv "$venv" + local ok=0 + for attempt in 1 2 3 4 5; do + if "$venv/bin/pip" install --quiet "$@" "scenedetect==$VERSION"; then + ok=1 + break + fi + echo "pip install attempt $attempt failed; retrying in 30s..." + sleep 30 + done + [[ "$ok" -eq 1 ]] + local out + out=$("$venv/bin/scenedetect" version) + echo "$out" + grep -F "$VERSION" <<<"$out" + } + EOF + + - name: 'Stage 1 - Verify: Windows installer (install + upgrade on clean runner)' + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + dispatch_and_watch test-installer.yml "$GITHUB_REF_NAME" -f "tag=$TAG" + + - name: 'Stage 2 - Publish: TestPyPI' + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=testpypi" + + - name: 'Stage 2 - Verify: pip install from TestPyPI' + run: | + set -euo pipefail + source "$RUNNER_TEMP/smoke.sh" + # Dependencies are not mirrored on TestPyPI, so resolve them from the + # production index. + pip_smoke_install smoke-testpypi \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ + + - name: 'Stage 3 - Publish: PyPI (production)' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + # publish-pypi.yml additionally gates production publishes on the + # build + release-test workflows being green for the tag. + dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=pypi" + + - name: 'Stage 3 - Verify: pip install from PyPI' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + source "$RUNNER_TEMP/smoke.sh" + pip_smoke_install smoke-pypi + + - name: 'Stage 4 - Publish: Docker image (version tags + latest)' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + source "$RUNNER_TEMP/dispatch.sh" + # Dispatched on the release tag itself so docker/metadata-action + # derives the semver image tags from it (requires the tag to contain + # docker-publish.yml, i.e. v0.7.1 or newer). + dispatch_and_watch docker-publish.yml "$TAG" -f "tag_latest=true" + + - name: 'Stage 4 - Verify: docker pull + smoke run from GHCR' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + image="ghcr.io/breakthrough/pyscenedetect" + docker pull "$image:$VERSION" + docker pull "$image:latest" + # `latest` must point at the build we just published. + v=$(docker image inspect "$image:$VERSION" --format '{{.Id}}') + l=$(docker image inspect "$image:latest" --format '{{.Id}}') + if [[ "$v" != "$l" ]]; then + echo "::error::latest ($l) does not match $VERSION ($v)" + exit 1 + fi + out=$(docker run --rm "$image:$VERSION" version) + echo "$out" + grep -F "$VERSION" <<<"$out" + + - name: Summary + run: | + if [[ "${{ inputs.verify-only }}" == "true" ]]; then + echo "Verification of $TAG passed. Re-run without verify-only to publish." + else + echo "Release $TAG verified and published:" + echo " https://pypi.org/project/scenedetect/$VERSION/" + echo " https://pypi.org/project/scenedetect-headless/$VERSION/" + echo " https://pypi.org/project/scenedetect-core/$VERSION/" + echo " https://github.com/$GH_REPO/pkgs/container/pyscenedetect" + fi diff --git a/.github/workflows/test-installer.yml b/.github/workflows/test-installer.yml new file mode 100644 index 00000000..d6795d2d --- /dev/null +++ b/.github/workflows/test-installer.yml @@ -0,0 +1,654 @@ +# Post-release verification of the signed Windows MSI installer. +# +# Downloads the MSI attached to a published release, then on a clean +# windows-latest runner: verifies checksum + Authenticode signature, performs a +# silent install, checks the Apps & Features registration / PATH / smoke-runs +# the CLI, and uninstalls cleanly. A second job installs the previous release's +# MSI first and upgrades over it to catch duplicate-entry and leftover-file +# regressions. + +name: Windows Installer Test + +# Dispatched by release.yml (the release orchestrator) as part of post-release +# artifact verification; can also be run manually against any published release. +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to test (e.g. v0.7-release or v0.7.1)' + required: true + previous-tag: + description: 'Release tag to upgrade from (default: auto-detect previous release)' + required: false + +permissions: + contents: read + +jobs: + resolve: + name: Resolve Release Tags + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + msi-version: ${{ steps.resolve.outputs.msi-version }} + prev-tag: ${{ steps.resolve.outputs.prev-tag }} + prev-msi-version: ${{ steps.resolve.outputs.prev-msi-version }} + steps: + - name: Resolve current and previous release + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_PREV_TAG: ${{ inputs.previous-tag }} + run: | + set -euo pipefail + TAG="$INPUT_TAG" + if [[ -z "$TAG" ]]; then + echo "::error::No tag to test: the 'tag' input is required." + exit 1 + fi + # Both tag styles are in use (v0.7-release and v0.7.1); mirror the + # normalization release-test.yml applies to pushed tags. + VERSION="${TAG#v}" + VERSION="${VERSION%-release}" + # Pad to the numeric X.Y.Z the installer stamps into the MSI + # ProductVersion / DisplayVersion; mirrors msi_version() in + # scripts/_release_common.py ("0.7" -> "0.7.0"). + IFS=. read -r a b c _ <<<"$VERSION" + MSI_VERSION="${a}.${b:-0}.${c:-0}" + + if [[ -n "$INPUT_PREV_TAG" ]]; then + PREV_TAG="$INPUT_PREV_TAG" + else + # Newest-first list of published stable releases; the previous + # release is the entry right after the current tag. + mapfile -t tags < <(gh release list --exclude-drafts --exclude-pre-releases \ + --limit 30 --json tagName -q '.[].tagName') + PREV_TAG="" + found=0 + for i in "${!tags[@]}"; do + if [[ "${tags[$i]}" == "$TAG" ]]; then + found=1 + PREV_TAG="${tags[$((i + 1))]:-}" + break + fi + done + if [[ "$found" -eq 0 ]]; then + # Manual dispatch before the release is published: the current + # tag is not listed yet, so upgrade from the newest listed tag + # that differs from it. + for t in "${tags[@]}"; do + if [[ "$t" != "$TAG" ]]; then + PREV_TAG="$t" + break + fi + done + fi + fi + if [[ -z "$PREV_TAG" ]]; then + echo "::error::Could not determine a previous release to upgrade from; pass the 'previous-tag' input." + exit 1 + fi + PREV_VERSION="${PREV_TAG#v}" + PREV_VERSION="${PREV_VERSION%-release}" + IFS=. read -r a b c _ <<<"$PREV_VERSION" + PREV_MSI_VERSION="${a}.${b:-0}.${c:-0}" + + { + echo "tag=$TAG" + echo "version=$VERSION" + echo "msi-version=$MSI_VERSION" + echo "prev-tag=$PREV_TAG" + echo "prev-msi-version=$PREV_MSI_VERSION" + } >> "$GITHUB_OUTPUT" + echo "Testing $TAG (display $VERSION, MSI $MSI_VERSION); upgrading from $PREV_TAG (MSI $PREV_MSI_VERSION)" + + fresh-install: + name: Fresh Install + runs-on: windows-latest + needs: resolve + env: + TAG: ${{ needs.resolve.outputs.tag }} + VERSION: ${{ needs.resolve.outputs.version }} + MSI_VERSION: ${{ needs.resolve.outputs.msi-version }} + steps: + # Test videos live on the resources branch; this is the same layout + # build-windows.yml smoke-tests the portable distribution against. + - uses: actions/checkout@v5 + with: + ref: resources + + - name: Write helper functions + # Each `run:` step is a fresh pwsh process, so shared functions go into + # a file (in RUNNER_TEMP, which checkout cannot clean away) that later + # steps dot-source. + run: | + $helpers = @' + # All hives an uninstall entry could land in. The MSI installs + # per-machine (the .aip sets ALLUSERS=2 and the runner is elevated), + # so 64-bit HKLM is the expected home; the others are scanned so a + # misplaced entry fails the assertions loudly instead of hiding. + $UninstallRoots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + ) + + function Get-PySceneDetectEntries { + # Every uninstall entry (any hive, visible or hidden) for + # PySceneDetect. A healthy install has exactly two: the MSI + # ProductCode key (hidden from Apps & Features by + # ARPSYSTEMCOMPONENT=1, set in PySceneDetect.aip) and the visible + # custom key "PySceneDetect " that carries + # DisplayVersion / InstallLocation. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + foreach ($key in Get-ChildItem $root) { + $props = Get-ItemProperty $key.PSPath -ErrorAction SilentlyContinue + if ($props.DisplayName -like 'PySceneDetect*') { + [pscustomobject]@{ + KeyPath = $key.PSPath + KeyName = $key.PSChildName + DisplayName = $props.DisplayName + DisplayVersion = $props.DisplayVersion + InstallLocation = $props.InstallLocation + SystemComponent = $props.SystemComponent + } + } + } + } + } + + function Get-VisiblePySceneDetectEntries { + # The set Apps & Features actually shows: SystemComponent != 1. + Get-PySceneDetectEntries | Where-Object { $_.SystemComponent -ne 1 } + } + + function Get-UninstallKeyByName { + # The visible key's name embeds the MSI version ("PySceneDetect + # 0.7.0"), so a lookup by name across hives is a precise + # per-version existence check. + param([Parameter(Mandatory)][string]$KeyName) + foreach ($root in $UninstallRoots) { + $path = Join-Path $root $KeyName + if (Test-Path $path) { $path } + } + } + + function Invoke-Msiexec { + # msiexec detaches from the console immediately, so a bare + # `msiexec ...` would return before the Windows Installer service + # finishes (and without the real exit code); Start-Process + # -Wait -PassThru blocks and surfaces it. + param( + [Parameter(Mandatory)][string[]]$MsiArgs, + [Parameter(Mandatory)][string]$LogPath + ) + $log = Join-Path (Get-Location) $LogPath + for ($attempt = 1; $attempt -le 3; $attempt++) { + $p = Start-Process msiexec.exe -ArgumentList ($MsiArgs + @('/L*v', $log)) -Wait -PassThru + switch ($p.ExitCode) { + 0 { Write-Host "msiexec $($MsiArgs -join ' ') succeeded (exit 0)"; return } + 3010 { Write-Host 'msiexec exit 3010 (success, reboot required) - treated as success'; return } + 1618 { + # ERROR_INSTALL_ALREADY_RUNNING: runner provisioning + # sometimes still holds the machine-wide MSI mutex. + Write-Host "msiexec exit 1618 (another install in progress), attempt $attempt of 3" + if ($attempt -lt 3) { Start-Sleep -Seconds 30 } + } + default { throw "msiexec $($MsiArgs -join ' ') failed with exit code $($p.ExitCode); see $LogPath" } + } + } + throw 'msiexec still blocked by another installation (exit 1618) after 3 attempts' + } + + function Get-PathRegistryValues { + # The installer edits PATH in the registry only; neither this + # process nor its children see the change, so assertions must + # read the raw values. Machine PATH is where a per-machine + # install writes (the .aip Environment row uses the '*' system + # prefix); HKCU is read too for completeness. + $values = @() + $machine = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path -ErrorAction SilentlyContinue + if ($machine) { $values += $machine.Path } + $user = Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue + if ($user) { $values += $user.Path } + $values + } + + function Test-DirOnRegistryPath { + param([Parameter(Mandatory)][string]$Directory) + $needle = $Directory.TrimEnd('\') + foreach ($value in Get-PathRegistryValues) { + # -contains is case-insensitive, matching how Windows treats paths. + if (@($value -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $needle) { + return $true + } + } + return $false + } + + function Assert-CleanRemoval { + param([string]$InstallDir) + $entries = @(Get-PySceneDetectEntries) + if ($entries.Count -ne 0) { + $entries | Format-List | Out-String | Write-Host + throw "Expected zero uninstall entries after uninstall, found $($entries.Count)" + } + # Also match stale keys by name in case a leftover key lost its + # DisplayName value. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + $stale = @(Get-ChildItem $root | Where-Object { $_.PSChildName -like 'PySceneDetect*' }) + if ($stale.Count -ne 0) { + throw "Stale uninstall keys remain under ${root}: $($stale.PSChildName -join ', ')" + } + } + if ($InstallDir) { + if (Test-Path (Join-Path $InstallDir 'scenedetect.exe')) { + throw "scenedetect.exe still present in $InstallDir after uninstall" + } + if (Test-DirOnRegistryPath -Directory $InstallDir) { + throw "$InstallDir still present in a PATH registry value after uninstall" + } + } + Write-Host 'Verified clean removal.' + } + '@ + $dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1' + Set-Content -LiteralPath $dest -Value $helpers + Write-Host "Wrote $dest" + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release download $env:TAG --pattern 'PySceneDetect-*-win64.msi' --pattern 'SHA256SUMS' --dir dist + if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:TAG" } + $expected = "PySceneDetect-$env:VERSION-win64.msi" + $msis = @(Get-ChildItem dist -Filter '*.msi') + if ($msis.Count -ne 1 -or $msis[0].Name -ne $expected) { + throw "Expected exactly one MSI named $expected, got: $($msis.Name -join ', ')" + } + if (-not (Test-Path dist\SHA256SUMS)) { throw 'SHA256SUMS missing from release assets' } + + - name: Verify checksum and Authenticode signature + run: | + $msi = "dist\PySceneDetect-$env:VERSION-win64.msi" + $name = Split-Path $msi -Leaf + # SHA256SUMS lines are ` ` (two spaces; the sha256sum -c + # format written by scripts/finalize_windows_dist.py). + $sums = @{} + foreach ($line in Get-Content dist\SHA256SUMS) { + $hash, $entry = $line -split ' ', 2 + $sums[$entry.Trim()] = $hash.Trim() + } + if (-not $sums.ContainsKey($name)) { throw "SHA256SUMS has no entry for $name" } + $actual = (Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $sums[$name].ToLowerInvariant()) { + throw "SHA256 mismatch for ${name}: expected $($sums[$name]), got $actual" + } + Write-Host "Checksum OK: $actual" + $sig = Get-AuthenticodeSignature $msi + if ($sig.Status -ne 'Valid') { + throw "Authenticode signature status is '$($sig.Status)' (expected 'Valid')" + } + Write-Host "Signature OK: $($sig.SignerCertificate.Subject)" + + - name: Install MSI + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/i', $msi, '/qn', '/norestart') -LogPath install.log + + - name: Verify installation + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + # Locate the install via the registry - never hard-code the path: the + # .aip uses ALLUSERS=2 with a MixedAllUsersInstallLocation custom + # action, so APPDIR is resolved at install time. + $visible = @(Get-VisiblePySceneDetectEntries) + if ($visible.Count -ne 1) { + Get-PySceneDetectEntries | Format-List | Out-String | Write-Host + throw "Expected exactly 1 visible uninstall entry, found $($visible.Count)" + } + $entry = $visible[0] + if ($entry.DisplayVersion -ne $env:MSI_VERSION) { + throw "DisplayVersion is '$($entry.DisplayVersion)', expected '$env:MSI_VERSION'" + } + if (-not $entry.InstallLocation) { throw 'InstallLocation is empty' } + $exe = Join-Path $entry.InstallLocation 'scenedetect.exe' + if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($entry.InstallLocation)" } + # Persist the discovered install dir for the later steps. + Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)" + # Invoke by absolute path: the installer's PATH change is + # registry-only and not visible to this already-running process. + $out = & $exe version 2>&1 | Out-String + Write-Host $out + if ($LASTEXITCODE -ne 0) { throw "scenedetect version exited with $LASTEXITCODE" } + if (-not $out.Contains($env:VERSION)) { throw "version output does not mention $env:VERSION" } + if (-not (Test-DirOnRegistryPath -Directory $entry.InstallLocation)) { + throw "$($entry.InstallLocation) was not added to any PATH registry value" + } + Write-Host 'Install verified.' + + - name: Functional smoke test + run: | + # Same clip and command build-windows.yml uses to smoke-test the + # portable distribution. + $exe = Join-Path $env:INSTALL_DIR 'scenedetect.exe' + & $exe -i tests/resources/goldeneye.mp4 detect-content time --end 10s + if ($LASTEXITCODE -ne 0) { throw "smoke test exited with $LASTEXITCODE" } + + - name: Uninstall MSI + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + # Uninstall via the same local MSI file (not the ProductCode) so a + # broken product registration surfaces as a failure here. + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/x', $msi, '/qn', '/norestart') -LogPath uninstall.log + + - name: Verify clean removal + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + Assert-CleanRemoval -InstallDir $env:INSTALL_DIR + + - name: Upload msiexec logs + if: failure() + uses: actions/upload-artifact@v6 + with: + name: fresh-install-logs + path: '*.log' + if-no-files-found: ignore + + upgrade: + name: Upgrade From Previous Release + runs-on: windows-latest + needs: resolve + env: + TAG: ${{ needs.resolve.outputs.tag }} + VERSION: ${{ needs.resolve.outputs.version }} + MSI_VERSION: ${{ needs.resolve.outputs.msi-version }} + PREV_TAG: ${{ needs.resolve.outputs.prev-tag }} + PREV_MSI_VERSION: ${{ needs.resolve.outputs.prev-msi-version }} + steps: + - name: Write helper functions + # Identical to the fresh-install helpers; jobs cannot share script + # blocks, so the definitions are duplicated per job. + run: | + $helpers = @' + # All hives an uninstall entry could land in. The MSI installs + # per-machine (the .aip sets ALLUSERS=2 and the runner is elevated), + # so 64-bit HKLM is the expected home; the others are scanned so a + # misplaced entry fails the assertions loudly instead of hiding. + $UninstallRoots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + ) + + function Get-PySceneDetectEntries { + # Every uninstall entry (any hive, visible or hidden) for + # PySceneDetect. A healthy install has exactly two: the MSI + # ProductCode key (hidden from Apps & Features by + # ARPSYSTEMCOMPONENT=1, set in PySceneDetect.aip) and the visible + # custom key "PySceneDetect " that carries + # DisplayVersion / InstallLocation. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + foreach ($key in Get-ChildItem $root) { + $props = Get-ItemProperty $key.PSPath -ErrorAction SilentlyContinue + if ($props.DisplayName -like 'PySceneDetect*') { + [pscustomobject]@{ + KeyPath = $key.PSPath + KeyName = $key.PSChildName + DisplayName = $props.DisplayName + DisplayVersion = $props.DisplayVersion + InstallLocation = $props.InstallLocation + SystemComponent = $props.SystemComponent + } + } + } + } + } + + function Get-VisiblePySceneDetectEntries { + # The set Apps & Features actually shows: SystemComponent != 1. + Get-PySceneDetectEntries | Where-Object { $_.SystemComponent -ne 1 } + } + + function Get-UninstallKeyByName { + # The visible key's name embeds the MSI version ("PySceneDetect + # 0.7.0"), so a lookup by name across hives is a precise + # per-version existence check. + param([Parameter(Mandatory)][string]$KeyName) + foreach ($root in $UninstallRoots) { + $path = Join-Path $root $KeyName + if (Test-Path $path) { $path } + } + } + + function Invoke-Msiexec { + # msiexec detaches from the console immediately, so a bare + # `msiexec ...` would return before the Windows Installer service + # finishes (and without the real exit code); Start-Process + # -Wait -PassThru blocks and surfaces it. + param( + [Parameter(Mandatory)][string[]]$MsiArgs, + [Parameter(Mandatory)][string]$LogPath + ) + $log = Join-Path (Get-Location) $LogPath + for ($attempt = 1; $attempt -le 3; $attempt++) { + $p = Start-Process msiexec.exe -ArgumentList ($MsiArgs + @('/L*v', $log)) -Wait -PassThru + switch ($p.ExitCode) { + 0 { Write-Host "msiexec $($MsiArgs -join ' ') succeeded (exit 0)"; return } + 3010 { Write-Host 'msiexec exit 3010 (success, reboot required) - treated as success'; return } + 1618 { + # ERROR_INSTALL_ALREADY_RUNNING: runner provisioning + # sometimes still holds the machine-wide MSI mutex. + Write-Host "msiexec exit 1618 (another install in progress), attempt $attempt of 3" + if ($attempt -lt 3) { Start-Sleep -Seconds 30 } + } + default { throw "msiexec $($MsiArgs -join ' ') failed with exit code $($p.ExitCode); see $LogPath" } + } + } + throw 'msiexec still blocked by another installation (exit 1618) after 3 attempts' + } + + function Get-PathRegistryValues { + # The installer edits PATH in the registry only; neither this + # process nor its children see the change, so assertions must + # read the raw values. Machine PATH is where a per-machine + # install writes (the .aip Environment row uses the '*' system + # prefix); HKCU is read too for completeness. + $values = @() + $machine = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path -ErrorAction SilentlyContinue + if ($machine) { $values += $machine.Path } + $user = Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue + if ($user) { $values += $user.Path } + $values + } + + function Test-DirOnRegistryPath { + param([Parameter(Mandatory)][string]$Directory) + $needle = $Directory.TrimEnd('\') + foreach ($value in Get-PathRegistryValues) { + # -contains is case-insensitive, matching how Windows treats paths. + if (@($value -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $needle) { + return $true + } + } + return $false + } + + function Assert-CleanRemoval { + param([string]$InstallDir) + $entries = @(Get-PySceneDetectEntries) + if ($entries.Count -ne 0) { + $entries | Format-List | Out-String | Write-Host + throw "Expected zero uninstall entries after uninstall, found $($entries.Count)" + } + # Also match stale keys by name in case a leftover key lost its + # DisplayName value. + foreach ($root in $UninstallRoots) { + if (-not (Test-Path $root)) { continue } + $stale = @(Get-ChildItem $root | Where-Object { $_.PSChildName -like 'PySceneDetect*' }) + if ($stale.Count -ne 0) { + throw "Stale uninstall keys remain under ${root}: $($stale.PSChildName -join ', ')" + } + } + if ($InstallDir) { + if (Test-Path (Join-Path $InstallDir 'scenedetect.exe')) { + throw "scenedetect.exe still present in $InstallDir after uninstall" + } + if (Test-DirOnRegistryPath -Directory $InstallDir) { + throw "$InstallDir still present in a PATH registry value after uninstall" + } + } + Write-Host 'Verified clean removal.' + } + '@ + $dest = Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1' + Set-Content -LiteralPath $dest -Value $helpers + Write-Host "Wrote $dest" + + - name: Download previous release MSI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release download $env:PREV_TAG --pattern '*-win64.msi' --dir prev + if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:PREV_TAG" } + $msis = @(Get-ChildItem prev -Filter '*.msi') + if ($msis.Count -ne 1) { + throw "Expected exactly one MSI from $env:PREV_TAG, got: $($msis.Name -join ', ')" + } + # Older releases may not ship SHA256SUMS, so the previous MSI is + # deliberately not checksummed; only the new MSI under test is. + Add-Content $env:GITHUB_ENV "PREV_MSI=$($msis[0].FullName)" + + - name: Install previous release + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + Invoke-Msiexec -MsiArgs @('/i', $env:PREV_MSI, '/qn', '/norestart') -LogPath install-prev.log + # Sanity check the baseline before upgrading over it. + $visible = @(Get-VisiblePySceneDetectEntries) + if ($visible.Count -ne 1) { + Get-PySceneDetectEntries | Format-List | Out-String | Write-Host + throw "Expected exactly 1 visible uninstall entry after baseline install, found $($visible.Count)" + } + if ($visible[0].DisplayVersion -ne $env:PREV_MSI_VERSION) { + throw "Baseline DisplayVersion is '$($visible[0].DisplayVersion)', expected '$env:PREV_MSI_VERSION'" + } + $exe = Join-Path $visible[0].InstallLocation 'scenedetect.exe' + if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($visible[0].InstallLocation)" } + & $exe version + if ($LASTEXITCODE -ne 0) { throw "baseline scenedetect version exited with $LASTEXITCODE" } + Add-Content $env:GITHUB_ENV "OLD_INSTALL_DIR=$($visible[0].InstallLocation)" + + - name: Download new release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release download $env:TAG --pattern 'PySceneDetect-*-win64.msi' --pattern 'SHA256SUMS' --dir dist + if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $env:TAG" } + $expected = "PySceneDetect-$env:VERSION-win64.msi" + $msis = @(Get-ChildItem dist -Filter '*.msi') + if ($msis.Count -ne 1 -or $msis[0].Name -ne $expected) { + throw "Expected exactly one MSI named $expected, got: $($msis.Name -join ', ')" + } + if (-not (Test-Path dist\SHA256SUMS)) { throw 'SHA256SUMS missing from release assets' } + + - name: Verify checksum and Authenticode signature + run: | + $msi = "dist\PySceneDetect-$env:VERSION-win64.msi" + $name = Split-Path $msi -Leaf + # SHA256SUMS lines are ` ` (two spaces; the sha256sum -c + # format written by scripts/finalize_windows_dist.py). + $sums = @{} + foreach ($line in Get-Content dist\SHA256SUMS) { + $hash, $entry = $line -split ' ', 2 + $sums[$entry.Trim()] = $hash.Trim() + } + if (-not $sums.ContainsKey($name)) { throw "SHA256SUMS has no entry for $name" } + $actual = (Get-FileHash $msi -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $sums[$name].ToLowerInvariant()) { + throw "SHA256 mismatch for ${name}: expected $($sums[$name]), got $actual" + } + Write-Host "Checksum OK: $actual" + $sig = Get-AuthenticodeSignature $msi + if ($sig.Status -ne 'Valid') { + throw "Authenticode signature status is '$($sig.Status)' (expected 'Valid')" + } + Write-Host "Signature OK: $($sig.SignerCertificate.Subject)" + + - name: Install new release over previous + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/i', $msi, '/qn', '/norestart') -LogPath install-upgrade.log + + - name: Verify upgrade + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $visible = @(Get-VisiblePySceneDetectEntries) + if ($visible.Count -ne 1) { + Get-PySceneDetectEntries | Format-List | Out-String | Write-Host + throw "Expected exactly 1 visible uninstall entry after upgrade, found $($visible.Count)" + } + $entry = $visible[0] + if ($entry.DisplayVersion -ne $env:MSI_VERSION) { + throw "DisplayVersion is '$($entry.DisplayVersion)', expected '$env:MSI_VERSION'" + } + # The visible key name embeds the version ("PySceneDetect + # "), so the old key vanishing from every hive is a + # precise duplicate-Apps-&-Features-entry check. + $stale = @(Get-UninstallKeyByName -KeyName "PySceneDetect $env:PREV_MSI_VERSION") + if ($stale.Count -ne 0) { + throw "Previous version's uninstall key still present: $($stale -join ', ')" + } + # Belt and braces: no entry anywhere (visible or hidden) may still + # report the old version. + $old = @(Get-PySceneDetectEntries | Where-Object { $_.DisplayVersion -eq $env:PREV_MSI_VERSION }) + if ($old.Count -ne 0) { + $old | Format-List | Out-String | Write-Host + throw "Found $($old.Count) uninstall entries still at $env:PREV_MSI_VERSION" + } + if (-not $entry.InstallLocation) { throw 'InstallLocation is empty after upgrade' } + $exe = Join-Path $entry.InstallLocation 'scenedetect.exe' + if (-not (Test-Path $exe)) { throw "scenedetect.exe not found in $($entry.InstallLocation)" } + $out = & $exe version 2>&1 | Out-String + Write-Host $out + if ($LASTEXITCODE -ne 0) { throw "scenedetect version exited with $LASTEXITCODE" } + if (-not $out.Contains($env:VERSION)) { throw "version output does not mention $env:VERSION" } + # If the upgrade relocated the install, the old copy must be gone. + if ($env:OLD_INSTALL_DIR.TrimEnd('\') -ne $entry.InstallLocation.TrimEnd('\')) { + if (Test-Path (Join-Path $env:OLD_INSTALL_DIR 'scenedetect.exe')) { + throw "Old install at $env:OLD_INSTALL_DIR still present after relocating upgrade" + } + } + Add-Content $env:GITHUB_ENV "INSTALL_DIR=$($entry.InstallLocation)" + Write-Host 'Upgrade verified.' + + - name: Uninstall new MSI + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + $msi = (Resolve-Path "dist\PySceneDetect-$env:VERSION-win64.msi").Path + Invoke-Msiexec -MsiArgs @('/x', $msi, '/qn', '/norestart') -LogPath uninstall.log + + - name: Verify clean removal + run: | + . (Join-Path $env:RUNNER_TEMP 'installer-helpers.ps1') + Assert-CleanRemoval -InstallDir $env:INSTALL_DIR + + - name: Upload msiexec logs + if: failure() + uses: actions/upload-artifact@v6 + with: + name: upgrade-logs + path: '*.log' + if-no-files-found: ignore diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index 70fa3249..f6c9f66a 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -5,9 +5,9 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## 1. Version Identifiers, Branch Prep -- [ ] Create / fast-forward release branch: `releases/X.Y` off `main` if major/minor release. If patch release, fast-forward current `releases/X.Y` branch. +- [ ] Create release branch `releases/X.Y[.Z]` off `main` (each release, including patches, gets its own branch - e.g. `releases/0.6.7`, `releases/0.7.1`); fast-forward it to `main` as release work lands. - [ ] Bump `__version__` in `scenedetect/__init__.py` -- [ ] Bump `docs/LATEST_VERSION` if needed (stable major/minor releases only) +- [ ] Bump `docs/LATEST_VERSION` for any stable release: it must match the `releases/X.Y[.Z]` branch suffix for `generate-docs.yml` to update `docs/latest` - [ ] Regular release: No `-dev` suffix or other, pre-release: has suffix `-dev0`, `-dev1`, ... ## 2. Documentation, Website, Changelog @@ -30,7 +30,7 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## 4. Prepare Windows Distribution - [ ] Update `packaging/windows/requirements.txt` and bump bundled ffmpeg version in `appveyor.yml` -- [ ] Run Appyeyor build on release branch, ensure resulting portable distribution and MSI installer are correct +- [ ] Run AppVeyor build on release branch, ensure resulting portable distribution and MSI installer are correct > **GUI required for structural changes.** `scripts/update_installer.py` covers routine version bumps and `--sync-files` covers dependency-driven file-list changes, but anything that touches the *project structure* of the .aip still needs the AdvancedInstaller GUI. Examples: > @@ -41,8 +41,8 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## 5. Tag & Draft Release -- [ ] Final commit on `releases/X.Y`: "Release vX.Y[.Z]". -- [ ] Tag `vX.Y[.Z]-release` on that commit and push. Wait for all tests/builds to pass. +- [ ] Final commit on `releases/X.Y[.Z]`: "Release vX.Y[.Z]". +- [ ] Tag `vX.Y[.Z]` on that commit and push (the legacy `vX.Y[.Z]-release` form is also accepted by all workflows). Wait for all tests/builds to pass. - [ ] Approve code signing request on SignPath, download `scenedetect-signed.zip` - [ ] Finalize Windows artifacts locally (CI can't do this - signing happens after the AppVeyor build, so the post-signing steps must run locally): - Create `dist/signed/` and drop `scenedetect-signed.zip` (from SignPath) into it. No other inputs needed - the portable .zip is rebuilt from the signed .msi via `msiexec /a`, eliminating the AppVeyor download. @@ -54,7 +54,7 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## 6. Publish & Release Checks - [ ] Publish Github release -- [ ] Upload to PyPI: `publish-pypi.yml` must be manually triggered on a release tag. Specify `testpypi` first, and make sure everything goes okay on the test instance. When verified and smoke tested, specify `pypi` as the environment, and publish the production package. The artifact contains 6 files (sdist + wheel for `scenedetect-core`, `scenedetect`, and `scenedetect-headless`). +- [ ] Dispatch `release.yml` (Release Orchestrator) with the release tag. It publishes stage by stage, verifying each stage before the next: MSI install/uninstall + upgrade-from-previous on a clean Windows runner (`test-installer.yml`) -> TestPyPI publish -> pip smoke install from TestPyPI -> production PyPI publish (all 6 artifacts - sdist + wheel for `scenedetect-core`, `scenedetect`, and `scenedetect-headless`) -> pip smoke install from PyPI -> Docker publish (version tags + `latest`) -> docker pull + smoke run from GHCR. Use the `verify-only` input to stop after the TestPyPI stage without publishing anything user-facing. The underlying workflows (`test-installer.yml`, `publish-pypi.yml`, `docker-publish.yml`) can still be dispatched individually as a fallback. - [ ] Verify all three projects: https://pypi.org/project/scenedetect/, https://pypi.org/project/scenedetect-headless/, and https://pypi.org/project/scenedetect-core/. - [ ] Deploy website: `generate-website.yml` - [ ] Deploy docs: `generate-docs.yml` @@ -69,6 +69,6 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## Notes -- **Branching model**: work spans multiple commits on `releases/X.Y`; the final one gets the `vX.Y[.Z]-release` tag which gates the release-test workflow. A passing release-test is a hard prerequisite for publishing. +- **Branching model**: work spans multiple commits on `releases/X.Y[.Z]`; the final one gets the `vX.Y[.Z]` tag which gates the release-test workflow. A passing release-test is a hard prerequisite for publishing. - **Version consistency** is enforced in two places (`__init__.py`, `PySceneDetect.aip`). The `static` job of `release-test.yml` checks `__init__.py` against the tag and verifies the changelog has a matching `## PySceneDetect X.Y` heading; the installer parity is checked by `scripts/pre_release.py --release`. - **Changelog convention**: the in-development section lives at the *bottom* of `website/pages/changelog.md` under the "Development" heading - don't move it to the top. From f8b05b243e67116e4afd29b8fe3600ebbe0253bc Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 21 Jul 2026 20:51:44 -0400 Subject: [PATCH 396/407] [release] Release 0.7.1 --- Dockerfile | 5 +- appveyor.yml | 12 +++- docs/LATEST_VERSION | 2 +- docs/cli.rst | 14 +++- packaging/logo/pyscenedetect-new.svg | 41 ++++++++++++ packaging/windows/installer/PySceneDetect.aip | 10 +-- packaging/windows/requirements.txt | 14 ++-- scenedetect/__init__.py | 2 +- scenedetect/detectors/histogram_detector.py | 10 +-- website/pages/changelog.md | 65 ++++++++++--------- website/pages/download.md | 10 +-- website/pages/index.md | 2 +- 12 files changed, 127 insertions(+), 60 deletions(-) create mode 100644 packaging/logo/pyscenedetect-new.svg diff --git a/Dockerfile b/Dockerfile index b7fcc0de..039790f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,10 @@ RUN apt-get update && \ # moviepy provides an alternative video splitting backend RUN --mount=type=cache,target=/root/.cache/pip \ cp packaging/variants/pyproject-scenedetect-headless.toml pyproject.toml && \ - pip install ".[pyav,moviepy]" + pip install ".[pyav,moviepy]" && \ + # TODO(https://github.com/Zulko/moviepy/issues/2553): moviepy caps pillow<12.0, but 11.x has + # CVEs only fixed in 12.3.0+. Tests pass against 12.3.0; drop this once moviepy lifts the cap. + pip install "pillow==12.3.0" # Switch to the non-root user USER scenedetect diff --git a/appveyor.yml b/appveyor.yml index e10f47ba..0ffafc7c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,12 +12,13 @@ cache: - '%LOCALAPPDATA%\uv\cache -> pyproject.toml' - 'C:\Program Files\Inkscape' -# Branches applies to tags as well. We only build on tagged releases of the form vX.Y.Z-release +# Branches applies to tags as well. We only build on tagged releases of the form +# vX.Y[.Z] (the legacy -release suffix is also accepted, matching the GitHub workflows). branches: only: - main - /releases\/.+/ - - /v.+-release/ + - /v.+/ skip_tags: false skip_non_tags: true @@ -30,7 +31,7 @@ environment: secure: QRCPoNYF1nqgXDn7pHgBzg== ai_license_salt: secure: +Gy+SRk8JUsaM+5pMEKITiJxdLilrxHpkKlrZzR3C9DPwdgYLGxt5sJn6uXuAJg7e6JsKHcT7tRks/HcSKkHPw== - ffmpeg_version: "8.1" + ffmpeg_version: "8.1.2" # SignPath Config for Code Signing deploy: @@ -50,6 +51,11 @@ install: - python -m pip install uv - uv pip install --system .[docs] - uv pip install --system -r packaging/windows/requirements.txt --no-binary imageio-ffmpeg + # TODO(https://github.com/Zulko/moviepy/issues/2553): moviepy caps pillow<12.0, but 11.x has + # CVEs only fixed in 12.3.0+. Installed as a separate step since a pin in requirements.txt + # would fail strict resolution against moviepy's constraint. Tests pass against 12.3.0; + # drop this once moviepy lifts the cap. + - uv pip install --system pillow==12.3.0 - if not exist ffmpeg-%ffmpeg_version%-full_build.7z appveyor DownloadFile https://github.com/GyanD/codexffmpeg/releases/download/%ffmpeg_version%/ffmpeg-%ffmpeg_version%-full_build.7z - 7z e ffmpeg-%ffmpeg_version%-full_build.7z -odist/ffmpeg ffmpeg.exe LICENSE -r # moviepy.config reads FFMPEG_BINARY (which routes through imageio_ffmpeg) at import time. diff --git a/docs/LATEST_VERSION b/docs/LATEST_VERSION index eb49d7c7..7deb86fe 100644 --- a/docs/LATEST_VERSION +++ b/docs/LATEST_VERSION @@ -1 +1 @@ -0.7 +0.7.1 \ No newline at end of file diff --git a/docs/cli.rst b/docs/cli.rst index c252efad..d19cc4a2 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -61,9 +61,13 @@ Options Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis. -.. option:: -f FPS, --framerate FPS +.. option:: -f FPS, --frame-rate FPS - Override framerate with value as frames/sec. + Override frame rate with value as frames/sec. + +.. option:: --framerate FPS + + [DEPRECATED] Use :option:`-f/--frame-rate <-f>` instead. .. option:: -m TIMECODE, --min-scene-len TIMECODE @@ -549,6 +553,10 @@ Options Output directory to save EDL file to. Overrides global option :option:`-o/--output `. +.. option:: -s TIMECODE, --start-timecode TIMECODE + + Start timecode added to every event so the EDL aligns with the source media's on-screen timecode. Accepts SMPTE HH:MM:SS:FF or 8 digits (HHMMSSFF, e.g. 01000000). + .. _command-save-fcp: @@ -859,7 +867,7 @@ Options .. option:: --expand - Extend the first/last output clips to cover the full input video, even if the :ref:`time ` command's ``--start``/``--end`` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split. + Extend the first/last output clips to cover the full input video, even if `time -s/-e` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split. .. _command-time: diff --git a/packaging/logo/pyscenedetect-new.svg b/packaging/logo/pyscenedetect-new.svg new file mode 100644 index 00000000..fb383852 --- /dev/null +++ b/packaging/logo/pyscenedetect-new.svg @@ -0,0 +1,41 @@ + + + + + + + + + diff --git a/packaging/windows/installer/PySceneDetect.aip b/packaging/windows/installer/PySceneDetect.aip index 607630a9..7865d2a2 100644 --- a/packaging/windows/installer/PySceneDetect.aip +++ b/packaging/windows/installer/PySceneDetect.aip @@ -23,10 +23,10 @@ - + - + @@ -159,7 +159,7 @@
    - + @@ -291,7 +291,7 @@ - + @@ -1743,7 +1743,7 @@ - + diff --git a/packaging/windows/requirements.txt b/packaging/windows/requirements.txt index edf740a7..dc31fa21 100644 --- a/packaging/windows/requirements.txt +++ b/packaging/windows/requirements.txt @@ -1,12 +1,14 @@ # PySceneDetect Requirements for Windows Build -av==17.0.1 -click==8.2.1 +# NOTE: pillow (transitive, via moviepy) is overridden to 12.3.0 in appveyor.yml for CVE fixes +# (see https://github.com/Zulko/moviepy/issues/2553). +av==18.0.0 +click==8.4.2 imageio-ffmpeg==0.6.0 moviepy==2.2.1 -opencv-python-headless==4.13.0.92 -numpy==2.4.4 -platformdirs==4.9.6 -tqdm==4.67.3 +opencv-python-headless==5.0.0.93 +numpy==2.5.1 +platformdirs==4.11.0 +tqdm==4.69.0 # Build-only and test-only requirements. pyinstaller diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 1e36405a..ad2dc8dc 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -79,7 +79,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = "0.7.1-dev0" +__version__ = "0.7.1" init_logger() logger = getLogger("pyscenedetect") diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index c8b74112..da20359f 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -145,10 +145,12 @@ def calculate_histogram( relative frequency. Example: - >>> img = cv2.imread("path_to_image.jpg") - >>> hist = calculate_histogram(img, bins=256, normalize=True) - >>> print(hist.shape) - (256,) + + .. code:: python + + img = cv2.imread("path_to_image.jpg") + hist = HistogramDetector.calculate_histogram(img, bins=256, normalize=True) + assert hist.shape == (256,) """ # Extract Luma channel from the frame image y, _, _ = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2YUV)) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 7750c66e..1b9812f1 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -3,6 +3,40 @@ ## PySceneDetect 0.7 +### PySceneDetect 0.7.1 (July 2026) + +PySceneDetect 0.7.1 adds a new `scenedetect-core` package and official Docker images to make downstream integration easier, along with support for concatenating multiple videos. It also includes several stability and robustness fixes for the PyAV and OpenCV backends. + +#### CLI Changes + + - [feature] `split-video` has a new `--expand` flag: when scenes are detected within a time window (`-s`/`-e`), the first output clip is extended back to the start of the video and the last clip is extended forward to the end, so no footage outside the analysis window is dropped [#115](https://github.com/Breakthrough/PySceneDetect/issues/115) + +#### API Changes + + - [feature] `scenedetect.detect()` now accepts a `backend` keyword argument (`"opencv"`, `"pyav"`, or `"moviepy"`) similar to `open_video`. Defaults to `"opencv"`, matching prior behavior. + - [feature] Add `expand_scenes_to_bounds()` helper in `scenedetect.scene_manager` to extend a scene list so the first scene starts at a given lower bound and the last scene ends at a given upper bound + - [feature] `VideoStream` now provides a public read-only `decode_failures` property reporting the number of frames that failed to decode and were skipped (defaults to 0; populated by the OpenCV and PyAV backends) + - [feature] Add `VideoStreamConcat` (`scenedetect.backends.concat`) which concatenates multiple videos into a single continuous stream with a monotonic PTS timeline; `open_video()` and `detect()` now accept a list of paths. `VideoStreamConcat.map_span()` maps spans of the global timeline back to per-source local times + - [bugfix] The PyAV backend (`VideoStreamAv`) now skips corrupt frames during `read()` and continues decoding instead of failing, giving up only after 8 consecutive decode failures (matching the OpenCV backend's tolerance behavior) + - [bugfix] The PyAV backend now normalizes presentation times by the stream start time, so files with a delayed start (e.g. from edit lists) report the first frame at `position` 0, consistent with other backends and with `seek()` + - [bugfix] Comparisons between two `FrameTimecode` objects that both carry exact presentation times (e.g. positions from VFR videos) and share the same frame rate are now performed exactly using `pts` and `time_base` instead of rounded frame numbers. Previously, distinct frames in VFR sections could compare equal or fail strict ordering when their times rounded to the same approximate frame number. Comparisons involving frame- or seconds-based timecodes, plain values (`int`/`float`/`str`), or differing frame rates are unchanged + - [bugfix] Fix image sequence inputs when using OpenCV 5.0 + +#### Packaging + + - [feature] Add `scenedetect-core`, a new library-only package with minimal dependencies (`numpy` only): it does not depend on any specific OpenCV variant, allowing downstream projects to choose their own (e.g. `opencv-contrib-python`), and does not include the CLI dependencies or the `scenedetect` command [#558](https://github.com/Breakthrough/PySceneDetect/issues/558). Convenience extras `scenedetect-core[opencv]` and `scenedetect-core[opencv-headless]` are provided + - [general] `scenedetect` and `scenedetect-headless` are unchanged: they continue to ship the full program (library + CLI) with `opencv-python` / `opencv-python-headless` respectively. All three packages provide the same `scenedetect` module (install or depend only one) + - [feature] Official Docker images are now published to the GitHub Container Registry with the full CLI, all backends, and external tools (ffmpeg, mkvmerge) included, thanks [@FNGarvin](https://github.com/FNGarvin) [#537](https://github.com/Breakthrough/PySceneDetect/pull/537) + - Example usage (process a video in the current directory): +```bash +docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect -i /files/video.mp4 detect-adaptive split-video -o /files +``` + - [general] The Windows distribution now bundles OpenCV 5.0, PyAV 18, and FFmpeg 8.1.2. The Windows and Docker builds also override Pillow to 12.3.0 for upstream security fixes ([moviepy#2553](https://github.com/Zulko/moviepy/issues/2553)) + +#### General + + - [general] Benchmark results are now published on the website ([scenedetect.com/benchmarks](https://www.scenedetect.com/benchmarks/)), including accuracy at default settings and parameter sweep curves for each detector + ### 0.7 (May 3, 2026) PySceneDetect 0.7 is a **major breaking release** which overhauls how timestamps are handled. This allows PySceneDetect to properly process variable framerate (VFR) videos. A significant amount of technical debt has been addressed, including removal of deprecated or overly complicated APIs. @@ -747,33 +781,4 @@ Both the Windows installer and portable distributions now include signed executa Development ========================================================== -## PySceneDetect 0.7.1 (TDB) - -#### CLI Changes - - - [feature] `split-video` has a new `--expand` flag: when scenes are detected within a time window (`-s`/`-e`), the first output clip is extended back to the start of the video and the last clip is extended forward to the end, so no footage outside the analysis window is dropped [#115](https://github.com/Breakthrough/PySceneDetect/issues/115) - -#### API Changes - - - [feature] `scenedetect.detect()` now accepts a `backend` keyword argument (`"opencv"`, `"pyav"`, or `"moviepy"`) similar to `open_video`. Defaults to `"opencv"`, matching prior behavior. - - [feature] Add `expand_scenes_to_bounds()` helper in `scenedetect.scene_manager` to extend a scene list so the first scene starts at a given lower bound and the last scene ends at a given upper bound - - [feature] `VideoStream` now provides a public read-only `decode_failures` property reporting the number of frames that failed to decode and were skipped (defaults to 0; populated by the OpenCV and PyAV backends) - - [feature] Add `VideoStreamConcat` (`scenedetect.backends.concat`) which concatenates multiple videos into a single continuous stream with a monotonic PTS timeline; `open_video()` and `detect()` now accept a list of paths. `VideoStreamConcat.map_span()` maps spans of the global timeline back to per-source local times - - [bugfix] The PyAV backend (`VideoStreamAv`) now skips corrupt frames during `read()` and continues decoding instead of failing, giving up only after 8 consecutive decode failures (matching the OpenCV backend's tolerance behavior) - - [bugfix] The PyAV backend now normalizes presentation times by the stream start time, so files with a delayed start (e.g. from edit lists) report the first frame at `position` 0, consistent with other backends and with `seek()` - - [bugfix] Comparisons between two `FrameTimecode` objects that both carry exact presentation times (e.g. positions from VFR videos) and share the same frame rate are now performed exactly using `pts` and `time_base` instead of rounded frame numbers. Previously, distinct frames in VFR sections could compare equal or fail strict ordering when their times rounded to the same approximate frame number. Comparisons involving frame- or seconds-based timecodes, plain values (`int`/`float`/`str`), or differing frame rates are unchanged - - [bugfix] Fix image sequence inputs when using OpenCV 5.0 - -#### Packaging - - - [feature] Add `scenedetect-core`, a new library-only package with minimal dependencies (`numpy` only): it does not depend on any specific OpenCV variant, allowing downstream projects to choose their own (e.g. `opencv-contrib-python`), and does not include the CLI dependencies or the `scenedetect` command [#558](https://github.com/Breakthrough/PySceneDetect/issues/558). Convenience extras `scenedetect-core[opencv]` and `scenedetect-core[opencv-headless]` are provided - - [general] `scenedetect` and `scenedetect-headless` are unchanged: they continue to ship the full program (library + CLI) with `opencv-python` / `opencv-python-headless` respectively. All three packages provide the same `scenedetect` module (install or depend only one) - - [feature] Official Docker images are now published to the GitHub Container Registry with the full CLI, all backends, and external tools (ffmpeg, mkvmerge) included, thanks [@FNGarvin](https://github.com/FNGarvin) [#537](https://github.com/Breakthrough/PySceneDetect/pull/537) - - Example usage (process a video in the current directory): -```bash -docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect -i /files/video.mp4 detect-adaptive split-video -o /files -``` - -#### General - - - [general] Benchmark results are now published on the website ([scenedetect.com/benchmarks](https://www.scenedetect.com/benchmarks/)), including accuracy at default settings and parameter sweep curves for each detector +## PySceneDetect 0.7.2 (TBD) diff --git a/website/pages/download.md b/website/pages/download.md index 17b80fd8..d2b8f99e 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -26,10 +26,10 @@ All three provide the same `scenedetect` Python module -- install only one of th ## Windows Build (64-bit Only)  
    -

    Latest Release: v0.7

    -

      Release Date:  May 3, 2026

    -  Installer  (recommended)      -  Portable .zip      +

    Latest Release: v0.7.1

    +

      Release Date:  July 2026

    +  Installer  (recommended)      +  Portable .zip        Getting Started
    @@ -49,7 +49,7 @@ docker run --rm -v "$(pwd):/files" ghcr.io/breakthrough/pyscenedetect \ -i /files/video.mp4 detect-adaptive split-video -o /files ``` -The `latest` tag (the default when no tag is given) points to the most recent recommended build, and the `main` tag tracks the development branch. Starting with the next release, version tags (e.g. `0.7.1`) will also be published. `podman` can be used in place of `docker` in the commands above. +The `latest` tag (the default when no tag is given) points to the most recent recommended build, the `main` tag tracks the development branch, and version tags (e.g. `0.7.1`) point to specific releases. `podman` can be used in place of `docker` in the commands above. ## Post Installation diff --git a/website/pages/index.md b/website/pages/index.md index 946dccd5..e09063f4 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -3,7 +3,7 @@ PySceneDetect
    -

      Latest Release: v0.7 (May 3, 2026)

    +

      Latest Release: v0.7.1 (July 2026)

      Download        Changelog        Documentation        Getting Started
    From fb8534800504a1332f639e1394c8f7d653cfe6a5 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 21 Jul 2026 21:41:52 -0400 Subject: [PATCH 397/407] [build] Publish GH release at same time as packages --- .github/workflows/release.yml | 54 +++++++++++++++++----------- .github/workflows/test-installer.yml | 5 ++- RELEASE-PLAN.md | 3 +- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8751f890..cee76491 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,19 @@ -# Release orchestrator: verifies the artifacts attached to a published GitHub -# release actually work, then publishes them stage by stage, verifying each -# stage before starting the next: +# Release orchestrator: verifies the artifacts attached to a DRAFT GitHub +# release actually work, then publishes stage by stage, verifying each stage +# before starting the next: # -# MSI install/upgrade test (test-installer.yml) +# MSI install/upgrade test against the draft's assets (test-installer.yml) # -> TestPyPI publish -> pip smoke install from TestPyPI +# -> publish the GitHub release (draft -> public, marked latest) # -> PyPI publish -> pip smoke install from PyPI # -> Docker publish -> docker pull + smoke run from GHCR # -# Publishing is intentionally NOT triggered automatically by the release event - -# this workflow is dispatched manually once the GitHub release is published, and -# the publish steps only run after every verification step succeeds. Each stage -# is driven through the existing workflows via `gh workflow run` (rather than -# workflow_call) so they keep working standalone and the PyPI trusted-publisher -# configuration (which is bound to publish-pypi.yml as the top-level workflow) -# is unaffected. +# This workflow is dispatched manually once the GitHub release has been DRAFTED +# with its artifacts attached (signed MSI/zip, wheels, SHA256SUMS); nothing goes +# public until artifact verification passes. Each stage is driven through the +# existing workflows via `gh workflow run` (rather than workflow_call) so they +# keep working standalone and the PyPI trusted-publisher configuration (which is +# bound to publish-pypi.yml as the top-level workflow) is unaffected. name: Release Orchestrator @@ -29,8 +29,8 @@ on: default: false permissions: - contents: read - actions: write # `gh workflow run` on the workflows this one orchestrates + contents: write # read the draft release's assets and publish it (draft -> public) + actions: write # `gh workflow run` on the workflows this one orchestrates # The run-id lookup after each dispatch assumes this is the only orchestrator # running; never allow two concurrent releases. @@ -53,10 +53,10 @@ jobs: state=$(gh release view "$TAG" --json isDraft,isPrerelease \ -q 'if .isDraft then "draft" elif .isPrerelease then "prerelease" else "published" end') if [[ "$state" == "draft" ]]; then - echo "::error::Release $TAG is still a draft; publish it before running the orchestrator." - exit 1 + echo "Release $TAG is a draft; it will be published after artifact verification passes." + else + echo "Release $TAG is already $state; the GitHub release publish step will be a no-op (re-run mode)." fi - echo "Release $TAG is $state." # Display version used by the pip smoke test; mirrors the tag # normalization in release-test.yml (both vX.Y[.Z] and the legacy # vX.Y[.Z]-release tag styles are accepted). @@ -145,7 +145,19 @@ jobs: --index-url https://test.pypi.org/simple/ \ --extra-index-url https://pypi.org/simple/ - - name: 'Stage 3 - Publish: PyPI (production)' + - name: 'Stage 3 - Publish: GitHub release (draft -> public)' + if: ${{ !inputs.verify-only }} + run: | + set -euo pipefail + is_draft=$(gh release view "$TAG" --json isDraft -q .isDraft) + if [[ "$is_draft" == "true" ]]; then + gh release edit "$TAG" --draft=false --latest + echo "Published release $TAG (marked as latest)." + else + echo "Release $TAG is already published; skipping." + fi + + - name: 'Stage 4 - Publish: PyPI (production)' if: ${{ !inputs.verify-only }} run: | set -euo pipefail @@ -154,14 +166,14 @@ jobs: # build + release-test workflows being green for the tag. dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=pypi" - - name: 'Stage 3 - Verify: pip install from PyPI' + - name: 'Stage 4 - Verify: pip install from PyPI' if: ${{ !inputs.verify-only }} run: | set -euo pipefail source "$RUNNER_TEMP/smoke.sh" pip_smoke_install smoke-pypi - - name: 'Stage 4 - Publish: Docker image (version tags + latest)' + - name: 'Stage 5 - Publish: Docker image (version tags + latest)' if: ${{ !inputs.verify-only }} run: | set -euo pipefail @@ -171,7 +183,7 @@ jobs: # docker-publish.yml, i.e. v0.7.1 or newer). dispatch_and_watch docker-publish.yml "$TAG" -f "tag_latest=true" - - name: 'Stage 4 - Verify: docker pull + smoke run from GHCR' + - name: 'Stage 5 - Verify: docker pull + smoke run from GHCR' if: ${{ !inputs.verify-only }} run: | set -euo pipefail @@ -192,7 +204,7 @@ jobs: - name: Summary run: | if [[ "${{ inputs.verify-only }}" == "true" ]]; then - echo "Verification of $TAG passed. Re-run without verify-only to publish." + echo "Verification of $TAG passed (release left as draft). Re-run without verify-only to publish." else echo "Release $TAG verified and published:" echo " https://pypi.org/project/scenedetect/$VERSION/" diff --git a/.github/workflows/test-installer.yml b/.github/workflows/test-installer.yml index d6795d2d..45f77a5e 100644 --- a/.github/workflows/test-installer.yml +++ b/.github/workflows/test-installer.yml @@ -21,8 +21,11 @@ on: description: 'Release tag to upgrade from (default: auto-detect previous release)' required: false +# NOTE: This workflow never writes to the repository; `contents: write` is required +# only because draft-release assets are invisible to read-only tokens, and the +# release orchestrator runs this verification while the release is still a draft. permissions: - contents: read + contents: write jobs: resolve: diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index f6c9f66a..dc80f273 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -53,8 +53,7 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## 6. Publish & Release Checks -- [ ] Publish Github release -- [ ] Dispatch `release.yml` (Release Orchestrator) with the release tag. It publishes stage by stage, verifying each stage before the next: MSI install/uninstall + upgrade-from-previous on a clean Windows runner (`test-installer.yml`) -> TestPyPI publish -> pip smoke install from TestPyPI -> production PyPI publish (all 6 artifacts - sdist + wheel for `scenedetect-core`, `scenedetect`, and `scenedetect-headless`) -> pip smoke install from PyPI -> Docker publish (version tags + `latest`) -> docker pull + smoke run from GHCR. Use the `verify-only` input to stop after the TestPyPI stage without publishing anything user-facing. The underlying workflows (`test-installer.yml`, `publish-pypi.yml`, `docker-publish.yml`) can still be dispatched individually as a fallback. +- [ ] Dispatch `release.yml` (Release Orchestrator) with the release tag while the Github release is still a **draft**. It runs the verify-then-publish ladder (MSI install/upgrade test -> TestPyPI -> publish Github release -> PyPI -> Docker), verifying each stage before the next; `verify-only` stops before anything goes public. See the header of `release.yml` for details. - [ ] Verify all three projects: https://pypi.org/project/scenedetect/, https://pypi.org/project/scenedetect-headless/, and https://pypi.org/project/scenedetect-core/. - [ ] Deploy website: `generate-website.yml` - [ ] Deploy docs: `generate-docs.yml` From 4a167fee4dfcb7bcf18ab7e6210bc50d78b0a212 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 21 Jul 2026 21:46:21 -0400 Subject: [PATCH 398/407] [build] Upgrade setuptools in release-test static job --- .github/workflows/release-test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 4a0cc9b4..5dfad659 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -18,7 +18,9 @@ jobs: cache: 'pip' - name: Install dependencies run: | - python -m pip install --upgrade pip + # setuptools is upgraded because the toolcache copy periodically lags + # security fixes (e.g. PYSEC-2026-3447) and would fail the audit below. + python -m pip install --upgrade pip setuptools pip install build twine pip-audit - name: Version consistency check run: | From 1a5bdaa376c2eb3983a549d40d9ca4f946c70745 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 21 Jul 2026 22:53:44 -0400 Subject: [PATCH 399/407] [build] Dispatch PyPI publishes on the release tag ref --- .github/workflows/release.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cee76491..77e64ec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,7 +133,10 @@ jobs: run: | set -euo pipefail source "$RUNNER_TEMP/dispatch.sh" - dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=testpypi" + # Dispatched on the release tag: the pypi environment's deployment + # branch policy only permits release refs (v* tags / releases/* + # branches), and the tag is the immutable ref being released anyway. + dispatch_and_watch publish-pypi.yml "$TAG" -f "tag=$TAG" -f "environment=testpypi" - name: 'Stage 2 - Verify: pip install from TestPyPI' run: | @@ -163,8 +166,9 @@ jobs: set -euo pipefail source "$RUNNER_TEMP/dispatch.sh" # publish-pypi.yml additionally gates production publishes on the - # build + release-test workflows being green for the tag. - dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=pypi" + # build + release-test workflows being green for the tag. Dispatched + # on the tag ref to satisfy the pypi environment's deployment policy. + dispatch_and_watch publish-pypi.yml "$TAG" -f "tag=$TAG" -f "environment=pypi" - name: 'Stage 4 - Verify: pip install from PyPI' if: ${{ !inputs.verify-only }} From d40629d869216e45d22e2a9e9009aee5fc0ea3c4 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 21 Jul 2026 23:09:31 -0400 Subject: [PATCH 400/407] [docs] Update README for v0.7.1, use full release dates --- README.md | 4 ++-- website/pages/changelog.md | 2 +- website/pages/download.md | 2 +- website/pages/index.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8d9b2aa8..4ce8d4ca 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ---------------------------------------------------------- -### Latest Release: v0.7 (May 3, 2026) +### Latest Release: v0.7.1 (July 21, 2026) **Website**: [scenedetect.com](https://www.scenedetect.com) @@ -114,7 +114,7 @@ We evaluate the performance of different detectors in terms of accuracy and proc - [Documentation](https://www.scenedetect.com/docs/) (covers application and Python API) - [CLI Example](https://www.scenedetect.com/cli/) - - [Config File](https://www.scenedetect.com/docs/0.6.4/cli/config_file.html) + - [Config File](https://www.scenedetect.com/docs/latest/cli/config_file.html) ## Help & Contributing diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1b9812f1..def2a9b6 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -3,7 +3,7 @@ ## PySceneDetect 0.7 -### PySceneDetect 0.7.1 (July 2026) +### PySceneDetect 0.7.1 (July 21, 2026) PySceneDetect 0.7.1 adds a new `scenedetect-core` package and official Docker images to make downstream integration easier, along with support for concatenating multiple videos. It also includes several stability and robustness fixes for the PyAV and OpenCV backends. diff --git a/website/pages/download.md b/website/pages/download.md index d2b8f99e..2421cfef 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -27,7 +27,7 @@ All three provide the same `scenedetect` Python module -- install only one of th

    Latest Release: v0.7.1

    -

      Release Date:  July 2026

    +

      Release Date:  July 21, 2026

      Installer  (recommended)        Portable .zip        Getting Started diff --git a/website/pages/index.md b/website/pages/index.md index e09063f4..113ebdf9 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -3,7 +3,7 @@ PySceneDetect
    -

      Latest Release: v0.7.1 (July 2026)

    +

      Latest Release: v0.7.1 (July 21, 2026)

      Download        Changelog        Documentation        Getting Started
    From a1ca4bc9fee19c7d799b65ebffe11eb0d6b6a5fe Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 22 Jul 2026 22:23:28 -0400 Subject: [PATCH 401/407] [docs] Remove scenedetect-core references scenedetect-core 0.7.1 has been yanked from PyPI and the package discontinued: pip cannot safely support multiple packages installing the same module files, and restructuring the published packages around a shared core would break in-place upgrades. See #558. Update install docs to cover only scenedetect/scenedetect-headless and note the discontinuation in the changelog. --- .github/workflows/build.yml | 38 ++++----------------- .github/workflows/publish-pypi.yml | 4 +-- .github/workflows/release-test.yml | 2 +- .github/workflows/release.yml | 1 - RELEASE-PLAN.md | 2 +- docs/api.rst | 2 +- docs/cli.rst | 8 ++--- docs/index.rst | 2 +- packaging/build_all.py | 26 +++++++------- packaging/package-info.rst | 2 +- pyproject.toml | 11 +++--- scenedetect.cfg | 8 ++--- scenedetect/_cli/config.py | 8 ++--- scenedetect/detectors/hash_detector.py | 4 +-- scenedetect/detectors/histogram_detector.py | 4 +-- scenedetect/platform.py | 11 +++--- tests/test_detectors.py | 23 ++++++++++--- website/pages/changelog.md | 7 ++-- website/pages/download.md | 7 ++-- website/pages/faq.md | 9 +++-- 20 files changed, 89 insertions(+), 90 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a8ef826..d6f0f6ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: - name: Build Package shell: bash run: | - # Builds scenedetect-core plus the scenedetect/scenedetect-headless variants. + # Builds the scenedetect/scenedetect-headless packages. python packaging/build_all.py echo "scenedetect_version=`python -c \"import scenedetect; print(scenedetect.__version__.replace('-', '.'))\"`" >> "$GITHUB_ENV" @@ -132,40 +132,16 @@ jobs: scenedetect -i tests/resources/testvideo.mp4 -b opencv time --end 2s scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s - - name: Smoke Test Package (Core Only) - shell: bash - run: | - python -m venv .smoke-core - VENV_BIN=.smoke-core/bin - [ -d .smoke-core/Scripts ] && VENV_BIN=.smoke-core/Scripts - source "$VENV_BIN/activate" - pip install "dist/scenedetect_core-${{ env.scenedetect_version }}-py3-none-any.whl" - # Core declares no OpenCV variant; importing without one must raise the friendly error. - python -c " - try: - import scenedetect - raise AssertionError('import should fail without cv2') - except ModuleNotFoundError as ex: - assert ex.name == 'cv2', ex - " - # Any variant satisfies the library; core must not drag in CLI deps or the entry point. - pip install opencv-python-headless - python -c "import scenedetect; print(scenedetect.__version__)" - python -c "import importlib.util as u; assert u.find_spec('click') is None, 'click must not be a core dependency'" - python -c "import importlib.util as u; assert u.find_spec('tqdm') is None, 'tqdm must not be a core dependency'" - if command -v scenedetect >/dev/null 2>&1; then - echo "The scenedetect entry point must not be installed by scenedetect-core" - exit 1 - fi - - name: Smoke Test Package (Upgrade From 0.7) shell: bash run: | - # All three packages ship the code (no metapackage layering) precisely so that - # in-place upgrades from pre-0.7.1 installs keep working - a code-carrying dist + # Both packages ship the code (no metapackage layering) precisely so that + # in-place upgrades from older installs keep working - a code-carrying dist # flipped to a code-free metapackage would break here, since uninstalling the old - # version deletes module files a dependency just wrote. Keep this as a regression - # guard for that failure mode (https://scenedetect.com/issues/558). + # version deletes module files a dependency just wrote. This is also why the + # short-lived scenedetect-core (published in 0.7.1 only) was yanked rather than + # layered on. Keep this as a regression guard for that failure mode + # (https://scenedetect.com/issues/558). python -m venv .smoke-upgrade VENV_BIN=.smoke-upgrade/bin [ -d .smoke-upgrade/Scripts ] && VENV_BIN=.smoke-upgrade/Scripts diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 08569eb4..de468367 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -103,8 +103,8 @@ jobs: run-id: ${{ steps.resolve.outputs.run-id }} - name: List artifact contents - # Expect 6 files: sdist + wheel for each of scenedetect-core, scenedetect, - # and scenedetect-headless. All three projects publish from this one step; + # Expect 4 files: sdist + wheel for each of scenedetect and + # scenedetect-headless. Both projects publish from this one step; # each needs a trusted publisher configured on PyPI/TestPyPI for this workflow. run: ls -la pkg/ diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 5dfad659..4c6de70d 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -48,7 +48,7 @@ jobs: fi - name: Build and Check run: | - # Builds scenedetect-core plus the scenedetect/scenedetect-headless variants. + # Builds the scenedetect/scenedetect-headless packages. python packaging/build_all.py # Glob by extension: dist/ also holds tracked website assets (dist/logo/), # which `twine check dist/*` would reject as an unknown distribution. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77e64ec0..80f5a530 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -213,6 +213,5 @@ jobs: echo "Release $TAG verified and published:" echo " https://pypi.org/project/scenedetect/$VERSION/" echo " https://pypi.org/project/scenedetect-headless/$VERSION/" - echo " https://pypi.org/project/scenedetect-core/$VERSION/" echo " https://github.com/$GH_REPO/pkgs/container/pyscenedetect" fi diff --git a/RELEASE-PLAN.md b/RELEASE-PLAN.md index dc80f273..bcd86ce7 100644 --- a/RELEASE-PLAN.md +++ b/RELEASE-PLAN.md @@ -54,7 +54,7 @@ Optional: version referenced below as `X.Y[.Z]` - replace with the real version ## 6. Publish & Release Checks - [ ] Dispatch `release.yml` (Release Orchestrator) with the release tag while the Github release is still a **draft**. It runs the verify-then-publish ladder (MSI install/upgrade test -> TestPyPI -> publish Github release -> PyPI -> Docker), verifying each stage before the next; `verify-only` stops before anything goes public. See the header of `release.yml` for details. -- [ ] Verify all three projects: https://pypi.org/project/scenedetect/, https://pypi.org/project/scenedetect-headless/, and https://pypi.org/project/scenedetect-core/. +- [ ] Verify both projects: https://pypi.org/project/scenedetect/ and https://pypi.org/project/scenedetect-headless/. - [ ] Deploy website: `generate-website.yml` - [ ] Deploy docs: `generate-docs.yml` - [ ] Merge release branch back into `main`, verify `docs/LATEST_VERSION` is correct diff --git a/docs/api.rst b/docs/api.rst index 51bae1a7..650975b4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -58,7 +58,7 @@ Most types/functions are also available directly from the `scenedetect` package .. code:: python - scenedetect<0.8 + scenedetect~=0.7 .. _scenedetect-quickstart: diff --git a/docs/cli.rst b/docs/cli.rst index d19cc4a2..de49ad40 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -312,13 +312,13 @@ Options Max distance between hash values (0.0 to 1.0) of adjacent frames. Lower values are more sensitive to changes. - Default: ``0.395`` + Default: ``0.35`` .. option:: -s SIZE, --size SIZE Size of square of low frequency data to include from the discrete cosine transform. - Default: ``16`` + Default: ``8`` .. option:: -l FRAC, --lowpass FRAC @@ -363,13 +363,13 @@ Options Max difference (0.0 to 1.0) between histograms of adjacent frames. Lower values are more sensitive to changes. - Default: ``0.05`` + Default: ``0.2`` .. option:: -b NUM, --bins NUM The number of bins to use for the histogram calculation. - Default: ``256`` + Default: ``128`` .. option:: -m TIMECODE, --min-scene-len TIMECODE diff --git a/docs/index.rst b/docs/index.rst index f3bfe924..1fc92ea0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,7 +8,7 @@ PySceneDetect Documentation Welcome to the PySceneDetect docs. The docs are split into two separate parts: one for the command-line interface (the `scenedetect` command) and another for the Python API (the `scenedetect` module). -You can install the latest release of PySceneDetect by running `pip install scenedetect` (or `pip install scenedetect-headless` on servers without GUI libraries), or by downloading the Windows build from `scenedetect.com/download `_. Library-only installs that need a specific OpenCV variant (e.g. `opencv-contrib-python`) can use `pip install scenedetect-core`, which has no CLI dependencies and lets you supply any OpenCV package. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. +You can install the latest release of PySceneDetect by running `pip install scenedetect` (or `pip install scenedetect-headless` on servers without GUI libraries), or by downloading the Windows build from `scenedetect.com/download `_. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. .. note:: diff --git a/packaging/build_all.py b/packaging/build_all.py index 22428d7a..3ff99089 100644 --- a/packaging/build_all.py +++ b/packaging/build_all.py @@ -7,20 +7,23 @@ # # Copyright (C) 2026 Brandon Castellano . # -"""Builds all three PySceneDetect distributions into dist/: +"""Builds the two published PySceneDetect distributions into dist/: - - scenedetect-core: minimal dependencies (numpy only, no OpenCV variant declared, - no console script) - built from the repo root pyproject.toml - - scenedetect / scenedetect-headless: the same code plus an OpenCV variant, the - CLI dependencies, and the `scenedetect` console script + - scenedetect / scenedetect-headless: the full package (code, an OpenCV variant, + the CLI dependencies, and the `scenedetect` console script), produced by + temporarily swapping packaging/variants/pyproject-.toml into the repo + root (restored afterwards, even on failure) -All three are code-carrying packages built from the repo root, so they share the -same source, readme, and dynamic version. The scenedetect / scenedetect-headless -variants are produced by temporarily swapping packaging/variants/pyproject-.toml -into the repo root (restored afterwards, even on failure). +Both are standalone code-carrying packages built from the repo root, so they share +the same source, readme, and dynamic version. The root pyproject.toml +(`scenedetect-core`) is a development/local-install configuration only and is NOT +built or published here: scenedetect-core 0.7.1 was briefly published and then +yanked - layering packages over a shared core dist is unsafe with pip (co-installed +variants double-own files, and converting an existing code-carrying name to a +metapackage breaks in-place upgrades; see https://scenedetect.com/issues/558). Requires `build` (pip install build). Fails if dist/ ends up with any wheel/sdist -besides the six expected artifacts, so clear stale build artifacts from dist/ first. +besides the four expected artifacts, so clear stale build artifacts from dist/ first. (Other dist/ contents are ignored - e.g. dist/logo/ is tracked website assets.) """ @@ -63,7 +66,6 @@ def main() -> None: "and re-run." ) - build() # scenedetect-core from the unmodified repo root. try: for name in VARIANTS: variant = (ROOT / "packaging" / "variants" / f"pyproject-{name}.toml").read_text( @@ -76,7 +78,7 @@ def main() -> None: PYPROJECT.write_text(original, encoding="utf-8") expected = set() - for name in ("scenedetect-core", *VARIANTS): + for name in VARIANTS: normalized = name.replace("-", "_") expected.add(f"{normalized}-{version}.tar.gz") expected.add(f"{normalized}-{version}-py3-none-any.whl") diff --git a/packaging/package-info.rst b/packaging/package-info.rst index d055b49e..1721e516 100644 --- a/packaging/package-info.rst +++ b/packaging/package-info.rst @@ -25,7 +25,7 @@ Github Repo: https://github.com/Breakthrough/PySceneDetect/ Install: ``pip install --upgrade scenedetect`` (or ``scenedetect-headless`` for servers) -Packages: `scenedetect `_ (CLI + ``opencv-python``), `scenedetect-headless `_ (CLI + ``opencv-python-headless``), and `scenedetect-core `_ (library only, minimal dependencies, bring your own OpenCV variant). All provide the same ``scenedetect`` module -- install only one. +Packages: `scenedetect `_ (CLI + ``opencv-python``) and `scenedetect-headless `_ (CLI + ``opencv-python-headless``). Both provide the same ``scenedetect`` module -- install only one. ---------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 6200c263..88f2f034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,13 @@ classifiers = [ "Topic :: Utilities", ] # OpenCV is required at runtime but intentionally NOT declared: any of the four -# opencv-python* variants satisfies the library, and downstream projects must be -# able to pick their own (https://scenedetect.com/issues/558). The `scenedetect` -# and `scenedetect-headless` packages (packaging/variants/) ship the same code -# with a concrete OpenCV variant plus the CLI dependencies. +# opencv-python* variants satisfies the library for development installs. This root +# config (`scenedetect-core`) is used for local/dev installs only and is NOT +# published to PyPI (0.7.1 was published briefly and yanked - layering the published +# packages over a shared core dist is unsafe with pip; see +# https://scenedetect.com/issues/558). The published `scenedetect` and +# `scenedetect-headless` packages (packaging/variants/) ship the same code with a +# concrete OpenCV variant plus the CLI dependencies. dependencies = [ "numpy", ] diff --git a/scenedetect.cfg b/scenedetect.cfg index 0612c626..d987435e 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -126,7 +126,7 @@ [detect-hash] # Threshold between 0.0 and 1.0 to set the relative difference between # hashes required to trigger a shot change. Lower values are more sensitive. -#threshold = 0.395 +#threshold = 0.35 # The ratio between 1 and 256 of how much low frequency information to keep. # Represents highest frequency which will pass the filter. 1 means keep all, @@ -135,7 +135,7 @@ # Size between 1 and 256 representing size of square of low frequency data to # use for the direct cosine transform (DCT). -#size = 16 +#size = 8 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s @@ -145,10 +145,10 @@ # Threshold between 0.0 to 1.0 to set the relative difference between Y # channel histograms (YUV) required to trigger a shot change. Lower values # are more sensitive. -#threshold = 0.05 +#threshold = 0.20 # Number of bins between 1 and 256 to use for the histogram. -#bins = 256 +#bins = 128 # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 8e84ca94..26787080 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -379,13 +379,13 @@ class FcpFormat(Enum): "detect-hash": { "min-scene-len": TimecodeValue(0), "lowpass": RangeValue(2, min_val=1, max_val=256), - "size": RangeValue(16, min_val=1, max_val=256), - "threshold": RangeValue(0.395, min_val=0.0, max_val=1.0), + "size": RangeValue(8, min_val=1, max_val=256), + "threshold": RangeValue(0.35, min_val=0.0, max_val=1.0), }, "detect-hist": { "min-scene-len": TimecodeValue(0), - "threshold": RangeValue(0.05, min_val=0.0, max_val=1.0), - "bins": RangeValue(256, min_val=1, max_val=256), + "threshold": RangeValue(0.20, min_val=0.0, max_val=1.0), + "bins": RangeValue(128, min_val=1, max_val=256), }, "detect-threshold": { "add-last-scene": True, diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py index df1fa486..395766c9 100644 --- a/scenedetect/detectors/hash_detector.py +++ b/scenedetect/detectors/hash_detector.py @@ -46,8 +46,8 @@ class HashDetector(SceneDetector): def __init__( self, - threshold: float = 0.395, - size: int = 16, + threshold: float = 0.35, + size: int = 8, lowpass: int = 2, min_scene_len: TimecodeLike = 15, ): diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index da20359f..0018606e 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -32,8 +32,8 @@ class HistogramDetector(SceneDetector): def __init__( self, - threshold: float = 0.05, - bins: int = 256, + threshold: float = 0.20, + bins: int = 128, min_scene_len: TimecodeLike = 15, ): """ diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 536638fe..9a783ea2 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -369,11 +369,12 @@ def get_system_version_info() -> str: # installed. Kept intentionally: the fallback is what recovers the version in the # frozen Windows build (which ships cv2 without any `.dist-info`), and the # `opencv-python` row is metadata-only, so it remains accurate on its own. - # The same code ships in three distributions: `scenedetect-core` (minimal deps) - # and the `scenedetect`/`scenedetect-headless` variants (OpenCV variant + CLI - # deps). Metadata-only lookups (no module fallback) so each row reflects which - # distribution is actually installed - e.g. frozen builds show "Not Installed" - # here rather than misattributing the module version. + # The same code ships in the `scenedetect`/`scenedetect-headless` distributions + # (OpenCV variant + CLI deps). `scenedetect-core` was published in 0.7.1 only and + # then yanked (see https://scenedetect.com/issues/558); its row is kept so lingering + # installs remain visible. Metadata-only lookups (no module fallback) so each row + # reflects which distribution is actually installed - e.g. frozen builds show + # "Not Installed" here rather than misattributing the module version. scenedetect_packages = ( ("scenedetect-core", None), ("scenedetect-headless", None), diff --git a/tests/test_detectors.py b/tests/test_detectors.py index fdb6ae14..a6a9f283 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -97,7 +97,8 @@ def detect(self): def get_fast_cut_test_cases(): """Fixture for parameterized test cases that detect fast cuts.""" test_cases = [] - # goldeneye.mp4 with min_scene_len = 15 (default) + # goldeneye.mp4 with min_scene_len = 15 (default). HistogramDetector's recalibrated defaults + # (threshold=0.20, bins=128) are less sensitive and do not trigger on the cut at frame 1260. test_cases += [ pytest.param( TestCase( @@ -105,7 +106,11 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=15), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365], + scene_boundaries=( + [1199, 1226, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [1199, 1226, 1260, 1281, 1334, 1365] + ), ), id=f"{detector_type.__name__}/default", ) @@ -119,7 +124,11 @@ def get_fast_cut_test_cases(): detector=detector_type(min_scene_len=30), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365], + scene_boundaries=( + [1199, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [1199, 1260, 1334, 1365] + ), ), id=f"{detector_type.__name__}/m=30", ) @@ -241,7 +250,13 @@ def test_min_scene_len_accepts_time_values(detector_type, min_scene_len): detector=detector_type(min_scene_len=min_scene_len), start_time=1199, end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365], + # HistogramDetector's recalibrated defaults do not trigger on the cut at frame 1260 + # (see `get_fast_cut_test_cases`). + scene_boundaries=( + [1199, 1281, 1334, 1365] + if detector_type is HistogramDetector + else [1199, 1260, 1334, 1365] + ), ) scene_list = test_case.detect() start_frames = [timecode.frame_num for timecode, _ in scene_list] diff --git a/website/pages/changelog.md b/website/pages/changelog.md index def2a9b6..d1784fd2 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -5,7 +5,7 @@ ### PySceneDetect 0.7.1 (July 21, 2026) -PySceneDetect 0.7.1 adds a new `scenedetect-core` package and official Docker images to make downstream integration easier, along with support for concatenating multiple videos. It also includes several stability and robustness fixes for the PyAV and OpenCV backends. +PySceneDetect 0.7.1 adds support for concatenating multiple videos, along with several stability and robustness fixes for the PyAV and OpenCV backends. #### CLI Changes @@ -24,8 +24,7 @@ PySceneDetect 0.7.1 adds a new `scenedetect-core` package and official Docker im #### Packaging - - [feature] Add `scenedetect-core`, a new library-only package with minimal dependencies (`numpy` only): it does not depend on any specific OpenCV variant, allowing downstream projects to choose their own (e.g. `opencv-contrib-python`), and does not include the CLI dependencies or the `scenedetect` command [#558](https://github.com/Breakthrough/PySceneDetect/issues/558). Convenience extras `scenedetect-core[opencv]` and `scenedetect-core[opencv-headless]` are provided - - [general] `scenedetect` and `scenedetect-headless` are unchanged: they continue to ship the full program (library + CLI) with `opencv-python` / `opencv-python-headless` respectively. All three packages provide the same `scenedetect` module (install or depend only one) + - [general] `scenedetect` and `scenedetect-headless` are unchanged: they continue to ship the full program (library + CLI) with `opencv-python` / `opencv-python-headless` respectively. Both packages provide the same `scenedetect` module (install or depend only one) - [feature] Official Docker images are now published to the GitHub Container Registry with the full CLI, all backends, and external tools (ffmpeg, mkvmerge) included, thanks [@FNGarvin](https://github.com/FNGarvin) [#537](https://github.com/Breakthrough/PySceneDetect/pull/537) - Example usage (process a video in the current directory): ```bash @@ -782,3 +781,5 @@ Development ========================================================== ## PySceneDetect 0.7.2 (TBD) + + - [general] The `scenedetect-core` package introduced in 0.7.1 has been discontinued, and its only release (0.7.1) yanked from PyPI: pip cannot safely support multiple packages that install the same module files, and restructuring the existing packages around a shared core would break in-place upgrades. Existing `scenedetect-core` installs keep working but will not receive updates; install `scenedetect` or `scenedetect-headless` instead. Support for choosing a different OpenCV variant remains tracked in [#558](https://github.com/Breakthrough/PySceneDetect/issues/558) diff --git a/website/pages/download.md b/website/pages/download.md index 2421cfef..0dd1c64c 100644 --- a/website/pages/download.md +++ b/website/pages/download.md @@ -15,13 +15,12 @@ PySceneDetect requires at least Python 3.10 or higher.
    pip install --upgrade scenedetect-headless
    -PySceneDetect is available via `pip` as three packages: +PySceneDetect is available via `pip` as two packages: - [`scenedetect`](https://pypi.org/project/scenedetect/): full install with the CLI, depends on `opencv-python` - [`scenedetect-headless`](https://pypi.org/project/scenedetect-headless/): full install with the CLI, depends on `opencv-python-headless` (servers/containers without GUI libraries) - - [`scenedetect-core`](https://pypi.org/project/scenedetect-core/): library only, minimal dependencies. *Requires at least one of the `opencv-python` package variants to be installed*. -All three provide the same `scenedetect` Python module -- install only one of them. +Both provide the same `scenedetect` Python module -- install only one of them. ## Windows Build (64-bit Only)   @@ -60,7 +59,7 @@ After installation, you can call PySceneDetect from any terminal/command prompt ### Python Packages -PySceneDetect requires [Python 3](https://www.python.org/) and the following packages. The `scenedetect` and `scenedetect-headless` packages install all of them automatically; `scenedetect-core` installs only Numpy, and requires an OpenCV variant to be installed separately: +PySceneDetect requires [Python 3](https://www.python.org/) and the following packages, all of which the `scenedetect` and `scenedetect-headless` packages install automatically: - [OpenCV](http://opencv.org/): `pip install opencv-python` (any `opencv-python*` variant works) - [Numpy](https://numpy.org/): `pip install numpy` diff --git a/website/pages/faq.md b/website/pages/faq.md index 206254d6..0756fa33 100644 --- a/website/pages/faq.md +++ b/website/pages/faq.md @@ -16,13 +16,16 @@ For server environments without GUI libraries, install the headless variant inst pip install scenedetect-headless ``` -For projects that need a different OpenCV variant (e.g. `opencv-contrib-python`), install [`scenedetect-core`](https://pypi.org/project/scenedetect-core/) instead: it provides the library without the CLI and does not declare any OpenCV variant, so you can pair it with whichever one you need: +Both packages ship the same `scenedetect` Python module -- install only one of them. + +For projects that need a different OpenCV variant (e.g. `opencv-contrib-python`), install it *pinned to the same version* as the `opencv-python` variant your scenedetect package pulled in, so the two resolve to identical `cv2` files: ```bash -pip install scenedetect-core opencv-contrib-python +pip install scenedetect +pip install "opencv-contrib-python==$(pip show opencv-python | grep ^Version | cut -d' ' -f2)" ``` -All of these packages ship the same `scenedetect` Python module -- install only one of them. +Mixing OpenCV variants at *different* versions corrupts the shared `cv2` install. First-class support for choosing your own OpenCV variant is tracked in [#558](https://github.com/Breakthrough/PySceneDetect/issues/558). #### How can I enable video splitting support? From 82f96f759f65957ae926ded88cf19dbc3930df08 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 22 Jul 2026 22:34:27 -0400 Subject: [PATCH 402/407] [build] Deflake VideoStream tests --- tests/conftest.py | 27 ++++++++++++++ tests/helpers.py | 48 ++++++++++++++++++++++-- tests/test_backend_pyav.py | 12 +++--- tests/test_vfr.py | 58 ++++++++++++++--------------- tests/test_video_stream.py | 76 ++++++++++++++++++++++---------------- 5 files changed, 151 insertions(+), 70 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c26ea628..dee805a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -161,14 +161,41 @@ def delayed_start_video() -> str: return check_exists("tests/resources/delayed_start.mp4") +@pytest.fixture +def auto_close(): + """Registers VideoStreams (or anything closeable) for deterministic cleanup at test end. + + Usage: ``video = auto_close(open_video(path))``. Returns its argument unchanged. + Closing test-owned streams while the interpreter is healthy avoids ResourceWarnings + (unclosed PyAV containers / file handles) finalizing during interpreter shutdown, + where native teardown can crash the process exit code (windows-latest CI flake). + """ + from tests.helpers import close_video_stream + + streams = [] + + def _register(stream): + streams.append(stream) + return stream + + yield _register + for stream in streams: + close_video_stream(stream) + + def pytest_unconfigure(config): """Diagnostic for a windows-latest CI flake (silent exit 1 after a green run): report any non-main threads still alive at session end. Leaked threads keep VideoStreams alive into interpreter shutdown, where native teardown can crash the process exit code. tqdm's global monitor singleton is expected and ignored.""" + import gc import sys import threading + # Finalize any lingering test-owned objects (e.g. av containers kept alive by reference + # cycles) while the interpreter is still healthy, instead of at interpreter shutdown. + gc.collect() + leftover = [ t for t in threading.enumerate() diff --git a/tests/helpers.py b/tests/helpers.py index 94971abd..00d33add 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,6 +11,9 @@ # """Shared test helpers.""" +import contextlib +import typing as ty + from click.testing import CliRunner from scenedetect._cli import scenedetect as _scenedetect_cli @@ -18,6 +21,35 @@ from scenedetect._cli.controller import run_scenedetect +def close_video_stream(stream: ty.Any) -> None: + """Deterministically release a VideoStream's native resources. + + `VideoStream` has no public close()/context-manager API, so tests release the + backend-specific handles directly. Closing while the interpreter is healthy avoids + ResourceWarnings (and native teardown work) at interpreter shutdown. Safe to call + multiple times; never raises. + """ + backend = getattr(stream, "BACKEND_NAME", None) + if backend == "pyav": + # Close the decode generator first to break its cycle with the container. `_io` is + # the file handle backing the container (opened by the stream when given a path). + for attr in ("_decoder", "_container", "_io"): + handle = getattr(stream, attr, None) + if handle is not None: + with contextlib.suppress(Exception): + handle.close() + elif backend == "opencv": + cap = getattr(stream, "_cap", None) + if cap is not None: + with contextlib.suppress(Exception): + cap.release() + elif backend == "moviepy": + reader = getattr(stream, "_reader", None) + if reader is not None: + with contextlib.suppress(Exception): + reader.close() + + def invoke_cli(args: list[str], catch_exceptions: bool = False) -> tuple[int, str]: """Invoke the scenedetect CLI in-process using Click's CliRunner. @@ -30,7 +62,15 @@ def invoke_cli(args: list[str], catch_exceptions: bool = False) -> tuple[int, st """ context = CliContext() runner = CliRunner() - result = runner.invoke(_scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions) - if result.exit_code == 0: - run_scenedetect(context) - return result.exit_code, result.output + try: + result = runner.invoke( + _scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions + ) + if result.exit_code == 0: + run_scenedetect(context) + return result.exit_code, result.output + finally: + # The CLI opens a VideoStream on `context` and has no teardown path; close it here so + # its native handles are released deterministically instead of at interpreter shutdown. + if context.video_stream is not None: + close_video_stream(context.video_stream) diff --git a/tests/test_backend_pyav.py b/tests/test_backend_pyav.py index 4086f193..17bf0170 100644 --- a/tests/test_backend_pyav.py +++ b/tests/test_backend_pyav.py @@ -22,11 +22,11 @@ from scenedetect.backends.pyav import MAX_CONSECUTIVE_DECODE_FAILURES, VideoStreamAv -def test_video_stream_pyav_bytesio(test_video_file: str): +def test_video_stream_pyav_bytesio(test_video_file: str, auto_close): """Test that VideoStreamAv works with a BytesIO input in addition to a path.""" # Mode must be binary! with open(test_video_file, mode="rb") as video_file: - stream = VideoStreamAv(path_or_io=video_file, threading_mode=None) + stream = auto_close(VideoStreamAv(path_or_io=video_file, threading_mode=None)) assert stream.is_seekable stream.seek(50) for _ in range(10): @@ -56,9 +56,9 @@ def __getattr__(self, name): return getattr(self._container, name) -def test_read_tolerates_corrupt_frame(test_video_file: str): +def test_read_tolerates_corrupt_frame(test_video_file: str, auto_close): """A decode error partway through the stream must be skipped, not stop decoding.""" - stream = VideoStreamAv(test_video_file) + stream = auto_close(VideoStreamAv(test_video_file)) injected = False def fault_injecting_decode(container, *args, **kwargs): @@ -76,9 +76,9 @@ def fault_injecting_decode(container, *args, **kwargs): assert stream.decode_failures == 1 -def test_read_gives_up_after_consecutive_failures(test_video_file: str, caplog): +def test_read_gives_up_after_consecutive_failures(test_video_file: str, caplog, auto_close): """After too many consecutive decode failures, read() must return False, not hang.""" - stream = VideoStreamAv(test_video_file) + stream = auto_close(VideoStreamAv(test_video_file)) def always_failing_decode(container, *args, **kwargs): raise _make_invalid_data_error() diff --git a/tests/test_vfr.py b/tests/test_vfr.py index 4db6f415..0a6989e1 100644 --- a/tests/test_vfr.py +++ b/tests/test_vfr.py @@ -52,16 +52,16 @@ def _tc_to_secs(tc: str) -> float: return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000 -def test_vfr_position_is_timecode(test_vfr_video: str): +def test_vfr_position_is_timecode(test_vfr_video: str, auto_close): """Position should be a Timecode-backed FrameTimecode.""" - video = open_video(test_vfr_video, backend="pyav") + video = auto_close(open_video(test_vfr_video, backend="pyav")) assert video.read() is not False assert isinstance(video.position._time, Timecode) -def test_vfr_position_monotonic_pyav(test_vfr_video: str): +def test_vfr_position_monotonic_pyav(test_vfr_video: str, auto_close): """PTS-based position should be monotonically non-decreasing (PyAV).""" - video = open_video(test_vfr_video, backend="pyav") + video = auto_close(open_video(test_vfr_video, backend="pyav")) last_seconds = -1.0 frame_count = 0 while True: @@ -77,9 +77,9 @@ def test_vfr_position_monotonic_pyav(test_vfr_video: str): assert frame_count > 0 -def test_vfr_position_monotonic_opencv(test_vfr_video: str): +def test_vfr_position_monotonic_opencv(test_vfr_video: str, auto_close): """PTS-based position should be monotonically non-decreasing (OpenCV).""" - video = open_video(test_vfr_video, backend="opencv") + video = auto_close(open_video(test_vfr_video, backend="opencv")) last_seconds = -1.0 frame_count = 0 while True: @@ -96,13 +96,13 @@ def test_vfr_position_monotonic_opencv(test_vfr_video: str): @pytest.mark.parametrize("backend", ["pyav", "opencv"]) -def test_vfr_scene_detection(test_vfr_video: str, backend: str): +def test_vfr_scene_detection(test_vfr_video: str, backend: str, auto_close): """Scene detection on VFR video should produce timestamps matching known ground truth. Both PyAV (native PTS) and OpenCV (CAP_PROP_POS_MSEC) should agree on scene cuts since both expose accurate PTS-derived timestamps. """ - video = open_video(test_vfr_video, backend=backend) + video = auto_close(open_video(test_vfr_video, backend=backend)) sm = SceneManager() sm.add_detector(ContentDetector()) sm.detect_scenes(video=video, end_time=10.0) @@ -123,9 +123,9 @@ def test_vfr_scene_detection(test_vfr_video: str, backend: str): ) -def test_vfr_seek_pyav(test_vfr_video: str): +def test_vfr_seek_pyav(test_vfr_video: str, auto_close): """Seeking should work with VFR video.""" - video = open_video(test_vfr_video, backend="pyav") + video = auto_close(open_video(test_vfr_video, backend="pyav")) target_time = 2.0 # seconds video.seek(target_time) frame = video.read() @@ -134,9 +134,9 @@ def test_vfr_seek_pyav(test_vfr_video: str): assert abs(video.position.seconds - target_time) < 1.0 -def test_vfr_stats_manager(test_vfr_video: str): +def test_vfr_stats_manager(test_vfr_video: str, auto_close): """StatsManager should work correctly with VFR video.""" - video = open_video(test_vfr_video, backend="pyav") + video = auto_close(open_video(test_vfr_video, backend="pyav")) stats = StatsManager() sm = SceneManager(stats_manager=stats) sm.add_detector(ContentDetector()) @@ -144,11 +144,11 @@ def test_vfr_stats_manager(test_vfr_video: str): assert len(sm.get_scene_list()) > 0 -def test_vfr_csv_output(test_vfr_video: str, tmp_path): +def test_vfr_csv_output(test_vfr_video: str, tmp_path, auto_close): """CSV export should work correctly with VFR video.""" from scenedetect.output import write_scene_list - video = open_video(test_vfr_video, backend="pyav") + video = auto_close(open_video(test_vfr_video, backend="pyav")) sm = SceneManager() sm.add_detector(ContentDetector()) sm.detect_scenes(video=video) @@ -167,10 +167,10 @@ def test_vfr_csv_output(test_vfr_video: str, tmp_path): @pytest.mark.parametrize("backend", ["pyav", "opencv"]) -def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str): +def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str, auto_close): """Synthetic VFR video (drop every 3rd frame, alternating 1x/2x durations) should produce timecodes matching known ground truth with both backends.""" - video = open_video(test_vfr_drop3_video, backend=backend) + video = auto_close(open_video(test_vfr_drop3_video, backend=backend)) sm = SceneManager() sm.add_detector(ContentDetector()) sm.detect_scenes(video=video, show_progress=False) @@ -191,9 +191,9 @@ def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str): @pytest.mark.parametrize("backend", ["pyav", "opencv"]) -def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str): +def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str, auto_close): """PTS-based position should be monotonically non-decreasing on synthetic VFR video.""" - video = open_video(test_vfr_drop3_video, backend=backend) + video = auto_close(open_video(test_vfr_drop3_video, backend=backend)) last_seconds = -1.0 frame_count = 0 while True: @@ -208,22 +208,22 @@ def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str): assert frame_count == 160 # 2/3 of original 240 frames in 10s at 24000/1001 -def test_cfr_position_is_timecode(test_movie_clip: str): +def test_cfr_position_is_timecode(test_movie_clip: str, auto_close): """CFR video positions should also be Timecode-backed with PTS support.""" - video = open_video(test_movie_clip, backend="pyav") + video = auto_close(open_video(test_movie_clip, backend="pyav")) assert video.read() is not False assert isinstance(video.position._time, Timecode) -def test_cfr_frame_num_exact(test_movie_clip: str): +def test_cfr_frame_num_exact(test_movie_clip: str, auto_close): """For CFR video, frame_num should be exact (not approximate).""" - video = open_video(test_movie_clip, backend="pyav") + video = auto_close(open_video(test_movie_clip, backend="pyav")) for expected_frame in range(1, 11): assert video.read() is not False assert video.position.frame_num == expected_frame - 1 -def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path): +def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path, auto_close): """OpenCV save-images thumbnails should match PyAV thumbnails for all scenes. If the OpenCV seek off-by-one bug is present, scene thumbnails will show content from the @@ -233,7 +233,7 @@ def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path): # must not run per-backend: the cut at 00:01:39.474 scores content_val=27.08 against the # default threshold of 27.0, so decoder/colorspace differences between backends (or FFmpeg # builds - e.g. av 17.1.0 on macOS arm64) can flip it, changing the scene count. - video = open_video(test_vfr_video, backend="pyav") + video = auto_close(open_video(test_vfr_video, backend="pyav")) sm = SceneManager() sm.add_detector(ContentDetector()) sm.detect_scenes(video=video) @@ -246,7 +246,7 @@ def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path): for backend in ("pyav", "opencv"): out_dir = tmp_path / backend out_dir.mkdir() - video = open_video(test_vfr_video, backend=backend) + video = auto_close(open_video(test_vfr_video, backend=backend)) rebased = [ (FrameTimecode(start, fps=video.frame_rate), FrameTimecode(end, fps=video.frame_rate)) for start, end in scene_list @@ -282,9 +282,9 @@ def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path): @pytest.mark.parametrize("backend", ["pyav", "opencv"]) -def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path): +def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path, auto_close): """CSV timecodes for VFR video should match known ground truth for both backends.""" - video = open_video(test_vfr_video, backend=backend) + video = auto_close(open_video(test_vfr_video, backend=backend)) sm = SceneManager() sm.add_detector(ContentDetector()) sm.detect_scenes(video=video, end_time=10.0) @@ -417,7 +417,7 @@ def test_vfr_fcp_export(test_vfr_video: str, fcp_format: str, tmp_path): assert root.tag == ("fcpxml" if fcp_format == "fcpx" else "xmeml") -def test_vfr_csv_backend_conformance(test_vfr_video: str): +def test_vfr_csv_backend_conformance(test_vfr_video: str, auto_close): """PyAV and OpenCV should produce identical scene timecodes for VFR video. Only the known interior scenes are compared; the last scene's end time may vary slightly @@ -425,7 +425,7 @@ def test_vfr_csv_backend_conformance(test_vfr_video: str): """ timecodes: dict[str, list[tuple[str, str]]] = {} for backend in ("pyav", "opencv"): - video = open_video(test_vfr_video, backend=backend) + video = auto_close(open_video(test_vfr_video, backend=backend)) sm = SceneManager() sm.add_detector(ContentDetector()) sm.detect_scenes(video=video, end_time=10.0) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index a245ad40..d7e90336 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -139,9 +139,11 @@ def get_test_video_params() -> list[VideoParameters]: class TestVideoStream: """Fixture for tests which run against different input videos.""" - def test_properties(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): + def test_properties( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate video properties: frame size, frame rate, duration, aspect ratio, etc.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) assert stream.frame_size == (test_video.width, test_video.height) assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE) assert stream.duration is not None @@ -153,9 +155,11 @@ def test_properties(self, vs_type: ty.Callable[..., VideoStream], test_video: Vi test_video.aspect_ratio, PIXEL_ASPECT_RATIO_TOLERANCE ) - def test_read(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): + def test_read( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate basic `read` functionality.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) frame = stream.read() assert isinstance(frame, numpy.ndarray) # For now hard-code 3 channels/pixel for each test video @@ -163,18 +167,18 @@ def test_read(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoPar assert stream.frame_number == 1 def test_read_no_decode( - self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close ): """Validate invoking `read` with `decode` set to False.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) assert stream.read(decode=False) is True assert stream.frame_number == 1 def test_time_invariants( - self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close ): """Validate the `frame_number`, `position`, and `position_ms` properties.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # The video starts "before" the first frame, with everything set to zero. assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -195,9 +199,11 @@ def test_time_invariants( 1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS ) - def test_reset(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): + def test_reset( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Test `reset()` functions as expected.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Decode some frames, then reset the VideoStream and validate the time invariants. for _ in range(10): stream.read() @@ -207,9 +213,11 @@ def test_reset(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoPa assert stream.position == 0 assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS) - def test_seek(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): + def test_seek( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate `seek()` functionality with different offset types.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Seek to a given frame number (int). stream.seek(200) @@ -253,9 +261,11 @@ def test_seek(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoPar assert stream.position == stream.base_timecode + 2.0 assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate) - def test_seek_start(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): + def test_seek_start( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Validate behaviour of `seek()` at the start of a video.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Here we check similar invariants to test_time_invariants, but using seek(). assert stream.frame_number == 0 assert stream.position == stream.base_timecode @@ -286,11 +296,13 @@ def test_seek_start(self, vs_type: ty.Callable[..., VideoStream], test_video: Vi assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS) stream.read() assert stream.frame_number == 2 - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) - def test_read_eof(self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters): + def test_read_eof( + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close + ): """Ensure calling `read()` handles the end of the video correctly.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # To make the test faster, we seek to the second last frame. stream.seek(test_video.total_frames - 1) while stream.read() is not False: @@ -302,10 +314,10 @@ def test_read_eof(self, vs_type: ty.Callable[..., VideoStream], test_video: Vide assert stream.frame_number == test_video.total_frames def test_seek_past_eof( - self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close ): """Validate calling `seek()` to offset past end of video.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) # Seek to a large seek offset past the end of the video. Some backends only support 32-bit # frame numbers so that's our max offset. Certain backends disallow seek offsets past EOF, # in which case they should raise a SeekError (and the test is considered a pass). @@ -323,10 +335,10 @@ def test_seek_past_eof( assert stream.frame_number == test_video.total_frames def test_seek_invalid( - self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters + self, vs_type: ty.Callable[..., VideoStream], test_video: VideoParameters, auto_close ): """Test `seek()` throws correct exception when specifying in invalid seek value.""" - stream = vs_type(test_video.path) + stream = auto_close(vs_type(test_video.path)) with pytest.raises(ValueError): stream.seek(-1) @@ -346,19 +358,19 @@ def test_invalid_path(vs_type: ty.Callable[..., VideoStream]): _ = vs_type("this_path_should_not_exist.mp4") -def test_framerate_legacy_alias(vs_type: ty.Callable[..., VideoStream]): +def test_framerate_legacy_alias(vs_type: ty.Callable[..., VideoStream], auto_close): """`framerate=` is the soft-deprecated alias for `frame_rate=` (issue #548). All backends must accept both forms and produce the same `frame_rate`.""" path = get_absolute_path("resources/goldeneye.mp4") - legacy = vs_type(path, framerate=30.0) - canonical = vs_type(path, frame_rate=30.0) + legacy = auto_close(vs_type(path, framerate=30.0)) + canonical = auto_close(vs_type(path, frame_rate=30.0)) assert legacy.frame_rate == canonical.frame_rate # When both are provided, `frame_rate` wins (legacy is ignored). - both = vs_type(path, frame_rate=30.0, framerate=24.0) + both = auto_close(vs_type(path, frame_rate=30.0, framerate=24.0)) assert both.frame_rate == canonical.frame_rate -def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_file: str): +def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_file: str, auto_close): """Test that backend handles video with corrupt frame gracefully with defaults.""" if vs_type == VideoStreamMoviePy and get_moviepy_major_version() >= 2: # Due to changes in MoviePy 2.0 (#461), loading this file causes an exception to be thrown. @@ -367,7 +379,7 @@ def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_fil # on certain versions of MoviePy. pytest.skip(reason="https://github.com/Zulko/moviepy/pull/2253") - stream = vs_type(corrupt_video_file) + stream = auto_close(vs_type(corrupt_video_file)) # The fixture has 596 frames, one of which is corrupt. Depending on the FFmpeg build, the bad # frame is either skipped (incrementing `decode_failures`) or concealed and decoded anyway. @@ -380,19 +392,21 @@ def test_corrupt_video(vs_type: ty.Callable[..., VideoStream], corrupt_video_fil assert stream.decode_failures >= 0 -def test_decode_failures_clean_video(vs_type: ty.Callable[..., VideoStream]): +def test_decode_failures_clean_video(vs_type: ty.Callable[..., VideoStream], auto_close): """`decode_failures` must exist on every backend and stay 0 on a clean video.""" - stream = vs_type(get_absolute_path("resources/testvideo.mp4")) + stream = auto_close(vs_type(get_absolute_path("resources/testvideo.mp4"))) assert stream.decode_failures == 0 for _ in range(10): assert stream.read() is not False assert stream.decode_failures == 0 -def test_delayed_start_normalized(vs_type: ty.Callable[..., VideoStream], delayed_start_video: str): +def test_delayed_start_normalized( + vs_type: ty.Callable[..., VideoStream], delayed_start_video: str, auto_close +): """Files with a nonzero stream start time must report the first frame at t=0 on every backend (the fixture has a start time of 1.075s).""" - stream = vs_type(delayed_start_video) + stream = auto_close(vs_type(delayed_start_video)) assert stream.read(decode=False) is not False assert stream.position.seconds < 0.1 assert stream.frame_number == 1 From bba97f59ff082875cf1c41b8ce2cb52a34ed2020 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 22 Jul 2026 22:34:54 -0400 Subject: [PATCH 403/407] [docs] Update changelog after #558 --- website/pages/changelog.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index d1784fd2..334fb876 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -782,4 +782,6 @@ Development ## PySceneDetect 0.7.2 (TBD) - - [general] The `scenedetect-core` package introduced in 0.7.1 has been discontinued, and its only release (0.7.1) yanked from PyPI: pip cannot safely support multiple packages that install the same module files, and restructuring the existing packages around a shared core would break in-place upgrades. Existing `scenedetect-core` installs keep working but will not receive updates; install `scenedetect` or `scenedetect-headless` instead. Support for choosing a different OpenCV variant remains tracked in [#558](https://github.com/Breakthrough/PySceneDetect/issues/558) + - [general] The `scenedetect-core` package introduced in 0.7.1 has been discontinued, and its only release (0.7.1) yanked from PyPI: pip cannot safely support multiple packages that install the same module files, and restructuring the existing packages around a shared core would break in-place upgrades. Existing `scenedetect-core` installs keep working but will not receive updates; continue to install `scenedetect` or `scenedetect-headless` as usual. + - [improvement] `HistogramDetector` (`detect-hist`) default `threshold` changed from 0.05 to 0.20 and default `bins` from 256 to 128, calibrated from the [benchmark sweep](https://www.scenedetect.com/benchmarks/) for significantly better accuracy. Default output for this detector will change [#559](https://github.com/Breakthrough/PySceneDetect/issues/559) + - [improvement] `HashDetector` (`detect-hash`) default `threshold` changed from 0.395 to 0.35 and default `size` from 16 to 8, calibrated from the [benchmark sweep](https://www.scenedetect.com/benchmarks/) for better accuracy. Default output for this detector will change, including the statsfile metric key (now `hash_dist [size=8 lowpass=2]`) [#559](https://github.com/Breakthrough/PySceneDetect/issues/559) From 953233c9c968240e982ca6a71c132d1b81206a9a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 7 Aug 2026 22:00:11 -0400 Subject: [PATCH 404/407] [docs] Format md code blocks for new ruff 0.16 --- .github/ISSUE_TEMPLATE/scenedetect_package.md | 5 ++-- README.md | 29 ++++++++++++------- website/pages/api.md | 1 + website/pages/index.md | 5 ++-- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/scenedetect_package.md b/.github/ISSUE_TEMPLATE/scenedetect_package.md index 7fbca92c..3215afc0 100644 --- a/.github/ISSUE_TEMPLATE/scenedetect_package.md +++ b/.github/ISSUE_TEMPLATE/scenedetect_package.md @@ -14,8 +14,9 @@ Include code samples that demonstrate the issue: ```python from scenedetect import detect, ContentDetector, split_video_ffmpeg -scene_list = detect('my_video.mp4', ContentDetector()) -split_video_ffmpeg('my_video.mp4', scene_list) + +scene_list = detect("my_video.mp4", ContentDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) ``` **Environment:** diff --git a/README.md b/README.md index 4ce8d4ca..f3508a94 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ To get started, there is a high level function in the library that performs cont ```python from scenedetect import detect, ContentDetector -scene_list = detect('my_video.mp4', ContentDetector()) + +scene_list = detect("my_video.mp4", ContentDetector()) ``` `scene_list` will now be a list containing the start/end times of all scenes found in the video. There also exists a two-pass version `AdaptiveDetector` which handles fast camera movement better, and `ThresholdDetector` for handling fade out/fade in events. @@ -70,20 +71,28 @@ Try calling `print(scene_list)`, or iterating over each scene: ```python from scenedetect import detect, ContentDetector -scene_list = detect('my_video.mp4', ContentDetector()) + +scene_list = detect("my_video.mp4", ContentDetector()) for i, scene in enumerate(scene_list): - print(' Scene %2d: Start %s / Frame %d, End %s / Frame %d' % ( - i+1, - scene[0].get_timecode(), scene[0].frame_num, - scene[1].get_timecode(), scene[1].frame_num,)) + print( + " Scene %2d: Start %s / Frame %d, End %s / Frame %d" + % ( + i + 1, + scene[0].get_timecode(), + scene[0].frame_num, + scene[1].get_timecode(), + scene[1].frame_num, + ) + ) ``` We can also split the video into each scene if `ffmpeg` is installed (`mkvmerge` is also supported): ```python from scenedetect import detect, ContentDetector, split_video_ffmpeg -scene_list = detect('my_video.mp4', ContentDetector()) -split_video_ffmpeg('my_video.mp4', scene_list) + +scene_list = detect("my_video.mp4", ContentDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) ``` For more advanced usage, the API is highly configurable, and can easily integrate with any pipeline. This includes using different detection algorithms, splitting the input video, and much more. The following example shows how to implement a function similar to the above, but using [the `scenedetect` API](https://www.scenedetect.com/docs/latest/api.html): @@ -93,12 +102,12 @@ from scenedetect import open_video, SceneManager, split_video_ffmpeg from scenedetect.detectors import ContentDetector from scenedetect.video_splitter import split_video_ffmpeg + def split_video_into_scenes(video_path, threshold=27.0): # Open our video, create a scene manager, and add a detector. video = open_video(video_path) scene_manager = SceneManager() - scene_manager.add_detector( - ContentDetector(threshold=threshold)) + scene_manager.add_detector(ContentDetector(threshold=threshold)) scene_manager.detect_scenes(video, show_progress=True) scene_list = scene_manager.get_scene_list() split_video_ffmpeg(video_path, scene_list, show_progress=True) diff --git a/website/pages/api.md b/website/pages/api.md index 1456b356..009a6aac 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -45,6 +45,7 @@ import typing as ty import numpy as np from scenedetect import FrameTimecode, SceneDetector + class CustomDetector(SceneDetector): """CustomDetector class to implement a scene detection algorithm.""" diff --git a/website/pages/index.md b/website/pages/index.md index 113ebdf9..8b2d88ed 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -21,8 +21,9 @@ Split video on each fast cut using [Python API (docs)](docs.md): ```python from scenedetect import detect, AdaptiveDetector, split_video_ffmpeg -scene_list = detect('my_video.mp4', AdaptiveDetector()) -split_video_ffmpeg('my_video.mp4', scene_list) + +scene_list = detect("my_video.mp4", AdaptiveDetector()) +split_video_ffmpeg("my_video.mp4", scene_list) ``` From 69870c9e7a2760d64b3b11a58cf43996dd697526 Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Sat, 8 Aug 2026 19:40:49 +0500 Subject: [PATCH 405/407] Add deprecation warnings for legacy framerate aliases --- docs/cli/backends.rst | 2 +- scenedetect/__init__.py | 10 +++++++-- scenedetect/_cli/__init__.py | 10 +++++++-- scenedetect/backends/moviepy.py | 10 +++++++-- scenedetect/backends/opencv.py | 18 +++++++++++---- scenedetect/backends/pyav.py | 10 +++++++-- scenedetect/common.py | 24 ++++++++++++++------ scenedetect/video_stream.py | 2 +- tests/test_api.py | 11 ++++++--- tests/test_backend_opencv.py | 22 +++++++++++++++++- tests/test_cli.py | 40 +++++++++++++++++---------------- tests/test_timecode.py | 34 ++++++++++++++++++++++------ tests/test_video_stream.py | 8 ++++--- 13 files changed, 147 insertions(+), 54 deletions(-) diff --git a/docs/cli/backends.rst b/docs/cli/backends.rst index 2e28102b..7c235915 100644 --- a/docs/cli/backends.rst +++ b/docs/cli/backends.rst @@ -19,7 +19,7 @@ The `OpenCV `_ backend (usually `opencv-python `. """ +import warnings from logging import getLogger # OpenCV is a required package, but we don't have it as an explicit dependency since we @@ -115,8 +116,13 @@ def open_video( :class:`VideoOpenFailure`: Constructing the VideoStream fails. If multiple backends have been attempted, the error from the first backend will be returned. """ - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is - # used, once internal callers and downstream users have had a release to migrate. + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if frame_rate is None: frame_rate = framerate # A list of paths is opened as a single concatenated stream. VideoStreamConcat handles diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index dd21768c..ac506dff 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -22,6 +22,7 @@ import logging import os import os.path +import warnings from copy import copy import click @@ -361,8 +362,13 @@ def scenedetect( ctx = ctx.obj assert isinstance(ctx, CliContext) - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `--framerate` - # is used, once downstream users have had a release to migrate to `--frame-rate`. + if framerate_legacy is not None: + warnings.warn( + "`--framerate` is deprecated; use `--frame-rate` instead.", + DeprecationWarning, + stacklevel=2, + ) + if frame_rate is None: frame_rate = framerate_legacy elif framerate_legacy is not None: diff --git a/scenedetect/backends/moviepy.py b/scenedetect/backends/moviepy.py index aef9844d..d3167b26 100644 --- a/scenedetect/backends/moviepy.py +++ b/scenedetect/backends/moviepy.py @@ -19,6 +19,7 @@ import os import time import typing as ty +import warnings from fractions import Fraction from logging import getLogger @@ -94,8 +95,13 @@ def __init__( """ super().__init__() - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is - # used, once internal callers and downstream users have had a release to migrate. + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if frame_rate is None: frame_rate = framerate # TODO: Investigate how MoviePy handles ffmpeg not being on PATH. diff --git a/scenedetect/backends/opencv.py b/scenedetect/backends/opencv.py index 12294664..83cd60a8 100644 --- a/scenedetect/backends/opencv.py +++ b/scenedetect/backends/opencv.py @@ -101,8 +101,13 @@ def __init__( ValueError: specified frame rate is invalid """ super().__init__() - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is - # used, once internal callers and downstream users have had a release to migrate. + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if frame_rate is None: frame_rate = framerate if path_or_device is not None: @@ -395,8 +400,13 @@ def __init__( """ super().__init__() - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is - # used, once internal callers and downstream users have had a release to migrate. + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if frame_rate is None: frame_rate = framerate if frame_rate is not None and frame_rate < MAX_FPS_DELTA: diff --git a/scenedetect/backends/pyav.py b/scenedetect/backends/pyav.py index 0933c547..4fd60ea6 100644 --- a/scenedetect/backends/pyav.py +++ b/scenedetect/backends/pyav.py @@ -13,6 +13,7 @@ import os import typing as ty +import warnings from fractions import Fraction from logging import getLogger @@ -89,8 +90,13 @@ def __init__( # refinement for frames FFmpeg flags as corrupt but still decodes. super().__init__() - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is - # used, once internal callers and downstream users have had a release to migrate. + if framerate is not None: + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if frame_rate is None: frame_rate = framerate # Ensure specified frame rate is valid if set. diff --git a/scenedetect/common.py b/scenedetect/common.py index 653c5cf7..81086f60 100644 --- a/scenedetect/common.py +++ b/scenedetect/common.py @@ -296,8 +296,12 @@ def framerate(self) -> float | None: property returns an exact :class:`fractions.Fraction` and matches the naming used by :attr:`scenedetect.video_stream.VideoStream.frame_rate`. """ - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning here once internal - # callers and downstream users have had a release to migrate to `frame_rate`. + warnings.warn( + "`framerate` is deprecated and scheduled for removal in v0.9; " + "use `frame_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) if self._rate is None: return None return float(self._rate) @@ -335,16 +339,18 @@ def get_frames(self) -> int: def get_framerate(self) -> float | None: """[DEPRECATED] Get Framerate: Returns the framerate used by the FrameTimecode object. - Use the `framerate` property instead. + Use the `frame_rate` property instead. :meta private: """ warnings.warn( - "get_framerate() is deprecated, use the `framerate` property instead.", + "get_framerate() is deprecated, use the `frame_rate` property instead.", DeprecationWarning, stacklevel=2, ) - return self.framerate + if self.frame_rate is None: + return None + return float(self.frame_rate) def equal_frame_rate(self, other: "float | Fraction | FrameTimecode") -> bool: """Determine whether the passed frame rate equals this object's frame rate. @@ -368,8 +374,12 @@ def equal_frame_rate(self, other: "float | Fraction | FrameTimecode") -> bool: def equal_framerate(self, fps) -> bool: """[DEPRECATED] Use :meth:`equal_frame_rate` instead.""" - # TODO(https://scenedetect.com/issue/548): emit DeprecationWarning here once internal - # callers and downstream users have had a release to migrate to `equal_frame_rate`. + warnings.warn( + "`equal_framerate()` is deprecated and scheduled for removal in v0.9; " + "use `equal_frame_rate()` instead.", + DeprecationWarning, + stacklevel=2, + ) return self.equal_frame_rate(fps) @property diff --git a/scenedetect/video_stream.py b/scenedetect/video_stream.py index 48f25087..71af9950 100644 --- a/scenedetect/video_stream.py +++ b/scenedetect/video_stream.py @@ -66,7 +66,7 @@ class FrameRateUnavailable(VideoOpenFailure): def __init__(self): super().__init__( - "Unable to obtain video framerate! Specify `framerate` manually, or" + "Unable to obtain video framerate! Specify `frame_rate` manually, or" " re-encode/re-mux the video and try again." ) diff --git a/tests/test_api.py b/tests/test_api.py index 1ec880ec..190e5993 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,6 +14,9 @@ These tests demonstrate common workflow patterns used when integrating the PySceneDetect API.""" +import pytest + + def test_api_detect(test_video_file: str): """Demonstrate usage of the `detect()` function to process a complete video.""" from scenedetect import ContentDetector, detect @@ -73,15 +76,17 @@ def test_api_scene_manager_start_end_time(test_video_file: str): def test_api_open_video_framerate_legacy_alias(test_video_file: str): - """`open_video(framerate=...)` is the soft-deprecated alias for `frame_rate=` (issue #548). + """`open_video(framerate=...)` is the deprecated alias for `frame_rate=` (issue #548). Both forms must produce equivalent streams; when both are provided, `frame_rate` wins.""" from scenedetect import open_video - legacy = open_video(test_video_file, framerate=30.0) + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy = open_video(test_video_file, framerate=30.0) canonical = open_video(test_video_file, frame_rate=30.0) assert legacy.frame_rate == canonical.frame_rate # `frame_rate` takes precedence over `framerate` when both are provided. - both = open_video(test_video_file, frame_rate=30.0, framerate=24.0) + with pytest.warns(DeprecationWarning, match="frame_rate"): + both = open_video(test_video_file, frame_rate=30.0, framerate=24.0) assert both.frame_rate == canonical.frame_rate diff --git a/tests/test_backend_opencv.py b/tests/test_backend_opencv.py index 1f8c03be..9aefca78 100644 --- a/tests/test_backend_opencv.py +++ b/tests/test_backend_opencv.py @@ -18,6 +18,7 @@ """ import cv2 +import pytest from scenedetect import ContentDetector, SceneManager from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2 @@ -28,7 +29,7 @@ def test_open_image_sequence(test_image_sequence: str): """Test opening an image sequence. Currently, only VideoStreamCv2 supports this.""" - sequence = VideoStreamCv2(test_image_sequence, framerate=25.0) + sequence = VideoStreamCv2(test_image_sequence, frame_rate=25.0) assert sequence.is_seekable assert sequence.frame_size[0] > 0 and sequence.frame_size[1] > 0 assert sequence.duration is not None @@ -53,6 +54,25 @@ def test_capture_adapter(test_movie_clip: str): assert [start.frame_num for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST +def test_capture_adapter_framerate_legacy_alias(test_movie_clip: str): + """`framerate=` is the deprecated alias for `frame_rate=` on VideoCaptureAdapter.""" + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy = VideoCaptureAdapter(cap, framerate=30.0) + + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + canonical = VideoCaptureAdapter(cap, frame_rate=30.0) + assert canonical.frame_rate == legacy.frame_rate + + cap = cv2.VideoCapture(test_movie_clip) + assert cap.isOpened() + with pytest.warns(DeprecationWarning, match="frame_rate"): + both = VideoCaptureAdapter(cap, frame_rate=30.0, framerate=24.0) + assert both.frame_rate == canonical.frame_rate + + def test_decode_failures_exposed(corrupt_video_file: str): """The private decode failure counters must be surfaced by the public property on both VideoStreamCv2 and VideoCaptureAdapter.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index a807973e..5a220cb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -336,7 +336,7 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): def test_cli_framerate_legacy_alias(): - """`--framerate` is the soft-deprecated hidden alias for `-f/--frame-rate` (issue #548). + """`--framerate` is the deprecated hidden alias for `-f/--frame-rate` (issue #548). Both forms must be accepted; passing both should not error.""" # Canonical form. exit_code, _ = invoke_cli( @@ -344,26 +344,28 @@ def test_cli_framerate_legacy_alias(): ) assert exit_code == 0 # Legacy form. - exit_code, _ = invoke_cli( - ["-i", DEFAULT_VIDEO_PATH, "--framerate", "30.0", "time", "-s", "2s", "-d", "4s"] - ) + with pytest.warns(DeprecationWarning, match="--frame-rate"): + exit_code, _ = invoke_cli( + ["-i", DEFAULT_VIDEO_PATH, "--framerate", "30.0", "time", "-s", "2s", "-d", "4s"] + ) assert exit_code == 0 # Both forms together: `--frame-rate` wins, a warning is logged but no error. - exit_code, _ = invoke_cli( - [ - "-i", - DEFAULT_VIDEO_PATH, - "--frame-rate", - "30.0", - "--framerate", - "24.0", - "time", - "-s", - "2s", - "-d", - "4s", - ] - ) + with pytest.warns(DeprecationWarning, match="--frame-rate"): + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "--frame-rate", + "30.0", + "--framerate", + "24.0", + "time", + "-s", + "2s", + "-d", + "4s", + ] + ) assert exit_code == 0 diff --git a/tests/test_timecode.py b/tests/test_timecode.py index 3ea8fbd3..66017bd9 100644 --- a/tests/test_timecode.py +++ b/tests/test_timecode.py @@ -73,12 +73,16 @@ def test_frame_rate_property(): tc = FrameTimecode(timecode=0, fps=30.0) assert tc.frame_rate == Fraction(30, 1) assert isinstance(tc.frame_rate, Fraction) - assert tc.framerate == 30.0 - assert isinstance(tc.framerate, float) + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy_frame_rate = tc.framerate + + assert legacy_frame_rate == 30.0 + assert isinstance(legacy_frame_rate, float) # Constructed directly from a Fraction (the exact form for NTSC rates). tc = FrameTimecode(timecode=0, fps=Fraction(30000, 1001)) assert tc.frame_rate == Fraction(30000, 1001) - assert tc.framerate == pytest.approx(float(Fraction(30000, 1001))) + with pytest.warns(DeprecationWarning, match="frame_rate"): + assert tc.framerate == pytest.approx(float(Fraction(30000, 1001))) tc = FrameTimecode(timecode=0, fps=Fraction(24000, 1001)) assert tc.frame_rate == Fraction(24000, 1001) # time_base equals 1 / frame_rate for CFR sources. @@ -108,17 +112,21 @@ def test_frame_num_and_frame_rate_are_read_only(): def test_equal_frame_rate_legacy_alias(): - """`equal_framerate()` is the soft-deprecated alias for `equal_frame_rate()` (issue #548). + """`equal_framerate()` is the deprecated alias for `equal_frame_rate()` (issue #548). Both forms should produce identical results for every accepted operand type.""" tc = FrameTimecode(timecode=0, fps=30.0) # float, Fraction, FrameTimecode operands. other_tc = FrameTimecode(timecode=0, fps=30.0) for other in (30.0, Fraction(30, 1), other_tc): - assert tc.equal_frame_rate(other) == tc.equal_framerate(other) - assert tc.equal_frame_rate(other) is True + expected = tc.equal_frame_rate(other) + with pytest.warns(DeprecationWarning, match="equal_frame_rate"): + actual = tc.equal_framerate(other) + assert actual == expected + assert actual is True # Mismatched rate. assert tc.equal_frame_rate(24.0) is False - assert tc.equal_framerate(24.0) is False + with pytest.warns(DeprecationWarning, match="equal_frame_rate"): + assert tc.equal_framerate(24.0) is False def test_timecode_numeric(): @@ -555,3 +563,15 @@ def test_min_scene_len_accepts_timecode_like(): # ContentDetector: same. ContentDetector(min_scene_len=FrameTimecode(timecode=15, fps=30.0)) ContentDetector(min_scene_len=Timecode(pts=500, time_base=Fraction(1, 1000))) + + +def test_get_framerate(): + """`get_framerate()` emits one warning and preserves its legacy float return value.""" + tc = FrameTimecode(timecode=0, fps=30.0) + + with pytest.warns(DeprecationWarning, match="frame_rate") as warning_info: + frame_rate = tc.get_framerate() + + assert len(warning_info) == 1 + assert frame_rate == 30.0 + assert isinstance(frame_rate, float) diff --git a/tests/test_video_stream.py b/tests/test_video_stream.py index d7e90336..856ad1e7 100644 --- a/tests/test_video_stream.py +++ b/tests/test_video_stream.py @@ -359,14 +359,16 @@ def test_invalid_path(vs_type: ty.Callable[..., VideoStream]): def test_framerate_legacy_alias(vs_type: ty.Callable[..., VideoStream], auto_close): - """`framerate=` is the soft-deprecated alias for `frame_rate=` (issue #548). All backends + """`framerate=` is the deprecated alias for `frame_rate=` (issue #548). All backends must accept both forms and produce the same `frame_rate`.""" path = get_absolute_path("resources/goldeneye.mp4") - legacy = auto_close(vs_type(path, framerate=30.0)) + with pytest.warns(DeprecationWarning, match="frame_rate"): + legacy = auto_close(vs_type(path, framerate=30.0)) canonical = auto_close(vs_type(path, frame_rate=30.0)) assert legacy.frame_rate == canonical.frame_rate # When both are provided, `frame_rate` wins (legacy is ignored). - both = auto_close(vs_type(path, frame_rate=30.0, framerate=24.0)) + with pytest.warns(DeprecationWarning, match="frame_rate"): + both = auto_close(vs_type(path, frame_rate=30.0, framerate=24.0)) assert both.frame_rate == canonical.frame_rate From b9a6879ec03e8838f85eda94d98e4aee3d09cd7e Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Sat, 8 Aug 2026 20:24:48 +0500 Subject: [PATCH 406/407] Format API test --- tests/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 190e5993..5bde6cfd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,7 +13,6 @@ These tests demonstrate common workflow patterns used when integrating the PySceneDetect API.""" - import pytest From 5c487e833365ec300a8e7f0f43d042845db3b593 Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Sun, 9 Aug 2026 11:33:53 +0500 Subject: [PATCH 407/407] Keep --framerate as a hidden CLI alias --- docs/cli.rst | 2 +- scenedetect/_cli/__init__.py | 19 ++---------- tests/test_cli.py | 56 ++++++++++++++++++------------------ 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index de49ad40..b145a112 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -67,7 +67,7 @@ Options .. option:: --framerate FPS - [DEPRECATED] Use :option:`-f/--frame-rate <-f>` instead. + Alias of :option:`-f/--frame-rate <-f>`. .. option:: -m TIMECODE, --min-scene-len TIMECODE diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ac506dff..85fc17ef 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -22,7 +22,6 @@ import logging import os import os.path -import warnings from copy import copy import click @@ -238,14 +237,15 @@ def print_command_help(ctx: click.Context, command: click.Command): default=None, help="Override frame rate with value as frames/sec.", ) +# Keep --framerate separate so Click can hide it while mapping both spellings to frame_rate. @click.option( "--framerate", - "framerate_legacy", + "frame_rate", metavar="FPS", type=click.FLOAT, default=None, hidden=True, - help="[DEPRECATED] Use -f/--frame-rate instead.", + help="Alias of -f/--frame-rate.", ) @click.option( "--min-scene-len", @@ -347,7 +347,6 @@ def scenedetect( stats: str | None, config: str | None, frame_rate: float | None, - framerate_legacy: float | None, min_scene_len: str | None, drop_short_scenes: bool | None, merge_last_scene: bool | None, @@ -362,18 +361,6 @@ def scenedetect( ctx = ctx.obj assert isinstance(ctx, CliContext) - if framerate_legacy is not None: - warnings.warn( - "`--framerate` is deprecated; use `--frame-rate` instead.", - DeprecationWarning, - stacklevel=2, - ) - - if frame_rate is None: - frame_rate = framerate_legacy - elif framerate_legacy is not None: - logger.warning("Both --frame-rate and --framerate were specified; using --frame-rate.") - ctx.handle_options( input_path=input, output=output, diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a220cb0..cb1b1a84 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -335,38 +335,38 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str): # and ensuring that we got some frames. -def test_cli_framerate_legacy_alias(): - """`--framerate` is the deprecated hidden alias for `-f/--frame-rate` (issue #548). - Both forms must be accepted; passing both should not error.""" - # Canonical form. +@pytest.mark.parametrize( + "option", + ["--frame-rate", "--framerate"], +) +def test_cli_frame_rate_aliases(option: str): + """Both long frame-rate spellings are accepted by the CLI.""" exit_code, _ = invoke_cli( - ["-i", DEFAULT_VIDEO_PATH, "--frame-rate", "30.0", "time", "-s", "2s", "-d", "4s"] + ["-i", DEFAULT_VIDEO_PATH, option, "30.0", "time", "-s", "2s", "-d", "4s"] ) assert exit_code == 0 - # Legacy form. - with pytest.warns(DeprecationWarning, match="--frame-rate"): - exit_code, _ = invoke_cli( - ["-i", DEFAULT_VIDEO_PATH, "--framerate", "30.0", "time", "-s", "2s", "-d", "4s"] - ) - assert exit_code == 0 - # Both forms together: `--frame-rate` wins, a warning is logged but no error. - with pytest.warns(DeprecationWarning, match="--frame-rate"): - exit_code, _ = invoke_cli( - [ - "-i", - DEFAULT_VIDEO_PATH, - "--frame-rate", - "30.0", - "--framerate", - "24.0", - "time", - "-s", - "2s", - "-d", - "4s", - ] - ) + + +@pytest.mark.parametrize( + ("options", "succeeds"), + [ + (["--frame-rate", "30.0", "--framerate", "0"], False), + (["--framerate", "0", "--frame-rate", "30.0"], True), + ], +) +def test_cli_frame_rate_aliases_last_value_wins(options: list[str], succeeds: bool): + """When a frame-rate option is repeated, only the last value is validated and used.""" + exit_code, _ = invoke_cli(["-i", DEFAULT_VIDEO_PATH, *options, "time", "-s", "2s", "-d", "4s"]) + assert (exit_code == 0) is succeeds + + +def test_cli_framerate_alias_is_hidden(): + """Help shows the primary frame-rate spellings but not the supported hidden alias.""" + exit_code, output = invoke_cli(["--help"]) + assert exit_code == 0 + assert "-f, --frame-rate FPS" in output + assert "--framerate" not in output def test_cli_min_scene_len_accepts_all_timecode_forms(tmp_path: Path):