Mercurial > codedump
diff create.py @ 132:71df0cf3aa05 default tip
add create.py
this is a script to render out video files from entire albums,
singles, or EPs. eventually it can be edited to be more robust
(such as automatically finding discogs/musicbrainz links) but
I think it's pretty damn good for now.
It's basically just an ffmpeg frontend with a few hardcoded options
that are suitable for this kind of thing.
| author | Paper <paper@tflc.us> |
|---|---|
| date | Fri, 02 Jan 2026 10:35:03 -0500 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/create.py Fri Jan 02 10:35:03 2026 -0500 @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# Simple python script to create a video from a bunch of wav files +# and an image. Also creates a cue-like track listing that contains +# track numbers etc. + +import argparse +import json +import subprocess + +# Build filter string +def build_filter_string(length: int, cover: bool) -> str: + f = "" + i = 1 if cover else 0 + for x in range(length): + f += ("[%d:0]" % (x + i)) + f += ("concat=n=%d:v=0:a=1[out]" % length) + return f + +def build_arguments_list(audio_files: list[str], cover, output) -> list: + args = ["ffmpeg", "-y"] + if not cover is None: + args.extend(["-r", "1", "-loop", "1", "-i", cover]) + # I want you to play that song again + for f in audio_files: + args.extend(["-i", f]) + args.extend(["-filter_complex", build_filter_string(len(audio_files), False if cover is None else True), "-map", "[out]"]) + if not cover is None: + # Letterbox to 1920x1080 + args.extend(["-map", "0:v", "-tune", "stillimage", "-c:v", "libx264", "-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"]) + args.extend(["-shortest"]) + args.append(output + ".mkv") + return args + + +# Returns a Popen object that should be waited on +def run_ffmpeg(audio_files: list[str], cover, output) -> subprocess.Popen: + args = build_arguments_list(audio_files, cover, output) + print(args) + return subprocess.Popen(args) + + +# Returns a Popen object that should be waited on. +# stdout is also piped... +def run_ffprobe(audio_file: str) -> subprocess.Popen: + return subprocess.Popen(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", audio_file], stdout=subprocess.PIPE, text=True) + + +def get_title_from_tags(j, default: str) -> str: + # Is there an easier way to do this...? + for tag in j: + if tag.lower() == "title": + return j[tag] + + # Ugh + return default + +def doit(audio_files: list[str], cover, output) -> int: + ffprobes = [run_ffprobe(x) for x in audio_files] + ffmpeg = run_ffmpeg(audio_files, cover, output) + + # Wait for all ffprobe processes to finish their jobs + for f in ffprobes: + f.wait() + + # Iterate the list again, and add up all the times. + # This also accounts for milliseconds etc. to be + # more accurate + with open(output + ".txt", "w", encoding="utf-8") as fw: + dur = 0.0 + for f in ffprobes: + j = json.load(f.stdout) + fw.write("%d:%02d %s\n" % (int(dur) // 60, int(dur) % 60, get_title_from_tags(j["format"]["tags"], "OOPS...."))) + dur += float(j["format"]["duration"]) + + # Finally, wait on the big ffmpeg process + ffmpeg.wait() + +def main() -> int: + parser = argparse.ArgumentParser(prog='create', description='Creates album videos', epilog='(c) paper 2025 all rights reversed') + parser.add_argument('-c', '--cover', required=True) + parser.add_argument('-o', '--output', required=True) + parser.add_argument('audio', action='extend', nargs='+', type=str) + args = parser.parse_args() + doit(args.audio, args.cover, args.output) + +if __name__ == "__main__": + exit(main())
