Created
October 6, 2025 08:22
-
-
Save jannisteunissen/f8efd9ad6edb25710d6d49c38ba3d8cf to your computer and use it in GitHub Desktop.
Combine multiple videos using ffmpeg concat demuxer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # Author: Jannis Teunissen | |
| # Inspired by https://gist.github.com/tom-huntington/1323af4b991ca34ef4a46b3e44532ada | |
| import subprocess | |
| from os.path import basename, splitext | |
| from tempfile import NamedTemporaryFile | |
| import argparse | |
| def get_video_metadata(videos): | |
| """Get ffmpeg metadata for videos | |
| :param videos: list of videos | |
| :returns: metadata as a string | |
| """ | |
| chapters = [] | |
| for i, video in enumerate(videos): | |
| command = ["ffprobe", "-v", "quiet", "-of", "csv=p=0", | |
| "-show_entries", "format=duration", video] | |
| output = subprocess.run(command, capture_output=True, text=True) | |
| stdout = output.stdout.strip() | |
| duration_us = int(stdout.replace(".", "")) | |
| title = splitext(basename(video))[0] | |
| chapters.append({"duration": duration_us, | |
| "title": title}) | |
| end = 0 | |
| for i in range(len(videos)): | |
| chapters[i]["start"] = end + 1 | |
| end = chapters[i]["start"] + chapters[i]["duration"] | |
| chapters[i]["end"] = end | |
| metadata = ";FFMETADATA1\n" | |
| for chapter in chapters: | |
| metadata += f""" | |
| [CHAPTER] | |
| TIMEBASE=1/1000000 | |
| START={chapter["start"]} | |
| END={chapter["end"]} | |
| title={chapter["title"]} | |
| """ | |
| return metadata | |
| def combine_videos(videos, output_file): | |
| """Combine several videos into one, including metadata | |
| :param videos: list of videos | |
| :param output_file: single output file | |
| :returns: noting | |
| """ | |
| metadata = get_video_metadata(videos) | |
| # With Python 3.12, delete_on_close can be used instead | |
| with NamedTemporaryFile(mode='wt', delete=False) as tmp_file_list, \ | |
| NamedTemporaryFile(mode='wt', delete=False) as tmp_metadata: | |
| # Escape single quotes in filenames according to | |
| # https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping | |
| filelist = ["file '{}'".format(fname.replace("'", r"'\''")) | |
| for fname in videos] | |
| # Write list of files | |
| tmp_file_list.write('\n'.join(filelist) + '\n') | |
| tmp_file_list.close() | |
| # Write metadata | |
| tmp_metadata.write(metadata) | |
| tmp_metadata.close() | |
| command = ["ffmpeg", "-hide_banner", "-y", | |
| "-v", "quiet", | |
| "-f", "concat", | |
| "-safe", "0", | |
| "-i", tmp_file_list.name, | |
| "-i", tmp_metadata.name, | |
| "-map_metadata", "1", | |
| "-codec", "copy", | |
| output_file] | |
| output = subprocess.run(command, capture_output=True, text=True) | |
| if output.stderr: | |
| raise RuntimeError(output.stderr) | |
| if __name__ == '__main__': | |
| p = argparse.ArgumentParser( | |
| description='Combine multiple videos using ffmpeg concat demuxer') | |
| p.add_argument('-i', required=True, type=str, nargs='+', | |
| help='Input videos') | |
| p.add_argument('-o', required=True, type=str, | |
| help='Output video. Will be overwritten if it exists.') | |
| args = p.parse_args() | |
| combine_videos(args.i, args.o) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment