Skip to content

Instantly share code, notes, and snippets.

@noelleleigh
Last active January 10, 2025 02:14
Show Gist options
  • Select an option

  • Save noelleleigh/2a8cc16ce60376ae79bf6a5a1b89f7f6 to your computer and use it in GitHub Desktop.

Select an option

Save noelleleigh/2a8cc16ce60376ae79bf6a5a1b89f7f6 to your computer and use it in GitHub Desktop.
import argparse
import logging
import subprocess
from collections.abc import Iterable
from contextlib import contextmanager
from inspect import cleandoc
from pathlib import Path
from tempfile import NamedTemporaryFile
logging.basicConfig()
logger = logging.getLogger("ffmpeg_concat")
def escape_path(path: Path) -> str:
"""
Escape a file path for a concat script.
https://ffmpeg.org/ffmpeg-formats.html#Syntax
"""
return str(path).replace("'", "'\\''")
def make_file_directive(path: Path) -> str:
"""
Make a file directive for a concat script.
https://ffmpeg.org/ffmpeg-formats.html#Syntax
"""
escaped = escape_path(path)
return f"file '{escaped}'"
def get_file_duration(path: Path) -> float:
"""
Return the duration of a file in fractional seconds.
Source: https://stackoverflow.com/a/22243834
"""
args = (
("ffprobe", "-hide_banner")
+ ("-show_entries", "format=duration")
+ ("-print_format", "csv=p=0")
+ (str(path),)
)
logger.debug("ffprobe invocation:\n%s", " ".join(args))
result = subprocess.run(
args=args,
capture_output=True,
check=True,
text=True,
)
return float(result.stdout)
@contextmanager
def make_concat_script(
input_file_paths: Iterable[Path],
include_chapters: bool = False,
keep_script: bool = False,
):
"""
Context manager for making a temporary concat script.
"""
concat_script_lines = ["ffconcat version 1.0"]
file_duration_pointer = 0.0
for file_num, file_path in enumerate(input_file_paths, start=1):
concat_script_lines.append(make_file_directive(file_path))
if include_chapters:
file_duration = get_file_duration(file_path)
concat_script_lines.append(
" ".join(
(
"chapter",
str(file_num),
str(file_duration_pointer),
str(file_duration_pointer + file_duration),
)
)
)
file_duration_pointer = file_duration_pointer + file_duration
# On Windows, the file can't be opened a second time while it is open for creation.
# So we close the file after writing, and handle deletion after the fact.
with NamedTemporaryFile(
mode="w+t", encoding="utf-8", suffix=".txt", delete=False
) as concat_script:
concat_script_body = "\n".join(concat_script_lines)
logger.debug("concat script:\n%s", concat_script_body)
concat_script.write(concat_script_body)
try:
yield concat_script.name
finally:
if not keep_script:
# Delete the file when we're done with it.
Path(concat_script.name).unlink()
def main(
input_file_paths: Iterable[Path],
output_file_path: Path,
overwrite: bool = False,
include_chapters: bool = False,
keep_script: bool = False,
):
with make_concat_script(
input_file_paths,
include_chapters=include_chapters,
keep_script=keep_script,
) as concat_script_path:
args = (
("ffmpeg", "-hide_banner")
+ (("-y",) if overwrite else ())
+ ("-f", "concat")
+ ("-safe", "0")
+ ("-i", concat_script_path)
+ ("-c", "copy")
+ (str(output_file_path),)
)
logger.debug("FFmpeg invocation:\n%s", subprocess.list2cmdline(args))
return subprocess.run(args, encoding="utf-8", check=True)
if __name__ == "__main__":
def path_type(val: str) -> Path:
"""Make a value an absolute Path."""
return Path(val).resolve()
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=cleandoc(
"""
Concatenate media files of identical formats together.
Requires ffmpeg to be installed and available on the PATH.
Uses the concat demuxer (https://ffmpeg.org/ffmpeg-formats.html#concat-1)
under the hood.
"""
),
epilog=cleandoc(
"""
Bash example:
python ffmpeg_concat.py -i ./album/*.mp3 -o ./album.mp3
Powershell example:
python ffmpeg_concat.py -i (Get-ChildItem ./album/*.mp3) -o ./album.mp3
"""
),
)
parser.add_argument(
"--input",
"-i",
type=path_type,
nargs="+",
required=True,
help="Input file(s)",
)
parser.add_argument(
"--output",
"-o",
type=path_type,
required=True,
help="Output file",
)
parser.add_argument(
"--overwrite",
"-y",
action="store_true",
help="Overwrite the output file if it already exists.",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Output more information to help with troubleshooting.",
)
parser.add_argument(
"--chapters",
action="store_true",
help="Include unnamed chapters for each input file.",
)
parser.add_argument(
"--keep-script",
action="store_true",
help=("Don't delete the concat script when the program exits."),
)
args = parser.parse_args()
logger.setLevel(logging.DEBUG if args.verbose else logging.INFO)
logger.debug("Parsed arguments:\n%s", args)
main(
input_file_paths=args.input,
output_file_path=args.output,
overwrite=args.overwrite,
include_chapters=args.chapters,
keep_script=args.keep_script,
)
import argparse
import logging
import subprocess
from inspect import cleandoc
from pathlib import Path
logging.basicConfig()
logger = logging.getLogger("ffmpeg_split")
def main(
input_file_path: Path,
output_file_path: Path,
split_times: list[str],
):
args = (
("ffmpeg", "-hide_banner")
+ ("-i", str(input_file_path))
+ ("-c", "copy")
+ ("-f", "segment")
+ ("-segment_times", ",".join(split_times))
+ ("-segment_start_number", "1")
+ ("-reset_timestamps", "1")
+ (str(output_file_path),)
)
logger.debug("FFmpeg invocation:\n%s", subprocess.list2cmdline(args))
return subprocess.run(args, encoding="utf-8", check=True)
def path_type(val: str) -> Path:
"""Make a value an absolute Path."""
return Path(val).resolve()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=cleandoc(
"""
Split a file into two or more segments at specific timestamps.
Requires ffmpeg to be installed and available on the PATH.
Uses the segment muxer (https://ffmpeg.org/ffmpeg-formats.html#segment)
under the hood.
""",
),
epilog=cleandoc(
"""
Example:
python ffmpeg_split.py -i './album.mp3' -o './album/track %02d.mp3' --split-times 01:23 5:00 11:04
"""
),
)
parser.add_argument(
"--input",
"-i",
type=path_type,
required=True,
help="Input file",
)
parser.add_argument(
"--output",
"-o",
type=path_type,
required=True,
help=(
"The output filenames are specified by a pattern, which can be used to "
"produce sequentially numbered series of files. "
'The pattern may contain the string "%%d" or "%%0Nd", this string '
"specifies the position of the characters representing a numbering in the "
"filenames. "
'If the form "%%0Nd" is used, the string representing the number in each '
"filename is 0-padded to N digits. "
"The literal character ’%%’ can be specified in the pattern with the "
'string "%%%%".\n'
"More format details: https://ffmpeg.org/ffmpeg-formats.html#image2"
),
)
parser.add_argument(
"--split-times",
nargs="+",
required=True,
help=(
"The timestamp of when to split the file in [-][HH:]MM:SS[.m...] or "
"[-]S+[.m...][s|ms|us] formats.\n"
"Format details: https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax"
),
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Output more information to help with troubleshooting.",
)
args = parser.parse_args()
logger.setLevel(logging.DEBUG if args.verbose else logging.INFO)
logger.debug("Parsed arguments:\n%s", args)
main(
input_file_path=args.input,
output_file_path=args.output,
split_times=args.split_times,
)
@noelleleigh
Copy link
Author

