changeset 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 35580b661882
children
files create.py
diffstat 1 files changed, 87 insertions(+), 0 deletions(-) [+]
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())