diff --git a/docs/cli.rst b/docs/cli.rst index 7685db12..ff959c7a 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -49,6 +49,10 @@ Options [REQUIRED] Input video file. Image sequences and URLs are supported. +.. option:: --stdin + + Read the input video from standard input instead of :option:`-i/--input <-i>`. Requires the pyav backend and a container that can be read from a pipe (e.g. MKV or MPEG-TS). Commands that need to seek in the video are not supported. + .. option:: -o DIR, --output DIR Output directory for created files. If unset, working directory will be used. May be overridden by command options. diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index f97bc320..f074117e 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -49,6 +49,8 @@ def main(): if DEBUG_MODE: raise raise SystemExit(1) from None + except SystemExit: + raise except BaseException as ex: if DEBUG_MODE: raise diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index ab19561c..9c91e6ab 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -205,6 +205,12 @@ def print_command_help(ctx: click.Context, command: click.Command): type=click.STRING, help="[REQUIRED] Input video file. Image sequences and URLs are supported.", ) +@click.option( + "--stdin", + is_flag=True, + default=False, + help="Read the input video from standard input instead of -i/--input. Requires the pyav backend and a container that can be read from a pipe (e.g. MKV or MPEG-TS). Commands that need to seek in the video are not supported.", +) @click.option( "--output", "-o", @@ -336,6 +342,7 @@ def print_command_help(ctx: click.Context, command: click.Command): def scenedetect( ctx: click.Context, input: str | None, + stdin: bool, output: str | None, stats: str | None, config: str | None, @@ -356,6 +363,7 @@ def scenedetect( ctx.handle_options( input_path=input, + use_stdin=stdin, output=output, frame_rate=frame_rate, stats_file=stats, @@ -374,6 +382,12 @@ def scenedetect( ) +def check_not_stdin(ctx: CliContext, command: str) -> None: + """Rejects `command` if the input is read from stdin, which cannot seek and has no path.""" + if ctx.use_stdin: + raise click.BadParameter(f"{command} is incompatible with --stdin.", param_hint=command) + + def add_hidden_alias(command: click.Command, alias: str): """Adds a copy of `command` that can be invoked under the name `alias`.""" # Shallow copy: deepcopy fails on Python 3.10 + click >=8.3 because click's internal @@ -492,6 +506,8 @@ def time_command( "Only one of --duration/-d or --end/-e can be specified, not both.", param_hint="time", ) + if start is not None: + check_not_stdin(ctx, "time -s/--start") 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, @@ -1004,6 +1020,7 @@ def load_scenes_command(ctx: click.Context, input: str | None, start_col_name: s ctx = ctx.obj assert isinstance(ctx, CliContext) + check_not_stdin(ctx, "load-scenes") 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: @@ -1092,7 +1109,8 @@ def save_html_command( 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) + no_images = ctx.config.get_value("save-html", "no-images", no_images) or ctx.use_stdin + include_images = not no_images if include_images and not ctx.save_images: assert save_images_command.callback is not None save_images_command.callback() @@ -1100,7 +1118,7 @@ def save_html_command( "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), - "no_images": ctx.config.get_value("save-html", "no-images", no_images), + "no_images": no_images, "show": ctx.config.get_value("save-html", "show", show), } ctx.add_command(cli_commands.save_html, save_html_args) @@ -1329,6 +1347,7 @@ def split_video_command( ctx = ctx.obj assert isinstance(ctx, CliContext) + check_not_stdin(ctx, "split-video") 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: @@ -1574,6 +1593,7 @@ def save_images_command( assert isinstance(ctx, CliContext) assert ctx.video_stream is not None + check_not_stdin(ctx, "save-images") if "://" in ctx.video_stream.path: error_str = "\nThe save-images command is incompatible with URLs." logger.error(error_str) @@ -1896,6 +1916,7 @@ def save_fcp_command( ctx = ctx.obj assert isinstance(ctx, CliContext) + check_not_stdin(ctx, "save-fcp") save_fcp_args = { "filename": ctx.config.get_value("save-fcp", "filename", filename), "format": ctx.config.get_value("save-fcp", "format", format), @@ -1959,6 +1980,7 @@ def save_otio_command( ctx = ctx.obj assert isinstance(ctx, CliContext) + check_not_stdin(ctx, "save-otio") if audio and no_audio: raise click.BadArgumentUsage("Only one of --audio or --no-audio can be specified.") diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index d2914d46..3aa6dde2 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -12,6 +12,7 @@ """Context of which command-line options and config settings the user provided.""" import logging +import sys import typing as ty import click @@ -24,6 +25,7 @@ ConfigRegistry, CropValue, ) +from scenedetect.backends import VideoStreamAv from scenedetect.common import MAX_FPS_DELTA, FrameTimecode from scenedetect.detector import SceneDetector from scenedetect.detectors import ( @@ -45,6 +47,20 @@ """The user config, which can be overriden by command-line. If not found, will be default config.""" +class _PipeReader: + """Read-only view of a pipe. Windows reports pipes as seekable, but seeking on one fails or + returns bogus positions, so this keeps PyAV from trying.""" + + def __init__(self, pipe: ty.BinaryIO): + self._pipe = pipe + + def read(self, size: int = -1) -> bytes: + return self._pipe.read(size) + + def seekable(self) -> bool: + return False + + 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. @@ -90,6 +106,7 @@ def __init__(self): # Input: self.video_stream: VideoStream | None = None + self.use_stdin: bool = False # --stdin 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 @@ -177,6 +194,7 @@ def parse_timecode(self, value: str | None, correct_pts: bool = False) -> FrameT def handle_options( self, input_path: str | None, + use_stdin: bool, output: str | None, frame_rate: float | None, stats_file: str | None, @@ -256,12 +274,20 @@ def handle_options( param_hint="frame skip + stats file", ) + if use_stdin and input_path is not None: + raise click.BadParameter( + "-i/--input cannot be used with --stdin.", param_hint="--stdin" + ) + # Handle case where -i/--input was not specified (e.g. for the `help` command). - if input_path is None: + if input_path is None and not use_stdin: return # Load the input video to obtain a time base for parsing timecodes. - self._open_video_stream(input_path, frame_rate, backend) + if use_stdin: + self._open_stdin_stream(frame_rate, backend) + else: + self._open_video_stream(input_path, frame_rate, backend) self.output = self.config.get_value("global", "output", output) if self.output: @@ -492,13 +518,27 @@ 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 _open_stdin_stream(self, frame_rate: float | None, backend: str | None): + if backend not in (None, "pyav") or VideoStreamAv is None: + raise click.BadParameter( + "--stdin requires the pyav backend. Install it with `pip install av`.", + param_hint="--stdin", + ) + if sys.stdin.isatty(): + raise click.BadParameter( + "--stdin requires a video to be piped to the program.", param_hint="--stdin" + ) + self.use_stdin = True + self._open_video_stream(None, frame_rate, "pyav") + def _open_video_stream( self, - input_path: str, + input_path: str | None, frame_rate: float | None, backend: str | None, ): - if "%" in input_path and backend != "opencv": + input_hint = "--stdin" if self.use_stdin else "-i/--input" + if input_path is not None and "%" in input_path and backend != "opencv": raise click.BadParameter( "The OpenCV backend (`--backend opencv`) must be used to process image sequences.", param_hint="-i/--input", @@ -514,7 +554,15 @@ def _open_video_stream( ) # Open the video with the specified backend, loading any required config settings. - if backend == "pyav": + if self.use_stdin: + self.video_stream = VideoStreamAv( + _PipeReader(sys.stdin.buffer), + frame_rate=frame_rate, + name="stdin", + threading_mode=self.config.get_value("backend-pyav", "threading-mode"), + suppress_output=self.config.get_value("backend-pyav", "suppress-output"), + ) + elif backend == "pyav": self.video_stream = open_video( path=input_path, frame_rate=frame_rate, @@ -558,7 +606,7 @@ def _open_video_stream( raise click.BadParameter( "Failed to obtain frame rate for input video. Manually specify frame rate with the" " -f/--frame-rate option, or try re-encoding the file.", - param_hint="-i/--input", + param_hint=input_hint, ) from ex except VideoOpenFailure as ex: if DEBUG_MODE: @@ -567,11 +615,9 @@ def _open_video_stream( "Failed to open input video{}: {}".format( f" using {backend} backend" if backend else "", str(ex) ), - param_hint="-i/--input", + param_hint=input_hint, ) from ex except OSError as ex: if DEBUG_MODE: raise - raise click.BadParameter( - f"Input error:\n\n\t{ex!s}\n", param_hint="-i/--input" - ) from None + raise click.BadParameter(f"Input error:\n\n\t{ex!s}\n", param_hint=input_hint) from None diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index bf8b6f63..591ff7b2 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -143,6 +143,14 @@ def _detect(context: CliContext) -> tuple[SceneList, CutList] | None: ) return None + if num_frames <= 0 and context.use_stdin: + logger.critical( + "Failed to read any frames from stdin. Some containers can't be read from a pipe, such" + " as an MP4 with its index at the end of the file. Try remuxing to MKV or MPEG-TS:\n" + " ffmpeg -i input.mp4 -c copy -f matroska -\n" + ) + raise SystemExit(1) + perf_duration = time.time() - perf_start_time logger.info( "Processed %d frames in %.1f seconds (average %.2f FPS).", diff --git a/tests/test_cli.py b/tests/test_cli.py index f1f731b6..65938148 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,13 +30,16 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. import sys from pathlib import Path +from types import SimpleNamespace +import click import cv2 import numpy as np import pytest import scenedetect from scenedetect._cli.config import ConfigLoadFailure, ConfigRegistry +from scenedetect._cli.context import CliContext from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available from scenedetect.platform import StrPath from tests.helpers import invoke_cli @@ -1735,3 +1738,116 @@ def test_cli_save_fcp_fcp7(tmp_path: Path): assert second_file is not None assert second_file.attrib["id"] == "file1" assert second_file.find("pathurl") is None + + +# Matroska can be demuxed from a pipe, unlike an MP4 with its index at the end of the file. +STDIN_VIDEO_PATH = "tests/resources/issue-236-split-copy.mkv" + + +def run_stdin(*args: str, video: str | None = STDIN_VIDEO_PATH) -> subprocess.CompletedProcess: + """Runs the CLI with `video` written to stdin through a real pipe.""" + return subprocess.run( + [*SCENEDETECT_CMD.split(" "), *args], + input=Path(video).read_bytes() if video else b"", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + +def test_cli_stdin_matches_file_input(): + """Scenes detected from stdin should match those from the same video opened by path.""" + command = ["detect-content", "list-scenes", "-n"] + piped = run_stdin("--stdin", *command) + assert piped.returncode == 0, piped.stdout.decode(errors="replace") + from_file = subprocess.run( + [*SCENEDETECT_CMD.split(" "), "-i", STDIN_VIDEO_PATH, "-b", "pyav", *command], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + assert from_file.returncode == 0 + + def scene_rows(output: bytes) -> list[str]: + return [line for line in output.decode().splitlines() if line.strip().startswith("|")] + + assert scene_rows(piped.stdout) + assert scene_rows(piped.stdout) == scene_rows(from_file.stdout) + + +def test_cli_stdin_output_filename(tmp_path: Path): + """$VIDEO_NAME should have a usable value since there is no input path.""" + result = run_stdin("--stdin", "-o", str(tmp_path), "detect-content", "list-scenes") + assert result.returncode == 0, result.stdout.decode(errors="replace") + assert (tmp_path / "stdin-Scenes.csv").exists() + + +def test_cli_stdin_save_html_without_images(tmp_path: Path): + """save-html needs to seek for images, so they should be left out when reading from stdin.""" + result = run_stdin("--stdin", "-o", str(tmp_path), "detect-content", "save-html") + assert result.returncode == 0, result.stdout.decode(errors="replace") + assert (tmp_path / "stdin-Scenes.html").exists() + assert not list(tmp_path.glob("*.jpg")) + + +def test_cli_stdin_with_input(): + """--stdin and -i/--input should be mutually exclusive.""" + result = run_stdin("--stdin", "-i", DEFAULT_VIDEO_PATH, "detect-content") + assert result.returncode != 0 + assert b"cannot be used with --stdin" in result.stdout + + +def test_cli_stdin_with_other_backend(): + """--stdin is only implemented by the PyAV backend.""" + result = run_stdin("--stdin", "-b", "opencv", "detect-content") + assert result.returncode != 0 + assert b"requires the pyav backend" in result.stdout + + +def test_cli_stdin_terminal(monkeypatch: pytest.MonkeyPatch): + """Reading from a terminal would block forever, so it should be rejected.""" + monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True)) + with pytest.raises(click.BadParameter, match="requires a video to be piped"): + CliContext()._open_stdin_stream(frame_rate=None, backend=None) + + +def test_cli_stdin_stats_file(tmp_path: Path): + """A stats file should be written for each frame read from stdin.""" + result = run_stdin("--stdin", "-o", str(tmp_path), "-s", "stats.csv", "detect-content") + assert result.returncode == 0, result.stdout.decode(errors="replace") + assert len((tmp_path / "stats.csv").read_text().splitlines()) > 1 + + +@pytest.mark.parametrize( + "command", + [ + "split-video", + "save-images", + "save-fcp", + "save-otio", + # The option requires a path that exists, but it's rejected before the file is parsed. + f"load-scenes -i {DEFAULT_CONFIG_FILE}", + "time -s 1s", + ], +) +def test_cli_stdin_incompatible_commands(command: str): + """Commands that need to seek or refer to a source file should fail before processing.""" + result = run_stdin("--stdin", *command.split(" ")) + assert result.returncode != 0 + output = result.stdout.decode(errors="replace") + assert "incompatible with --stdin" in output + assert "Processed" not in output + + +def test_cli_stdin_unreadable_container(): + """An MP4 with its index at the end decodes no frames from a pipe, which shouldn't be silent.""" + result = run_stdin("--stdin", "detect-content", video="tests/resources/testvideo.mp4") + assert result.returncode != 0 + assert b"Failed to read any frames from stdin" in result.stdout + + +def test_cli_stdin_no_data(monkeypatch: pytest.MonkeyPatch): + """An empty pipe should be reported as a failure to open the input.""" + # Errors opening the input are only reported as usage errors outside of debug mode. + monkeypatch.setenv("SCENEDETECT_DEBUG", "") + result = run_stdin("--stdin", "detect-content", video=None) + assert result.returncode != 0 + assert b"Invalid value for --stdin: Failed to open input video" in result.stdout diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 0a336e81..bf9c69ee 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -817,6 +817,7 @@ Development ## PySceneDetect 0.7.3 (TBD) + - [feature] Add `--stdin` to read the input video from a pipe, e.g. `ffmpeg -i input.mp4 -c copy -f matroska - | scenedetect --stdin detect-content list-scenes`. Requires the PyAV backend. `save-html` omits images, and commands that need to seek or refer to a source file (`split-video`, `save-images`, `save-fcp`, `save-otio`, `load-scenes`, `time -s`) are not supported [#189](https://github.com/Breakthrough/PySceneDetect/issues/189) [#434](https://github.com/Breakthrough/PySceneDetect/issues/434) - [feature] Add `-i`/`--interval` to `save-images` to save images based on the length of each scene: one image per `DURATION`, with `--min-images`/`--max-images` to limit the number of images per scene, e.g. `save-images -i 2s --max-images 10` (`interval`/`min-images`/`max-images` in the config file, and `interval`/`min_images`/`max_images` in `save_images()`) [#280](https://github.com/Breakthrough/PySceneDetect/issues/280) - [bugfix] `$VIDEO_NAME` is now derived from the path component of URL inputs, ignoring the query string and fragment (e.g. `https://host/clip.mp4?token=1.2` -> `clip`). URLs with no path use the host. Previously the query string leaked into output filenames, which fails on Windows. [#197](https://github.com/Breakthrough/PySceneDetect/issues/197) - [bugfix] OTIO exports use the full source duration when provided, including from the CLI, instead of declaring a fixed 1,980-frame duration. The available range remains unspecified when the duration is unknown. Scene source ranges are unchanged. [#580](https://github.com/Breakthrough/PySceneDetect/pull/580)