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
36 changes: 36 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scenedetect -o>`.


.. _command-save-edl:

.. program:: scenedetect save-edl
Expand Down
12 changes: 12 additions & 0 deletions scenedetect.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions scenedetect/_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions scenedetect/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading