view create.py @ 136:da4f7200665f default tip

buncha shit
author Paper <paper@tflc.us>
date Sat, 07 Mar 2026 18:04:10 -0500
parents 0d8eabdd12ab
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
import os
import math #ceil
import typing

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, offset: int) -> str:
	f = ""
	i = offset
	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: typing.Optional[str], output: str, blur: bool, duration: float) -> list:
	args = ["ffmpeg", "-y"]
	# I want you to play that song again
	if not cover is None:
		args.extend(["-r", "1", "-loop", "1", "-i", cover])
	for f in audio_files:
		args.extend(["-i", f])
	args.extend(["-filter_complex", build_filter_string(len(audio_files), 1 if cover else 0), "-map", "[out]"])
	if not cover is None:
		# Letterbox to 1920x1080
		args.extend(["-map", "0:v",
		             "-tune", "stillimage",
		             "-c:v", "libx264",
		             # Add a second to account for inconsistencies in durations
		             "-t", "%f" % (duration + 1.0)])
		args.extend(build_vf(blur))
	args.append(output + ".mkv")
	return args


# Returns a Popen object that should be waited on
def run_ffmpeg(audio_files: list[str], cover: typing.Optional[str], output: str, blur: bool, duration: float) -> subprocess.Popen:
	args = build_arguments_list(audio_files, cover, output, blur, duration)
	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: list, 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:
	dur = 0.0

	ffprobes = [run_ffprobe(x) for x in audio_files]

	# Create output txt file with timestamps; we also use this duration
	# to force ffmpeg to do the right thing instead of elongating our
	# video
	with open(output + ".txt", "w", encoding="utf-8") as fw:
		for f in ffprobes:
			f.wait()
			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"])

	ffmpeg = run_ffmpeg(audio_files, cover, output, blur, dur)

	# 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())