Skip to content

Commit 770d514

Browse files
authored
[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
1 parent 64f1bae commit 770d514

File tree

5 files changed

+166
-16
lines changed

5 files changed

+166
-16
lines changed

scenedetect.cfg

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,18 @@
283283
#start-col-name = Start Frame
284284

285285

286+
[save-qp]
287+
288+
# Filename format of QP file. Can use $VIDEO_NAME macro.
289+
#filename = $VIDEO_NAME.qp
290+
291+
# Folder to output QP file to. Overrides [global] output option.
292+
#output = /usr/tmp/images
293+
294+
# Disable shifting frame numbers by start time (yes/no).
295+
#disable-shift = no
296+
297+
286298
#
287299
# BACKEND OPTIONS
288300
#

scenedetect/_cli/__init__.py

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ def scenedetect(
346346
)
347347
@click.pass_context
348348
def help_command(ctx: click.Context, command_name: str):
349-
"""Print help for command (`help [command]`)."""
349+
"""Print full help reference."""
350350
assert isinstance(ctx.parent.command, click.MultiCommand)
351351
parent_command = ctx.parent.command
352352
all_commands = set(parent_command.list_commands(ctx))
@@ -989,6 +989,9 @@ def export_html_command(
989989
image_height: ty.Optional[int],
990990
):
991991
"""Export scene list to HTML file. Requires save-images unless --no-images is specified."""
992+
# TODO: Rename this command to save-html to align with other export commands. This will require
993+
# that we allow `export-html` as an alias on the CLI and via the config file for a few versions
994+
# as to not break existing workflows.
992995
ctx = ctx.obj
993996
assert isinstance(ctx, CliContext)
994997

@@ -1011,7 +1014,7 @@ def export_html_command(
10111014
"-o",
10121015
metavar="DIR",
10131016
type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False),
1014-
help="Output directory to save videos to. Overrides global option -o/--output if set.%s"
1017+
help="Output directory to save videos to. Overrides global option -o/--output.%s"
10151018
% (USER_CONFIG.get_help_string("list-scenes", "output", show_default=False)),
10161019
)
10171020
@click.option(
@@ -1084,7 +1087,7 @@ def list_scenes_command(
10841087
"-o",
10851088
metavar="DIR",
10861089
type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False),
1087-
help="Output directory to save videos to. Overrides global option -o/--output if set.%s"
1090+
help="Output directory to save videos to. Overrides global option -o/--output.%s"
10881091
% (USER_CONFIG.get_help_string("split-video", "output", show_default=False)),
10891092
)
10901093
@click.option(
@@ -1259,7 +1262,7 @@ def split_video_command(
12591262
"-o",
12601263
metavar="DIR",
12611264
type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False),
1262-
help="Output directory for images. Overrides global option -o/--output if set.%s"
1265+
help="Output directory for images. Overrides global option -o/--output.%s"
12631266
% (USER_CONFIG.get_help_string("save-images", "output", show_default=False)),
12641267
)
12651268
@click.option(
@@ -1445,30 +1448,76 @@ def save_images_command(
14451448
ctx.save_images = True
14461449

14471450

1451+
@click.command("save-qp", cls=_Command)
1452+
@click.option(
1453+
"--filename",
1454+
"-f",
1455+
metavar="NAME",
1456+
default=None,
1457+
type=click.STRING,
1458+
help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-qp", "filename")),
1459+
)
1460+
@click.option(
1461+
"--output",
1462+
"-o",
1463+
metavar="DIR",
1464+
type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False),
1465+
help="Output directory to save QP file to. Overrides global option -o/--output.%s"
1466+
% (USER_CONFIG.get_help_string("save-qp", "output", show_default=False)),
1467+
)
1468+
@click.option(
1469+
"--disable-shift",
1470+
"-d",
1471+
is_flag=True,
1472+
flag_value=True,
1473+
default=None,
1474+
help="Disable shifting frame numbers by start time.%s"
1475+
% (USER_CONFIG.get_help_string("save-qp", "disable-shift")),
1476+
)
1477+
@click.pass_context
1478+
def save_qp_command(
1479+
ctx: click.Context,
1480+
filename: ty.Optional[ty.AnyStr],
1481+
output: ty.Optional[ty.AnyStr],
1482+
disable_shift: ty.Optional[bool],
1483+
):
1484+
"""Save cuts as keyframes (I-frames) for video encoding.
1485+
1486+
The resulting QP file can be used with the `--qpfile` argument in x264/x265."""
1487+
ctx = ctx.obj
1488+
assert isinstance(ctx, CliContext)
1489+
1490+
save_qp_args = {
1491+
"filename_format": ctx.config.get_value("save-qp", "filename", filename),
1492+
"output_dir": ctx.config.get_value("save-qp", "output", output),
1493+
"shift_start": not ctx.config.get_value("save-qp", "disable-shift", disable_shift),
1494+
}
1495+
ctx.add_command(cli_commands.save_qp, save_qp_args)
1496+
1497+
14481498
# ----------------------------------------------------------------------
1449-
# Commands Omitted From Help List
1499+
# CLI Sub-Command Registration
14501500
# ----------------------------------------------------------------------
14511501

1452-
# Info Commands
1502+
# Informational
14531503
scenedetect.add_command(about_command)
14541504
scenedetect.add_command(help_command)
14551505
scenedetect.add_command(version_command)
14561506

1457-
# ----------------------------------------------------------------------
1458-
# Commands Added To Help List
1459-
# ----------------------------------------------------------------------
1460-
1461-
# Input / Output
1462-
scenedetect.add_command(export_html_command)
1463-
scenedetect.add_command(list_scenes_command)
1507+
# Input
14641508
scenedetect.add_command(load_scenes_command)
1465-
scenedetect.add_command(save_images_command)
1466-
scenedetect.add_command(split_video_command)
14671509
scenedetect.add_command(time_command)
14681510

1469-
# Detection Algorithms
1511+
# Detectors
14701512
scenedetect.add_command(detect_adaptive_command)
14711513
scenedetect.add_command(detect_content_command)
14721514
scenedetect.add_command(detect_hash_command)
14731515
scenedetect.add_command(detect_hist_command)
14741516
scenedetect.add_command(detect_threshold_command)
1517+
1518+
# Output
1519+
scenedetect.add_command(export_html_command)
1520+
scenedetect.add_command(save_qp_command)
1521+
scenedetect.add_command(list_scenes_command)
1522+
scenedetect.add_command(save_images_command)
1523+
scenedetect.add_command(split_video_command)

scenedetect/_cli/commands.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ def export_html(
6464
)
6565

6666

67+
def save_qp(
68+
context: CliContext,
69+
scenes: SceneList,
70+
cuts: CutList,
71+
output_dir: str,
72+
filename_format: str,
73+
shift_start: bool,
74+
):
75+
"""Handler for the `save-qp` command."""
76+
del scenes # We only use cuts for this handler.
77+
qp_path = get_and_create_path(
78+
Template(filename_format).safe_substitute(VIDEO_NAME=context.video_stream.name),
79+
output_dir,
80+
)
81+
start_frame = context.start_time.frame_num if context.start_time else 0
82+
offset = start_frame if shift_start else 0
83+
with open(qp_path, "wt") as qp_file:
84+
qp_file.write(f"{0 if shift_start else start_frame} I -1\n")
85+
# Place another I frame at each detected cut.
86+
qp_file.writelines(f"{cut.frame_num - offset} I -1\n" for cut in cuts)
87+
logger.info(f"QP file written to: {qp_path}")
88+
89+
6790
def list_scenes(
6891
context: CliContext,
6992
scenes: SceneList,

scenedetect/_cli/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,11 @@ def format(self, timecode: FrameTimecode) -> str:
336336
"scale-method": Interpolation.LINEAR,
337337
"width": 0,
338338
},
339+
"save-qp": {
340+
"disable-shift": False,
341+
"filename": "$VIDEO_NAME.qp",
342+
"output": None,
343+
},
339344
"split-video": {
340345
"args": DEFAULT_FFMPEG_ARGS,
341346
"copy": False,

tests/test_cli.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,67 @@ def test_cli_export_html(tmp_path: Path):
430430
# TODO: Check for existence of HTML & image files.
431431

432432

433+
def test_cli_save_qp(tmp_path: Path):
434+
"""Test `save-qp` command with and without a custom filename format."""
435+
EXPECTED_QP_CONTENTS = """
436+
0 I -1
437+
90 I -1
438+
"""
439+
for filename in (None, "custom.txt"):
440+
filename_format = f"--filename {filename}" if filename else ""
441+
assert (
442+
invoke_scenedetect(
443+
f"-i {{VIDEO}} time -e 95 {{DETECTOR}} save-qp {filename_format}",
444+
output_dir=tmp_path,
445+
)
446+
== 0
447+
)
448+
output_path = tmp_path.joinpath(filename if filename else f"{DEFAULT_VIDEO_NAME}.qp")
449+
assert os.path.exists(output_path)
450+
assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:]
451+
452+
453+
def test_cli_save_qp_start_offset(tmp_path: Path):
454+
"""Test `save-qp` command but using a shifted start time."""
455+
# The QP file should always start from frame 0, so we expect a similar result to the above, but
456+
# with the frame numbers shifted by the start frame. Note that on the command-line, the first
457+
# frame is frame 1, but the first frame in a QP file is indexed by 0.
458+
#
459+
# Since we are starting at frame 51, we must shift all cuts by 50 frames.
460+
EXPECTED_QP_CONTENTS = """
461+
0 I -1
462+
40 I -1
463+
"""
464+
assert (
465+
invoke_scenedetect(
466+
"-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp",
467+
output_dir=tmp_path,
468+
)
469+
== 0
470+
)
471+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.qp")
472+
assert os.path.exists(output_path)
473+
assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:]
474+
475+
476+
def test_cli_save_qp_no_shift(tmp_path: Path):
477+
"""Test `save-qp` command with start time shifting disabled."""
478+
EXPECTED_QP_CONTENTS = """
479+
50 I -1
480+
90 I -1
481+
"""
482+
assert (
483+
invoke_scenedetect(
484+
"-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-qp --disable-shift",
485+
output_dir=tmp_path,
486+
)
487+
== 0
488+
)
489+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.qp")
490+
assert os.path.exists(output_path)
491+
assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:]
492+
493+
433494
@pytest.mark.parametrize("backend_type", ALL_BACKENDS)
434495
def test_cli_backend(backend_type: str):
435496
"""Test setting the `-b`/`--backend` argument."""

0 commit comments

Comments
 (0)