Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions scenedetect/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -356,6 +363,7 @@ def scenedetect(

ctx.handle_options(
input_path=input,
use_stdin=stdin,
output=output,
frame_rate=frame_rate,
stats_file=stats,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1092,15 +1109,16 @@ 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()
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),
"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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.")

Expand Down
66 changes: 56 additions & 10 deletions scenedetect/_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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
8 changes: 8 additions & 0 deletions scenedetect/_cli/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
Loading