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
28 changes: 28 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,34 @@ Options
Width (pixels) of images.


.. _command-save-keyframes:

.. program:: scenedetect save-keyframes


``save-keyframes``
========================================================================

Save detected cuts using keyframe format v1.

Frame numbers are currently approximate for variable framerate (VFR) video.


Options
------------------------------------------------------------------------


.. option:: -f NAME, --filename NAME

Filename format to use.

Default: ``$VIDEO_NAME-keyframes.txt``

.. option:: -o DIR, --output DIR

Output directory to save keyframes to. Overrides global option :option:`-o/--output <scenedetect -o>`.


.. _command-save-otio:

.. program:: scenedetect save-otio
Expand Down
9 changes: 9 additions & 0 deletions scenedetect.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,15 @@
#disable-shift = no


[save-keyframes]

# Filename format of keyframe file. Can use $VIDEO_NAME macro.
#filename = $VIDEO_NAME-keyframes.txt

# Folder to output keyframe file to. Overrides [global] output option.
#output = /usr/tmp/keyframes


[save-fcp]

# Filename format of XML file. Can use $VIDEO_NAME macro.
Expand Down
43 changes: 43 additions & 0 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,48 @@ def save_qp_command(
ctx.add_command(cli_commands.save_qp, save_qp_args)


SAVE_KEYFRAMES_HELP = """Save detected cuts using keyframe format v1.

Frame numbers are currently approximate for variable framerate (VFR) video.
"""


@click.command("save-keyframes", cls=Command, help=SAVE_KEYFRAMES_HELP)
@click.option(
"--filename",
"-f",
metavar="NAME",
default=None,
type=click.STRING,
help="Filename format to use.{}".format(
USER_CONFIG.get_help_string("save-keyframes", "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 keyframes to. Overrides global option -o/--output.{}".format(
USER_CONFIG.get_help_string("save-keyframes", "output", show_default=False)
),
)
@click.pass_context
def save_keyframes_command(
ctx: click.Context,
filename: str | None,
output: str | None,
):
ctx = ctx.obj
assert isinstance(ctx, CliContext)

save_keyframes_args = {
"filename": ctx.config.get_value("save-keyframes", "filename", filename),
"output": ctx.config.get_value("save-keyframes", "output", output),
}
ctx.add_command(cli_commands.save_keyframes, save_keyframes_args)


SAVE_FCP_HELP = """Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX)."""


Expand Down Expand Up @@ -1841,6 +1883,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_keyframes_command)
scenedetect.add_command(save_fcp_command)
scenedetect.add_command(save_otio_command)
scenedetect.add_command(split_video_command)
Expand Down
23 changes: 23 additions & 0 deletions scenedetect/_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ def save_qp(
logger.info(f"QP file written to: {qp_path}")


def save_keyframes(
context: CliContext, scenes: SceneList, cuts: CutList, output: str, filename: str
):
"""Handler for the `save-keyframes` command."""
del scenes # We only use cuts for this handler.
assert context.video_stream is not None
keyframes_path = get_and_create_path(
Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name),
output,
)
with open(keyframes_path, "w") as keyframes_file:
keyframes_file.write("# keyframe format v1\n")
# The keyframe format v1 specification includes an FPS field, but Aegisub does not
# use it and documents `0` as the conventional value:
# https://aegisub.org/docs/latest/video/#keyframe-file-specification
keyframes_file.write("fps 0\n")
keyframes_file.write("0\n")
# TODO(https://scenedetect.com/issues/569): Frame numbers are approximate for VFR
# input until exact presentation-frame ordinals are tracked.
keyframes_file.writelines(f"{cut.frame_num}\n" for cut in cuts)
logger.info(f"Keyframes written to: {keyframes_path}")


def list_scenes(
context: CliContext,
scenes: SceneList,
Expand Down
4 changes: 4 additions & 0 deletions scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,10 @@ class FcpFormat(Enum):
"filename": "$VIDEO_NAME.qp",
"output": None,
},
"save-keyframes": {
"filename": "$VIDEO_NAME-keyframes.txt",
"output": None,
},
"save-fcp": {
"format": FcpFormat.FCPX,
"filename": "$VIDEO_NAME.xml",
Expand Down
53 changes: 53 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,59 @@ def test_cli_save_qp_no_shift(tmp_path: Path):
assert output_path.read_text() == EXPECTED_QP_CONTENTS[1:]


def test_cli_save_keyframes(tmp_path: Path):
"""Test `save-keyframes` command."""
EXPECTED_KEYFRAMES_CONTENTS = """# keyframe format v1
fps 0
0
90
"""
assert (
invoke_scenedetect(
"-i {VIDEO} time -e 95 {DETECTOR} save-keyframes",
output_dir=tmp_path,
)
== 0
)
output_path = tmp_path / f"{DEFAULT_VIDEO_NAME}-keyframes.txt"
assert output_path.exists()
assert output_path.read_text() == EXPECTED_KEYFRAMES_CONTENTS


def test_cli_save_keyframes_start_offset(tmp_path: Path):
"""Test `save-keyframes` command"""
EXPECTED_KEYFRAMES_CONTENTS = """# keyframe format v1
fps 0
0
90
"""
assert (
invoke_scenedetect(
"-i {VIDEO} time -s 51 -e 95 {DETECTOR} save-keyframes",
output_dir=tmp_path,
)
== 0
)
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-keyframes.txt")
assert os.path.exists(output_path)
assert output_path.read_text() == EXPECTED_KEYFRAMES_CONTENTS


def test_cli_save_keyframes_custom_filename(tmp_path: Path):
"""Test `save-keyframes` with a custom filename."""
custom_filename = "custom-keyframes.txt"
assert (
invoke_scenedetect(
f"-i {{VIDEO}} time -s 51 -e 95 {{DETECTOR}} "
f"save-keyframes --filename {custom_filename}",
output_dir=tmp_path,
)
== 0
)
output_path = tmp_path / custom_filename
assert output_path.exists()


@pytest.mark.parametrize("backend_type", ALL_BACKENDS)
def test_cli_backend(backend_type: str):
"""Test setting the `-b`/`--backend` argument."""
Expand Down
4 changes: 4 additions & 0 deletions website/pages/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -789,3 +789,7 @@ Development
- [api] Legacy `framerate` argument aliases in `FrameTimecode`, `open_video()`, and the video backends now emit a `DeprecationWarning`; use `frame_rate` instead. When both forms are provided, `frame_rate` takes precedence [#548](https://github.com/Breakthrough/PySceneDetect/issues/548)
- [general] `-f`, `--frame-rate`, and `--framerate` are now aliases of the same CLI option, and all forms appear in help and documentation. If multiple forms are given, the last value is used [#548](https://github.com/Breakthrough/PySceneDetect/issues/548)
- [api] `write_scene_list()` now also accepts a path (`str` or `pathlib.Path`) as the first argument in addition to an open file handle; paths are opened and closed automatically [#523](https://github.com/Breakthrough/PySceneDetect/issues/523)

## PySceneDetect 0.8 (TBD)

- [feature] Added `save-keyframes` command to export detected cuts using `# keyframe format v1` for Aegisub-compatible tools [#534](https://github.com/Breakthrough/PySceneDetect/issues/534). Frame numbers are currently approximate for VFR input [#569](https://github.com/Breakthrough/PySceneDetect/issues/569)
Loading