usage: ffmpeg_split.py [-h] --input INPUT --output OUTPUT --split-times SPLIT_TIMES
                       [SPLIT_TIMES ...] [--verbose]

Split a file into two or more segments at specific timestamps.
Requires ffmpeg to be installed and available on the PATH.

Uses the segment muxer (https://ffmpeg.org/ffmpeg-formats.html#segment)
under the hood.

options:
  -h, --help            show this help message and exit
  --input INPUT, -i INPUT
                        Input file
  --output OUTPUT, -o OUTPUT
                        The output filenames are specified by a pattern, which can be used to
                        produce sequentially numbered series of files. The pattern may contain the    
                        string "%d" or "%0Nd", this string specifies the position of the characters   
                        representing a numbering in the filenames. If the form "%0Nd" is used, the    
                        string representing the number in each filename is 0-padded to N digits. The  
                        literal character ’%’ can be specified in the pattern with the string "%%".   
                        More format details: https://ffmpeg.org/ffmpeg-formats.html#image2
  --split-times SPLIT_TIMES [SPLIT_TIMES ...]
                        The timestamp of when to split the file in [-][HH:]MM:SS[.m...] or
                        [-]S+[.m...][s|ms|us] formats. Format details: https://ffmpeg.org/ffmpeg-     
                        utils.html#time-duration-syntax
  --verbose, -v         Output more information to help with troubleshooting.

Example:

    python ffmpeg_split.py -i './album.mp3' -o './album/track %02d.mp3' --split-times 01:23 5:00 11:04

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