Skip to content

Instantly share code, notes, and snippets.

@nchietala
Forked from cliss/mergechapters.py
Last active November 29, 2024 00:15
Show Gist options
  • Save nchietala/7b2b71615114e082a0cb3d90473b1a2a to your computer and use it in GitHub Desktop.
Save nchietala/7b2b71615114e082a0cb3d90473b1a2a to your computer and use it in GitHub Desktop.
Merge Files with Chapters
import json
import os
import subprocess
import sys
import tempfile
def get_chapter_list(video_file: str) -> list[dict[str, str | float]]:
"""
Extracts chapter information from a video file.
:param video_file: Path to the video file.
:return: A list of dictionaries containing "index" (str), "title" (str), "start" (float), and "end" (float) keys.
"""
result = subprocess.run(
["ffprobe", "-print_format", "json", "-show_chapters", video_file],
capture_output=True,
text=True
)
file_json = json.loads(result.stdout)['chapters']
return list(
map(
lambda c: {
'index': c['id'],
'title': c['tags']['title'],
'start': float(c['start_time']),
'end': float(c['end_time'])},
file_json
)
)
def chapter_to_metadata(chapter: dict[str, str | float], offset: float, timebase) -> str:
"""
Converts a chapter dictionary to a valid chapter entry in a metadata file
:param chapter: Chapter dictionary, as structured by get_chapter_list function
:param offset: Start time of the video wherein this chapter starts.
:param timebase: Divisions of a second used by ffmpeg for chapter timing
:return:
"""
return (
"[CHAPTER]\n"
f"TIMEBASE=1/{timebase}\n"
f"START={int((chapter['start'] + offset) * timebase)}\n"
f"END={int((chapter['end'] + offset) * timebase)}\n"
f"title={chapter['title']}\n"
)
def get_extension(path: str) -> str:
filename = os.path.basename(path)
if "." in filename:
return "." + filename.split(".")[-1]
return ""
def main():
if len(sys.argv) < 4:
print("Usage:")
print("{} [input file] [input file] [output file]".format(sys.argv[0]))
print("")
print("Both files are assumed to have their chapters")
print("entered correctly and completely.")
sys.exit(0)
output_file = os.path.abspath(sys.argv[-1])
if os.path.exists(output_file):
if input("Output file exists. Overwrite? [y/N]: ").lower()[0] != "y":
return
video_files = [os.path.abspath(i) for i in sys.argv[1:-1]]
offset = 0
result = subprocess.run(
["ffmpeg", "-i", sys.argv[1], "-f", "ffmetadata", "-"],
capture_output=True,
text=True
)
timebase = 1e3
metadata = ""
find_timebase = False
for line in result.stdout.splitlines():
if not find_timebase:
if "[CHAPTER]" in line.upper():
find_timebase = True
else:
metadata += line + "\n"
if find_timebase and "TIMEBASE" in line.upper():
timebase = int(line.split("/")[-1].strip())
break
for video_file in video_files:
if not os.path.isfile(video_file):
raise FileNotFoundError(f"File {video_file} does not exist or is not a file.")
for video_file in video_files:
metadata += "".join(map(lambda c: chapter_to_metadata(c, offset, timebase), get_chapter_list(video_file)))
dur_result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", video_file],
capture_output=True,
text=True
)
offset += float(dur_result.stdout.rstrip())
with (tempfile.TemporaryFile(delete=False) as metadata_file,
tempfile.TemporaryFile(delete=False) as list_file):
metadata_file_name = metadata_file.name
list_file_name = list_file.name
metadata_file.write(metadata.strip().encode("utf-8"))
list_file.write("\n".join(f"file '{i}'" for i in video_files).encode("utf-8"))
try:
output_file_temp = output_file + (
"" if not os.path.exists(output_file) else ("-tmp" + get_extension(output_file))
)
subprocess.run(
[
"ffmpeg",
"-f", "concat",
"-safe", "-0",
"-i", list_file_name,
"-i", metadata_file_name,
"-map_metadata", "1",
"-map", "0:v",
"-map", "0:a",
"-map", "0:s",
"-disposition:s:0", "none",
"-c", "copy",
output_file_temp
],
check=True
)
if output_file_temp != output_file:
os.remove(output_file)
os.rename(output_file_temp, output_file)
finally:
os.remove(metadata_file_name)
os.remove(list_file_name)
if __name__ == "__main__":
exit(main())
@nchietala
Copy link
Author

Revision to original merges a list of files of arbitrary length instead of just 2

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