diff --git a/docs/cli.rst b/docs/cli.rst index c904c43d..7d223b23 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -583,6 +583,42 @@ Options Default: ``"Start Frame"`` +.. _command-save-chapters: + +.. program:: scenedetect save-chapters + + +``save-chapters`` +======================================================================== + +Save scenes as chapters in FFmetadata format. + +The chapters can be added to a video with ffmpeg without re-encoding, e.g.: + + ``ffmpeg -i video.mp4 -i video-Chapters.txt -map_metadata 1 -codec copy output.mp4`` + + +Options +------------------------------------------------------------------------ + + +.. option:: -f NAME, --filename NAME + + Filename format to use. + + Default: ``$VIDEO_NAME-Chapters.txt`` + +.. option:: -t NAME, --title NAME + + Chapter title format to use. Can use $SCENE_NUMBER macro. + + Default: ``"Scene $SCENE_NUMBER"`` + +.. option:: -o DIR, --output DIR + + Output directory to save chapters file to. Overrides global option :option:`-o/--output `. + + .. _command-save-edl: .. program:: scenedetect save-edl diff --git a/scenedetect.cfg b/scenedetect.cfg index 0587a2c8..a0d94334 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -317,6 +317,18 @@ #start-col-name = Start Frame +[save-chapters] + +# Filename format of chapters file. Can use $VIDEO_NAME macro. +#filename = $VIDEO_NAME-Chapters.txt + +# Folder to output chapters file to. Overrides [global] output option. +#output = /usr/tmp/chapters + +# Title format to use for each chapter. Can use $SCENE_NUMBER macro. +#title = Scene $SCENE_NUMBER + + [save-edl] # Filename format of EDL file. Can use $VIDEO_NAME macro. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 58e51e3b..23bafb01 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1691,6 +1691,62 @@ def save_images_command( ctx.save_images = True +SAVE_CHAPTERS_HELP = """Save scenes as chapters in FFmetadata format. + +The chapters can be added to a video with ffmpeg without re-encoding, e.g.: + + ffmpeg -i video.mp4 -i video-Chapters.txt -map_metadata 1 -codec copy output.mp4 +""" + + +@click.command("save-chapters", cls=Command, help=SAVE_CHAPTERS_HELP) +@click.option( + "--filename", + "-f", + metavar="NAME", + default=None, + type=click.STRING, + help="Filename format to use.{}".format( + USER_CONFIG.get_help_string("save-chapters", "filename") + ), +) +@click.option( + "--title", + "-t", + metavar="NAME", + default=None, + type=click.STRING, + help="Chapter title format to use. Can use $SCENE_NUMBER macro.{}".format( + USER_CONFIG.get_help_string("save-chapters", "title") + ), +) +@click.option( + "--output", + "-o", + metavar="DIR", + type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), + help="Output directory to save chapters file to. Overrides global option -o/--output.{}".format( + USER_CONFIG.get_help_string("save-chapters", "output", show_default=False) + ), +) +@click.pass_context +def save_chapters_command( + ctx: click.Context, + filename: str | None, + title: str | None, + output: str | None, +): + ctx = ctx.obj + assert isinstance(ctx, CliContext) + + save_chapters_args = { + "filename": ctx.config.get_value("save-chapters", "filename", filename), + "title": ctx.config.get_value("save-chapters", "title", title), + "output": ctx.config.get_value("save-chapters", "output", output), + } + ctx.add_command(cli_commands.save_chapters, save_chapters_args) + + SAVE_EDL_HELP = """Save cuts in EDL format (CMX 3600). Timecodes are non-drop-frame (HH:MM:SS:FF), counting frames at the nominal rate: 24 frames per @@ -2001,6 +2057,7 @@ def save_otio_command( # Output scenedetect.add_command(list_scenes_command) +scenedetect.add_command(save_chapters_command) scenedetect.add_command(save_edl_command) scenedetect.add_command(save_html_command) scenedetect.add_command(save_images_command) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 7667f163..5dd512bf 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -29,6 +29,7 @@ write_scene_list_edl, write_scene_list_fcp7, write_scene_list_fcpx, + write_scene_list_ffmetadata, write_scene_list_html, write_scene_list_otio, ) @@ -297,6 +298,29 @@ def split_video( logger.info("Video splitting completed, scenes written to disk.") +def save_chapters( + context: CliContext, + scenes: SceneList, + cuts: CutList, + filename: str, + output: str, + title: str, +): + """Handles the `save-chapters` command. Outputs chapters in FFmetadata format.""" + del cuts # We only use scene information. + assert context.video_stream is not None + video_name = context.video_stream.name + chapters_path = get_and_create_path( + Template(filename).safe_substitute(VIDEO_NAME=video_name), + output, + ) + write_scene_list_ffmetadata( + output_path=chapters_path, + scene_list=scenes, + title_format=title, + ) + + def save_edl( context: CliContext, scenes: SceneList, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 03918dcc..7beffaf7 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -445,6 +445,11 @@ def default_to_str(value: ConfigValue) -> str: "output": None, "verbosity": "info", }, + "save-chapters": { + "filename": "$VIDEO_NAME-Chapters.txt", + "output": None, + "title": "Scene $SCENE_NUMBER", + }, "save-edl": { "filename": "$VIDEO_NAME.edl", "output": None, diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index b2f45cbf..25f4796d 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -26,6 +26,7 @@ import typing as ty from fractions import Fraction from pathlib import Path +from string import Template from xml.dom import minidom from xml.etree import ElementTree @@ -474,6 +475,56 @@ def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction: return Fraction(tc.pts) * tc.time_base +def _escape_ffmetadata(value: str) -> str: + """Escape `value` for use in an FFmetadata file. + + The characters ``=``, ``;``, ``#``, ``\\`` and newlines have special meaning in FFmetadata and + must be preceded by a backslash. See https://ffmpeg.org/ffmpeg-formats.html#Metadata-2 + """ + for char in ("\\", "=", ";", "#", "\n"): + value = value.replace(char, "\\" + char) + return value + + +@_open_output_file("FFMETADATA") +def write_scene_list_ffmetadata( + output_path: StrPath | BytePath | ty.TextIO, + scene_list: SceneList, + title_format: str = "Scene $SCENE_NUMBER", +): + """Writes the given list of scenes to `output_path` as chapters in FFmetadata format. + + The resulting file can be added to a video as chapter markers with ffmpeg, for example: + ``ffmpeg -i video.mp4 -i chapters.txt -map_metadata 1 -codec copy output.mp4`` + + Arguments: + output_path: Open text file or path to write the FFmetadata file to. Parent directories + must exist. When a path is provided, the file is closed after writing. + scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end. + title_format: Template for each chapter's title. ``$SCENE_NUMBER`` is replaced with the + 1-based scene number. + """ + # `_open_output_file` replaces filesystem paths with writable text streams. + assert isinstance(output_path, _SupportsWrite) + output_file = output_path + lines = [";FFMETADATA1"] + for i, (start, end) in enumerate(scene_list): + # Chapter times are written in milliseconds from exact (PTS-based) seconds, so they don't + # drift on fractional frame rates like 23.976 or 29.97. + start_ms = round(_frame_timecode_seconds(start) * 1000) + end_ms = round(_frame_timecode_seconds(end) * 1000) + title = Template(title_format).safe_substitute(SCENE_NUMBER=i + 1) + lines += [ + "[CHAPTER]", + "TIMEBASE=1/1000", + f"START={start_ms}", + f"END={end_ms}", + f"title={_escape_ffmetadata(title)}", + ] + output_file.write("\n".join(lines)) + output_file.write("\n") + + @_open_output_file("FCPX") def write_scene_list_fcpx( output_path: StrPath | BytePath | ty.TextIO, diff --git a/tests/test_cli.py b/tests/test_cli.py index 9139ba1b..ca1eda24 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1215,6 +1215,72 @@ def test_cli_load_scenes_round_trip(): assert first.readlines() == second.readlines() +def test_cli_save_chapters(tmp_path: Path): + """Test `save-chapters` command.""" + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-chapters", + ] + ) + assert exit_code == 0 + output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}-Chapters.txt") + assert os.path.exists(output_path) + EXPECTED_CHAPTERS_OUTPUT = """;FFMETADATA1 +[CHAPTER] +TIMEBASE=1/1000 +START=2002 +END=3754 +title=Scene 1 +[CHAPTER] +TIMEBASE=1/1000 +START=3754 +END=6006 +title=Scene 2 +""" + assert output_path.read_text() == EXPECTED_CHAPTERS_OUTPUT + + +def test_cli_save_chapters_with_params(tmp_path: Path): + """Test `save-chapters` command with the filename, title, and output options overridden.""" + output_dir = tmp_path / "chapters" + exit_code, _ = invoke_cli( + [ + "-i", + DEFAULT_VIDEO_PATH, + "time", + "-s", + "2s", + "-d", + "4s", + "detect-content", + "save-chapters", + "-f", + "my_chapters.txt", + "-t", + "Shot $SCENE_NUMBER", + "-o", + str(output_dir), + ] + ) + assert exit_code == 0 + output_path = output_dir.joinpath("my_chapters.txt") + assert os.path.exists(output_path) + content = output_path.read_text() + assert content.startswith(";FFMETADATA1\n") + assert "title=Shot 1\n" in content + assert "title=Shot 2\n" in content + + def test_cli_save_edl(tmp_path: Path): """Test `save-edl` command.""" exit_code, _ = invoke_cli( diff --git a/tests/test_output.py b/tests/test_output.py index 15c5b462..23888359 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -33,6 +33,7 @@ from scenedetect.output import ( SceneMetadata, VideoMetadata, + _escape_ffmetadata, _open_output_file, is_ffmpeg_available, split_video_ffmpeg, @@ -40,6 +41,7 @@ write_scene_list_edl, write_scene_list_fcp7, write_scene_list_fcpx, + write_scene_list_ffmetadata, write_scene_list_otio, ) @@ -567,6 +569,128 @@ def test_write_scene_list_edl_non_standard_rate_uses_wall_clock(tmp_path: Path): assert "003 AX V C 00:00:02:00 00:00:02:12 00:00:02:00 00:00:02:12" in content +def test_write_scene_list_ffmetadata(tmp_path: Path): + """FFmetadata output has the header and one chapter block per scene, in milliseconds.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 90)]) + output_path = tmp_path / "chapters.txt" + write_scene_list_ffmetadata(output_path, scenes) + + assert output_path.read_text() == ( + ";FFMETADATA1\n" + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle=Scene 1\n" + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000\nEND=3000\ntitle=Scene 2\n" + ) + + +def test_write_scene_list_ffmetadata_empty_scene_list(tmp_path: Path): + """An empty scene list still writes a valid FFmetadata header with no chapters.""" + output_path = tmp_path / "chapters.txt" + write_scene_list_ffmetadata(output_path, []) + assert output_path.read_text() == ";FFMETADATA1\n" + + +@pytest.mark.parametrize("target_kind", _OUTPUT_TARGET_KINDS) +def test_write_scene_list_ffmetadata_output_target(tmp_path: Path, target_kind: str): + """FFmetadata output accepts a file handle, string, Path, or bytes path.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_target, read_output = _make_output_target(tmp_path, target_kind, "chapters.txt") + write_scene_list_ffmetadata(output_target, scenes, title_format="Tést $SCENE_NUMBER") + text = read_output() + assert text.startswith(";FFMETADATA1\n") + assert "title=Tést 1" in text + assert "\r\n" not in text + + +@pytest.mark.parametrize( + "fps,end_frame,expected_end_ms", + [ + (Fraction(24, 1), 7200, 300000), + (Fraction(25, 1), 7200, 288000), + (Fraction(30, 1), 7200, 240000), + (Fraction(24000, 1001), 7200, 300300), + (Fraction(30000, 1001), 7200, 240240), + (Fraction(60000, 1001), 7200, 120120), + ], +) +def test_write_scene_list_ffmetadata_frame_rates( + tmp_path: Path, fps: Fraction, end_frame: int, expected_end_ms: int +): + """Chapter times match the exact frame time at both whole and fractional frame rates.""" + scenes = _fake_scenes(fps, [(0, end_frame)]) + output_path = tmp_path / "chapters.txt" + write_scene_list_ffmetadata(output_path, scenes) + assert f"END={expected_end_ms}\n" in output_path.read_text() + + +def test_write_scene_list_ffmetadata_title_format(tmp_path: Path): + """title_format replaces $SCENE_NUMBER with the 1-based scene number.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60), (60, 90)]) + output_path = tmp_path / "chapters.txt" + write_scene_list_ffmetadata(output_path, scenes, title_format="Shot $SCENE_NUMBER") + titles = [line for line in output_path.read_text().splitlines() if line.startswith("title=")] + assert titles == ["title=Shot 1", "title=Shot 2", "title=Shot 3"] + + +def test_write_scene_list_ffmetadata_escapes_title(tmp_path: Path): + """Special characters in chapter titles are escaped so ffmpeg reads them literally.""" + scenes = _fake_scenes(_FPS_CFR, [(0, 30)]) + output_path = tmp_path / "chapters.txt" + write_scene_list_ffmetadata(output_path, scenes, title_format="A=B; #$SCENE_NUMBER") + assert "title=A\\=B\\; \\#1\n" in output_path.read_text() + + +@pytest.mark.parametrize( + "value,expected", + [ + ("Scene 1", "Scene 1"), + ("a=b", "a\\=b"), + ("a;b", "a\\;b"), + ("#1", "\\#1"), + ("back\\slash", "back\\\\slash"), + ("two\nlines", "two\\\nlines"), + ("=;#\\", "\\=\\;\\#\\\\"), + ], +) +def test_escape_ffmetadata(value: str, expected: str): + """Each FFmetadata special character is escaped with a single backslash.""" + assert _escape_ffmetadata(value) == expected + + +@pytest.mark.skipif(not is_ffmpeg_available(), reason="ffmpeg is not available") +def test_write_scene_list_ffmetadata_ffmpeg_roundtrip(test_video_file, tmp_path: Path): + """ffmpeg can mux the generated chapters into a video and reads them back.""" + import subprocess + + scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)]) + chapters_path = tmp_path / "chapters.txt" + output_path = tmp_path / "output.mp4" + write_scene_list_ffmetadata(chapters_path, scenes) + subprocess.run( + [ + "ffmpeg", + "-v", + "error", + "-y", + "-i", + test_video_file, + "-i", + str(chapters_path), + "-map_metadata", + "1", + "-codec", + "copy", + str(output_path), + ], + check=True, + ) + probe = subprocess.run( + ["ffmpeg", "-i", str(output_path)], capture_output=True, text=True, check=False + ) + assert "Chapter #0:0: start 0.000000, end 1.000000" in probe.stderr + assert "Chapter #0:1: start 1.000000, end 2.000000" in probe.stderr + assert "title : Scene 2" in probe.stderr + + def test_write_scene_list_fcpx(tmp_path: Path): """FCPXML output declares version 1.9, rational time strings, and an asset-clip per scene.""" scenes = _fake_scenes(_FPS_NTSC, [(48, 96), (96, 144)]) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index cfec4bc1..a8d5bf40 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -817,6 +817,7 @@ Development ## PySceneDetect 0.7.3 (TBD) + - [feature] Add `save-chapters` command to save scenes as chapters in FFmetadata format, which can be added to a video with ffmpeg without re-encoding. Also available as `write_scene_list_ffmetadata()` in the `scenedetect.output` module [#323](https://github.com/Breakthrough/PySceneDetect/issues/323) - [feature] Add `-i`/`--interval` to `save-images` to save images based on the length of each scene: one image per `DURATION`, with `--min-images`/`--max-images` to limit the number of images per scene, e.g. `save-images -i 2s --max-images 10` (`interval`/`min-images`/`max-images` in the config file, and `interval`/`min_images`/`max_images` in `save_images()`) [#280](https://github.com/Breakthrough/PySceneDetect/issues/280) - [bugfix] `$VIDEO_NAME` is now derived from the path component of URL inputs, ignoring the query string and fragment (e.g. `https://host/clip.mp4?token=1.2` -> `clip`). URLs with no path use the host. Previously the query string leaked into output filenames, which fails on Windows. [#197](https://github.com/Breakthrough/PySceneDetect/issues/197) - [bugfix] OTIO exports use the full source duration when provided, including from the CLI, instead of declaring a fixed 1,980-frame duration. The available range remains unspecified when the duration is unknown. Scene source ranges are unchanged. [#580](https://github.com/Breakthrough/PySceneDetect/pull/580) diff --git a/website/pages/features.md b/website/pages/features.md index ba2d6f4e..e40b6cc0 100644 --- a/website/pages/features.md +++ b/website/pages/features.md @@ -35,6 +35,7 @@ ### Output Formats + - **Chapters**: `save-chapters` command (save as FFmetadata chapters, which can be added to a video with ffmpeg) - **EDL**: `save-edl` command (save as edit decision list in CMX 3600 format, compatible with most editors) - **HTML**: `save-html` command (save HTML table that can be viewed with browser) - **OTIO**: `save-otio` command (save as [OpenTimelineIO](https://github.com/AcademySoftwareFoundation/OpenTimelineIO) file)