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


.. _command-save-keyframes:

.. program:: scenedetect save-keyframes


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

Save detected cuts using keyframe format v1.


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
41 changes: 41 additions & 0 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,46 @@ def save_qp_command(
ctx.add_command(cli_commands.save_qp, save_qp_args)


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


@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 @@ -1850,6 +1890,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
18 changes: 18 additions & 0 deletions scenedetect/_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ 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")
keyframes_file.write("fps 0\n")
keyframes_file.write("0\n")
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 @@ -786,6 +786,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 @@ -785,3 +785,7 @@ Development
- [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)

## 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)
Loading