From 53c4d71a395300f76f7ba1bbe955d90709f12184 Mon Sep 17 00:00:00 2001 From: MuhammadBilalKhan267 Date: Wed, 12 Aug 2026 23:34:15 +0500 Subject: [PATCH] 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