comparison 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
comparison
equal deleted inserted replaced
132:71df0cf3aa05 133:0d8eabdd12ab
4 # track numbers etc. 4 # track numbers etc.
5 5
6 import argparse 6 import argparse
7 import json 7 import json
8 import subprocess 8 import subprocess
9
10 def build_vf(blur) -> list[str]:
11 if blur:
12 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"]
13
14 return ["-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"]
9 15
10 # Build filter string 16 # Build filter string
11 def build_filter_string(length: int, cover: bool) -> str: 17 def build_filter_string(length: int, cover: bool) -> str:
12 f = "" 18 f = ""
13 i = 1 if cover else 0 19 i = 1 if cover else 0
14 for x in range(length): 20 for x in range(length):
15 f += ("[%d:0]" % (x + i)) 21 f += ("[%d:0]" % (x + i))
16 f += ("concat=n=%d:v=0:a=1[out]" % length) 22 f += ("concat=n=%d:v=0:a=1[out]" % length)
17 return f 23 return f
18 24
19 def build_arguments_list(audio_files: list[str], cover, output) -> list: 25 def build_arguments_list(audio_files: list[str], cover, output, blur) -> list:
20 args = ["ffmpeg", "-y"] 26 args = ["ffmpeg", "-y"]
21 if not cover is None: 27 if not cover is None:
22 args.extend(["-r", "1", "-loop", "1", "-i", cover]) 28 args.extend(["-r", "1", "-loop", "1", "-i", cover])
23 # I want you to play that song again 29 # I want you to play that song again
24 for f in audio_files: 30 for f in audio_files:
25 args.extend(["-i", f]) 31 args.extend(["-i", f])
26 args.extend(["-filter_complex", build_filter_string(len(audio_files), False if cover is None else True), "-map", "[out]"]) 32 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: 33 if not cover is None:
28 # Letterbox to 1920x1080 34 # 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"]) 35 args.extend(["-map", "0:v", "-tune", "stillimage", "-c:v", "libx264"])
36 args.extend(build_vf(blur))
30 args.extend(["-shortest"]) 37 args.extend(["-shortest"])
31 args.append(output + ".mkv") 38 args.append(output + ".mkv")
32 return args 39 return args
33 40
34 41
35 # Returns a Popen object that should be waited on 42 # Returns a Popen object that should be waited on
36 def run_ffmpeg(audio_files: list[str], cover, output) -> subprocess.Popen: 43 def run_ffmpeg(audio_files: list[str], cover, output, blur) -> subprocess.Popen:
37 args = build_arguments_list(audio_files, cover, output) 44 args = build_arguments_list(audio_files, cover, output, blur)
38 print(args) 45 print(args)
39 return subprocess.Popen(args) 46 return subprocess.Popen(args)
40 47
41 48
42 # Returns a Popen object that should be waited on. 49 # Returns a Popen object that should be waited on.
52 return j[tag] 59 return j[tag]
53 60
54 # Ugh 61 # Ugh
55 return default 62 return default
56 63
57 def doit(audio_files: list[str], cover, output) -> int: 64 def doit(audio_files: list[str], cover, output, blur) -> int:
58 ffprobes = [run_ffprobe(x) for x in audio_files] 65 ffprobes = [run_ffprobe(x) for x in audio_files]
59 ffmpeg = run_ffmpeg(audio_files, cover, output) 66 ffmpeg = run_ffmpeg(audio_files, cover, output, blur)
60 67
61 # Wait for all ffprobe processes to finish their jobs 68 # Wait for all ffprobe processes to finish their jobs
62 for f in ffprobes: 69 for f in ffprobes:
63 f.wait() 70 f.wait()
64 71
67 # more accurate 74 # more accurate
68 with open(output + ".txt", "w", encoding="utf-8") as fw: 75 with open(output + ".txt", "w", encoding="utf-8") as fw:
69 dur = 0.0 76 dur = 0.0
70 for f in ffprobes: 77 for f in ffprobes:
71 j = json.load(f.stdout) 78 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...."))) 79 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....")))
73 dur += float(j["format"]["duration"]) 80 dur += float(j["format"]["duration"])
74 81
75 # Finally, wait on the big ffmpeg process 82 # Finally, wait on the big ffmpeg process
76 ffmpeg.wait() 83 ffmpeg.wait()
77 84
78 def main() -> int: 85 def main() -> int:
79 parser = argparse.ArgumentParser(prog='create', description='Creates album videos', epilog='(c) paper 2025 all rights reversed') 86 parser = argparse.ArgumentParser(prog='create', description='Creates album videos', epilog='(c) paper 2025 all rights reversed')
80 parser.add_argument('-c', '--cover', required=True) 87 parser.add_argument('-c', '--cover', required=True)
81 parser.add_argument('-o', '--output', required=True) 88 parser.add_argument('-o', '--output', required=True)
89 parser.add_argument('-b', '--blur', action='store_true')
82 parser.add_argument('audio', action='extend', nargs='+', type=str) 90 parser.add_argument('audio', action='extend', nargs='+', type=str)
83 args = parser.parse_args() 91 args = parser.parse_args()
84 doit(args.audio, args.cover, args.output) 92 doit(args.audio, args.cover, args.output, args.blur)
85 93
86 if __name__ == "__main__": 94 if __name__ == "__main__":
87 exit(main()) 95 exit(main())