From a99aed18c17bbc549201b9e2608cc7e22e5ea1c1 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Thu, 19 Sep 2024 23:59:26 -0400 Subject: [PATCH 001/268] [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 002/268] [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 1154c0103ac1d2721758cc235b8cdc3744daa2ec Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 29 Sep 2024 21:05:37 -0400 Subject: [PATCH 003/268] [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 02bf0afe215d1ebe0db4df0ab6a739b52f6b159e Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Fri, 18 Oct 2024 21:26:42 -0400 Subject: [PATCH 004/268] [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 005/268] 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 006/268] 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 007/268] [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 008/268] [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 009/268] [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 010/268] [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 011/268] [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 012/268] [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 013/268] [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 014/268] [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 015/268] [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 016/268] [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 017/268] [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 018/268] [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 019/268] [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 020/268] [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 021/268] [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 022/268] [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 023/268] [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 024/268] [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 025/268] [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 026/268] [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 027/268] [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 028/268] [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 029/268] [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 030/268] [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 031/268] [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 032/268] 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 033/268] 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 034/268] [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 035/268] [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 036/268] [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 037/268] [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 038/268] [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 039/268] [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 040/268] [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 041/268] [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 042/268] [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 043/268] [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 044/268] [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 045/268] [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 046/268] [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 047/268] 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 048/268] [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 049/268] [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 050/268] [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 051/268] [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 052/268] [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 053/268] 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 054/268] [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 055/268] [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 056/268] 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 057/268] [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 058/268] 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 059/268] [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 060/268] 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 061/268] [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 062/268] [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 063/268] [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 064/268] [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 065/268] [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 066/268] [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 067/268] [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 068/268] [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 069/268] [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 070/268] [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 071/268] [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 072/268] [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 073/268] [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 074/268] [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 075/268] [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 076/268] [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 077/268] [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 078/268] [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 079/268] [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 080/268] [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 081/268] [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 082/268] [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 083/268] [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 084/268] [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 085/268] [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 086/268] [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 087/268] [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 088/268] [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 089/268] [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 090/268] [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 091/268] [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 092/268] [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 093/268] [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 094/268] [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 095/268] [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 096/268] 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 097/268] [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 098/268] [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 099/268] [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 100/268] [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 101/268] [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 102/268] [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 103/268] [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 104/268] [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 105/268] [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 106/268] [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 107/268] [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 108/268] [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 109/268] [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 110/268] [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 111/268] [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 112/268] [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 113/268] [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 114/268] [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 115/268] [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 116/268] [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 117/268] [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 118/268] [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 119/268] [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 120/268] [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 121/268] [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 122/268] [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 123/268] [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 124/268] [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 125/268] [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 126/268] [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 127/268] [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 128/268] [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 129/268] [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 130/268] [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 131/268] [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 132/268] [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 133/268] [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 134/268] [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 135/268] [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 136/268] [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 137/268] [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 138/268] [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 139/268] [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 140/268] [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 141/268] [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 142/268] [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 143/268] [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 144/268] [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 145/268] [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 149/268] [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 zcmbrlWpLfV(k;5TnZ3=-?AVT(nPZCCj+rTDW@ct)hB#(sW@hG?nfdyB=ly!`*1bQT zsz#%(mNYGCYE5^q761SQKm$M^z}H3$Nc`G6e>H6E|H_yU0012DOGEr$c?R<9_^UB8 z{#R~<0|2hSnxNo+WeG$8pacT|@b&$#%!dO2T#)|f_&*ylAmr=1Z@d6Nn7o_>A{_45 zl>o#alA?-V{eLU~!9acOiEcmjzdFqyqC(29nTDQMyL~=TZ~*wws~bcL>D$JM3Y^qx zSbxSEPfEv$VFuEE2_`)}oOB^y6!cJKL~unQ8hDT|WLf}z>l0v6kQ|E<(Fa^slg<|Nq9O|GWk4|FLP?h3^Rf0N?npOL7 zCi0_FF_onkvc@V~U@6Q(_K$FSkB@g?ZA{tiuKv(;D4aRU;Cb|X z1?1+*mX^b5FXf%$I&J`n(q?T7q_Su@$-y&=}?P{i%e{60qZh^ zOE&BMCV9SFf3?MUz4NmJ#~^kYg~b$d&F!7IWke+O$NDV2k>*->y}P{e@M1W+Axr&{ zhVr=h`0U{lu<{=rkxmjt|dN@itX!~-F`!T`{onCgrqX3w}vT)glORD zh@A%-HmYszdc*p6h{fn*s$Uh}0f#>ntdH$l{Q}^yK49ca7+OEWmpXu>s3*W1>D=V& zjfvhk|xd|2rj0*JKKCWb{uH4b-KYrcadsrJbK~7qr;4&#^IO}B zsF~5PE&(xov_vw(5C;;Mabi6kZprd$X#1+sEu!M{;SN+>v+4-k;|>loZ%>img@wXE z61#@()nLzpGboNg1Vd?ylAE(2 z(Q*=qjxCsX;wl&C&d8qn2<37j@PHqVL(xLkn3RA?-&_+r3x1gbak|3FgLt8v^RvX# z@MO6CK8uEM`xZD^rMz-DmHk&B!kn*uDvT$Q5GWQ0VNSFeZ_I9vaT~+s%C=~6pUSzK zu1cA9r=!ls(8~!k$IG2iWowA)a4N65G4M=1n7sY!S_0jNKbs`%qM87D<_KQC zLEGY2k4ajDA(dSC-UvtkLD+BM-H0l0^zR+a#GTnsNxR>>K7X}iuf>@2vJ*4>yy)6m z@zQWa?)ae|(dTmA^J>%ch=)jJ@!%?DnNHjJx`USwja+Jpn`4ZYto%g|5afFwhVx4@7n@~>W!JOe#Sqnhp+*3)~K zkd;H-V5+!dM2QnMDqgAFJE_q}QyyKtSnP1I@o;BzW4%+BYwyZgzz(jZ;GIwq#=^cx zgM?w^SCwyBdAT?#!Tw(Q`-g=8#=%GnVhF(;iEXe0|FPS^*IlyxK;fLXL}R_GQ*?U< z)&Cb%ApX)5{|_oSxVzs00FbHwMTHKv-Ra6d}eLgQJ|R zy1bhVH|HwrHQVyUHvjE=dUZKW-gZuTaM8nW5mo`3I-#qvkp-~Uu)I2MVxi zPcixjNen)9K_(s)nzEvn2VZ3C0G*%hy8w{`3yF`lbO8=XO>0q;Hb038ReJ1@6|)f| znUZfJO~h*U7ApnpNU(dmlY|0=`HqP20SQJx$zVSwpapKQZ=UI@$KfOaKA$^2-eQ#q zFJV^*ikuEk$x3}r9I=pAvVXXti+EN)QKISNacnyoB^*BwqqbT|QtR*@S>Fe6NZbEl6* zqpDzI#=l{PB)CNTgV{#F$IwK>W6=rjg>#R8V-R}XDUg(eW&F__@jD~T?T?vO^yB7M z7?Ip(V2X}6WTl4-F=|+T_i-ROlO#K2i4QMXD%frsLSiC%!C;&4cqVwOd9S7tWQ-|^ zTqY;%0E6K>-s#oDzk5bU;xWtzuQ@rJa!)Y-(4|Pwy%2F zm0dAOvCn!M&)ZPaLW%Rtx05NB3xpgKP!c1v!^KK#wN#vG_P-Rz&pXcBb-$p)JX*Q^cH{mo#MwP3|xSV+2h?I?Y8Ln(dC zTRsoACf`yr_<1n%2P!reS{Wna8}0Ox0I|KW0Yb5P52GWKlJ`@40}Mnv%+v1ymSdPu zYN~3-?qkxaMd-vIw9vu9BtO_o?J(p@!1-+uaq&)fs_ygutvhs z8>lx5lK@4Lm=;Myz^qgpFfl*#g=+p+@;}Sr_FJ%Xjp~~r+rGYgof@ z-|~c0$wvoFZLEjOHH!FTy4;_w!z~EJr!fRvkvBKYnfz$|jmdn%KKped)Zf4Lt?0f~ zA8cUm9M?oUC#ED(I%#TjZe_n13!px=cijnOUcV~qV?0@)>~RPu8zVx{^!ZQ3zt*9OIcN#0D<~IMRFxZ03*zA zxRe7a;e5>{wi)qb#XPRK)W3$3%mj#V-v8*O_>rx&QVe@LjQZ9|PnQL>PI$&j@f z;_7H3Fzl5v}gt4$n3NFIz*e%1>1gdiO#>Cn$-1yi$Nk}ms2)t|7}4e&O;3^ z?cTT!Wuzwh5uB@@nI>DPg*Wo(*)zbkD>v*Fop>5#hT}j!{SGrW>-i7C&nQ7&S@5SA zGBtF(>1GNrlBpREey7aRT~H@$`n*m zzc*`WwXi=Bphmz^=T-wl zQRBT#9xpnY941ZrC({K(;C6+NFYJo=$kbr@JueIq;5cr=fQPnejY zH5X2$59Z+n3UV3?#0^Gym0wY{H&W4#RwCf!KZ2>HLBM58_C{pbs2%m+cqb=#R}ED< zev+b>7|5tsK96=*UVI5oDJuEpC&Z3(z5}EVf#=RVnthSj!=sbFbQP|TIiCh3tVkpz zLIZdn5Bfa?4zF;4U&C*tFcM^*tubSxwAM;{dimdt=#`|Dm!S+ri1XDDgXqjH+8XeHD2C8y;+C#`P>ZIw8aD; z1o_;2x!JbO@O=pALV>Cb58Z9ikpC{q&x|)} zRp$#{yPD!^LJU`HK1|KpVel^=0?qG80aPC+}pMi%oAd zuA#x+*3H4pxNV*mt&Z-VIO9br(cy0Oj@61t{+yawSo#m-Z`Y1>`ZrAd?JvAzee#?+ zYRWn5j_)<*RMv!6NOXQaSM%S!0 z7n_g}HJ@l?rU8K>Yv}QC67~XTOUWI}&xR}#pNUVPDGZjaaZM(Z!H$EJLE!5*A>aYK zrn}n14jbQJ-;w$|<8NWGXjb-o zy%BrQmt)vQFqI)3md>d6*D-}(ZlHKEf3akbd>Qh51-GaVPJHxpYO|O6>fj=2%-v0o&apslo_1;)da;?4C|^B$ z)-Y_b%y5tlis&woV|ngu(L7gT2hc`8@Pt(DQ4m0&F!1?kLtuTlrhT|`jU zioP6~VAl_Ua(!a;Y$JClBJ4DF>NMRv)$*`4-QLx!vlNiAF=#H2N-@8%wD(-uC9Je3 zA&*hC4toXq3?*=As_u$;qntpLZ;nY;?lkFTZMbxb{meQL~ z4FeGX3Ny&Z+zwIbp`FOel1FJ)(GLPrltlHbAV+#%8#}z)1wEXDI4i{lOnd; zYG%jc*zy!{6fvmV9TGczGB%3o9eBoOzq60dH;7YccKqd%fin2wB(}Z<>Unr@wsij} zS(i{4yV*yB#HSlD5W3FxdK%)ny!LYAVpw-Roml@=vZL3i15)vD|E!ShphJHZiYboQ zS+rXwaMka#piDiowGH1 z-vfib5H3OZOyVPVdNv^YJI<_$EdO{NW_B@7k!<3sIBM#qtrV~HPx;_!)#blCZ|e^E zc(mgLhbIr2?rMFJmiSrHVgyCqCEvgDq42BCH3hB_l6B(ywBNQu^2^_N?$2i?nnqMh zD}MWym$%2|Bzj{^fiNZ(4Xrr6-#7(+vTzynhh__}PTK~$=yaSSPp=&JmTRr3u$EZDI8PB=J4hUzvbYDInB!t& zr>Fd^Z88@ev08VUL8*sV7gJpp3{0We`yi%a5`$Qge&Uc&Atbllhtu^@;%hcv3~ylo&0FiSw=QRDj#KM>ssJweLe zI(7D4CDl=VJ!GqlzQ|e-lfu!J7xcZ%CzTTtoYEmuKm$EJ#tA>jm#IE2SHM^bHLi;l z65uFrLJETEceX(kR+2sh&o}x&4FI)YP{5Y=pY(+czMx%qv>s-g_vXj#a(3bPapC)-Fd#)O#gc}7T1nL1^L4&~r3Byk3 zSv+NWiTyJ4h7`O!RngBr%v-R?MPUQ6@qT#RaY{*llXu9&bTFClEvJ=7XPaCQCX#GS z5d}=`ax1B$Lzp#aNXGjrl}zR}yAshp5(pl2zT38J#FEqFwj&;GoT~ELes-9dnMty4 zBV1(EIbDy>nFrF)h11njR6^YoepgCFXZV`#N+oh4@#H2TW`vy)c|SqG`|HEt>!p>r zRVB;L6Dc=E44VuNH*IFPh>C=KOBEc(LDJLiGE*onoEvjER2e&-Qftd}OJB|>-r>{| z^B(yqBq=vZOc$rKKaI4M8UZKRmBlY`UeVf0+tuQfG0mu>*G|j$2y`*H*0;FmkKjC!FJ!x|bU$3T(vH*1!r}3nY2YV2qu8P@HJz2cs%7K5|s$ z*8f4pj7Xcvn>-fY2?V3_a6n;=_tlf^$t&+$)^!TSd0(^XGTi%`Zc0i49UhH+2JB6_ zz?45gZ%e*tn1-VHOd~`bgf=oPNSA1l1;449qx&L8Y$r=gDQk0coMrhtO!vnZ8u+bH z7bV5Wl zazlU8=C-e)h+%jgxls@|i5tC}5v5bA3*s4kxP^XJcjy%@g8 zonIn$Yrftvqj0RagcwQCOur|`Vh?P=;2{F!jUdN9ss0Wvr0dMC7nTkg zpTo+o*jYty!DvKKh!yQIz9$VA!;LYk!~oua0`3oIe(mjAoZ_Z^)>D2Xxu)C-pVKBd zGCyi|J70{beNPDaWrDh7v-zAyW16K7B>(8dK~B;y28pHSCuopB9M z5CpaJ^0c~)33d}@SKz}j@#4_N*`k2Q=9`yc-Vrc@m6bK7UM>DW6^|VD@8iccytSVx zXhXzf_x9cDs-a`|D=N-6+km?}orAWH3E9Y;EGogRzTLB@Q*VYT&#$d9zu8}LZ%Vnp zFJ#rvXsTy*gEO`V5OCQJPfqrlc_7`KI~yS#fpe8s$Cj9xOYf*6>Pgq++WdnlEYW5L z{CaVzqVFzv{6J+Rv=GBnH=jAZw?j<@Q|rL1GnE_4{PmLzq)Y;XA5F*;762WS`?R<5 zbmHoyJaQ$|Odl<1Kn9c_L=e{`z+pqoeC)nJdmOY)3FI zO5(h!`V=f#sJ&UyTCOMKwMO_QcdkyT8*N+iyW=E9m#{A&hBqvOc~&#f3_eIX$;oLx z13teya-khIY-|q*zCB#mm(2rLP+P~HIFv$08gvG&DIglYfUC4-uGoL=iifC-aWtpA z3qA|q~;tJzILvxfnYf3-8?op9=P0>g50XZWRyO@?{Tk(_e# zq6Qm*7MqC|zUTlW^0a=oqV1^v--J(C2E>oI4*)MAhj7$SE!S*A+2|R)&`nMn8Dc<8 z8;97mx8dfAx)vkH>r9)`jG_@%=+(K%!=-jW z4npL4Dq9!uqskC=1PdYeZ7z#$mqA~db8sE9vQYmxUgP2>eiu8k8@$EMMW~>N%ihvT zvC##K2dgKPG)8L(A{3XzWWz46I2q?yc%q1WcrNDU#a+hj4F&(x6!tZn+E3v@>t*ow zv{{katw94WYvJCs4}zu6e=Mc^wjZ-i+upYFIs5F!D|K z>%51M@zJwUx>X~_>A@r&w1MD)T%3Om2MG^%6@a)OM1zHO#f?|wsA;hRa9A2a8d;dU zoUZTu%8BT8Ay^7g_{>Pmkun5}umL{tQAysCJ-(6I-- zn0i(Dj@`XDBOhC2k5d$R6en<{+L^+=IBglq0)oYDRm;LC%^>iXM5?0$!oQl5Lnb&1 zw3qX6fQrh_wX3>0S!*l|FLc2O`rMQJ@|-*Wjj(WtLFC|(9Alqrhb7Ghx*;R3<{t%a zaxl*5fDt4AnbhVL7{f@$^5BX9Cfr{?<6sj0gq{Pb90DeTj~T&Bv;H()QtyS1_>%Gn z?Woq(;rSar`w|Y5Cm;+o0^($6Sg4R{$2V5M{myM}Lfe|$exXd~0jm-G{r&;uQT5&2uXb1OD z!2R;lm;v>Q6;sVQkV~E3#PO8lrd1P(3a!R~StOp#s_{yI)^P+8CH~a!Q$XC+(GDf! zbH}1H*O86lW>c{OD)!h5GrBJbzTr%0Ad37>Fxk!Yqvef?-4f>wqB};C8fy+kl9Ic(A*j}^wyH5d zI1v90qt3OVT@#HPy-Sv4OemAk<>PP>@(F5x7(s<6x1Z_*yo9yqKY%_# z`GY{)J7unY=3~q<8P+RY-E!aGfTkf$BNxs(_x0pyUO;qKrA0~nLS`2x7U}j&uMWN3 zNjj=i9tj{iB{eruI^Ds~mmNG#5+Y0#EDXZ7t>em-w~`S`S!F5Iog9z2xU0y(h9+i- z)|V=_PlXoa?!}CJ;FLIFOhN07?2emWnO5v5xWgB26YfPtS<65!VYEHusnrZviFY7X zglb=g4p)S2z*KF##&5Y&kAVlru{#~N$LdOw6kgMEg@+yG-mG;ID^Rr>agnctHGUZ? zx+$Syw*jV+wx~i&p}>f)xZRCxTFul2%3!w{YNT%?jtk2zg@H8jyu<{dr}iD#|C5gbE3IGBQ<}m2clBn& zk-z`uW;SmzcyAGftI$e~Ne#53HF~nw2HMv_^%&HVpojV*p#v)A(ZIm2nmOEpL z9Hq{oqw)Rxi##Ev-^)fufCLxt77e&LUY(eaQNuwLte84S=i z%QX|Zbb({Rd%YbF>udWc86jw@ew?AA|K+lzI# zOj>?G+Xf0MA`A`ypVxbr%5q&kv^W2Jx%0iP!j+eci^eEKVdS4}v!2W9PA$*O?cZ!p z&m$0mJNOtgeTQ|DMi5(0)xgo~rHBfQIWZ{$T zj?JDy;M|7c#lp;JCNHqRZ&aX`@=)CjY@2{ob7C8VyGzrEg80ez4#uz1iu&URY^+p( z^yJ+~o#|Bm(=t%GO`E>hFM}OGPKxq7%l>`=nTTr=KE~lINH92x)p|RRI$YT@kwDC3 z;J`t^7S6`Bw2KG!D(iLKLLwo7hd>^ZmX+w_}O zNt8yJSij?i+1Wh}?fQ^1F!*@|e^;ktS5{Rq*g3sra(eUP)J17F7eTVYD(DFrTiU&L zn<5?5(_`(ys7_Rnk_v@-=sCA*3Wc&R1;F(09Zu@%m8T*Tx+K({BXMfU6)(=Ux(Mm$ zFhC)m#AJTB^vV_JLf~^d2g5egRzxXv$5r0k*xy}jK#wJN!l#M$<8lxpKUPxl%*<%0 zTggTG*uo(Z@<%tPs!Ni0c1x^(DVO&rI zuvX~(F=lC`#^|~0`bOGDut33XR#34`6y2*0&Qwzn+=>>Up%HVtpk@PCG8QDj%Yr;d zxgJt(ky|@Eihm*9&W36GjOVh;!9o-yLwkU3yMGmdHOBZ)t)Tj-GDW|^O|L-%&yu49 z+P)Q9;^$6VmPhs5+(NVM4ylAM&{S%2_vj-FBuybS*X@Mp3q_c+bluGAdC$4AZ4m=4 z*OnEfS6if7;k8<@l$;^cT-jHt-oWe%l#okVgl(SCbRmuw=~g(Ys#O-IP0&A}x-3w0 zr&6~h95#+O;R{22&QKRrTuTw}5)hKizYQBQF&w-bVa%vlH32;BUPlFGC@9wxKtjGP z?A>c~lsC}uX;`tZFYaI>;KWBMbm9BG`3wIiVygv8%KZ<`1nOPNq_-o}vmo#*>% zHjv_KzgahZd^c{_*$EytIt!0=^<-EV$6^NRRDVQVw4oLpS#glP@JpQ0B?j@$y!32{ zeTx4mOY;gjB?KVeKe6rF*@KFdPs+v07KSHr1OEttGwyF{kq=ntS3qg~wJ>92#;-uL zRjnXUP~gr1tI+AbKlb9_*J*`Nj0>e7+073q2D9!Bq~Y|u<1{eb^W={6pEs$i`CUE{ zSgs1er_NS=*d}bowwi0^`v|!D6{AZ=Sn@q{l^+9bh~Jmr>r%KWn#Q&j7&6v}?qx_= zg=MRe1W`xy3eNxPi8uzO1vdG`NFV$e+@7f<6e#2pt>2A{RP;#}DK-Kle0M`&~Y#>o}%O>fvu;HwHJV=kJIK*_dj zc3{P=!lk^AWZ;uNJ;n|>vUmR2fjEH!%l6JgSs8tumV?mUj9Wa0FwCd@o%Rm{veJW5 z1yjRJxK#$)vb=oYSB;F};p8Q8+)Wo{?(t(Dpz04R85a%Hk&{1(v9BQSq$%H~EAzmP ztuQHy$X{i_Q;F`Rq>Sn7MkMuy-9AiM&M=5nUSsw~OJwqGx>(5p^_DZQyOuhZOg3L( zgtK)I1UwE*eJ>#rq;mm?7O5FQt1pg0q>V#P{f~1g8|}E)(^Bf=+AnIvT66?%z!7|` zh>HG2MFM!(S0x7(UdO@$+mX*tD0_Qo14$jiSsseLpFTQ)kTx9w&amgHjb_#XC%fFh z$3ah-jH@wB*yf^dOFJW5?;QE_xR}_Tf{hGX)B}Ai#k=c4@WWGEfw;)BXY(fnvwVd< zPog3cvj@R*$G%H@VqN3j!xN*T*3OizE>dDi0j)7TRp?0AfP4SJozlTkm5Q2+Q=1yY zq3r4_PHoO4<}4m3QEO{V&1-Hmd7F7xU`a=CnaN4d(4d*^(Qphlk2M^=oSGhRi39xe zMUP1o^*imi05n?_73VJu!j=1YBD-_%m|B{ZEk=#v|3z6rukd{5=A}_WJGf(C6K&+~ zj=cX5Nqlh|*i1!6z{ch~C-fN1TsU^=0#5@#-OayV>hkh^u4?~q(6ABXn}mFc|H>|R zD>+csb~WrE3xVnoP5wFmu&rkTgw@0j9V1Lb_|JvSK%Vd4P7{vn!nr9UwTW!cU zwUxhZpRs#X&K=m7Dl=2+MNB2{uGvJWqgjkCp3DDcm9Ow@(tQ@3gCuLErnk3;(!bi4 z**`F;nO)F9Ts~t+et&Z!cSH8=4>Uu-ev?j4)?^05RZQuFOqoNJUcLR_Przm?J3YJ>Uh#pq5*Zc(uD zU*(!rSaoYyMR^)$UWOn?lWKQX?-Q(&UL6n4gze9~^l6?wYr5AfZAps!Tu6NLkRNj< zyCG-;GIdW5k>oN+HsgEeN=mUADN$$Rz+oitm=JmZ>esZJi%lQxfNOl%?O0Cy#6U%) zW%n>?QmlVfExZR~NQYQgnlfCu`5Biu%ok))qmkA@5wX=9nwQ28skPS$v7sfwR_(Hd zGVWzbh%aZVZgJ(yn=Q+`Nz~H9B^e!Iz&j~wh3(2~35*3*iC8M)qWStU3x4IIyj;Suk_VxpV*cw37h${D;~D2HX4zGGQz9# zazR7U9{}0JVEQV!klPNO@6roaK|_5RKz|rtcPef6j2RNHuVlyr5W(FG9wHMap=7f6 zAN@L5v^B8;K%r+8gNS5NuN&)h(QvMq)N-R0o-{1fu34EjM3DyhTMW-RQehJbVb@>u zX>`^)<8sEmmP7A|62dfpcpRXd&gI|hO7<5ic@V0pU<k_5utf_j37u2X6Dgy~(r^N6K3f-qy*_bBnn7g~ z9SJ!VxVxKQ^VtpiwiEMx`znrX`V_GeRq-Co`spi=Uh4|vb+XJ&1{#|{vs+Rdv4lYe zWyqU13KrxgF-mP}R&lVz++qU64Ox>JG=r$JU-%T`(cJbHztBio^7)$uQU$Cm|Ijpe^ZHEme6ACj#*Cps0VyKKc0L_?8I?`Yr=@D z$|;9EpZ5khF$D>33mMgR$(-p5+&mr-k@v#6?+4A=xmcH5*`quUFVti%)4)}f?qh87!x!8T>5r{@#u$ zG}}(tM8-w|Tk!eYNQCdNQn+5(-nH~WUQQa;43Ib_qUQ0{D>4QA+Qlm(<=lM9qIcG! z2uBRJqrP6VB(G@WS3`n~TR=pSy4ji+$)0q_dB+HbD?3DE)N8U;-5x%xUgYN zxb1aDI67YER*kk~_&B~FV-GuXec#)Co4iw@8xARIckE|KGaW4_bpxbR#y`M)F}=w* zX5oXe4eMIF9t3+MIK65g$Z2WoU>ZD56OU{)xz|we{?303ZJrhkcz$bvFQwxpvMAd-@)A4i_=TyxG)~v*KNk}IBt$He#l1H1{r9TE5d~N09 z-7bjSuP~S(69C*mTLyfS1vPv?ObPzA*RfjNcQk;F1Wr1DU5X9clA6#|xXs zn^sYA^}2O%^8BINN_Pn9+(aY4Gi4gxQr1^Fl40YOn4h6>g{|83K&v&dAjGAvI97lM zE(js3?Or@GW)o^9PmERq`2vET=Ha&ljYLU3e`wSq$J*XcXNF{*UcXD1X*9L>RRtQ1 zqxDMUJ1A|Ijs0tP+v_umv#lmX+MrDOvcuYfIhvUoB`C+44>exs3SfWK+)o{6sBF(& zluRy%=y!q>CF=D2KYtJ+Hr)x}f)bq`j|@ED`%jZx%}y+nTfHFle*J>n)ViXbr!^Mj zL6*x}s8-onqQR}|Vkg3_T&>E2=s-=NoC-{bEVkkC@jh_FzFYNmHW_zEfGZj=nUgu7 zY`@#R)Mq`>L)J50y`v&Ykak7eQloD#Sg9A_=fTSHX-zC*E9o044%5tdfkrxtu`9UI zW43D|FGKLMS+O{Kw*J0Gzdwd_j_Pi9cIE5LuXBy@Md(e)-J4kt`DY&=KTQNY)|{Ju zoYB2{@-;fF)TrA^#8|t!-e5Z;0ZRzHSXI4SP)<24Z_Gm!eNq^-uZAOGn>}0N=a7nC z7j&_9GHg2U%|n{l{O&{0X7lBfV5Unpdz>!>_i{H=jd-~;$QGw=smt{PN zb#CSK->SN6MB28Um!4!BNNZp=E{`b*Sr$ymyGrq-p8PI_-#k`HD>?}?}|lkV#*9+?45T7SII6DO&f3emaTLb6K2WBbsj zR)NcrE9m)(fv5WPLgDlz0_>EC4hmxzi7kIu(HPIsV0PiA^_ifx2p)`YZE1!Dgv8hv zfADvB7_7hh(HM=){(M&)*5;dIGXMHIWuFBYx=_Wj*F6fui&i{bG=YkrXXYO?f$raP zUh-nAtr7ANa%In=AIj)G8VSNAZj)&71zIEcSp80)8ylcm-DlpTJwQZ6OO|&b0m8i2 z+)Z6p^22!@JjCXZL4iSFPu&Bv%%0Wz1g;&*1}*Q!A7{Fel{|UUsCMZ2zbS(1f57D` zOMi0{N5+GwTlpEzD!O;>BG5ClCtzD_`9WQ{geXmt!9`sts|YCaUfodg+~rYLd2e?u zq)D{rlJ+=uK@=1=XpfoKl_nXWa_%$OY3$3cJ*X7YsPfJ%>uT=}B#OS!hgNxNAiIGo z1l5Y*Omq=I6vmRhIt!5}JvE4E-n5n&&li0pa~%AIhn&_&()DUU#nt#*q84AlrMvdI zIWldRi6LtG?pBL}Tn=gx&d3o9t#eTIO;HWEXCr&^^2RiFVk z9Ye&^H#ELo2y!33E%}z5uT6LE6{yQYMl~L&IPi%hrau8dH%|GkYtkFz(GUxx7=|rg zuz7h+Ul?iS%w4ke*&jD!c4sqW89cG-&SO%S2FXd}Ox+DG8;TP-sG23`{Kd^e!fpTp z@|={?8=UVLT?;gAB&tkV+$tLimQKCocD0asJW1(hd66k*ly1C!113d-2E@l^Sr@R8XMfCXtQ}l= zVGPg?2#mOw1VS+I|1)$TAk-EH`2XjG!kyKAVoQXb&+Nu$KAQB96nh8*#w$?*tX}Iu2bW_ z&C3CAvk(=~ZnqzVFMN6FVm*lxr$;vs#Qx;KJhID~#O*e!t+wp;o14WAZH(9^IGlsw zqbnPyt9P-Hhp^2dl!$no4WWW)#Sh62uFb54bTX zkjw<{)(Nm)dUc1;(&WTHa(CI{zTcu*a2>z6d71};S@SPzU({&FM-3gjC>ZA1w)fAA zm4S!?)IeSwWTMvJEg2rqwQZvKetp`gEqrP#Mh%VqJQXuY<3aB30%Vq*qQNvX33ZYY zF(kOf-Wt~G%r8IRg?~bqF$#-UCl37hKa(fGFEtSGKjev)eW}UUUlelx4|$RW?U=am z@Q`!tMVWR7MM@0=lOK9VPEP#NgcVLIcBaGR~8BbFroI%K$ec%|*cUvhHz30u=^gTE{s_RisielL{!8 z0ztmw4cmVHK>;BY7{m(rC5T!I1L)$?&X(o7nO%M7A*cEV;CW=HNwS!tgD7+vJ%|U5 zQ^1hMyLJE}$Um^wkp@Kg+HiUOVY(+fWKoMon^YN65HLpN{fS8psL83$3cN{<1v|Dkav%ub3T)u;GcLinU5ThbB z%!5idJ6@X%;eCH*)zSgTUFwY?gd8FKl*8EH``DFyx1@eUl_rME5*R=Wk#jPqA^eAW zY02awTxXzKC;TpK)`o%~LSXvkF}ys-0xZ>`_cQfjyQv}?tsqcRhRIQ>7c8igz7h$F zk`&#oY+Ht}HXhrL9!#~(*dJ(HHqzH$yl@=|_QJP$jk7!@_{6Wo^TcVzqaH2chuU_b%fN>we^7tisWPtZIBXW!vYJ9ne0-96-38tsof;&h zCOq6IINGH6kk7N%fG1BNE9-pT&m5NPt)k1^_F9tiPr|C9rD?XMO!5^q)ymjmjEhnY z5fD@6P=&J8k+NVx-;~}6-nn2JmC-yM!g;L+0DxP(0McTe zL!S%`jIg6yy9{MSb6-;u)YCigtIpoe~xhxa_umBXNU8b++>(B|I zkWSvcKAy{`1SEoim=cCkY(oUNV+3le_!X|)J8U`6mYZ(ZN{fK#Ku~_=(2y{$UL9AB z^PK7(cY%a*J^&R!N*YS?b*n77h@zYi_`}H~Gz?%ax^L)O=Zk3f>1|$3CrVM5PYk$% zBset~v8Z-T47+_A0)H3L*pg?6!%Nn}&i@#sNLZx?M6}Bu^621q<+bjF`#pb>{Om=5 z?9q|T@Qxgeh%>zdCP)LA>E!*$$f-8C=l-MKfW+f`7U{0*<)OFJFn7Ek@WsJU5iO`_ zsR!zGwC)Y0=m;q)qPh%54<6q3D5}NOb_nE`4~~9z6%Yf7g~#`j42v@A+8r7Om?0#j zOIo_SLqWQw1VL$Oq+=KmNu{JqT0pvE=tk*o5QZE&hHu{Q{5ikQ_5MA7pKD*wu6ymh z_Py@4m={XI#;}lHjZ0&lIqGSq1@bLA(a$}0ZFWZ&1_w3vo_6DUTznXUZ>-v`&TC74 z9W|-TvjCWwr|u5ofrpQ11XKZZmKzJN)~@T`xS@E8B^)Shh!$xqdF0+qV^<66L^jad z3}`78%z(aiPcr5Yz{`I9Qdyuk({cRQB!n8BE#e@ZcVO}MM!R*Nii*mwX}NfB!@7MA z+>{x_1Js~d)HD62#xnWObMm8Ylg}cluj>Yrt-ZbXAhxxa^Ggf=TP!-f>Zn|11~j!( zDGLsqhT~-@8P}}Un)!IuEI~|@%H1v!yH*>69e=rCxTkxx?u%9cW#LzD8reu zo;f(-zx}zCn=q6&cgTEa%Fl1(4{#mNh&?!nQ^!yPY-TErZfQkE{$%~iLlMD)%yS;!=^XA3h&7Y3)8>V z*iPLK4yh|Dh9~>n;l=85sDAzp;vEgd(UN(T`tx@*Oa#ntE#;Pre*ZK-%jkof1n31s zrw!m_xEcQ&`{&(#!cqafdH^$aA1f5L;LM+RLBv9M+2&O`&Xrh#_EK@Lc{>1_nubaw zBq8jHQGNl{jjP9QU3QJkyrMXLZBn9`1wcfe;bXno$(gU!^r2O0IV{308VQLC`f-U9>>Mm8y_B&fc0V*v;M!(fnqW%PYt0Pfm9>mI$NkT26n4)7@i&rLEB?Byyp-Hr4CX1tTMbm_S14r!;_o5^+voH=m4@|F;Kg zI(pFP0(JV|dvxQQV<`gKY0*u_OA0R0cjwcc^2LS-b;XU2ewRiH9FXC93N`B%r1)pv z9p;##^W}S|HS@@%B(eu_;n@wp?t!J{O***(fRlxl!H{Xi+g6#fz~`5j zgiU)LOZ;9v^$+#rR8x3z`D4wFju**e4yy6TD}4Fv^nF;jlrn9y4`}3f=&WR?cV;>3 z-KyCk;>sl;DV#EggVTN3^?In}FIBICZ$3N9`55s#GdSSc&0+qz{IV8om02kkrRGUp zDB|uKR-(gvy3pCtwVTr@SE{4@C~lSY^<$}>lW~m@(?_`;{QvYWIlIp_`@@vaiSneS z^)yUJP0-QGM_5*_a>U~|Ia^tI)j+b*o1UDV_i7?9@TNnJpf1H(UtZ9^3>mVG`ha8c zMLwD>CyLYIVuOr^yYMh6!YeZKJo z{QYHb9LO#*33;IJ0nImePzPN_hZ;<4|=kRz$lH>CXE}wC?4sX800cmS1%1w;@sl;&PVG@mL~8K z-A)z(mcfXh*?`1h$7O~#I^VyMmpl~^{8PQXb)A@=9u}!WO>x-?GVuFhbYtMCqx+j` zMp$_Az-zKZUzD5lbh$vNUne#p78Y5 z;m|9-dX*G1rbsR&b>t!yFh@9F_a2PA?wf;x4C+CLTgnfL{w_3b5-V=4rm-5-D6iO* zN33RH`C);?be)+806$8quLz3%K7eB?vOAAD1CHz(ml9GEBX#<)*heQ+b#!Djs5mOR zr;J>{!Ti5(CMNKCi|I|eA|Ay|lx9XS}#6pid@xg0>%wlL*F{%u~^F+^{5$?z^U0|`% z@&Lxl+kl@d(Je$zm4^p)t zOU))wTMPDRkTv$tudQO6w7yH;@V@y^-)`v8{HyUEk_TGb-I_B`aRMksJlTN=X#NK! zw9j56yZDfMmR8{{6+jqRd1!8Jy#rcYP*#>w<&ClwdOVNJiwhxw{a}$u0@{e4Fl)Y& z7hw>4)rl_0O|C{1a)l|y_NCgcucU%bq-9%qZ?N+SOk=UtQKyujFRRS$g@;S$JZ@d$ z!1eJGb-@>)c7iNH`1nyD!Wa4%Ts1e=$+igMZ30wdNeiw-K!3`{i0-zBfBmX)eDmq| zWFwxxB9wqG>Uwt!-2-tT(J<^E;oP zqXE0hLAfk+Mh^|ui$~SMCTBer&(p+qd!izzD=<#(=^Hl|4_(4!iHIsNs(!an6~x4x z&YF2BIMBTlDG4t!9h)*ra$ac0kcfSguoSq-8ZWwkdoVt3^Hr)7cR_XM%tGG((#8}p zpTUC%SowUlhhxwqtxM&|$M#Nm9}%OnK6dM|?ycN}p~cfTo$)lT#RHrQrJz~s^*IKNGH=t~(;)dc+c ztJ!pM9zBn^myzdHBe)#1@jCNBc>$0D;8dv|+w!M9MeR}uHP@Kkx@Nij`n=H+J=#-`_M-4z&~;TcpTR zI=n|@uqC5g_yD%p)}l^C0-<%&`rQ(z8m!DGU6PM=Z>N>YPz$cA@YUXT0ayZtdu z?`xQO?$EE%mZOH!`STG_g=K3Z^j>PtsPv{SLdpHcMpKKEb5!e@6)opNN1W2_1VVyk#6!2&u7=X+P?oN@|&>BC44ALdfw;+ z5pJZQkd^(tB;p02`JW2Q&V`DLcM5|wGgp1JO3Iro2^(Af!xrjDVytk6Z;?6Q_@P4@ zvsSw?uJ8J5Jrc50hFi~jLfhRy{s@9LitJ$EoTbM{r4dMl--o$zS$?}7+a?+wN`j3Q zsc*)90X$z~jOk=4A$_odKSsO~XoDJ}OfOm~&fR~*$N{?#fp-`Ee;`%oPxb{>aP&P} zdA7U9YxlR(-XsUq2UzWphNlpZ)9zN+^H3_yS(WS#T&!B1BK;mG{V)^%!}h3{LX(QyIcE5Wlxho#1CZZdVB zHI)<`?0%{gC@0B>7Nfk)TFW0X6{Fm!Ck)({I%DH{ab#j+__gZ!nR%WHMg>)_zQ{>$ zty~{?eJM8MR{fQC)4_FMFsmn~o2_)Vxw5w{-C8!spb{n~1BZHi=9?ZoK=={7_7?78 z@dLt7K>^)SV)F>H{_E4ZzBnY>bAsQME9yQ#`YvGZ$=nldHT#;zNrb+FjUS_`@_sMq zZpWy?xCT@{`wQ0Z8XRWY7g>y{%vfWHWFE+Fr5z(?l(aBUki`tgx!Ab$9o`~+3jleo z8_eZav(w}6PCy1|n?X|E3x)D8VPig0QAH!jmxIRt)^B*O*0xaC_ePkIzHuWN4`n{i zu?Zxks{8^)MmLzP8CnKfxQyr3>@foow|*GITQ+fQ zz{|b@6$aihfLVFX-m}>lC-C~bb47B~aZ8QwZS#P(E8()JBMZ0^I~?eLrVWp@bVepReI0Q$Y__h{?5+?Ed1b4|S$8o7^fgI=H5CrDTxN=`tbT`lxBRva%z!C_3XWzJAB`UN23s)3+I? zKG~{a%a*sJI_x5C#HBHaFP5^{Mo(D4_w%D=1IW)qNKzKDrSA6IoAw&iOQKG(t^b9Z zzLnfDoHm?`;w1SXO_Y3a7OhI@40boY?R)+UFj8c|K2tXr0CEUrib_gaPXr@?U0;cKJ4JZC@L7KgVMnh*~U z-RzwCQWhGS+5qE{oTPWXJ{b+~dcK=kHHzno9^CB2%By}Mi}MO)^$#i1{kJjqYU#na z#$C8GyLMKGW;L`#sd8 zBzJc5OPYuvu*lqMHFMYX!VyH5H!=h1 z9!3^3Y9K9KQnYQmG;O}m1j8d-`2r`FU+ezQZV}yt>4tsK0~r6JKfV2=vKIQ#Ql+%O z9&;`>Ondp$g>4c0U7y`le3@ zUQKon2K!T6&ycrSOV=AP<(zlOCH%zfY!XqXE~NC>T)gClxA@fk*KBqg9k&v?iHD`5 zE7-<)et!ROa3moGVNz5gnAjOTY&pft_&MLpv54qzu6wG1ij_~b202+GX6@%5wzg?g z)6;KjK|d+6!%1+49dv~5bk&NI(oO`e{zHeufAs`d#GI_vI@W12DaBRoz#Oj6IR1v= z0?Y~o1T_YETWDtL3AG`7_i?n6(#ATmV{26rQ??;94zjO`=HCt49na0o<~`*y*}g9t zcmOU)pu0(=rQLS@dc>zO-R9_JlK_QV;55Gt{Rdu&=la!wszrA*GV+2%4NZS;8Y)$` z=u*GAOU-tg+*#I=H+Etj)~6x7Z}s&0k29mqEG+uo zHbC7^apUNtD2SSq?aRLv6(WxFTKN})Pu5uuPM_cSTLXK&?^}INJlXeT>EQn3`=2XC zw=ooLwN`sL*ZyGtE|4^Vfg~A*8S-+_A(?YKA(NKnZXMRwHtoYSe;sf$Rs3L&8y3|9 z5Rz=~es&j0!*wzid~Z&}ULG2n!zy*>KfgCWH-FuBHrx8Y4Sxfwm2+kqY6S&v2->x+ z@&er5NxrC9Mz*wkUNxRxzT(=Vhflyg;Er&A-Lau(NK&PWLI52-sv6!H^=Ac$xOhf; z@OX%Vltdu;@Xc7uXXM!)QWq(HC441avx=og6g_So2!PufnYf?Wp9)wBM*VRtML-?WYcyS7oBdVU3DVN1JDe zLGERugykY~ zXIhQw+`D)dL-Pb0N(J-P2KtEcZ(HEE@37HDWWdy>GtwY}t5CGj|2ENEyb zZ|N^1dKU^VFFTeY0UyONYOzC%SRw$=A97rmbKcC1*~)c(%)rq?j}wYFZgFaHv$ZiZwW@9$t|JTy>g2d$@p6r4guzWsaZ1bvT=A zJx`_q_njAYcZX&!z%$A9*W>k3XfKW&u5x8zo_%2 zUmkfQ?fkiK24L4rg3M&zP-Z88-K6Ozcp2WLqZi|fuT#ym^-3{*8Ik4>fx~qD2)dbs*ah><6A2!+{YX42Pn9Dvb(?Q~YhC|`NKJN%QEMGgd6>~j*g^fOM`+kJ9Dv!l&)#(voUw9_0qRYr(Q1CfT)$uBfO9Ckr3qb=jpyTs~I|a z%puZtq^oyU&l{Xp$djbGFGMrGoozGwbic!M?VyD@iEM-ySz5#TPZX(yw%nWZ{!;Tf z_A8dSJU^uZasRNm>Z1y{ocWSQ6Y$p#IC+;eDR~@>{yHU~UoTX9x}Dj&Xe2z@VEzuF z!cHcDw2KT^DNwLcBps(S4q1j@t$iC*d^!g{JisI2B>GtJ#QeK)#lzy34Y2%r3JB2$n)I3-1%x+m{N!w;{+MKYfi zPg-uT*syuQy&0^V$7@-IUkcmXJL`F^uFu%X_%|+nQr1(CBo*H%|M{!S_ZVu1rLJte z|AfqcdZ$O3*U3K=QT%f)#*|Svk%g4}$^J!$X@>->;?uWRXkmO__TVgCjy@y2U4I$R9U%Wb>N6fhY*DN&as|qdUkqaIZt_TWZdEUU;Cb3BqY>)pm*;} zPDS06sfsUf=0`HW&p{`MqU<~{@E>ymch&bVOyw1x^utM*CpzgqGLCau zWofC(1LF1x8lIfYIL2jFKJam+Wy8<{^5Izpu;zN=ka@L_ILBMukVrIm^)0vNaIgw-M0@2^R;8xubkzj2m z`C?Dzi*)HJ4qi4+VgR_Fi>oFO!K(INcl8w&XtcY`k=q)A?B)P$+| zxB5gg%83^F%7_}@kdy$edt$11x_;5ND*jTi-Lc#1ZgnC01w6ajtS%!e<>2tWK;iqf za!;v(QcRNunsJG_o*6W&(c5K?jjBjj=ts2g9cHb zDD=qkG0KS~gI8$X_vv37M13GN;s}{LRIKa^GhYf088{+mxv+1)gCSI*ba#4Wn`zo) zOzu$vSS1w|{TYZ|ImXm(ZxW*fdhnH9x+jpJ!|v5!o~!|59N2*IT}~6q<9VfzqCSkw zh0rfAH=v6g9G|6b_7NW{Tygm?TzeJ*@7G)SDS+g61G;3J)wM*7hKw!V(l5Nrh(I_3 zwZGr}1QK)gDg(PDh^P-TP7lX5+cJCY>LD1^dG*0hK?L-l7~MS(i_);T9O;tMzF*E6 z>xerT_>r^@SaVx^Iq5jfuLGG6&PA84unhhFeY?bnF+xtCqpN$^fjjMq6CLg2{P@Hw zclevgnmJA7&k~X_B2%)>WbtS-h#Eh;4o$xat|7$tZYY@Hr7(wG8Bx4OD^9cDbJS0x zUc8gRvdX)P+6;K>-R&kMgiCt2CVRvoG%G5?<%fHfxztJDZ;{w!5LKL$14Rj5to0e+ z3^Z7TDp6pz_;8tLb4ayF;e^(z_gIr5%a)ZSNa=FT@ zoe|jV+d%oqnnk$Cg=8z=_GD+<14Gb*3z*9>H2$yY<0cl+~%^x^ju zkV`GJi&vWO^w_*?WMpP$E;PAw3fZ+Ekx}`rf?W+pgX0wgyN8W)xJ{!f^vy@?dm4+y zzH(xYRIZuxfyGO)yW!7$7wS>gDw2WW+95-|;;xq=YwZ-=>%*d)szKNcB#!Cz#O%_q z{Pimi`bj3ZDO~+U0++3taE2oA5xnqs63{w^oudWtx7AJ48)JkZ<^7FKd;358Lw*lr zK0B{J8bx=BpP?eX%I%{oXSfQn40*R-A2nz2Ur*xy$A;pYfKq*6=Ni4UAa1_-gp(A}Uvvf|p^VgwviSe3?yMk9d2fXm zNi^p3kELqE;-nVzW{((CBGC+sp_uOJ&z?X3_+S!e@XioGi4y27ETm6~gfl3xrew+P zVlmKJ$c07OZ`u@xF+@el{kK^7ua}WD1yIcq_^Me$%n>(C>|@#qRz3#+nbI66^gDw= zf_&G*y(shEs!0$nJt{)TH~ul#v@&w(C{nldYlg`_Ch^JmiFa$BVF@f2fUW_c0n{BK zhvWdU-t=_-`)E@99^^D}DncgC8|jKfy?Ce${Dcv*G~_A9GEO>gWR$`AK=+*hKs*nL z9WD=UH^gorm1{)-&M)94y_!sVDTs);G&q)^vaCcc3dQ(g&UNO$Pi5<@$U8*O+UPeExgusS*8 z#UGbq3=1&1$`V}|g9NCG>-5cp%*$9g3rtq;^)*c*rSG4_G3IKY+|l;4GtlEHT$PsC zQ6?Hcx8>iwS6ccokz_^MJ-Y}@YcpLOW{bj9J#Sq}ni|C~pnEs^SY7Ll#a7DtO#jQGYv!PayG;1eIy{#r zBJTTlv6GXS`upGD1pOk0Sdw(G2n*MAwbCp%Y7DWkUBN4{ndRx(xj4U|Ml*XobFW;T z%}X_l_!2MFd@-xo_)?v3Xh&}2snP1}_ByE8XObGX1r{!GC-dmTqD)xO40kh5+_78; zQtslOj*i51>$WK5QdcLb(T2zpFtO#rI&iyiPr+dflX*g~La@Edz-Ufkm?KUGKaZp* 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 150/268] 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 151/268] 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 152/268] 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 153/268] 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 154/268] [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 155/268] 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 156/268] 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 157/268] 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 158/268] [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 159/268] [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 160/268] [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 161/268] 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 162/268] [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 163/268] [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 164/268] [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 165/268] [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 166/268] [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 167/268] [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 168/268] [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 169/268] [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 170/268] [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 171/268] [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 172/268] [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 173/268] [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 174/268] [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 175/268] [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 176/268] [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 177/268] [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 178/268] [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 179/268] [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 180/268] [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 181/268] [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 182/268] [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 183/268] [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 184/268] [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 146/268] [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 147/268] [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 zcmV-L0=oT)P)ixt#%!ZueX@zl6Bv2?Y?%RaY<=gIf`e&6?jNWtgkgHU1tm|(aZ z5K@wM>dpN@#q0GiR2>h;ad~4`IXwJHd9j3H=#fe4G~#=EKXBjrIF8H4hRxS+-(xc` zRxHYY3?@>&1dm!Rw*4BmdokjFboNTT{i74Y{zBWuW{6B^%T@pg}J$V{5n2nY3T_cK7PV5bjpjRPJ@S!9%ENs z8jZ%O1(R4`vpNlK&E^Iclx2x)SFa$1hR_s@n}X@-%W(ssFTr#=O*WfBXd1e%AT*VW zlaq-_Vg^Z!k6%DlvIwCfGzB44&X0}#b^auYTQoIw30+gsgo>`KWM*cNWvMU0Zu&|v z2<=CflbU8B$NHLuS-PLfVpn7qa+t+Dl@*K1%U4ltyLxAzT54c#C)fPjbsA|VVlw6wIqfPjEVcMp<7NcuP^9ZE=tfOLyUNh3Xs zG$TU{lHa`VIp6hO=bXRp``UZ$6~DdK+Oc-Lfu05x=r#xd04gm_)u#YJfd3@`$Vl)X zEALWA0N~}*QdKq%%-+u-4}6Pm!QDt4jVcWBXGBNyN01Q`)-~sgKqE@G%Ctj-*Joxb z8#h0j{wgwU4NR7AlrNci_6v69x{u}5|1~!AO!*xEj;@K1EF?ql2tC*Omir@pe(a=8 zOQ-ix$jgxXzFReH6O=TtH@TMq9T_#^|T<2QjeJ zmLNE~v?n|I&dVoU`h=K(xT0SINkX@eeKJXefe;%1dDrBvl(a`HqFMsPzv-b*wM5>j z{(O6;#qSX2yPla{DnM9+vqcU$rp#)kzf3lb#gk}@e5jz%dKZ+T?R_4W-O|~)7*LGz zLdvDBt_$+FrdE7vdHhKg+kT#mp_C<@`?XAIsdR3Tu2b~%B|n(|q|a{X3Tzg*xN*5& zPoZ?x{eXWlUJS6j?aCKj9Cx6KoPQkbI{MxyB88eu^=FPVdR@@L(_DmECda?oN>3(7 zPcxlZw)gp{mel^r&Gma?o&%w?5c+TWO)sJy*ab4k)A_-IgHeTqqOmN&p2gR!?;cpj z>t@-ggm-9MhlHhxJZg!(+BPzZ!0l!^&AH9Ey?^+Iw%xuq)3*MxZPTySFGQTL$xJKE z<0N-oYJyL0_o1}rTuoJZ4d*!g_ZDi2)0}VBW_Bz%K$Wdzo;TnsGYD&5@EQ1rzoPeA zin$-{Fg0u`!aP@DDw88MSJCwg!_~Djy<6jABI&=s%olgLKJ3!?J%ve;Z^qcEgc~{5 zLfIL1P~z;eJwEL*L7#|PdYJdpFc08>%miok+XaCqW9iag>Ikf%?|aVm^+@rJa}J&_ z=$2OgN-}$AQczz+#f2o5%DNTT3s$_Iq=j{Uo=o8*g$<@L!}5aQ zY}NUdQo2Z$4!fGBKq@~)g(JwXmBxoo{bEm1|< z+qZAmxbJ(vWo7|0CM)Up6&@ywqdOqmy+tFYT6Nxs3@X7vt&@~%*{`JAlFJ*X&09hx zKYYkqI}F^gf}j0a!xoxFB4}NtU3-x3mW6TzsTItaZocq(hq`ATu^ojB&_`-Au8C^t zN505mlBOvX`lk8v&eXJO7dDo!5iutptSy4+1x)>>X@2=?{`l8pd{yAzuH}!`+ z(=Ee0;|u~B+b-k+IwK9>=we=EwZ~pxp3NnDd3kxQ*Q8_1qgmweD>V8-g_)_IxY}(c z>JT-+%Lr07;}=)+6;Armz!_b7g4K(&pgwu!65{jMa;fB7pad`(xM)Qd?ZlG;__MdXR+8a0dMa2iC z`-@_H-_wDJBzL91$)A>l{576NOT>)0bm!uhHCh!xiTv9;zC6Sc#nZz$-xW7C-6a8; zNh{a*OF%16Mzqr|$mCh@UOio6e`If5%DQHv%{5H) z0$>Cl3W;JG2r+w#NgsAj@`7RELYr6@T1@;Ps%K&6nH7A0dTR%(LSq1Y%Wu2wW)=#|lB*u;0&N^Lc zq^U~F=998Kt|54Um=jBf^SXyS>l#2g`#0av+Sz6X^HVOfisBLVKGc zYd=;}(!AbM*gbF$NkQ7Qe33?0S1AYZd*ht~ z&nICpI>?|toUh8lm-y@DHaCMh!C9ZlB()3&^WTP<(_}hC_VTP{tgE z1pcjCq;pB^RW`RAY<2PraDW~TOEnT+QC2>%vp`j9(Zm&Bti^n5B5KSkx0sOb^n!>W zw84Wt`Gu`crh|?sCc5|ebun_KmGnW0(KQx*u|cQj+7B*MJqA>H#VgF)$t9dV-Czxd zG-1p3q{)&OhfPRiVek@3g>@&FK&F21U+mKvwAQ&-i*?{O+N{QNjRT}4+;(|BB4^lM zMPg#Id)OVv-L;SHLN^qBGb2mHJB%Qka`eSIzpWT;AG_T0cLbG+9nI>o)wQ`1OL_T> zo!d!pP_d+rqYNkY+`gW9CC5wMhZ+zrWZdi6UbCB6xA>ehNv)-cPpD zOgRb);Z6IUAU-+-VK`iAnL<R`uy}8!B0TLdBdLl#Tw=&cT`33|y`HGdg6R-M;dZb0@TKX4OxesfJV*Fb+ ztZa0;(aG{d8<8ohMOYG87k3{b?L}!tc1l7^z1IsgBxpv3hw||r%Nkrj5ZHbOD6FB` z<h!w{ zl!OG;^3^tSi;B8@irx}W!6XwbrR9#>j8Q0{2t9+XuZ7wn6Kq2w!zjP|z*W%S7uBX~ zT?ItPSk;xF%gZ3oxs$BHT|Izg2hHi<%CX>c$~olAmA89RxQ?~J3w1HP^Mqp2B4ZGr zBJ>osoTPApe;2;qH~;Et;(_Dz2}*ywe#p<8Cb4-qIm#Yy@b32^8yzrVW#724kv7A1 z&tPR2ZB|(}u;9nOkt`>EZf*tZK9Eay?^|cem7}LFYL$!h3?R!8%u(b&I%@z$ED3b) zd#Sj7n{^MQnx1B_DR;TQlfs$N-nN)zUq3`g0NySXO+m7R$V4mgMAzC6tS5YLDJ{)R z{CnisWyg$aj)QUKF%!_Lo~Z<}zoqTWW2E6{hVvFP{qB(!8o2Rv|uJ%Wv*TGhC?$%_<- z82n;GZ}C8`x>e$8^0Bf>5T{}YeO9V3`p|xqGwPo3WH@?L}!cQw+BJ!T5Y%1R_9C^<3=TSb_ zQK0zslO$`*?q|jBj^OkUWVSW>+K+}&K9JH(+;&iC7q^lV=^!l47^GBJLaF(b=qRx8 z6@ex%L82NN5I|s*HW(21Rorh~HE1$92r_}+N_`yL-v0iaOTP|62=IRbKA;c(F?-O3 zDCGEk$e;D>b@_ z#UhX_W~%koxvcR7l7#WvtZ~6y+|+*dpxwhhTUJMb)p_jMUnZY4lvpjECV}9DATt)q z#SH=vqSINx>fz zZDM~gCK;p7zxZZoQ@`($m`U(rr{C`|O_Q*|lX5WY3OmM6e`6rQ<;y#-^)$mWeM}f& z9*SIQJyoi+C>ZRAk3KF6#-wy|u;_Cf4UxVFT9=YITB#z=c==o7ap8ZXynOvmWF$Xk zD!4jh%rp`b+RoNFanA>{HO1K-rWXd1-4`p&c&PgY5l?!iNE%Z zuP)_3^bqUEO2w-}mlu=O>|~lf?j@HsKW$%Oa+0##FhKp$;4Y}B;Bb-mL&Xa$);Wl~ zce;sxf{Af4Q+RHzdR@A0dqa-uZF(|LF~cv-E^o_sM=Rij$tFOF{e)Q~B=D~4^T}n8 zTlk~m_}n;x*bEkzo=)E{N*~`hzC8W9{DtUxq?Y}}GMn+ zt*CHaBPwVtFaDfPHOM~h?SL#FHowR@b!ya{Yq}vauiTLWva+%czuVkId!Ysj*HlXu z5q!Lnxq5A4&z{L{d<#<9TEBFz2KM}tF;Z{}(5DtlE?xi{_FkH|)+ zQj$fOpDv}mtf@=N>$08A12OGaL<^dX=r;qSxym0L>B4EDPoH#t z)=^zk>mcm2`yPOn=VG}&+hkcAmJeaClAe9Ta)UK{Ol+H?0A^zbybz9y#pH+q`ve=M zsbjOM@_}8CgP5^Rc~lQ`3WY{tAaUTsQzM4)r1iBC&vv8zhXiTK!E7uMWCD3+$rGDN>X1(m5baX9 zLN$vS?8IUbw~CVSUFB)xl1>20J3G7fOpaaO&iFL)w&b{mA~p5cYi2Uy@nfzE^A-}C zP?X`@x0FUFc40a2ZUbQA+ugX=i+ar=MTC_Tb$LpOVdM)wUvL`NqQ^%UkyU$-Pkelv zuZ1qDLOjjY?)|YKR${f55@BH(wTiB?CDUug_R5yYP%m;9w$?N@a@GbyPecump@9&t zz;@Wu9O|HPu$U!evHeu&kq}3Ge#=LuT4$?9`E{rz^4FWtd9I9g2@&1=D-%C*b>&WNhwcY-?~bsw$)_ROL_dC%_h<_tlfFEn-zf9sedaSmwmQ|#qksL! zDexL6n%}h-lDg{&PW8zF#ZHA>;8=P3ZXdkWnSc3z~@Y^WcigX9-@LZzlS37p+JK zvZaYyU?~D+?hCvT3LmoM4$qo8@wXehp6jajycq+#eJ2(c5-^H~oM)jeSOR`YL_h59 zZkM0*BBf1k-Ji;q7N(W3JsWhtKwhZv3mP$zvDYz)9np(cn0yw?m5ZZqo#C8C8q^vY zPeN|>QxUm0F@Pp#*t+YGH%SpIqAU4D8me3H<+<9k9c$@=%1Wb? zBXYXG6B1*nRgJhcZz|aFWjBv{vT&~oZt3-mDb?N(ZhhY7hP)NGZ}c_+G2Yo6D8qFz zfSjm^kyjASg|FRlgOgOi?}R~DDID_2OssElJPnssZ zxGT5lq!iZ9VLzB%6ukOL2>#1WFIzE)1dH{NJh^`M5r}ec#%yi%G@6ZeS4xM$mX&81 z?%%IGo5)yE3D4CyS;tUZd9+`-2aTz`+`2jIl#g6544qE2X$x1*B}(*Y$GZ%=;HD@A z5@E>q#8~t?*SIg<(!HI+y~MIso<<>1Q9y^IGWd`p;V7s1YBXf6VVz+5x zuZ;4Vn9jWqH}v6ADLW}$A8l3NpDCqqO5hXHU;hS|w+WD|M8(x88Uxh46>IS_* zzMHi@S$Jk0d!1kup`f~5wyi4RIMVvWFDjL}0B&R?;osSA;;*}oEF(b<$G`h6W%bYr ztp1joy8)-z4k}O+UZ?T3Ur_TgfJs?d2=khO`{E`(%ig9dfb-uToZ6N!F#&&Zw@O^= zP1p#dZ!G8>a_#A(c%WuzxR!4@lu=>=o{GDcFZn2Yzn9w~%JHz^>hl9PUc}I`&FP7=5^{GkBHyrw|o?6G%oV1~pRk0yc zPkc<`yV6o2QoC4pclkW3^n}Ouqi09N(DZBH=@GTj`S>Y*x;G$*>BeSsVe&e z>U+yARE~))B_Z0xcCRHRo612~ja5)zKfmYa{Y*llW~MPR!N)SoQ>7p<^v07v_T{A0 z-D>EuBo7l)IrY`avzSbwxa+8DsDlZJN9@HJee$Pw%$IxHH_+tSO0CkIJp2C0d!3~+ z$BK=h0+TKHzMphKzETj=ddNzcW)-xd!G+=6>~fOc&jb<2-B+5{E2*s}tmu;{V1f{M z^E3_qbvt)?Tv0B$!D)Z-{u-NTt^#M%{SNgLyIqQ_n=8^zC3~a#!tu^w(Q;apkx`6i zSt&I1B=NQhxb19P^dMC+s}VrwBHh&R z98*<^=np>7GKxA=;+FTPM2@xJegUGE8!G!2FVJ@J+tj?mQMa6S|Nefn=P99DndVs4 zjaS>%jCwPtp6=5$+fw$)Ph)CY7UCkp<+Lr2VSmFlrwiZ@4=(Qs9?F8^2p5E1-)7GE8*R;_`pFSJa*>cQ}@P z`@!xWJByUt>dz||?z>b=G>N`GFNZp~fl}kY=aF5Bwgd_@+t`#@Y~9Q;=ohtow3w)f z$}mCPnXLn*4G8xwhg~qmT?gYw_M+?k)dnx6TzRR@)K{KG__# zEQTCAF|8-nfYM|s-xEHPl3J4^_4};7Pl$*SbxTt zKy}Lof6Jg*C`i2 z8>|w)Ij)SsQ1Dwy?y4-|XxO&|MkT8|%M;5nVIfUJX#Pgh)V{~Dy>T@52a&G~UE)G9ZZnZ{5%nZi@h$b=xg^D=dn>J+Yo5l9^6pLS8p&KH z)-iMSKI}T_YK`)C-Az(W>vocZ`Rw|uOF5Rd|5SXSw5Pg7lo`Gjiub6ABC5H=^WZ?A zMz(?5$*N3wF|6KqmC6rE3C(NQMLjKnfLxVOeq+T=mV-+cMi!QPvi&?&njvV+4fkI? z3Z9NfH5$51P~V#N<8=yF*w(=n!^x>}xeNu^d-nzxw`pLSS@d2Wx#anIecVPz?s}v; zcVG_NldH(uMcn9QzvdTkSAjx&gBSShFwh(?@sye1Ta@Ong$=O>xdtI}iR9_ikqU|y+icfj$B=Ho?j^3ZV%V-0&2F8 z>l-rfEL6eT%Ek#nri4fc>^(Ya{JK0`?1FmwMNm|~!%h9(6j{mHd|MmDq>1UJOA2cY z`sDlC7p^@z1Wv$A<8+J4nj!+W;=9m`b1Kch5C*`iO>wXL)Dg|*>9px@#0Yu%x(om^ zaWO^!N}2S8V0`uf0(S8HU+uC4`JuGw)1KrEcD6!5G%G6z0F$VrEr3%3+Vqis6K{oc zMf}tfjNT&17%g(Z3uOffyAvh!_ovx|2!;f7u$mHH;BwZy5;K1JkZ><;y^9I!_fj#m z{TpvGBY&pbEvXZh-%=VXp6RAdJhdY9E}lS z4zKsWTMGbMJ6q#p0z@w`MFXv4tFrxXRw@K|L`pe8z5;%Zt z$mM@gFIfNp`MxW@y2kcDE&aDmj1)?|XW)88{jsA3islJ^Ao z+Yyq2^Ze6sUi~^sVBQ20Ogy)*3iQgsZoY z^#wAvClZ~hFD#mG*eX-XyHXuVCnA?}*#Oeu<|HEksQ&x{c($In$R=zteb z^6+JEqLg7?Zpw9c09+=xe1WDJn3zVJT|xx%VX6S8oDO&%Ve{oRs+C-eTT3Y5GHfiO z=}uPzu_o-}PP~Ikpbrh$LXEyUYXtFN7+>g2)v*npZ-Cp-n|i!TXM=!Apb31QQ%(<4 zI7#~o1HqH0w9ua#BH5Q$eRtd6D2elTrVh*sL_1`924M9L;TFIEl7;7XZ?%LQp~trLbtPW?AXb`Y&@*-)>7A2Hk)Pe)A&g_hVK+#Zt+cMPr9&y`9Jyw z3~uHq7TW&WhKGC8tol-iPNjMHndyZ;aG^5UZ6(!H zvp2n@Ud7I154z+l>>s9o8tZ2k^WAQKUo?BuRiQ z19!W7apPJeJWVaQanpm2&Mut!^(36D$`Osl64Q$*H|OgMkl7P*;CUXZB0-cS$TAN- zM&NC3fydj7wzdx3Z1f=dFa}wcA&DYXRfTDqEFxadISdYjVG(P5O>rhdN;c{l^-zRO z273M(!o|y1(b?VwkEa>!ogENm1qu@+L8AW&U}zc$Axr=j69@!w@Zb@=vu7s)f#8Hw zzN82Ug20FrMTR8tphCUP9q@R(Xlia{*KlNH?7753ACJdj7;%;kN|u?CiI4Z~#hSHl z-1)N`bvxb#U5JSo4#MlX$kIlmGD(0eNzmga8XIq+si_5?TP^75?1UglP?S;cC@3KX zB@=i7n_k&~HLI&(m~p(f_alH1Y}~jWhYo%YWpoO5*MEXbSFR&&nsBTt!|`K>F+3cc zNCC%j$jVAXTILcI78NkH)H{2Y9i~ZOQ)L#;MMaBHyRH)1SvmOhvo9ejA~>GMt1qtw zm9?(+I_h@p#+kDhP+svO4mW&(Kp=v&j12HR2USvW_S^-Q$^gf)H8~mqDxffb0rHoW zqp%>KI)SLBp`z>sEL-*rzBzURwsZx1->=69yS6Yg-~AzM-TpRi-}WIl$BrMqI|f-% zaP5W%2M&D2yp2Yqpy8#Fq5^bXNAKT#h>rANz#mF2pr9Zhx~Aj9ef8MA^)=8;wRLr4 z%l2L9@9W3X;-?u|$>MA@b@{OKt$KtbVT?qhuuKz*qOb_kRB5^nP18_PT#D6eD^cxo zqNdsjMOL93#zYNpQzAg`hlc}re#Nsm{_PPI*=?Y^_oYp*2k8bBVASL2Aye1#0fAs7@6D$xD+W!7Gcn4qw6pXgM~D1 zrV3}Gz4FuAsN=zbencPkj_(5TQ4ydG!?fZ^v)N#`+gUyX!5}L_Rka}_BMqiyA;@ed zR5fjiEio>grIm^jlQiW(r9=AORXk^KAHKd@_MNXf;I<1Y)U9_x{l$Ng}E8QE&3fFqZ zLAfX=tG`r52yiUivDicK0?%?U@H`@s=%|gSwgv3;oY5xm$gY2zvYa(cjaC z-u@Yi(6UU{XiUpwKfiw{`RS-_aCla>0sjzdUO740OypFEsq#NKG>idX%lICj3%D_o zyy6Pv=43xw07X1*GV1ebl~wnL!2ph$u0T6 zg08_mt^iRK*aMoo`6@!X=Bxy89EY6jr;xYQfw(#G{yrBZwqdG>ipe+hO@|g8-LJX1 zIhao*8WH74DIjjqqtegnavoBj5~IhlpK(nRKv?sMAR6n1_=L!(zK|rgx5&Oyfns|0 z4-AB!Ec+OedP_I`vM3w-LN1<45875?s9=FN4scW5=KL z>`BhO=biJNbI&<<5@wnvMn*Fah%$O%q6Nzg#((gi9AuxzqPVW&ncFsm-(n-9nePaq z`39B|f*?SWB*?M^pDaTZEgy6Pn3~F>P$+or(>3-W``=i4gaMylQiBM+b0n z;9od)d;l}qEPRU0Q>MN?#B+ufowcDT3I>PHVQ}a?+S*z?qp)ZZunnfi=aZmm1_lRb z(_-ZE2sg;*^KpjBP)*aI>pE0bgJBr(`z<-?vOz|Z@#Uf*Em~Sg0*23Bz_F93@Xr$i z7=Hge1W_!?C9H`=_#P+2n>XKqFW&hD+!O1@rcEvQ)pIZ6&wu$VLZL9F4ObaqPmn-1Jz-gQymXDvrl`Eq>EqL0xv^e(xmy?W)rY2LCVS!;+&*Rwk&{kv|3=a(r;|IHc zj`8tN5DW%*37wdjz&F3~b?n{qG%j9@jBCk>Qim=%Kw*m1htRmb6-{f`7E^*j({*U7%3q}T6)2w(!(L+p{Ylq! zXqpODRdIDZiR9QNq|#*^Xlw}K*48K*8rJYbwv-IpEK_OKxX~I^O+{OCGrD6@L_6ED zB^E`%?}w^su;J~%ng}7N-#%kQr_Vzo`6^)`R zdN;beqNocfD5%9Xs?|4~@N}TDVGV!T^z;lG8ynEKzZYA!L`w`uf+G6gc^A+1y#P(M z6jo!H^bx~kgvGMT*K~Haqbu5hF8Uqogb2o+v5loYo)c*`2kdf4iZU5u_-k&4&tE`1 zK2nxp`e>S#!ITR5e4eMtuBX;9SxJJfR-mLwXy+*N{d zmr{ZZ%I6B$xN$vV-BHf&SHE&EHf(G{K5yr_T4eafCYhQ>392}Lzn?cPw^M~u{!+Er ze8(n4qwVPG?!cGty$9h{LF833SVf_rBIzi9iv_9Hg~XOsQZ4pqR_W-l@;BOXH)63U zcT`n%=tZZlrziPdr_s*HE(CK4kZLvlmRr^$*40&FxMh8VrI6%wRw(BU6lZyQW}~%O z&}HF^I9mS^VV3-{&o!l#_hN_DAqhVh|rDhHy1;7W?+?<%hSdymt?m6CtT|+9h}D zx9<^N=SQg3_@|zH3{UOcj@9O#PJpI4ix8GIyyihfTGMNl^_3XgTo=1_%Od}kw!+}GGap2Hz5e|hgF*(U! zZp%Zf>+s6qH_-R|L4McGOibeGosXmE$!*sD(0uv#oN|Z$NPT_P?5OAfbN z{uZ((@VSOM{QAXN%RvoK;X|IMyxM-|mfLm_XzjJ+bzHtQ=(-P7 zi~yBRtLj+uiTy%)+_03HAbFlYp7Lt@O{bhe6d1x`8r|oV9tX-gK*}gTKaQV4)^OJP zr2%6=KmgXY#t^9wmt9vi0%ZKP4wg~yIJZ^%u2)D?3Ho>5r{;pUn)LJuqt^p&w=Hdd&&PYgLNWerhU!dzqrDp&A>1KrKPNPdK zaV2xVD4G3OfzS-|KH;_vp|^Am572*{SZ;%A218skjQg9{)gSpk97$M#mb%?_00000 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 148/268] [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 199/268] [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 200/268] [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 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<Jq?U}9uuW@2GxWo2Ojs;&jfGq4D<3Mm>ovIz$!vMUve7&T5@$f4}C@t|nX#SbdR zNkvVZTw>x9l2WQ_>Kd9_CZ=ZQ7M51dF0O9w9-dyoA)#U65s^{JDXD4c8JStdC8cHM z6_r)ZEv;?s9i3g1CQq3GGAU*RJ2VdF$b$$4{OPfBE|D`;VW$K>lK6V1{@L?lUxh2?G7a#KOYN z!VdBmBU3pLGYhh?DjKp0IR>&P778mFHFAhJO|5iQLSiMQ@&71!W4>KzIe}CKm{rP`}_3nRk`33(o z)LsAg2WZsmo<-dXyHQQK9{fOGGV7k=b@eU#GkiRAF7-})Yu&8zZ`q!{-RE2Ge=?1& zzdrdt!;4JMrHRYlRjY~wMv939x-!rUr~a7y&owvx-=1av87}bFJdby)Koy8`n8}(E z{i15>8M~@mTMBY{pZmE4KDPLMwxR0Vs&D2>rvtX_(tO6D8flhy?2SO-yt~>L^taj? z-FxK`AIkKOy`-{h!tV{vxqmMTPP!vIjEu#lAVSuI=bPJUvfs z*R`~HISl7jHW@1Duk8MPr<$++=9;&D$6foqGq?QSHmh`^XxlSoRzH7@y~o(&X1;IW zG_UoMdT%FOf8uH>FbVkoXIQwjp!8mi+JAvzO||MR&e=X)M}kLXWfx zv%~0*=YIyJZR>%~3a)>_wy${oVjDD(wF*8>TfY8yVyAqS!%gAt>DzxdM^4SiDu0j= zAJbsPZ@T~3nkIfN|DVt9PmS!AEV(`Rl>2+-SN8Xo{JV0+{`s%h|Nb+W|F}{d7aODC z`7Uj$;|YGn#QDb>t~8-0!x-7}`JDToWbFc`ZgpUixHM&()Sw8E6&kC0)~Kzk+3#BG z=o)UZ(|`Jl^(!Z=+Dgq)D1-TS-I0OmchzCW{7Ue3$NV0wDVFy2SY`@s<&i}*CKD+b$p8f9wd*NfC z>gMF?1P}xPThR&N1F#wp@pu9rMbR#Qcv!&*2Hk)B+v%;EX$FyLxW-mcVB$5(YNmE%_lch`7W&Ps;>i~raTor;b zh#pX&AQ%b)8vz3V1czojoA4h4QNUnvcmk26NJaxHRDlA5!6;xcI2;y>Mt_a&11trn zrfcqqSLbdc=mk?*(TO6W{;JZO8lIh_i!J;^j*=8<^EI`!4L&h6GB#l^v$V3dv0c4} zbAja<0fyO&*tr4?ARF)xNCRl{;&h#5s?RDzKM-H7Jpojl$?@!I_%Fe-`#s{`zWygq2Y(!T_I&uo%dzo^$zP_X zC2yp&TnNDYj`fc0JuV8$rGUj^u!LDIL?Ij%hJwZEn&Z_RxrA-O>UykbB6U@wsPraD z-@qi%xku$L0$m|!ZSy&hP{3>*~NeT0D`go{Q5p|?ei{o2x|91&?F{s z=mpHSiMeNmk}bB=GtynP(<2Z_svi&Mq~iJp-aFa}0Xe`If=qWSxvUm~`JXMMhGhL< z|82*myzWWU1us%*K2Ll!b#xu|ukKk@dRQL!Or>`JzdeQ378G<0cp#bmomdqMx120P2C6Naa|+}0t3 z9b3Z~5l2F7N``05&+{6u_>y>M>y{0Y0~=1=tx|GwIIeXg<>J~TXIi1p(E@|xbs%JS zQ%`23e`&?T;*QAlzGouO;DphroQaDyk`u1?rpYwj8{?m*=ms|{+Fi5{dl{K4@47129-5%C()47B@^ZOd1z$SBklLG=(JyFCayb^OhxZZD zoJF}1h_CQX`iuSME`6V&&h`Y^0;7+vxz#Okn_GNUhD|3&qbfZ}yrHT*I!o9Rm>!do zflUc&W?5l`^?CBXDhSS5G9*i4Ac!xP8fheoWM!cc*nR0R>PLWp;-2PReR_ar*`Mtu z@@p*@uidATPzr3=VCfY^B`;6qCM67Y?E{qFB-5!Kv z5Y(5UzI}TW0)CHBvT3$qWJWl=mWr=&z1v!boc}h$dHFoA2>y(z18Vj58pgCN5bfLn zXpfnWkwZ{&D3%&}`2()lDT!*^>(J0c3kM`oG`G$MME|tei|gURz@L!!9t- z-DQ;vK%r0|P@9*;J+G54voR$@bUx&MQMUy9*{$!(Q+6x7K_11vkyq` z0}K(Xy2#uSH=h-R*WHa+97???ueb8X1Jb(sA&O=2o>+o{n)(6_&Bgi#R70AT^%5If zJ9{T*hKsA)Dt8ah^1#>vl~F4CpoB(AKOUM0=s+|{q{IuY(cjevT z5Hu=6pbx=k1-gtInWTwXHrsMyD!L)aQI~uV!8kJ0kY10n9>u;NQ<>OEACrTird(2y zGNXUz9@lQI|M`m$C?-lSY!cf*(9D`?yieD=(iDptW#=7?rsqNM!<&fK)2TvEcgpGV zh*#!X;SLT){WMW|A_OCPJ9r<wfj`;LKlA_$8C=CVj#%k3dZZS#bnAPxdY z(%_5Pk?>a;%-8H_cj-$Pig#wcJimLCBnawyY^NV< zl)p+Cno!FOC7PS}+ooYu&DO{x1xakTUTUtX&WWZzKaL9}>5=WzmES14t_}(i4M8Ar z-eo!U5`y~V0K%*Q%Yl2WU-~M&wj>~|71+6~D3xR7!iX32cNZXd#@Ih5>o?lyyDpV- z#Ag)#VM2y^4T-B! zoA;Ko(0^gh>#LdBMWG?xcNZ53Zy#IbbScfZ@*ewPN&?d|oMylGDVn^Fr`}Ri_M~wv zrsHwxk2Eu$&gUH79Wg#v$n(uyt-0%D!7>jFyUOHLa^f=X zG!;w*4EQV)9af{9$!~N{bl9V=@rCVgIGk|uV3M_#pQsRb?(tEB>g8_@2A4a&<%GO* zimIqv*ReG#Pi4v41`aD;jCW>sM8`FbF!Qk{_GCj0naFi-9bSLG^ri4NCe_%?mJ;|& z<(2F;bjva8pFOu{KJ&i&g)S|?g{vWu?gFDB-m$YIp3LB^lIQgdQbrI1k=EZFRsg#9 znpjJ~oP0wcB?e5%Sa0Q;)H*4}Mg6+V5a@O!j`ARI@v;u9%Gr@N;FTUZE=tIcj1T3k zcf#2TNmlI=G6d%}uT4Ra0>P0>?$4_E0?`Cp61B5jB%w=mlVuaK)peU7=$+J_{8TLc zlMHxe^PQAab-hK zKZp92HpU3P@EqbeFD$`>fp=AN&`?!O3F~2(Lh}&2^?>AZ)Wjl?ji|F)iug%aqiW tb*c=|n|-y4&-IHXD}wsM+RBWXt9hGzsh8S+LF=iQmP zU$_hY1uXf@c84v1AP9IN4}hNn8z3hui#rdaDP~mtPmN4WOh~%s78{MX7_2ujo?isQ;c$uyifT$qYQ|bbE#rUO z;98&}2ackKQ4kr(s6Z$c2(AMp03Zw!?R>z0G)M-8#>isjaPkU>L&Xvx1EEkdXcPv6 zMkC(Q$T&c&V3ukb(PWn~ys_jUf^kgB6}dHAOMX^$Xnmu-et+;Ooc!_?YAe;(>gZCa z>r6~Hm~AxQWV4NKYqx#JXO4TmblU6e;(Fk#gNF|L`1*x}h8+z*c0A(rnb>dR;?J_q zoxkvXYT6I!8JwKlysOvp3$B-zl~+_&RsU3T_g)>p{(i%Q#wTs<9i4)%?w*%@{l5+j z4h@gIns_@o^-efFBbuG(f&l8jSbvgzz@>t4$)M3FGW!O07VppBoCzm ztibdvg>zDVF&US6e0A2fXD=QWwLYzTQcXLzHSuC(2kXIv-5e`}Cpq2(gUjkmh>zz! z$-+vbO%27u>^XPRo61mkoCh6`y=4QuqJ*Fd9y$UwQxog~K!$Wbd+< zO0L0Rh24TV$DR{{H3tuuGVgY`Fo)V4V8HW*0l)RbSsrF|V!V0!X!iSYgnlqfkko2N zR{prK+yfH|X+Ms9>gQBIw%IUNNP)gpD`>`P*cC@T_n3GyF5R}vOxgp3VL`zK2ba|*R95ZheNpnrYnuh><` zNy;$zbz2*Aj4MrwcoQ+rj$^(h`|^YW7}(z4CM1S$Vo^9{`V= zAw%g&f7j9|1`NnoxiIhuM)r?v#xs1D4~NzD@OSuPdqZVE!N?%}Ao*T1r(z1-GFv=}_!LZ)#veqK+ z^s#`@{cF!>`it7ezNvnMh=}19t5d)#XiH$}OqE={Xnh&O!VYTJ57DsZyvt zFXocVSpSKw^|=k&1F$TDCxYi0!A^$ToA>MB$`+FFYh%Ece(S76G90ZxZ4-1ozYrh+3i0fJ?s zk-_a&HT#H$H~94QQwh(?BCpMylL{VYy53lRQpBve>D&i{@PXRLo%zhWxa*RfQE7Bs zc2)TI!kC+@tLuaI+9jCK!u$<|?t(O*>Z;R^JPp=Melco_yx4A2H)~yWGtu>xN5IoV zk!*`C0@Cy57c07;&m9ED-2~oCr8{D)9?Pd!k8iFNW&5oP8M%~R^vKh$rZup$XT0e} zgSJCbwW>L(MPM7{cqKEzML8{Oz|}yrYI1L*jb!vd>a=!;Cvmqyn9`DqnX2^ z6g4fX!v4NUSy;_Gi?RvKhJr#E2rlwwp{rG>Ay*2ga=rYQ;@9tT^E_`^mP1wjc+sjb z((JI+l!|lMlbN#JcQW~|XHs0;YD`WmmKb3NG$x#R-L7m2>NgmS-($wEEiv+ID4_3? zHnuZ|NyuS8v1*R;eutVoT;PsRu#_gjz$@QM==b3LQ9Vz7R->qDnsd*6 zakm-Gt#m@8{KA@X%wl0aIg!env*-kdOX^=0pvCa+pA=vpgPTR`V%?o)C~CmXid1YU zl$ODO=083s$DAm7MNv)3k?@LO(BdkFfedNRN7~!T9R4I41~GW4X%-#&$_EBVURzBq zzXOBXI2fp3v#6VPT=}EUIp2MS4g9lnyXc4$@>@~==H*{u^zovNy3&T~1PatNlgk;_ zOC-qxY4jCD@9$@qAiDZ-xz+S2BBx0QkSjXdhdJJrGkTOaIfj>r2@Gp&*BEoCha?tK z#2VZ=u?=$9@Cjc)!p)u{VVqRLvb>L6^p-6!=q;fWK=el~y>1UZX)7m|(L$+3lxZ09 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 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%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 201/268] [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 202/268] [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 256/268] [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 257/268] [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 258/268] [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 259/268] 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 260/268] [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 261/268] [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 262/268] [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 263/268] [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 264/268] [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 265/268] [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 266/268] [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 267/268] [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 268/268] [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)