view create.py @ 133:0d8eabdd12ab default tip

create: write H:MM:SS timestamps, add option to fill with gaussian-blur instead of black many albums are longer than one hour so writing H:MM:SS is a necessity. if anything there will just be verbose info that isn't important for my use-case. however the gaussian-blur is simply broken. It works, and it plays locally just fine, but YouTube in particular elongates the video to fit the full width. I'm not entirely sure why it does this, but it makes it useless and ugly.
author Paper <paper@tflc.us>
date Sat, 03 Jan 2026 20:25:38 -0500
parents 71df0cf3aa05
children
line wrap: on
line source

#!/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

def build_vf(blur) -> list[str]:
	if blur:
		return ["-filter_complex", "[0:v]split=2[bg][fg];[bg]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,boxblur=luma_radius=10:chroma_radius=10[bg_final];[fg]scale=1920:1080:force_original_aspect_ratio=decrease[fg_final];[bg_final][fg_final]overlay=(W-w)/2:(H-h)/2"]

	return ["-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"]

# 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, blur) -> 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"])
        args.extend(build_vf(blur))
    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, blur) -> subprocess.Popen:
    args = build_arguments_list(audio_files, cover, output, blur)
    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, blur) -> int:
    ffprobes = [run_ffprobe(x) for x in audio_files]
    ffmpeg = run_ffmpeg(audio_files, cover, output, blur)

    # 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:%02d %s\n" % (int(dur) // 3600, int(dur) % 3600 // 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('-b', '--blur', action='store_true')
    parser.add_argument('audio', action='extend', nargs='+', type=str)
    args = parser.parse_args()
    doit(args.audio, args.cover, args.output, args.blur)

if __name__ == "__main__":
    exit(main())