Skip to content

Add save-chapters command for FFmetadata chapter export (#323) - #599

Open
Davidvisualstudio007 wants to merge 6 commits into
Breakthrough:mainfrom
Davidvisualstudio007:save-chapters
Open

Davidvisualstudio007 wants to merge 6 commits into
Breakthrough:mainfrom
Davidvisualstudio007:save-chapters

Conversation

@Davidvisualstudio007

@Davidvisualstudio007 Davidvisualstudio007 commented Nov 8, 2026 •

Copy link
Copy Markdown

Closes #323

This adds a save-chapters command that saves the detected scenes as chapters in FFmetadata format. You can then add them to a video with ffmpeg without re-encoding:

scenedetect -i video.mp4 detect-content save-chapters
ffmpeg -i video.mp4 -i video-Chapters.txt -map_chapters 1 -codec copy output.mp4

What I changed:

  • Added write_scene_list_ffmetadata() to scenedetect.output. I based it on the EDL writer, so it works with a file path or an open file
  • Added the save-chapters command with -f, -t and -o options, and a [save-chapters] section in the config. -t lets you change chapter names using $VIDEO_NAME and $SCENE_NUMBER
  • Special characters in titles get escaped so ffmpeg doesn't misread them
  • Updated the changelog and features list, regenerated the CLI docs, and added save-chapters to the docs generator's CONCORDANCE and the API docs page

For the timing I used _frame_timecode_seconds(), so it stays accurate at 23.976 and 29.97 and doesn't drift like the EDL issue in #593. Times round up to the next millisecond so a chapter never starts before its first frame.

I added 25 tests in test_output.py and 2 in test_cli.py, including one that actually runs ffmpeg to add the chapters to a video and checks they're there. Ruff passes and the output and CLI tests all pass on the latest main. I also tried it on goldeneye.mp4 and the chapters showed up in VLC.

I left YouTube style chapters out for now. YouTube needs the first chapter at 00:00, at least 3 chapters, and each one at least 10 seconds long, so I wasn't sure how you'd want short scenes handled.

Comment thread scenedetect/_cli/__init__.py Outdated

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be using -map_chapters 1 here instead of -map_metadata 1?

The same command is also in the write_scene_list_ffmetadata docstring, the roundtrip test in tests/test_output.py, and the PR description.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ohh mb, didn't realise -map_metadata copies the other metadata too. Switched it to -map_chapters everywhere (help text, docstring, test and the PR description).

Comment thread docs/cli.rst Outdated
Comment on lines +591 to +592
``save-chapters``
========================================================================

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you merge main, run python docs/generate_cli_docs.py, and commit what it changes under docs/? It should handle most of these docs changes for you now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, rebased and ran the script. It also flagged that save-chapters was missing from CONCORDANCE and the API page, so I added it to both.

Comment thread scenedetect/_cli/commands.py Outdated
write_scene_list_ffmetadata(
output_path=chapters_path,
scene_list=scenes,
title_format=title,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

save-edl and save-otio substitute $VIDEO_NAME in their title before passing it on. Should we do the same here, so -t '$VIDEO_NAME - Scene $SCENE_NUMBER' works?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nice, didn't think of that. Added it, and changed the CLI test to use "$VIDEO_NAME - Shot $SCENE_NUMBER" so it should be sweettt

Comment thread scenedetect/output/__init__.py Outdated
def write_scene_list_ffmetadata(
output_path: StrPath | BytePath | ty.TextIO,
scene_list: SceneList,
title_format: str = "Scene $SCENE_NUMBER",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call this title_format_template to be more clear that it's just a template, and for consistency w/ other commands args.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

Comment thread scenedetect/output/__init__.py Outdated
Comment on lines +514 to +515
start_ms = round(_frame_timecode_seconds(start) * 1000)
end_ms = round(_frame_timecode_seconds(end) * 1000)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would math.ceil() be safer? Frame 12 at 24000/1001 fps (500.5 ms) would make a good test case since it should equal a start of 501.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, switched to math.ceil and added your frame 12 example as a test, it comes out as 501 now.

@Breakthrough

Copy link
Copy Markdown
Owner

Thanks for the PR. Left you a few comments. Also I'll fix up the docs generator script to make it easier to update, so you will need to rebase this on main again, sorry.

Comment thread scenedetect/output/__init__.py Outdated
Comment on lines +484 to +486
for char in ("\\", "=", ";", "#", "\n"):
value = value.replace(char, "\\" + char)
return value

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also escape \r, and append a space when the escaped value ends in a backslash.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added both, plus tests for each. Missed the trailing backslash one, thanks 4 the catch

@Davidvisualstudio007

Copy link
Copy Markdown
Author

Thanks for going through it! much appreciated. Rebased on main and sorted all the comments. I also moved the changelog entry to 0.8 since 0.7.3 is out now. LMK if anything needs changing, Cheers!

@MuhammadBilalKhan267 MuhammadBilalKhan267 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one minor issue with title substitution.

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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we pass the video name to the writer and substitute both variables in one pass per scene? The current two passes can interpret $SCENE_NUMBER inside the video name or an escaped literal as a placeholder.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The writer now takes a video_name argument (same as write_scene_list_fcpx) and fills in both in one pass. Added tests for a video name with a placeholder in it and for $$ escapes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Export scenes in chapter format

3 participants