Last active
December 23, 2023 01:10
-
-
Save noelleleigh/2a8cc16ce60376ae79bf6a5a1b89f7f6 to your computer and use it in GitHub Desktop.
Python CLI script for using the concat demuxer in ffmpeg (https://trac.ffmpeg.org/wiki/Concatenate)
This file contains 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
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.info("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.info("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.info("FFmpeg invocation:\n%s", " ".join(args)) | |
return subprocess.run(args) | |
if __name__ == "__main__": | |
def path_type(val: str) -> Path: | |
"""Make a value an absolute Path.""" | |
return Path(val).absolute() | |
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. | |
Bash example: | |
python ffmpeg_concat.py -o ./album.mp3 ./album/*.mp3 | |
Powershell example: | |
python ffmpeg_concat.py -o ./album.mp3 (Get-ChildItem ./album/*.mp3) | |
""" | |
), | |
) | |
parser.add_argument( | |
"input_file", | |
type=path_type, | |
nargs="+", | |
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", | |
required=False, | |
help="Overwrite the output file if it already exists.", | |
) | |
parser.add_argument( | |
"--verbose", | |
"-v", | |
action="store_true", | |
required=False, | |
help="Output more information to help with troubleshooting.", | |
) | |
parser.add_argument( | |
"--chapters", | |
action="store_true", | |
required=False, | |
help="Include unnamed chapters for each input file.", | |
) | |
parser.add_argument( | |
"--keep-script", | |
action="store_true", | |
required=False, | |
help=("Don't delete the concat script when the program exits."), | |
) | |
args = parser.parse_args() | |
if args.verbose: | |
logger.setLevel(logging.INFO) | |
logger.info("Parsed args:\n%s", args) | |
main( | |
input_file_paths=args.input_file, | |
output_file_path=args.output, | |
overwrite=args.overwrite, | |
include_chapters=args.chapters, | |
keep_script=args.keep_script, | |
) |
This file contains 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
import argparse | |
import logging | |
import subprocess | |
from pathlib import Path | |
logging.basicConfig() | |
logger = logging.getLogger("ffmpeg_split") | |
def make_part_name(path: Path, index: int): | |
return path.with_suffix(f".{index:02}{path.suffix}") | |
def main( | |
input_file_path: Path, | |
output_file_path: Path, | |
overwrite: bool, | |
start: str, | |
end: str, | |
): | |
part_1_path = make_part_name(output_file_path, 1) | |
part_2_path = make_part_name(output_file_path, 2) | |
args = ( | |
("ffmpeg", "-hide_banner") | |
+ (("-y",) if overwrite else ()) | |
+ ("-i", input_file_path) | |
+ ("-t", start) | |
+ ("-c", "copy") | |
+ (part_1_path,) | |
+ ("-ss", end) | |
+ ("-c", "copy") | |
+ (part_2_path,) | |
) | |
logger.info("FFmpeg invocation: %s", " ".join(map(str, args))) | |
subprocess.run(args) | |
return [part_1_path, part_2_path] | |
if __name__ == "__main__": | |
logger.setLevel(logging.INFO) | |
def path_type(val: str) -> Path: | |
"""Make a value an absolute Path.""" | |
return Path(val).absolute() | |
parser = argparse.ArgumentParser( | |
description="Split a file into two pieces with a part in the middle removed. " | |
"Meant to remove midroll ads." | |
) | |
parser.add_argument("input_file", type=path_type, help="Input file") | |
parser.add_argument( | |
"--output", | |
"-o", | |
type=path_type, | |
required=True, | |
help="Output file. " | |
'The script will insert "01" and "02" before the file extension on both parts. ' | |
'Example: "foo.mp3" will result in "foo.01.mp3" and "foo.02.mp3"', | |
) | |
parser.add_argument( | |
"--start", | |
"-t", | |
required=True, | |
help="The timestamp of when to start cutting " | |
"(Format: https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax)", | |
) | |
parser.add_argument( | |
"--end", | |
"-ss", | |
required=True, | |
help="The timestamp of when to stop cutting " | |
"(Format: https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax)", | |
) | |
parser.add_argument( | |
"--overwrite", | |
"-y", | |
action="store_true", | |
required=False, | |
help="Overwrite the output file if it already exists.", | |
) | |
args = parser.parse_args() | |
output_paths = main( | |
input_file_path=args.input_file, | |
output_file_path=args.output, | |
overwrite=args.overwrite, | |
start=args.start, | |
end=args.end, | |
) | |
for path in output_paths: | |
print(path) |
Author
noelleleigh
commented
Jul 18, 2021
•
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment