From 53c4d71a395300f76f7ba1bbe955d90709f12184 Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Wed, 12 Aug 2026 23:34:15 +0500 Subject: [PATCH 1/3] Add keyframe format v1 CLI export --- docs/cli.rst | 26 ++++++++++++++++++ scenedetect.cfg | 9 ++++++ scenedetect/_cli/__init__.py | 41 ++++++++++++++++++++++++++++ scenedetect/_cli/commands.py | 18 ++++++++++++ scenedetect/_cli/config.py | 4 +++ tests/test_cli.py | 53 ++++++++++++++++++++++++++++++++++++ website/pages/changelog.md | 4 +++ 7 files changed, 155 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index b145a112..15bf1f74 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -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 `. + + .. _command-save-otio: .. program:: scenedetect save-otio diff --git a/scenedetect.cfg b/scenedetect.cfg index d987435e..945362af 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -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. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 85fc17ef..1cdb0b11 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -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).""" @@ -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) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 740f38b3..b505ee66 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -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, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 26787080..dc7684fe 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -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", diff --git a/tests/test_cli.py b/tests/test_cli.py index cb1b1a84..f384f410 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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.""" diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 334fb876..e513189e 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -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) \ No newline at end of file From 1fc1cc906d3f45c7f5ff8905e6998d3c775eef42 Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Sun, 23 Aug 2026 22:27:12 +0500 Subject: [PATCH 2/3] Document keyframe format VFR limitations --- docs/cli.rst | 2 ++ scenedetect/_cli/__init__.py | 2 ++ scenedetect/_cli/commands.py | 5 +++++ website/pages/changelog.md | 2 +- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 15bf1f74..d01d14be 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -725,6 +725,8 @@ Options Save detected cuts using keyframe format v1. +Frame numbers are currently approximate for variable framerate (VFR) video. + Options ------------------------------------------------------------------------ diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 1cdb0b11..23f88886 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1707,6 +1707,8 @@ def save_qp_command( SAVE_KEYFRAMES_HELP = """Save detected cuts using keyframe format v1. + +Frame numbers are currently approximate for variable framerate (VFR) video. """ diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index b505ee66..bbee0332 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -114,8 +114,13 @@ def save_keyframes( ) 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/566): 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}") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index e513189e..b8a08105 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -788,4 +788,4 @@ Development ## 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) \ No newline at end of file +- [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 [#566](https://github.com/Breakthrough/PySceneDetect/issues/566) \ No newline at end of file From 6e7a366a39b1346f3f6c77e5a3d3730a176c7ede Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Sun, 23 Aug 2026 22:44:35 +0500 Subject: [PATCH 3/3] Update VFR issue reference to #569 --- scenedetect/_cli/commands.py | 2 +- website/pages/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index bbee0332..2940a11c 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -119,7 +119,7 @@ def save_keyframes( # 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/566): Frame numbers are approximate for VFR + # 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}") diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 9332e643..99eb2ac4 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -792,4 +792,4 @@ Development ## 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 [#566](https://github.com/Breakthrough/PySceneDetect/issues/566) +- [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)