Mercurial > codedump
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 131:35580b661882 | 132:71df0cf3aa05 |
|---|---|
| 1 #!/usr/bin/env python3 | |
| 2 # Simple python script to create a video from a bunch of wav files | |
| 3 # and an image. Also creates a cue-like track listing that contains | |
| 4 # track numbers etc. | |
| 5 | |
| 6 import argparse | |
| 7 import json | |
| 8 import subprocess | |
| 9 | |
| 10 # Build filter string | |
| 11 def build_filter_string(length: int, cover: bool) -> str: | |
| 12 f = "" | |
| 13 i = 1 if cover else 0 | |
| 14 for x in range(length): | |
| 15 f += ("[%d:0]" % (x + i)) | |
| 16 f += ("concat=n=%d:v=0:a=1[out]" % length) | |
| 17 return f | |
| 18 | |
| 19 def build_arguments_list(audio_files: list[str], cover, output) -> list: | |
| 20 args = ["ffmpeg", "-y"] | |
| 21 if not cover is None: | |
| 22 args.extend(["-r", "1", "-loop", "1", "-i", cover]) | |
| 23 # I want you to play that song again | |
| 24 for f in audio_files: | |
| 25 args.extend(["-i", f]) | |
| 26 args.extend(["-filter_complex", build_filter_string(len(audio_files), False if cover is None else True), "-map", "[out]"]) | |
| 27 if not cover is None: | |
| 28 # Letterbox to 1920x1080 | |
| 29 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"]) | |
| 30 args.extend(["-shortest"]) | |
| 31 args.append(output + ".mkv") | |
| 32 return args | |
| 33 | |
| 34 | |
| 35 # Returns a Popen object that should be waited on | |
| 36 def run_ffmpeg(audio_files: list[str], cover, output) -> subprocess.Popen: | |
| 37 args = build_arguments_list(audio_files, cover, output) | |
| 38 print(args) | |
| 39 return subprocess.Popen(args) | |
| 40 | |
| 41 | |
| 42 # Returns a Popen object that should be waited on. | |
| 43 # stdout is also piped... | |
| 44 def run_ffprobe(audio_file: str) -> subprocess.Popen: | |
| 45 return subprocess.Popen(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", audio_file], stdout=subprocess.PIPE, text=True) | |
| 46 | |
| 47 | |
| 48 def get_title_from_tags(j, default: str) -> str: | |
| 49 # Is there an easier way to do this...? | |
| 50 for tag in j: | |
| 51 if tag.lower() == "title": | |
| 52 return j[tag] | |
| 53 | |
| 54 # Ugh | |
| 55 return default | |
| 56 | |
| 57 def doit(audio_files: list[str], cover, output) -> int: | |
| 58 ffprobes = [run_ffprobe(x) for x in audio_files] | |
| 59 ffmpeg = run_ffmpeg(audio_files, cover, output) | |
| 60 | |
| 61 # Wait for all ffprobe processes to finish their jobs | |
| 62 for f in ffprobes: | |
| 63 f.wait() | |
| 64 | |
| 65 # Iterate the list again, and add up all the times. | |
| 66 # This also accounts for milliseconds etc. to be | |
| 67 # more accurate | |
| 68 with open(output + ".txt", "w", encoding="utf-8") as fw: | |
| 69 dur = 0.0 | |
| 70 for f in ffprobes: | |
| 71 j = json.load(f.stdout) | |
| 72 fw.write("%d:%02d %s\n" % (int(dur) // 60, int(dur) % 60, get_title_from_tags(j["format"]["tags"], "OOPS...."))) | |
| 73 dur += float(j["format"]["duration"]) | |
| 74 | |
| 75 # Finally, wait on the big ffmpeg process | |
| 76 ffmpeg.wait() | |
| 77 | |
| 78 def main() -> int: | |
| 79 parser = argparse.ArgumentParser(prog='create', description='Creates album videos', epilog='(c) paper 2025 all rights reversed') | |
| 80 parser.add_argument('-c', '--cover', required=True) | |
| 81 parser.add_argument('-o', '--output', required=True) | |
| 82 parser.add_argument('audio', action='extend', nargs='+', type=str) | |
| 83 args = parser.parse_args() | |
| 84 doit(args.audio, args.cover, args.output) | |
| 85 | |
| 86 if __name__ == "__main__": | |
| 87 exit(main()) |
