fixes for empty titles & mov intro/outro

This commit is contained in:
kleines Filmröllchen 2025-04-23 17:51:27 +02:00
parent 9c533338ff
commit c6cea4e456
Signed by: filmroellchen
SSH key fingerprint: SHA256:NarU6J/XgCfEae4rbei0YIdN2pYaYDccarK6R53dnc8
8 changed files with 195 additions and 1539 deletions

BIN
fsck2025/AgencyFB-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,7 +1,7 @@
[meta] [meta]
schedule = https://cfp.ctbk.de/fsck-2024/schedule/export/schedule.xml schedule = https://cfp.ctbk.de/fsck-2025/schedule/export/schedule.xml
;; path to background video ;; path to background video
template = artwork/fsck-intro-base.mp4 template = artwork/fsck-intro-base.mov
;; whether background video uses transparency (needs to be .mov) ;; whether background video uses transparency (needs to be .mov)
alpha = false alpha = false
;; whether background video is prores 4444 ;; whether background video is prores 4444
@ -15,7 +15,7 @@ fade_duration = 1
;; 'title', 'speaker' and 'text' sections below. ;; 'title', 'speaker' and 'text' sections below.
[default] [default]
;; default font ;; default font
fontfile = LiberationSans-Regular.ttf fontfile = AgencyFB-Regular.ttf
;; default font color ;; default font color
fontcolor = #fae7e3 fontcolor = #fae7e3
@ -28,12 +28,15 @@ fontcolor = #fae7e3
;; - fontsize: font size (pixel) ;; - fontsize: font size (pixel)
;; - x: horizontal position (top left corner) ;; - x: horizontal position (top left corner)
;; - y: vertical position (top left corner) ;; - y: vertical position (top left corner)
;; - w: width of the text field
;; - h: height of the text field
[title] [title]
in = 11.5 in = 11.5
out = 15 out = 15
fontsize = 67 fontsize = 67
x = 300 x = 300
y = 780 y = 780
fontfile = AgencyFB-Bold.ttf
[speaker] [speaker]
in = 12 in = 12

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import platform
from PIL import ImageFont from PIL import ImageFont
import schedulelib import schedulelib
ssl._create_default_https_context = ssl._create_unverified_context ssl._create_default_https_context = ssl._create_unverified_context
FRAME_WIDTH = 1920 FRAME_WIDTH = 1920
@ -34,31 +35,32 @@ class TextConfig:
return self.fontfile_path is not None return self.fontfile_path is not None
def parse(self, cparser_sect, default_fontfile, default_fontcolor): def parse(self, cparser_sect, default_fontfile, default_fontcolor):
self.inpoint = cparser_sect.getfloat('in') self.inpoint = cparser_sect.getfloat("in")
self.outpoint = cparser_sect.getfloat('out') self.outpoint = cparser_sect.getfloat("out")
self.x = cparser_sect.getint('x') self.x = cparser_sect.getint("x")
self.y = cparser_sect.getint('y') self.y = cparser_sect.getint("y")
self.w = cparser_sect.getint('w') self.w = cparser_sect.getint("w")
self.fontcolor = cparser_sect.get('fontcolor', default_fontcolor) self.fontcolor = cparser_sect.get("fontcolor", default_fontcolor)
fontfile = cparser_sect.get('fontfile', default_fontfile) fontfile = cparser_sect.get("fontfile", default_fontfile)
self.fontfile_path = str(PurePath(args.project, fontfile).as_posix()) self.fontfile_path = str(PurePath(args.project, fontfile).as_posix())
if not os.path.exists(self.fontfile_path): if not os.path.exists(self.fontfile_path):
error("Font file {} in Project Path is missing".format(self.fontfile_path)) error("Font file {} in Project Path is missing".format(self.fontfile_path))
self.fontsize = cparser_sect.getint('fontsize') self.fontsize = cparser_sect.getint("fontsize")
self.bordercolor = cparser_sect.get('bordercolor', None) self.bordercolor = cparser_sect.get("bordercolor", None)
def fit_text(self, text: str) -> str: def fit_text(self, text: str) -> str:
if not text: if not text:
return "" return ""
font = ImageFont.truetype( font = ImageFont.truetype(
self.fontfile_path, size=self.fontsize, encoding="unic") self.fontfile_path, size=self.fontsize, encoding="unic"
)
fitted= '\n'.join(fit_text(text, self.w or (FRAME_WIDTH-self.x-100), font)) fitted = "\n".join(fit_text(text, self.w or (FRAME_WIDTH - self.x - 100), font))
print(repr(fitted)) print(repr(fitted))
return fitted return fitted
@ -66,21 +68,21 @@ class TextConfig:
if not text: if not text:
return "" return ""
filter_str = "" filter_str = ""
filter_str += "drawtext=enable='between({},{},{})':fix_bounds=true:text_align=C:x={}:y={}".format( filter_str += "drawtext=enable='between({},{},{})':fix_bounds=true:text_align=C:x={}:y={}".format(
inout_type, self.inpoint, self.outpoint, self.x, self.y) inout_type, self.inpoint, self.outpoint, self.x, self.y
)
if self.w: if self.w:
filter_str+=f":boxw={self.w}" filter_str += f":boxw={self.w}"
print(f"{text}, {type(text)}")
filter_str += ":fontfile='{}':fontsize={}:fontcolor={}:text={}".format( filter_str += ":fontfile='{}':fontsize={}:fontcolor={}:text={}".format(
self.fontfile_path, self.fontsize, self.fontcolor, ffmpeg_escape_str(text)) self.fontfile_path, self.fontsize, self.fontcolor, ffmpeg_escape_str(text)
)
if self.bordercolor is not None: if self.bordercolor is not None:
filter_str += ":borderw={}:bordercolor={}".format( filter_str += ":borderw={}:bordercolor={}".format(
self.fontsize / 30, self.bordercolor) self.fontsize / 30, self.bordercolor
)
if fade_time > 0: if fade_time > 0:
filter_str += ":alpha='if(lt(t,{fade_in_start_time}),0,if(lt(t,{fade_in_end_time}),(t-{fade_in_start_time})/{fade_duration},if(lt(t,{fade_out_start_time}),1,if(lt(t,{fade_out_end_time}),({fade_duration}-(t-{fade_out_start_time}))/{fade_duration},0))))'".format( filter_str += ":alpha='if(lt(t,{fade_in_start_time}),0,if(lt(t,{fade_in_end_time}),(t-{fade_in_start_time})/{fade_duration},if(lt(t,{fade_out_start_time}),1,if(lt(t,{fade_out_end_time}),({fade_duration}-(t-{fade_out_start_time}))/{fade_duration},0))))'".format(
@ -88,7 +90,8 @@ class TextConfig:
fade_in_end_time=self.inpoint + fade_time, fade_in_end_time=self.inpoint + fade_time,
fade_out_start_time=self.outpoint - fade_time, fade_out_start_time=self.outpoint - fade_time,
fade_out_end_time=self.outpoint, fade_out_end_time=self.outpoint,
fade_duration=fade_time) fade_duration=fade_time,
)
filter_str += "," filter_str += ","
@ -120,34 +123,34 @@ def parse_config(filename) -> Config:
cparser = ConfigParser() cparser = ConfigParser()
cparser.read(filename) cparser.read(filename)
meta = cparser['meta'] meta = cparser["meta"]
conf.schedule = meta.get('schedule') conf.schedule = meta.get("schedule")
infile = PurePath(args.project, meta.get('template')) infile = PurePath(args.project, meta.get("template"))
conf.template_file = str(infile) conf.template_file = str(infile)
conf.alpha = meta.getboolean('alpha', conf.alpha) conf.alpha = meta.getboolean("alpha", conf.alpha)
conf.prores = meta.getboolean('prores', conf.prores) conf.prores = meta.getboolean("prores", conf.prores)
conf.inout_type = meta.get('inout_type', conf.inout_type) conf.inout_type = meta.get("inout_type", conf.inout_type)
conf.fade_duration = meta.getfloat('fade_duration', conf.fade_duration) conf.fade_duration = meta.getfloat("fade_duration", conf.fade_duration)
defaults = cparser['default'] defaults = cparser["default"]
default_fontfile = defaults.get('fontfile', None) default_fontfile = defaults.get("fontfile", None)
default_fontcolor = defaults.get('fontcolor', "#ffffff") default_fontcolor = defaults.get("fontcolor", "#ffffff")
conf.title = TextConfig() conf.title = TextConfig()
conf.title.parse(cparser['title'], default_fontfile, default_fontcolor) conf.title.parse(cparser["title"], default_fontfile, default_fontcolor)
conf.speaker = TextConfig() conf.speaker = TextConfig()
conf.speaker.parse(cparser['speaker'], default_fontfile, default_fontcolor) conf.speaker.parse(cparser["speaker"], default_fontfile, default_fontcolor)
conf.text = TextConfig() conf.text = TextConfig()
conf.text.parse(cparser['text'], default_fontfile, default_fontcolor) conf.text.parse(cparser["text"], default_fontfile, default_fontcolor)
conf.extra_text = cparser['text'].get('text', '') conf.extra_text = cparser["text"].get("text", "")
conf.fileext = infile.suffix conf.fileext = infile.suffix
if not os.path.exists(conf.template_file): if not os.path.exists(conf.template_file):
error("Template file {} in Project Path is missing".format(conf.template_file)) error("Template file {} in Project Path is missing".format(conf.template_file))
if conf.alpha and conf.fileext != '.mov': if conf.alpha and conf.fileext != ".mov":
error("Alpha can only be rendered with .mov source files") error("Alpha can only be rendered with .mov source files")
if not args.project: if not args.project:
@ -169,7 +172,7 @@ def error(err_str):
def describe_event(event): def describe_event(event):
return "#{}: {}".format(event['id'], event['title']) return "#{}: {}".format(event["id"], event["title"])
def event_print(event, message): def event_print(event, message):
@ -184,20 +187,20 @@ def fit_text(string: str, max_width: int, font: ImageFont) -> list[str]:
w = 0 w = 0
line = [] line = []
for word in split_line: for word in split_line:
new_line = line + [word.rstrip(':')] new_line = line + [word.rstrip(":")]
w = font.getlength(" ".join(new_line)) w = font.getlength(" ".join(new_line))
if w > max_width: if w > max_width:
lines.append(' '.join(line)) lines.append(" ".join(line))
line = [] line = []
line.append(word.rstrip(':')) line.append(word.rstrip(":"))
if word.endswith(':'): if word.endswith(":"):
lines.append(' '.join(line)) lines.append(" ".join(line))
line = [] line = []
if line: if line:
lines.append(' '.join(line)) lines.append(" ".join(line))
return lines return lines
@ -206,65 +209,106 @@ def ffmpeg_escape_str(text: str) -> str:
# Escape according to https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping # Escape according to https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping
# and don't put the string in quotes afterwards! # and don't put the string in quotes afterwards!
text = text.replace(",", r"\,") text = text.replace(",", r"\,")
text = text.replace(':', r"\\:") text = text.replace(":", r"\\:")
text = text.replace("'", r"\\\'") text = text.replace("'", r"\\\'")
return text return text
def enqueue_job(conf: Config, event): def enqueue_job(conf: Config, event):
event_id = str(event['id']) event_id = str(event["id"])
outfile = str(PurePath(args.project, event_id + '.ts')) outfile = str(PurePath(args.project, event_id + ".ts"))
outfile_mov = str(PurePath(args.project, event_id + '.mov')) outfile_mov = str(PurePath(args.project, event_id + ".mov"))
if event_id in args.skip: if event_id in args.skip:
event_print(event, "skipping " + str(event['id'])) event_print(event, "skipping " + str(event["id"]))
return return
if (os.path.exists(outfile) or os.path.exists(outfile_mov)) and not args.force: if (os.path.exists(outfile) or os.path.exists(outfile_mov)) and not args.force:
event_print(event, "file exist, skipping " + str(event['id'])) event_print(event, "file exist, skipping " + str(event["id"]))
return return
event_title = str(event['title']) event_title = str(event["title"])
event_personnames = str(event['personnames']) event_personnames = str(event["personnames"])
title = conf.title.fit_text(event_title) title = conf.title.fit_text(event_title)
speakers = conf.speaker.fit_text(event_personnames) speakers = conf.speaker.fit_text(event_personnames)
extra_text = conf.text.fit_text(conf.extra_text) extra_text = conf.text.fit_text(conf.extra_text)
if args.debug: if args.debug:
print('Title: ', title) print("Title: ", title)
print('Speaker: ', speakers) print("Speaker: ", speakers)
if platform.system() == 'Windows': if platform.system() == "Windows":
ffmpeg_path = './ffmpeg.exe' ffmpeg_path = "./ffmpeg.exe"
else: else:
ffmpeg_path = 'ffmpeg' ffmpeg_path = "ffmpeg"
videofilter = conf.title.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, title) + "," videofilters = [
videofilter += conf.speaker.get_ffmpeg_filter(conf.inout_type, conf.title.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, title),
conf.fade_duration, speakers) + "," conf.speaker.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, speakers),
videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, extra_text) conf.text.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, extra_text),
]
videofilter = ",".join(x for x in videofilters if len(x) > 0)
cmd = [ffmpeg_path, '-y', '-i', conf.template_file, '-vf', videofilter] cmd = [ffmpeg_path, "-y", "-i", conf.template_file, "-vf", videofilter]
if conf.fileext == '.mov' and conf.alpha: if conf.fileext == ".mov" and conf.alpha:
if conf.prores: if conf.prores:
cmd += ['-vcodec', 'prores_ks', '-pix_fmt', 'yuva444p10le', '-profile:v', cmd += [
'4444', '-shortest', '-movflags', 'faststart', '-f', 'mov', outfile_mov] "-vcodec",
"prores_ks",
"-pix_fmt",
"yuva444p10le",
"-profile:v",
"4444",
"-shortest",
"-movflags",
"faststart",
"-f",
"mov",
outfile_mov,
]
else: else:
cmd += ['-shortest', '-c:v', 'qtrle', '-movflags', cmd += [
'faststart', '-f', 'mov', outfile_mov] "-shortest",
"-c:v",
"qtrle",
"-movflags",
"faststart",
"-f",
"mov",
outfile_mov,
]
else: else:
cmd += ['-map', '0:0', '-c:v', 'mpeg2video', '-q:v', '2', '-aspect', '16:9', '-map', cmd += [
'0:1', '-c:a', 'mp2', '-b:a', '384k', '-shortest', '-f', 'mpegts', outfile] "-map",
"0:0",
"-c:v",
"mpeg2video",
"-q:v",
"2",
"-aspect",
"16:9",
"-map",
"0:1",
"-c:a",
"mp2",
"-b:a",
"384k",
"-shortest",
"-f",
"mpegts",
outfile,
]
if args.debug: if args.debug:
print(cmd) print(cmd)
subprocess.check_call(cmd, subprocess.check_call(
stderr=subprocess.STDOUT, cmd,
) stderr=subprocess.STDOUT,
)
return event_id return event_id
@ -272,59 +316,102 @@ def enqueue_job(conf: Config, event):
if __name__ == "__main__": if __name__ == "__main__":
# Parse arguments # Parse arguments
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='C3VOC Intro-Outro-Generator - Variant which renders only using video filters in ffmpeg', description="C3VOC Intro-Outro-Generator - Variant which renders only using video filters in ffmpeg",
usage="./make-ffmpeg.py yourproject/", usage="./make-ffmpeg.py yourproject/",
formatter_class=argparse.RawTextHelpFormatter) formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument('project', action="store", metavar='Project folder', type=str, help=''' parser.add_argument(
"project",
action="store",
metavar="Project folder",
type=str,
help="""
Path to your project folder Path to your project folder
''') """,
)
parser.add_argument('--debug', action="store_true", default=False, help=''' parser.add_argument(
"--debug",
action="store_true",
default=False,
help="""
Run script in debug mode and render with placeholder texts, Run script in debug mode and render with placeholder texts,
not parsing or accessing a schedule. not parsing or accessing a schedule.
This argument must not be used together with --id This argument must not be used together with --id
Usage: ./make-ffmpeg.py yourproject/ --debug Usage: ./make-ffmpeg.py yourproject/ --debug
''') """,
)
parser.add_argument('--id', dest='ids', nargs='+', action="store", type=int, help=''' parser.add_argument(
"--id",
dest="ids",
nargs="+",
action="store",
type=int,
help="""
Only render the given ID(s) from your projects schedule. Only render the given ID(s) from your projects schedule.
This argument must not be used together with --debug This argument must not be used together with --debug
Usage: ./make-adobe-after-effects.py yourproject/ --id 4711 0815 4223 1337 Usage: ./make-adobe-after-effects.py yourproject/ --id 4711 0815 4223 1337
''') """,
)
parser.add_argument('--room', dest='rooms', nargs='+', action="store", type=str, help=''' parser.add_argument(
"--room",
dest="rooms",
nargs="+",
action="store",
type=str,
help="""
Only render the given room(s) from your projects schedule. Only render the given room(s) from your projects schedule.
This argument must not be used together with --debug This argument must not be used together with --debug
Usage: ./make-adobe-after-effects.py yourproject/ --room "HfG_Studio" "ZKM_Vortragssaal" Usage: ./make-adobe-after-effects.py yourproject/ --room "HfG_Studio" "ZKM_Vortragssaal"
''') """,
)
parser.add_argument('--skip', nargs='+', action="store", type=str, help=''' parser.add_argument(
"--skip",
nargs="+",
action="store",
type=str,
help="""
Skip ID(s) not needed to be rendered. Skip ID(s) not needed to be rendered.
Usage: ./make-ffmpeg.py yourproject/ --skip 4711 0815 4223 1337 Usage: ./make-ffmpeg.py yourproject/ --skip 4711 0815 4223 1337
''') """,
)
parser.add_argument('--force', action="store_true", default=False, help=''' parser.add_argument(
"--force",
action="store_true",
default=False,
help="""
Force render if file exists. Force render if file exists.
''') """,
)
args = parser.parse_args() args = parser.parse_args()
if (args.skip is None): if args.skip is None:
args.skip = [] args.skip = []
config = parse_config(PurePath(args.project, 'config.ini')) config = parse_config(PurePath(args.project, "config.ini"))
if args.debug: if args.debug:
persons = ['Thomas Roth', 'Dmitry Nedospasov', 'Josh Datko',] persons = [
events = [{ "Thomas Roth",
'id': 'debug', "Dmitry Nedospasov",
'title': 'wallet.fail and the longest talk title to test if the template is big enough', "Josh Datko",
'subtitle': 'Hacking the most popular cryptocurrency hardware wallets', ]
'persons': persons, events = [
'personnames': ', '.join(persons), {
'room': 'Borg', "id": "debug",
}] "title": "wallet.fail and the longest talk title to test if the template is big enough",
"subtitle": "Hacking the most popular cryptocurrency hardware wallets",
"persons": persons,
"personnames": ", ".join(persons),
"room": "Borg",
}
]
else: else:
events = list(schedulelib.events(config.schedule)) events = list(schedulelib.events(config.schedule))
@ -341,18 +428,20 @@ if __name__ == "__main__":
print("enqueuing {} jobs".format(len(events))) print("enqueuing {} jobs".format(len(events)))
for event in events: for event in events:
if args.ids and event['id'] not in args.ids: if args.ids and event["id"] not in args.ids:
continue continue
if args.rooms and event['room'] not in args.rooms: if args.rooms and event["room"] not in args.rooms:
print("skipping room %s (%s)" % (event['room'], event['title'])) print("skipping room %s (%s)" % (event["room"], event["title"]))
continue continue
event_print(event, "enqueued as " + str(event['id'])) event_print(event, "enqueued as " + str(event["id"]))
job_id = enqueue_job(config, event) job_id = enqueue_job(config, event)
if not job_id: if not job_id:
event_print(event, "job was not enqueued successfully, skipping postprocessing") event_print(
event, "job was not enqueued successfully, skipping postprocessing"
)
continue continue
print('all done') print("all done")