From 0bb26d6b253580bdf488c68c45751acfcae09545 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 21 Sep 2024 21:54:15 +0200 Subject: [PATCH 1/8] make-ffmpeg: Refactor code --- make-ffmpeg.py | 455 ++++++++++++++++++++++++------------------------- 1 file changed, 227 insertions(+), 228 deletions(-) diff --git a/make-ffmpeg.py b/make-ffmpeg.py index fe4b745..51b9653 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -4,152 +4,143 @@ import os import sys import subprocess -import schedulelib import argparse -import shlex -from PIL import ImageFont -from configparser import ConfigParser -import json -import platform import ssl +from configparser import ConfigParser +from pathlib import PurePath +import platform + +from PIL import ImageFont +import schedulelib ssl._create_default_https_context = ssl._create_unverified_context -# Parse arguments -parser = argparse.ArgumentParser( - description='C3VOC Intro-Outro-Generator - Variant which renders only using video filters in ffmpeg', - usage="./make-ffmpeg.py yourproject/", - formatter_class=argparse.RawTextHelpFormatter) -parser.add_argument('project', action="store", metavar='Project folder', type=str, help=''' - Path to your project folder - ''') +class TextConfig: + inpoint: float + outpoint: float + x: int + y: int -parser.add_argument('--debug', action="store_true", default=False, help=''' - Run script in debug mode and render with placeholder texts, - not parsing or accessing a schedule. - This argument must not be used together with --id - Usage: ./make-ffmpeg.py yourproject/ --debug - ''') + use_fontfile: bool + fontfile: str + fontfile_path: str + fontfamily: str -parser.add_argument('--id', dest='ids', nargs='+', action="store", type=int, help=''' - Only render the given ID(s) from your projects schedule. - This argument must not be used together with --debug - Usage: ./make-adobe-after-effects.py yourproject/ --id 4711 0815 4223 1337 - ''') + fontsize: int + fontcolor: str = "#ffffff" -parser.add_argument('--room', dest='rooms', nargs='+', action="store", type=str, help=''' - Only render the given room(s) from your projects schedule. - This argument must not be used together with --debug - Usage: ./make-adobe-after-effects.py yourproject/ --room "HfG_Studio" "ZKM_Vortragssaal" - ''') + def parse(self, cparser_sect, use_fontfile: bool): + self.inpoint = cparser_sect.getfloat('in') + self.outpoint = cparser_sect.getfloat('out') + self.x = cparser_sect.getint('x') + self.y = cparser_sect.getint('y') -parser.add_argument('--skip', nargs='+', action="store", type=str, help=''' - Skip ID(s) not needed to be rendered. - Usage: ./make-ffmpeg.py yourproject/ --skip 4711 0815 4223 1337 - ''') + self.use_fontfile = use_fontfile + if use_fontfile: + self.fontfile = cparser_sect.get('fontfile') + self.fontfile_path = str(PurePath(args.project, self.fontfile).as_posix()) -parser.add_argument('--force', action="store_true", default=False, help=''' - Force render if file exists. - ''') + if not os.path.exists(self.fontfile_path): + error("Font file {} in Project Path is missing".format(self.fontfile_path)) + else: + self.fontfamily = cparser_sect.get('fontfamily') -args = parser.parse_args() + self.fontsize = cparser_sect.getint('fontsize') + self.fontcolor = cparser_sect.get('fontcolor', self.fontcolor) -if (args.skip is None): - args.skip = [] + def fit_text(self, text: str): + global translation_font + translation_font = ImageFont.truetype( + self.fontfile_path, size=self.fontsize, encoding="unic") + + # TODO: Make this work with font family as well! + + return fit_text(text, (1920-self.x-100)) + + def get_ffmpeg_filter(self, inout_type: str, text: str): + filter_str = "drawtext=enable='between({},{},{})'".format( + inout_type, self.inpoint, self.outpoint) + + if self.use_fontfile: + filter_str += ":fontfile='{}'".format(self.fontfile_path) + else: + filter_str += ":font='{}'".format(self.fontfamily) + + filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text='{4}'".format( + self.fontsize, self.fontcolor, self.x, self.y, text) + + return filter_str -def headline(str): +class Config: + schedule: str + template_file: str # video background + alpha: bool = False + prores: bool = False + use_fontfile: bool = False + inout_type: str = "t" # in and out time format: t for seconds, n for frame number + + fileext: str + + title: TextConfig + speaker: TextConfig + text: TextConfig + text_text: str = "" # additional text + + +def parse_config(filename) -> Config: + if not os.path.exists(filename): + error("config.ini file in Project Path is missing") + + conf = Config() + + cparser = ConfigParser() + cparser.read(filename) + + defaults = cparser['default'] + conf.schedule = defaults.get('schedule') + infile = PurePath(args.project, defaults.get('template')) + conf.template_file = str(infile) + conf.alpha = defaults.getboolean('alpha', conf.alpha) + conf.prores = defaults.getboolean('prores', conf.prores) + conf.use_fontfile = defaults.get('fontfile', conf.use_fontfile) + conf.inout_type = defaults.get('inout', conf.inout_type) + + conf.title = TextConfig() + conf.title.parse(cparser['title'], conf.use_fontfile) + conf.speaker = TextConfig() + conf.speaker.parse(cparser['speaker'], conf.use_fontfile) + conf.text = TextConfig() + conf.text.parse(cparser['text'], conf.use_fontfile) + + conf.text_text = cparser['text'].get('text', '') + + conf.fileext = infile.suffix + + if not os.path.exists(conf.template_file): + error("Template file {} in Project Path is missing".format(conf.template_file)) + + if conf.alpha and conf.fileext != '.mov': + error("Alpha can only be rendered with .mov source files") + + if not args.project: + error("The Project Path is a required argument") + + if not args.debug and not conf.schedule: + error("Either specify --debug or supply a schedule in config.ini") + + return conf + + +def error(err_str): print("##################################################") - print(str) + print(err_str) print("##################################################") print() - - -def error(str): - headline(str) parser.print_help() sys.exit(1) -cparser = ConfigParser() -cparser.read(os.path.join(os.path.dirname(args.project), 'config.ini')) -template = cparser['default']['template'] -alpha = cparser['default']['alpha'] -prores = cparser['default']['prores'] -fontfile = cparser['default']['fontfile'] # use a font file instead of a font family -inout = cparser['default']['inout'] # in and out time format: t for seconds, n for frame number - -title_in = cparser['title']['in'] -title_out = cparser['title']['out'] -title_fontfamily = cparser['title']['fontfamily'] -title_fontfile = cparser['title']['fontfile'] -title_fontsize = cparser['title']['fontsize'] -title_fontcolor = cparser['title']['fontcolor'] -title_x = cparser['title']['x'] -title_y = cparser['title']['y'] - -speaker_in = cparser['speaker']['in'] -speaker_out = cparser['speaker']['out'] -speaker_fontfamily = cparser['speaker']['fontfamily'] -speaker_fontfile = cparser['speaker']['fontfile'] -speaker_fontsize = cparser['speaker']['fontsize'] -speaker_fontcolor = cparser['speaker']['fontcolor'] -speaker_x = cparser['speaker']['x'] -speaker_y = cparser['speaker']['y'] - -text_in = cparser['text']['in'] -text_out = cparser['text']['out'] -text_fontfamily = cparser['text']['fontfamily'] -text_fontfile = cparser['text']['fontfile'] -text_fontsize = cparser['text']['fontsize'] -text_fontcolor = cparser['text']['fontcolor'] -text_x = cparser['text']['x'] -text_y = cparser['text']['y'] -text_text = cparser['text']['text'] - -font_t = os.path.join(os.path.dirname(args.project), title_fontfile) -font_s = os.path.join(os.path.dirname(args.project), speaker_fontfile) -font_tt = os.path.join(os.path.dirname(args.project), text_fontfile) - -fileformat = os.path.splitext(template)[1] -infile = os.path.join(os.path.dirname(args.project), template) - -schedule = cparser['default']['schedule'] - -if not (os.path.exists(os.path.join(args.project, template))): - error("Template file {} in Project Path is missing".format(template)) - -for ffile in (title_fontfile, speaker_fontfile, text_fontfile): - if not (os.path.exists(os.path.join(args.project, ffile))): - error("Font file {} in Project Path is missing".format(ffile)) - -if not (os.path.exists(os.path.join(args.project, 'config.ini'))): - error("config.ini file in Project Path is missing") - -if alpha == 'true' and not fileformat == '.mov': - error("Alpha can only be rendered with .mov source files") - -if not args.project: - error("The Project Path is a required argument") - -if not args.debug and not schedule: - error("Either specify --debug or supply a schedule in config.ini") - -if args.debug: - persons = ['Thomas Roth', 'Dmitry Nedospasov', 'Josh Datko',] - events = [{ - 'id': 'debug', - 'title': 'wallet.fail', - 'subtitle': 'Hacking the most popular cryptocurrency hardware wallets', - 'persons': persons, - 'personnames': ', '.join(persons), - 'room': 'Borg', - }] - -else: - events = list(schedulelib.events(schedule)) - - def describe_event(event): return "#{}: {}".format(event['id'], event['title']) @@ -158,27 +149,9 @@ def event_print(event, message): print("{} – {}".format(describe_event(event), message)) -def fmt_command(command, **kwargs): - args = {} - for key, value in kwargs.items(): - args[key] = shlex.quote(value) - - command = command.format(**args) - return shlex.split(command) - - -def run(command, **kwargs): - return subprocess.check_call( - fmt_command(command, **kwargs), - stderr=subprocess.STDOUT, - stdout=subprocess.DEVNULL) - - def fit_text(string: str, frame_width): split_line = [x.strip() for x in string.split()] lines = "" - w = 0 - line_num = 0 line = "" for word in split_line: left, top, right, bottom = translation_font.getbbox(" ".join([line, word])) @@ -193,30 +166,21 @@ def fit_text(string: str, frame_width): return lines -def fit_title(string: str, fontsize: int, x_offset: int): - global translation_font - translation_font = ImageFont.truetype( - font_t, size=fontsize, encoding="unic") - title = fit_text(string, (1920-x_offset-100)) - - return title +def ffmpeg_escape_str(text: str) -> str: + text = text.replace(':', "\:") # the ffmpeg command needs colons to be escaped + return text -def fit_speaker(string: str, fontsize: int, x_offset: int): - global translation_font - translation_font = ImageFont.truetype( - font_s, size=fontsize, encoding="unic") - speaker = fit_text(string, (1920-x_offset-100)) - - return speaker - - -def enqueue_job(event): +def enqueue_job(conf: Config, event): event_id = str(event['id']) + + outfile = str(PurePath(args.project, event_id + '.ts')) + outfile_mov = str(PurePath(args.project, event_id + '.mov')) + if event_id in args.skip: event_print(event, "skipping " + str(event['id'])) return - if (os.path.exists(os.path.join(args.project, event_id + '.ts')) or os.path.exists(os.path.join(args.project, event_id + '.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'])) return @@ -226,96 +190,131 @@ def enqueue_job(event): event_title = event_title.replace('\'', '') event_personnames = event_personnames.replace('"', '\\"') - t = fit_title(event_title, int(title_fontsize), int(title_x)) - t = t.replace(':', "\:") # the ffmpeg command needs colons to be escaped - s = fit_speaker(event_personnames, int(speaker_fontsize), int(speaker_x)) + title = ffmpeg_escape_str(conf.title.fit_text(event_title)) + speakers = ffmpeg_escape_str(conf.speaker.fit_text(event_personnames)) + text = ffmpeg_escape_str(conf.text_text) if args.debug: - print('Title: ', t) - print('Speaker: ', s) - - outfile = os.path.join(os.path.dirname(args.project), event_id + '.ts') + print('Title: ', title) + print('Speaker: ', speakers) if platform.system() == 'Windows': ffmpeg_path = './ffmpeg.exe' - font_t_win = "/".join(font_t.split("\\")) - font_s_win = "/".join(font_s.split("\\")) - font_tt_win = "/".join(font_tt.split("\\")) else: ffmpeg_path = 'ffmpeg' - if fontfile == 'true': - if platform.system() == 'Windows': - videofilter = "drawtext=enable='between({8},{0},{1})':fontfile='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}',".format( - title_in, title_out, font_t_win, title_fontsize, title_fontcolor, title_x, title_y, t, inout) - videofilter += "drawtext=enable='between({8},{0},{1})':fontfile='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}',".format( - speaker_in, speaker_out, font_s_win, speaker_fontsize, speaker_fontcolor, speaker_x, speaker_y, s, inout) - videofilter += "drawtext=enable='between({8},{0},{1})':fontfile='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}'".format( - text_in, text_out, font_tt_win, text_fontsize, text_fontcolor, text_x, text_y, text_text, inout) - else: - videofilter = "drawtext=enable='between({8},{0},{1})':fontfile='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}',".format( - title_in, title_out, font_t, title_fontsize, title_fontcolor, title_x, title_y, t, inout) - videofilter += "drawtext=enable='between({8},{0},{1})':fontfile='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}',".format( - speaker_in, speaker_out, font_s, speaker_fontsize, speaker_fontcolor, speaker_x, speaker_y, s, inout) - videofilter += "drawtext=enable='between({8},{0},{1})':fontfile='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}'".format( - text_in, text_out, font_tt, text_fontsize, text_fontcolor, text_x, text_y, text_text, inout) - else: - videofilter = "drawtext=enable='between({8},{0},{1})':font='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}',".format( - title_in, title_out, title_fontfamily, title_fontsize, title_fontcolor, title_x, title_y, t, inout) - videofilter += "drawtext=enable='between({8},{0},{1})':font='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}',".format( - speaker_in, speaker_out, speaker_fontfamily, speaker_fontsize, speaker_fontcolor, speaker_x, speaker_y, s, inout) - videofilter += "drawtext=enable='between({8},{0},{1})':font='{2}':fontsize={3}:fontcolor={4}:x={5}:y={6}:text='{7}'".format( - text_in, text_out, text_fontfamily, text_fontsize, text_fontcolor, text_x, text_y, text_text, inout) + videofilter = conf.title.get_ffmpeg_filter(conf.inout_type, title) + "," + videofilter += conf.speaker.get_ffmpeg_filter(conf.inout_type, speakers) + "," + videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, text) - if fileformat == '.mov': - if alpha == 'true': - if prores == 'true': - cmd = '{3} -y -i "{0}" -vf "{1}" -vcodec prores_ks -pix_fmt yuva444p10le -profile:v 4444 -shortest -movflags faststart -f mov "{2}"'.format( - infile, videofilter, outfile, ffmpeg_path) - else: - cmd = '{3} -y -i "{0}" -vf "{1}" -shortest -c:v qtrle -movflags faststart -f mov "{2}"'.format( - infile, videofilter, outfile, ffmpeg_path) + cmd = [ffmpeg_path, '-y', '-i', conf.template_file, '-vf', videofilter] + + if conf.fileext == '.mov' and conf.alpha: + if conf.prores: + cmd += ['-vcodec', 'prores_ks', '-pix_fmt', 'yuva444p10le', '-profile:v', + '4444', '-shortest', '-movflags', 'faststart', '-f', 'mov', outfile_mov] else: - cmd = '{3} -y -i "{0}" -vf "{1}" -map 0:0 -c:v mpeg2video -q:v 2 -aspect 16:9 -map 0:1 -c:a mp2 -b:a 384k -shortest -f mpegts "{2}"'.format( - infile, videofilter, outfile, ffmpeg_path) + cmd += ['-shortest', '-c:v', 'qtrle', '-movflags', + 'faststart', '-f', 'mov', outfile_mov] else: - cmd = '{3} -y -i "{0}" -vf "{1}" -map 0:0 -c:v mpeg2video -q:v 2 -aspect 16:9 -map 0:1 -c:a mp2 -b:a 384k -shortest -f mpegts "{2}"'.format( - infile, videofilter, outfile, ffmpeg_path) + cmd += ['-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: print(cmd) - run(cmd) + subprocess.check_call(cmd, + stderr=subprocess.STDOUT, + stdout=subprocess.DEVNULL + ) return event_id -if args.ids: - if len(args.ids) == 1: - print("enqueuing {} job".format(len(args.ids))) +if __name__ == "__main__": + # Parse arguments + parser = argparse.ArgumentParser( + description='C3VOC Intro-Outro-Generator - Variant which renders only using video filters in ffmpeg', + usage="./make-ffmpeg.py yourproject/", + formatter_class=argparse.RawTextHelpFormatter) + + parser.add_argument('project', action="store", metavar='Project folder', type=str, help=''' + Path to your project folder + ''') + + parser.add_argument('--debug', action="store_true", default=False, help=''' + Run script in debug mode and render with placeholder texts, + not parsing or accessing a schedule. + This argument must not be used together with --id + Usage: ./make-ffmpeg.py yourproject/ --debug + ''') + + parser.add_argument('--id', dest='ids', nargs='+', action="store", type=int, help=''' + Only render the given ID(s) from your projects schedule. + This argument must not be used together with --debug + Usage: ./make-adobe-after-effects.py yourproject/ --id 4711 0815 4223 1337 + ''') + + parser.add_argument('--room', dest='rooms', nargs='+', action="store", type=str, help=''' + Only render the given room(s) from your projects schedule. + This argument must not be used together with --debug + Usage: ./make-adobe-after-effects.py yourproject/ --room "HfG_Studio" "ZKM_Vortragssaal" + ''') + + parser.add_argument('--skip', nargs='+', action="store", type=str, help=''' + Skip ID(s) not needed to be rendered. + Usage: ./make-ffmpeg.py yourproject/ --skip 4711 0815 4223 1337 + ''') + + parser.add_argument('--force', action="store_true", default=False, help=''' + Force render if file exists. + ''') + + args = parser.parse_args() + + if (args.skip is None): + args.skip = [] + + config = parse_config(PurePath(args.project, 'config.ini')) + + if args.debug: + persons = ['Thomas Roth', 'Dmitry Nedospasov', 'Josh Datko',] + events = [{ + 'id': 'debug', + 'title': 'wallet.fail', + 'subtitle': 'Hacking the most popular cryptocurrency hardware wallets', + 'persons': persons, + 'personnames': ', '.join(persons), + 'room': 'Borg', + }] + else: - print("enqueuing {} jobs".format(len(args.ids))) -else: - if len(events) == 1: - print("enqueuing {} job".format(len(events))) + events = list(schedulelib.events(config.schedule)) + + if args.ids: + if len(args.ids) == 1: + print("enqueuing {} job".format(len(args.ids))) + else: + print("enqueuing {} jobs".format(len(args.ids))) else: - print("enqueuing {} jobs".format(len(events))) + if len(events) == 1: + print("enqueuing {} job".format(len(events))) + else: + print("enqueuing {} jobs".format(len(events))) + for event in events: + if args.ids and event['id'] not in args.ids: + continue -for event in events: - if args.ids and event['id'] not in args.ids: - continue + if args.rooms and event['room'] not in args.rooms: + print("skipping room %s (%s)" % (event['room'], event['title'])) + continue - if args.rooms and event['room'] not in args.rooms: - print("skipping room %s (%s)" % (event['room'], event['title'])) - continue + event_print(event, "enqueued as " + str(event['id'])) - event_print(event, "enqueued as " + str(event['id'])) + job_id = enqueue_job(config, event) + if not job_id: + event_print(event, "job was not enqueued successfully, skipping postprocessing") + continue - job_id = enqueue_job(event) - if not job_id: - event_print(event, "job was not enqueued successfully, skipping postprocessing") - continue - - -print('all done') + print('all done') From 9210d362480ba3bb9888202c6961f15292f622f4 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sun, 6 Oct 2024 21:49:08 +0200 Subject: [PATCH 2/8] make-ffmpeg: Properly escape special characters --- make-ffmpeg.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/make-ffmpeg.py b/make-ffmpeg.py index 51b9653..b341907 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -66,8 +66,8 @@ class TextConfig: else: filter_str += ":font='{}'".format(self.fontfamily) - filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text='{4}'".format( - self.fontsize, self.fontcolor, self.x, self.y, text) + filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text={4}".format( + self.fontsize, self.fontcolor, self.x, self.y, ffmpeg_escape_str(text)) return filter_str @@ -167,7 +167,12 @@ def fit_text(string: str, frame_width): def ffmpeg_escape_str(text: str) -> str: - text = text.replace(':', "\:") # the ffmpeg command needs colons to be escaped + # Escape according to https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping + # and don't put the string in quotes afterwards! + text = text.replace(",", r"\,") + text = text.replace(':', r"\\:") + text = text.replace("'", r"\\\'") + return text @@ -186,13 +191,9 @@ def enqueue_job(conf: Config, event): event_title = str(event['title']) event_personnames = str(event['personnames']) - event_title = event_title.replace('"', '\\"') - event_title = event_title.replace('\'', '') - event_personnames = event_personnames.replace('"', '\\"') - title = ffmpeg_escape_str(conf.title.fit_text(event_title)) - speakers = ffmpeg_escape_str(conf.speaker.fit_text(event_personnames)) - text = ffmpeg_escape_str(conf.text_text) + title = conf.title.fit_text(event_title) + speakers = conf.speaker.fit_text(event_personnames) if args.debug: print('Title: ', title) @@ -205,7 +206,7 @@ def enqueue_job(conf: Config, event): videofilter = conf.title.get_ffmpeg_filter(conf.inout_type, title) + "," videofilter += conf.speaker.get_ffmpeg_filter(conf.inout_type, speakers) + "," - videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, text) + videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, conf.text_text) cmd = [ffmpeg_path, '-y', '-i', conf.template_file, '-vf', videofilter] From 526ef6231a46807c226ea50dba1e2d684cc8ca38 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 21 Sep 2024 22:03:56 +0200 Subject: [PATCH 3/8] make-ffmpeg: add border option --- make-ffmpeg.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/make-ffmpeg.py b/make-ffmpeg.py index b341907..0fbc353 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -28,6 +28,7 @@ class TextConfig: fontsize: int fontcolor: str = "#ffffff" + bordercolor: str = None # border is added, if a color is set def parse(self, cparser_sect, use_fontfile: bool): self.inpoint = cparser_sect.getfloat('in') @@ -47,6 +48,7 @@ class TextConfig: self.fontsize = cparser_sect.getint('fontsize') self.fontcolor = cparser_sect.get('fontcolor', self.fontcolor) + self.bordercolor = cparser_sect.get('bordercolor', None) def fit_text(self, text: str): global translation_font @@ -66,6 +68,9 @@ class TextConfig: else: filter_str += ":font='{}'".format(self.fontfamily) + if self.bordercolor is not None: + filter_str += ":borderw={}:bordercolor={}".format(self.fontsize / 30, self.bordercolor) + filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text={4}".format( self.fontsize, self.fontcolor, self.x, self.y, ffmpeg_escape_str(text)) From 6876f2cf44e302f272fc15bdc45743d23c30955a Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 2 Nov 2024 14:23:34 +0100 Subject: [PATCH 4/8] make-ffmpeg: Rework config file --- camp2023/config.ini | 8 ++---- cccamp19/config.ini | 8 ++---- denog11/config.ini | 8 ++---- jh19-berlin/config.ini | 8 ++---- jh20-jue/config.ini | 7 +----- jh21-rn/config.ini | 8 ++---- jugendhackt/config.ini | 39 ++++++++++++++++++++---------- make-ffmpeg.py | 55 +++++++++++++++++++++++++----------------- mrmcd2019/config.ini | 8 ++---- vcfb24/config.ini | 2 +- 10 files changed, 73 insertions(+), 78 deletions(-) diff --git a/camp2023/config.ini b/camp2023/config.ini index 2072250..5dbefd6 100644 --- a/camp2023/config.ini +++ b/camp2023/config.ini @@ -1,15 +1,13 @@ -[default] +[meta] schedule = https://pretalx.c3voc.de/camp2023/schedule/export/schedule.xml template = intro.mp4 alpha = false prores = false -fontfile = true -inout = t +inout_type = t [title] in = 16 out = 24 -fontfamily = BeonRegular fontfile = saira.ttf fontsize = 100 fontcolor = #FB48C4 @@ -19,7 +17,6 @@ y = 100 [speaker] in = 16 out = 24 -fontfamily = BeonRegular fontfile = saira.ttf fontsize = 70 fontcolor = #3FFF21 @@ -29,7 +26,6 @@ y = 800 [text] in = 16 out = 24 -fontfamily = BeonRegular fontfile = saira.ttf fontsize = 45 fontcolor = #FB48C4 diff --git a/cccamp19/config.ini b/cccamp19/config.ini index 181caed..0316909 100644 --- a/cccamp19/config.ini +++ b/cccamp19/config.ini @@ -1,15 +1,13 @@ -[default] +[meta] schedule = https://fahrplan.events.ccc.de/camp/2019/Fahrplan/schedule.xml template = cccamp19_talks_intro_1080p.mov alpha = true prores = true -fontfile = true -inout = n +inout_type = n [title] in = 193 out = 324 -fontfamily = fontfile = Marvel-Bold.ttf fontsize = 120 fontcolor = #c68100 @@ -19,7 +17,6 @@ y = 480 [speaker] in = 233 out = 324 -fontfamily = fontfile = Marvel-Regular.ttf fontsize = 70 fontcolor = #c68100 @@ -29,7 +26,6 @@ y = 845 [text] in = 242 out = 324 -fontfamily = fontfile = Marvel-Regular.ttf fontsize = 45 fontcolor = #c68100 diff --git a/denog11/config.ini b/denog11/config.ini index 6f403b5..0716e7e 100644 --- a/denog11/config.ini +++ b/denog11/config.ini @@ -1,15 +1,13 @@ -[default] +[meta] schedule = https://pretalx.denog.de/denog11/schedule/export/schedule.xml template = denog11_intro_template.ts alpha = false prores = false -fontfile = true -inout = n +inout_type = n [title] in = 1 out = 6.5 -fontfamily = fontfile = DejaVuSans.ttf fontsize = 100 fontcolor = #f9cc12 @@ -19,7 +17,6 @@ y = 200 [speaker] in = 2 out = 6.5 -fontfamily = fontfile = DejaVuSans.ttf fontsize = 60 fontcolor = #ffffff @@ -29,7 +26,6 @@ y = 900 [text] in = 3 out = 6.5 -fontfamily = fontfile = DejaVuSans.ttf fontsize = 45 fontcolor = #ffffff diff --git a/jh19-berlin/config.ini b/jh19-berlin/config.ini index 6a8740d..171ea67 100644 --- a/jh19-berlin/config.ini +++ b/jh19-berlin/config.ini @@ -1,15 +1,13 @@ -[default] +[meta] schedule = https://projects.alpaka.space/media/jhber19-schedule.xml template = intro-alpha.mov alpha = true prores = true -fontfile = true -inout = n +inout_type = n [title] in = 175 out = 260 -fontfamily = fontfile = SourceSansPro-Bold.otf fontsize = 90 fontcolor = #ffffff @@ -19,7 +17,6 @@ y = 450 [speaker] in = 175 out = 260 -fontfamily = fontfile = SourceSansPro-Regular.otf fontsize = 36 fontcolor = #ffffff @@ -29,7 +26,6 @@ y = 900 [text] in = 200 out = 250 -fontfamily = fontfile = SourceSansPro-Regular.otf fontsize = 45 fontcolor = #c68100 diff --git a/jh20-jue/config.ini b/jh20-jue/config.ini index 163ca71..7f65b89 100644 --- a/jh20-jue/config.ini +++ b/jh20-jue/config.ini @@ -1,12 +1,10 @@ -[default] +[meta] schedule = https://releasing.c3voc.de/releases/jhjue2020/schedule-jhjue20.xml ; intro_template.ts was derived from a png which was derived from jhjue-20-intro.svg ; ffmpeg -loop 1 -i jh20-jue/jh20-jue-intro.png -ar 48000 -ac 2 -f s16le -i /dev/zero -c:v mpeg2video -pix_fmt:v yuv420p -qscale:v 2 -qmin:v 2 -qmax:v 7 -keyint_min 0 -bf 0 -g 0 -intra:0 -maxrate:0 90M -c:a mp2 -b:a 384k -t 5 jh20-jue/jh20-jue_intro_template.ts template = jh20-jue_intro_template.ts alpha = false prores = false -; enable using a font file -fontfile = true ; in and out time format: t for seconds, n for frame number inout = n @@ -17,7 +15,6 @@ in = 20 ; outframe for totle out = 225 ; title font (either font family or file, see default setting above) -fontfamily = fontfile = ebisu.ttf ; title font size fontsize = 70 @@ -30,7 +27,6 @@ y = 865 [speaker] in = 40 out = 225 -fontfamily = fontfile = ebisu.ttf fontsize = 40 fontcolor = #eeeeee @@ -41,7 +37,6 @@ y = 950 [text] in = 3 out = 4 -fontfamily = fontfile = ebisu.ttf fontsize = 45 fontcolor = #ffffff diff --git a/jh21-rn/config.ini b/jh21-rn/config.ini index a7870db..8c25d4d 100644 --- a/jh21-rn/config.ini +++ b/jh21-rn/config.ini @@ -1,15 +1,13 @@ -[default] +[meta] schedule = https://pretalx.c3voc.de/jugend-hackt-rhein-neckar-2021/schedule/export/schedule.xml template = jh21-rn-template.ts alpha = false prores = false -fontfile = true -inout = t +inout_type = t [title] in = 1 out = 6.5 -fontfamily = fontfile = SourceSansPro-Semibold.ttf fontsize = 85 fontcolor = #ffffff @@ -19,7 +17,6 @@ y = 877 [speaker] in = 2 out = 6.5 -fontfamily = fontfile = SourceSansPro-Semibold.ttf fontsize = 45 fontcolor = #ffffff @@ -29,7 +26,6 @@ y = 954 [text] in = 3 out = 6.5 -fontfamily = fontfile = SourceSansPro-Semibold.ttf fontsize = 45 fontcolor = #ffffff diff --git a/jugendhackt/config.ini b/jugendhackt/config.ini index 2400940..da1b7bd 100644 --- a/jugendhackt/config.ini +++ b/jugendhackt/config.ini @@ -1,39 +1,52 @@ -[default] +[meta] schedule = https://pretalx.c3voc.de/jhhh23/schedule/export/schedule.xml +;; path to background video template = intro-background.ts +;; whether background video uses transparency (needs to be .mov) alpha = false +;; whether background video is prores 4444 prores = false -fontfile = true -inout = t +;; in and out time format: t for seconds, n for frame number +inout_type = t +;; Some font settings can have defaults, which can be overridden in the +;; 'title', 'speaker' and 'text' sections below. +[default] +;; default font (either use 'fontfamily' or 'fontfile') +; fontfamily = arial +fontfile = SourceSansPro-Semibold.ttf +;; default font color +fontcolor = #ffffff + +;; fields for title and speaker names are empty in the template.ts, so we'll render them in via ffmpeg +;; parameters are: +;; - in: start frame/ time +;; - out: end frame/ time +;; - fontfamily: font family +;; - fontfile: font file +;; - fontcolor: font color +;; - fontsize: font size (pixel) +;; - x: horizontal position (top left corner) +;; - y: vertical position (top left corner) [title] in = 1 out = 9.5 -fontfamily = -fontfile = SourceSansPro-Semibold.ttf fontsize = 67 -fontcolor = #ffffff x = 400 y = 870 [speaker] in = 2 out = 9 -fontfamily = -fontfile = SourceSansPro-Semibold.ttf fontsize = 50 -fontcolor = #ffffff x = 400 y = 950 - +;; optional extra text [text] in = 0 out = 0 -fontfamily = -fontfile = SourceSansPro-Semibold.ttf fontsize = 0 -fontcolor = #ffffff x = 0 y = 0 text = '' diff --git a/make-ffmpeg.py b/make-ffmpeg.py index 0fbc353..3b69978 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # vim: tabstop=4 shiftwidth=4 expandtab +"""See jugendhackt/config.ini for some config file documentation.""" + import os import sys import subprocess @@ -21,33 +23,39 @@ class TextConfig: x: int y: int - use_fontfile: bool - fontfile: str fontfile_path: str fontfamily: str fontsize: int - fontcolor: str = "#ffffff" + fontcolor: str bordercolor: str = None # border is added, if a color is set - def parse(self, cparser_sect, use_fontfile: bool): + def uses_fontfile(self): + return self.fontfile_path is not None + + def parse(self, cparser_sect, default_fontfile, default_fontfamily, default_fontcolor): self.inpoint = cparser_sect.getfloat('in') self.outpoint = cparser_sect.getfloat('out') self.x = cparser_sect.getint('x') self.y = cparser_sect.getint('y') - self.use_fontfile = use_fontfile - if use_fontfile: - self.fontfile = cparser_sect.get('fontfile') - self.fontfile_path = str(PurePath(args.project, self.fontfile).as_posix()) + self.fontcolor = cparser_sect.get('fontcolor', default_fontcolor) + + fontfile = cparser_sect.get('fontfile', default_fontfile) + fontfamily = cparser_sect.get('fontfamily', default_fontfamily) + if fontfile != None and fontfamily is None: + self.fontfile_path = str(PurePath(args.project, fontfile).as_posix()) if not os.path.exists(self.fontfile_path): error("Font file {} in Project Path is missing".format(self.fontfile_path)) + + elif fontfamily != None and fontfile is None: + self.fontfamily = fontfamily + else: - self.fontfamily = cparser_sect.get('fontfamily') + error("Either provide a 'fontfamily' or 'fontfile', not both") self.fontsize = cparser_sect.getint('fontsize') - self.fontcolor = cparser_sect.get('fontcolor', self.fontcolor) self.bordercolor = cparser_sect.get('bordercolor', None) def fit_text(self, text: str): @@ -63,7 +71,7 @@ class TextConfig: filter_str = "drawtext=enable='between({},{},{})'".format( inout_type, self.inpoint, self.outpoint) - if self.use_fontfile: + if self.uses_fontfile(): filter_str += ":fontfile='{}'".format(self.fontfile_path) else: filter_str += ":font='{}'".format(self.fontfamily) @@ -82,7 +90,6 @@ class Config: template_file: str # video background alpha: bool = False prores: bool = False - use_fontfile: bool = False inout_type: str = "t" # in and out time format: t for seconds, n for frame number fileext: str @@ -102,21 +109,25 @@ def parse_config(filename) -> Config: cparser = ConfigParser() cparser.read(filename) - defaults = cparser['default'] - conf.schedule = defaults.get('schedule') - infile = PurePath(args.project, defaults.get('template')) + meta = cparser['meta'] + conf.schedule = meta.get('schedule') + infile = PurePath(args.project, meta.get('template')) conf.template_file = str(infile) - conf.alpha = defaults.getboolean('alpha', conf.alpha) - conf.prores = defaults.getboolean('prores', conf.prores) - conf.use_fontfile = defaults.get('fontfile', conf.use_fontfile) - conf.inout_type = defaults.get('inout', conf.inout_type) + conf.alpha = meta.getboolean('alpha', conf.alpha) + conf.prores = meta.getboolean('prores', conf.prores) + conf.inout_type = meta.get('inout_type', conf.inout_type) + + defaults = cparser['default'] + default_fontfile = defaults.get('fontfile', None) + default_fontfamily = defaults.get('fontfamily', None) + default_fontcolor = defaults.get('fontcolor', "#ffffff") conf.title = TextConfig() - conf.title.parse(cparser['title'], conf.use_fontfile) + conf.title.parse(cparser['title'], default_fontfile, default_fontfamily, default_fontcolor) conf.speaker = TextConfig() - conf.speaker.parse(cparser['speaker'], conf.use_fontfile) + conf.speaker.parse(cparser['speaker'], default_fontfile, default_fontfamily, default_fontcolor) conf.text = TextConfig() - conf.text.parse(cparser['text'], conf.use_fontfile) + conf.text.parse(cparser['text'], default_fontfile, default_fontfamily, default_fontcolor) conf.text_text = cparser['text'].get('text', '') diff --git a/mrmcd2019/config.ini b/mrmcd2019/config.ini index 165753c..30da816 100755 --- a/mrmcd2019/config.ini +++ b/mrmcd2019/config.ini @@ -1,16 +1,14 @@ -[default] +[meta] #schedule = https://talks.mrmcd.net/2019/schedule/export/schedule.xml schedule = file:///home/thorti/git/c3voc/intro-outro-generator/mrmcd2019/schedule.xml template = mrmcd2019.mov alpha = false prores = false -fontfile = true -inout = n +inout_type = n [title] in = 50 out = 225 -fontfamily = fontfile = Jura-Bold.ttf fontsize = 80 fontcolor = #47acda @@ -20,7 +18,6 @@ y = 540 [speaker] in = 75 out = 225 -fontfamily = fontfile = Jura-Regular.ttf fontsize = 50 fontcolor = #094762 @@ -30,7 +27,6 @@ y = 950 [text] in = 242 out = 324 -fontfamily = fontfile = Jura-Regular.ttf fontsize = 45 fontcolor = #c68100 diff --git a/vcfb24/config.ini b/vcfb24/config.ini index 0fb3ad3..fff89ad 100644 --- a/vcfb24/config.ini +++ b/vcfb24/config.ini @@ -1,4 +1,4 @@ -[default] +[meta] schedule = http://vcfb.de/2024/schedule.xml # ffmpeg -loop 1 -i intro.png -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -c:v libx264 -tune stillimage -pix_fmt yuv420p -c:a aac -r 25 -t 10 intro.mp4 template = intro.mp4 From 31f01ca3861bbbb42a231587f7f1a4d287575270 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 2 Nov 2024 17:21:47 +0100 Subject: [PATCH 5/8] make-ffmpeg: Use fit_text from make-ffmpeg-fade (break into lines array) --- camp2023/config.ini | 2 +- cccamp19/config.ini | 2 +- denog11/config.ini | 2 +- jh19-berlin/config.ini | 2 +- jh20-jue/config.ini | 2 +- jh21-rn/config.ini | 2 +- jugendhackt/config.ini | 4 +- make-ffmpeg.py | 84 ++++++++++++++++++++++++++---------------- mrmcd2019/config.ini | 2 +- vcfb24/config.ini | 2 +- 10 files changed, 63 insertions(+), 41 deletions(-) diff --git a/camp2023/config.ini b/camp2023/config.ini index 5dbefd6..e8c7ab6 100644 --- a/camp2023/config.ini +++ b/camp2023/config.ini @@ -31,5 +31,5 @@ fontsize = 45 fontcolor = #FB48C4 x = (w-text_w)/2 y = 1000 -text = 'Chaos Communication Camp 2023' +text = Chaos Communication Camp 2023 diff --git a/cccamp19/config.ini b/cccamp19/config.ini index 0316909..d723d7d 100644 --- a/cccamp19/config.ini +++ b/cccamp19/config.ini @@ -31,5 +31,5 @@ fontsize = 45 fontcolor = #c68100 x = (w-text_w)/2 y = 927 -text = 'chaos communication camp 2019' +text = chaos communication camp 2019 diff --git a/denog11/config.ini b/denog11/config.ini index 0716e7e..89c8096 100644 --- a/denog11/config.ini +++ b/denog11/config.ini @@ -31,5 +31,5 @@ fontsize = 45 fontcolor = #ffffff x = 640 y = 1000 -text = '' +; text = diff --git a/jh19-berlin/config.ini b/jh19-berlin/config.ini index 171ea67..73365a8 100644 --- a/jh19-berlin/config.ini +++ b/jh19-berlin/config.ini @@ -31,5 +31,5 @@ fontsize = 45 fontcolor = #c68100 x = (w-text_w)/2 y = 927 -text = '' +; text = diff --git a/jh20-jue/config.ini b/jh20-jue/config.ini index 7f65b89..d48087f 100644 --- a/jh20-jue/config.ini +++ b/jh20-jue/config.ini @@ -42,7 +42,7 @@ fontsize = 45 fontcolor = #ffffff x = 640 y = 1000 -text = '' +; text = ; build intros via diff --git a/jh21-rn/config.ini b/jh21-rn/config.ini index 8c25d4d..626db55 100644 --- a/jh21-rn/config.ini +++ b/jh21-rn/config.ini @@ -31,5 +31,5 @@ fontsize = 45 fontcolor = #ffffff x = 1920 y = 1080 -text = '' +; text = diff --git a/jugendhackt/config.ini b/jugendhackt/config.ini index da1b7bd..8af8ab6 100644 --- a/jugendhackt/config.ini +++ b/jugendhackt/config.ini @@ -42,11 +42,11 @@ fontsize = 50 x = 400 y = 950 -;; optional extra text +;; optional extra text, comment out "text" field to disable [text] in = 0 out = 0 fontsize = 0 x = 0 y = 0 -text = '' +;text = some additional text diff --git a/make-ffmpeg.py b/make-ffmpeg.py index 3b69978..aa67320 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -16,6 +16,8 @@ from PIL import ImageFont import schedulelib ssl._create_default_https_context = ssl._create_unverified_context +FRAME_WIDTH = 1920 + class TextConfig: inpoint: float @@ -58,31 +60,41 @@ class TextConfig: self.fontsize = cparser_sect.getint('fontsize') self.bordercolor = cparser_sect.get('bordercolor', None) - def fit_text(self, text: str): - global translation_font - translation_font = ImageFont.truetype( + def fit_text(self, text: str) -> list[str]: + if not text: + return [""] + + font = ImageFont.truetype( self.fontfile_path, size=self.fontsize, encoding="unic") # TODO: Make this work with font family as well! - return fit_text(text, (1920-self.x-100)) + return fit_text(text, (FRAME_WIDTH-self.x-100), font) - def get_ffmpeg_filter(self, inout_type: str, text: str): - filter_str = "drawtext=enable='between({},{},{})'".format( - inout_type, self.inpoint, self.outpoint) + def get_ffmpeg_filter(self, inout_type: str, text: list[str]): + if not text: + return "" - if self.uses_fontfile(): - filter_str += ":fontfile='{}'".format(self.fontfile_path) - else: - filter_str += ":font='{}'".format(self.fontfamily) + filter_str = "" + for idx, line in enumerate(text): + filter_str += "drawtext=enable='between({},{},{})'".format( + inout_type, self.inpoint, self.outpoint) - if self.bordercolor is not None: - filter_str += ":borderw={}:bordercolor={}".format(self.fontsize / 30, self.bordercolor) + if self.uses_fontfile(): + filter_str += ":fontfile='{}'".format(self.fontfile_path) + else: + filter_str += ":font='{}'".format(self.fontfamily) - filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text={4}".format( - self.fontsize, self.fontcolor, self.x, self.y, ffmpeg_escape_str(text)) + if self.bordercolor is not None: + filter_str += ":borderw={}:bordercolor={}".format( + self.fontsize / 30, self.bordercolor) - return filter_str + filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text={4}".format( + self.fontsize, self.fontcolor, self.x, self.y + (idx*self.fontsize), ffmpeg_escape_str(line)) + + filter_str += "," + + return filter_str[:-1] class Config: @@ -97,7 +109,7 @@ class Config: title: TextConfig speaker: TextConfig text: TextConfig - text_text: str = "" # additional text + extra_text: str = "" # additional text def parse_config(filename) -> Config: @@ -129,7 +141,7 @@ def parse_config(filename) -> Config: conf.text = TextConfig() conf.text.parse(cparser['text'], default_fontfile, default_fontfamily, default_fontcolor) - conf.text_text = cparser['text'].get('text', '') + conf.extra_text = cparser['text'].get('text', '') conf.fileext = infile.suffix @@ -165,20 +177,29 @@ def event_print(event, message): print("{} – {}".format(describe_event(event), message)) -def fit_text(string: str, frame_width): +def fit_text(string: str, max_width: int, font: ImageFont) -> list[str]: + """Break text into array of strings which fit certain a width (in pixels) for the specified font.""" + split_line = [x.strip() for x in string.split()] - lines = "" - line = "" + lines = [] + w = 0 + line = [] for word in split_line: - left, top, right, bottom = translation_font.getbbox(" ".join([line, word])) - width, height = right - left, bottom - top - if width > (frame_width - (2 * 6)): - lines += line.strip() + "\n" - line = "" + new_line = line + [word.rstrip(':')] + w = font.getlength(" ".join(new_line)) + if w > max_width: + lines.append(' '.join(line)) + line = [] - line += word + " " + line.append(word.rstrip(':')) + + if word.endswith(':'): + lines.append(' '.join(line)) + line = [] + + if line: + lines.append(' '.join(line)) - lines += line.strip() return lines @@ -210,9 +231,10 @@ def enqueue_job(conf: Config, event): title = conf.title.fit_text(event_title) speakers = conf.speaker.fit_text(event_personnames) + extra_text = conf.text.fit_text(conf.extra_text) if args.debug: - print('Title: ', title) + print('Title: ', title) print('Speaker: ', speakers) if platform.system() == 'Windows': @@ -222,7 +244,7 @@ def enqueue_job(conf: Config, event): videofilter = conf.title.get_ffmpeg_filter(conf.inout_type, title) + "," videofilter += conf.speaker.get_ffmpeg_filter(conf.inout_type, speakers) + "," - videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, conf.text_text) + videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, extra_text) cmd = [ffmpeg_path, '-y', '-i', conf.template_file, '-vf', videofilter] @@ -298,7 +320,7 @@ if __name__ == "__main__": persons = ['Thomas Roth', 'Dmitry Nedospasov', 'Josh Datko',] events = [{ 'id': 'debug', - 'title': 'wallet.fail', + '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), diff --git a/mrmcd2019/config.ini b/mrmcd2019/config.ini index 30da816..fb74efc 100755 --- a/mrmcd2019/config.ini +++ b/mrmcd2019/config.ini @@ -32,4 +32,4 @@ fontsize = 45 fontcolor = #c68100 x = (w-text_w)/2 y = 927 -text = '' +; text = diff --git a/vcfb24/config.ini b/vcfb24/config.ini index fff89ad..b363273 100644 --- a/vcfb24/config.ini +++ b/vcfb24/config.ini @@ -34,4 +34,4 @@ fontsize = 0 fontcolor = #ffffff x = 0 y = 0 -text = '' +; text = From 8933550f31688ffeb56c1d2ce83a824b0104fcdd Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 2 Nov 2024 17:23:10 +0100 Subject: [PATCH 6/8] make-ffmpeg: Combine with make-ffmpeg-fade --- jugendhackt/config.ini | 2 + make-ffmpeg-fade.py | 326 +---------------------------------------- make-ffmpeg.py | 20 ++- requirements.txt | 2 +- vcfb24/config.ini | 10 +- 5 files changed, 26 insertions(+), 334 deletions(-) diff --git a/jugendhackt/config.ini b/jugendhackt/config.ini index 8af8ab6..4f507cd 100644 --- a/jugendhackt/config.ini +++ b/jugendhackt/config.ini @@ -8,6 +8,8 @@ alpha = false prores = false ;; in and out time format: t for seconds, n for frame number inout_type = t +;; fade-in duration (seconds), leave out or set to zero to disable +;fade_duration = 0.5 ;; Some font settings can have defaults, which can be overridden in the ;; 'title', 'speaker' and 'text' sections below. diff --git a/make-ffmpeg-fade.py b/make-ffmpeg-fade.py index 976c858..3c1eecd 100755 --- a/make-ffmpeg-fade.py +++ b/make-ffmpeg-fade.py @@ -1,327 +1,5 @@ #!/usr/bin/env python3 # vim: tabstop=4 shiftwidth=4 expandtab -import os -import sys -import subprocess -import renderlib -import schedulelib -import argparse -import shlex -from PIL import ImageFont -from configparser import ConfigParser -import json - -# Parse arguments -parser = argparse.ArgumentParser( - description='C3VOC Intro-Outro-Generator - Variant which renders only using video filters in ffmpeg', - usage="./make-ffmpeg.py yourproject/", - formatter_class=argparse.RawTextHelpFormatter) - -parser.add_argument('project', action="store", metavar='Project folder', type=str, help=''' - Path to your project folder - ''') - -parser.add_argument('--debug', action="store_true", default=False, help=''' - Run script in debug mode and render with placeholder texts, - not parsing or accessing a schedule. - This argument must not be used together with --id - Usage: ./make-ffmpeg.py yourproject/ --debug - ''') - -parser.add_argument('--id', dest='ids', nargs='+', action="store", type=int, help=''' - Only render the given ID(s) from your projects schedule. - This argument must not be used together with --debug - Usage: ./make-adobe-after-effects.py yourproject/ --id 4711 0815 4223 1337 - ''') - -parser.add_argument('--room', dest='rooms', nargs='+', action="store", type=str, help=''' - Only render the given room(s) from your projects schedule. - This argument must not be used together with --debug - Usage: ./make-adobe-after-effects.py yourproject/ --room "HfG_Studio" "ZKM_Vortragssaal" - ''') - -parser.add_argument('--skip', nargs='+', action="store", type=str, help=''' - Skip ID(s) not needed to be rendered. - Usage: ./make-ffmpeg.py yourproject/ --skip 4711 0815 4223 1337 - ''') - -parser.add_argument('--force', action="store_true", default=False, help=''' - Force render if file exists. - ''') - -args = parser.parse_args() - -if (args.skip is None): - args.skip = [] - - -def headline(str): - print("##################################################") - print(str) - print("##################################################") - print() - - -def error(str): - headline(str) - parser.print_help() - sys.exit(1) - - -cparser = ConfigParser() -cparser.read(os.path.join(os.path.dirname(args.project), 'config.ini')) -template = cparser['default']['template'] -alpha = cparser['default']['alpha'] -prores = cparser['default']['prores'] - -fade_duration = 0.5 - -title_in = float(cparser['title']['in']) -title_out = float(cparser['title']['out']) -title_duration = title_out - title_in -title_font = cparser['title']['font'] -title_fontsize = int(cparser['title']['fontsize']) -title_fontcolor = cparser['title']['fontcolor'] -title_x = int(cparser['title']['x']) -title_y = int(cparser['title']['y']) - -speaker_in = float(cparser['speaker']['in']) -speaker_out = float(cparser['speaker']['out']) -speaker_duration = speaker_out - speaker_in -speaker_font = cparser['speaker']['font'] -speaker_fontsize = int(cparser['speaker']['fontsize']) -speaker_fontcolor = cparser['speaker']['fontcolor'] -speaker_x = int(cparser['speaker']['x']) -speaker_y = int(cparser['speaker']['y']) - -text_in = float(cparser['text']['in']) -text_out = float(cparser['text']['out']) -text_duration = text_out - text_in -text_font = cparser['text']['font'] -text_fontsize = int(cparser['text']['fontsize']) -text_fontcolor = cparser['text']['fontcolor'] -text_x = int(cparser['text']['x']) -text_y = int(cparser['text']['y']) -text_text = cparser['text']['text'] - -font_t = os.path.join(os.path.dirname(args.project), title_font) -font_s = os.path.join(os.path.dirname(args.project), speaker_font) -font_tt = os.path.join(os.path.dirname(args.project), text_font) - -fileformat = os.path.splitext(template)[1] -infile = os.path.join(os.path.dirname(args.project), template) - -schedule = cparser['default']['schedule'] - -if not (os.path.exists(os.path.join(args.project, template))): - error("Template file {} in Project Path is missing".format(template)) - -for ffile in (title_font, speaker_font, text_font): - if not (os.path.exists(os.path.join(args.project, ffile))): - error("Font file {} in Project Path is missing".format(ffile)) - -if not (os.path.exists(os.path.join(args.project, 'config.ini'))): - error("config.ini file in Project Path is missing") - -if alpha == 'true' and not fileformat == '.mov': - error("Alpha can only be rendered with .mov source files") - -if not args.project: - error("The Project Path is a required argument") - -if not args.debug and not schedule: - error("Either specify --debug or supply a schedule in config.ini") - -if args.debug: - persons = ['Thomas Roth', 'Dmitry Nedospasov', 'Josh Datko'] - events = [{ - 'id': 'debug', - 'title': 'wallet.fail', - 'subtitle': 'Hacking the most popular cryptocurrency hardware wallets', - 'persons': persons, - 'personnames': ', '.join(persons), - 'room': 'Borg', - }] - -else: - events = list(schedulelib.events(schedule)) - - -def describe_event(event): - return "#{}: {}".format(event['id'], event['title']) - - -def event_print(event, message): - print("{} – {}".format(describe_event(event), message)) - - -def fmt_command(command, **kwargs): - args = {} - for key, value in kwargs.items(): - args[key] = shlex.quote(value) - - command = command.format(**args) - return shlex.split(command) - - -def run(command, **kwargs): - return subprocess.check_call( - fmt_command(command, **kwargs), - stderr=subprocess.STDOUT, - stdout=subprocess.DEVNULL) - - -def fit_text(string: str, frame_width): - split_line = [x.strip() for x in string.split()] - lines = [] - w = 0 - line_num = 0 - line = [] - for word in split_line: - new_line = line + [word.rstrip(':')] - w, _ = translation_font.getsize(" ".join(new_line)) - print(w, new_line) - if w > frame_width: - print("too wide, breaking", line) - lines.append(' '.join(line)) - line = [] - - line.append(word.rstrip(':')) - - if word.endswith(':'): - print(':, breaking', line) - lines.append(' '.join(line)) - line = [] - - if line: - lines.append(' '.join(line)) - return lines - - -def fit_title(string: str): - global translation_font - translation_font = ImageFont.truetype(font_t, size=title_fontsize-10, encoding="unic") - title = fit_text(string, 1080) - - return title - - -def fit_speaker(string: str): - global translation_font - translation_font = ImageFont.truetype(font_s, size=speaker_fontsize-10, encoding="unic") - speaker = fit_text(string, 1080) - - return speaker - - -def enqueue_job(event): - event_id = str(event['id']) - if event_id in args.skip: - event_print(event, "skipping " + str(event['id'])) - return - if (os.path.exists(os.path.join(args.project, event_id + '.ts')) or os.path.exists(os.path.join(args.project, event_id + '.mov'))) and not args.force: - event_print(event, "file exist, skipping " + str(event['id'])) - return - - event_title = str(event['title']) - event_personnames = str(event['personnames']) - event_title = event_title.replace('"', '') - event_title = event_title.replace('\'', '') - event_personnames = event_personnames.replace('"', '') - - t = fit_title(event_title) - s = fit_speaker(event_personnames) - print(s) - - if args.debug: - print('Title: ', t) - print('Speaker: ', s) - - outfile = os.path.join(os.path.dirname(args.project), event_id + '.ts') - - videofilter = "" - for idx, line in enumerate(t): - videofilter += "drawtext=fontfile={fontfile}:fontsize={fontsize}:fontcolor={fontcolor}:x={x}:y={y}:text='{text}':".format( - fontfile=font_t, - fontsize=title_fontsize, - fontcolor=title_fontcolor, - x=title_x, - y=title_y + (idx * title_fontsize), - text=line) - videofilter += "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( - fade_in_start_time=title_in, - fade_in_end_time=title_in + fade_duration, - fade_out_start_time=title_in + fade_duration + title_duration, - fade_out_end_time=title_in + fade_duration + title_duration + fade_duration, - fade_duration=fade_duration - ) - - for idx, line in enumerate(s): - videofilter += "drawtext=fontfile={fontfile}:fontsize={fontsize}:fontcolor={fontcolor}:x={x}:y={y}:text='{text}':".format( - fontfile=font_s, - fontsize=speaker_fontsize, - fontcolor=speaker_fontcolor, - x=speaker_x, - y=speaker_y + (idx * speaker_fontsize), - text=line) - videofilter += "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( - fade_in_start_time=speaker_in, - fade_in_end_time=speaker_in + fade_duration, - fade_out_start_time=speaker_in + fade_duration + speaker_duration, - fade_out_end_time=speaker_in + fade_duration + speaker_duration + fade_duration, - fade_duration=fade_duration - ) - - if fileformat == '.mov': - if alpha == 'true': - if prores == 'true': - cmd = 'ffmpeg -y -i "{0}" -vf "{1}" -vcodec prores_ks -pix_fmt yuva444p10le -profile:v 4444 -shortest -movflags faststart -f mov "{2}"'.format( - infile, videofilter, outfile) - else: - cmd = 'ffmpeg -y -i "{0}" -vf "{1}" -shortest -c:v qtrle -movflags faststart -f mov "{2}"'.format( - infile, videofilter, outfile) - else: - cmd = 'ffmpeg -y -i "{0}" -vf "{1}" -map 0:0 -c:v mpeg2video -q:v 2 -aspect 16:9 -map 0:1 -c:a mp2 -b:a 384k -shortest -f mpegts "{2}"'.format( - infile, videofilter, outfile) - else: - cmd = 'ffmpeg -y -i "{0}" -vf "{1}" -map 0:0 -c:v mpeg2video -pix_fmt:v yuv420p -qscale:v 2 -qmin:v 2 -qmax:v 7 -keyint_min 0 -bf 0 -g 0 -maxrate:0 90M -aspect 16:9 -map 0:1 -c:a mp2 -b:a 384k -shortest -f mpegts "{2}"'.format( - infile, videofilter, outfile) - - if args.debug: - print(cmd) - - run(cmd) - - return event_id - - -if args.ids: - if len(args.ids) == 1: - print("enqueuing {} job".format(len(args.ids))) - else: - print("enqueuing {} jobs".format(len(args.ids))) -else: - if len(events) == 1: - print("enqueuing {} job".format(len(events))) - else: - print("enqueuing {} jobs".format(len(events))) - - -for event in events: - if args.ids and event['id'] not in args.ids: - continue - - if args.rooms and event['room'] not in args.rooms: - print("skipping room %s (%s)" % (event['room'], event['title'])) - continue - - event_print(event, "enqueued as " + str(event['id'])) - - job_id = enqueue_job(event) - if not job_id: - event_print(event, "job was not enqueued successfully, skipping postprocessing") - continue - - -print('all done') +print("ERROR: The functionality of this script was added to 'make-ffmpeg.py'!") +print("Specify meta.fade_duration = 0.5 in the config.ini for the same effect.") diff --git a/make-ffmpeg.py b/make-ffmpeg.py index aa67320..68d2bf2 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -71,10 +71,11 @@ class TextConfig: return fit_text(text, (FRAME_WIDTH-self.x-100), font) - def get_ffmpeg_filter(self, inout_type: str, text: list[str]): + def get_ffmpeg_filter(self, inout_type: str, fade_time: float, text: list[str]): if not text: return "" + text_duration = self.outpoint - self.inpoint filter_str = "" for idx, line in enumerate(text): filter_str += "drawtext=enable='between({},{},{})'".format( @@ -92,6 +93,14 @@ class TextConfig: filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text={4}".format( self.fontsize, self.fontcolor, self.x, self.y + (idx*self.fontsize), ffmpeg_escape_str(line)) + 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( + fade_in_start_time=self.inpoint, + fade_in_end_time=self.inpoint + fade_time, + fade_out_start_time=self.inpoint + fade_time + text_duration, + fade_out_end_time=self.inpoint + fade_time + text_duration + fade_time, + fade_duration=fade_time) + filter_str += "," return filter_str[:-1] @@ -103,6 +112,7 @@ class Config: alpha: bool = False prores: bool = False inout_type: str = "t" # in and out time format: t for seconds, n for frame number + fade_duration: float = 0 # fade duration in seconds, 0 to disable fileext: str @@ -128,6 +138,7 @@ def parse_config(filename) -> Config: conf.alpha = meta.getboolean('alpha', conf.alpha) conf.prores = meta.getboolean('prores', conf.prores) conf.inout_type = meta.get('inout_type', conf.inout_type) + conf.fade_duration = meta.getfloat('fade_duration', conf.fade_duration) defaults = cparser['default'] default_fontfile = defaults.get('fontfile', None) @@ -242,9 +253,10 @@ def enqueue_job(conf: Config, event): else: ffmpeg_path = 'ffmpeg' - videofilter = conf.title.get_ffmpeg_filter(conf.inout_type, title) + "," - videofilter += conf.speaker.get_ffmpeg_filter(conf.inout_type, speakers) + "," - videofilter += conf.text.get_ffmpeg_filter(conf.inout_type, extra_text) + videofilter = conf.title.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, title) + "," + videofilter += 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) cmd = [ffmpeg_path, '-y', '-i', conf.template_file, '-vf', videofilter] diff --git a/requirements.txt b/requirements.txt index 181482a..f8a9b74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pillow +pillow>=8.0.0 cssutils==1.0.2 lxml==4.9.1 svg.path==4.0.2 diff --git a/vcfb24/config.ini b/vcfb24/config.ini index b363273..712eff6 100644 --- a/vcfb24/config.ini +++ b/vcfb24/config.ini @@ -4,13 +4,13 @@ schedule = http://vcfb.de/2024/schedule.xml template = intro.mp4 alpha = false prores = false -fontfile = true -inout = t +inout_type = t +fade_duration = 0.5 [title] in = 1 out = 9.5 -font = Computerfont.ttf +fontfile = Computerfont.ttf fontsize = 100 fontcolor = #ffffff x = 85 @@ -19,7 +19,7 @@ y = 122 [speaker] in = 2 out = 9 -font = SourceSansPro-Semibold.ttf +fontfile = SourceSansPro-Semibold.ttf fontsize = 65 fontcolor = #ffffff x = 85 @@ -29,7 +29,7 @@ y = 861 [text] in = 0 out = 0 -font = Computerfont.ttf +fontfile = Computerfont.ttf fontsize = 0 fontcolor = #ffffff x = 0 From ca224b9d84ac376149a009aaa30304a9ec9c0fb3 Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 2 Nov 2024 20:16:06 +0100 Subject: [PATCH 7/8] make-ffmpeg: Remove fontfamily option as it doesn't work --- jh20-jue/config.ini | 20 ++++++++++---------- jugendhackt/config.ini | 4 +--- make-ffmpeg.py | 40 +++++++++++----------------------------- 3 files changed, 22 insertions(+), 42 deletions(-) diff --git a/jh20-jue/config.ini b/jh20-jue/config.ini index d48087f..05878cf 100644 --- a/jh20-jue/config.ini +++ b/jh20-jue/config.ini @@ -8,26 +8,28 @@ prores = false ; in and out time format: t for seconds, n for frame number inout = n -; fields for title and speaker names are empty in the template.ts, so we'll render them in via ffmpeg +;; Some font settings can have defaults, which can be overridden in the +;; 'title', 'speaker' and 'text' sections below. +[default] +;; default font +fontfile = ebisu.ttf +;; default font color +fontcolor = #ffffff + +;; fields for title and speaker names are empty in the template.ts, so we'll render them in via ffmpeg [title] ; inframe for title in = 20 -; outframe for totle +; outframe for title out = 225 -; title font (either font family or file, see default setting above) -fontfile = ebisu.ttf ; title font size fontsize = 70 -; title color -fontcolor = #ffffff -; title position from upper left corner x = 600 y = 865 [speaker] in = 40 out = 225 -fontfile = ebisu.ttf fontsize = 40 fontcolor = #eeeeee x = 600 @@ -37,9 +39,7 @@ y = 950 [text] in = 3 out = 4 -fontfile = ebisu.ttf fontsize = 45 -fontcolor = #ffffff x = 640 y = 1000 ; text = diff --git a/jugendhackt/config.ini b/jugendhackt/config.ini index 4f507cd..94180a5 100644 --- a/jugendhackt/config.ini +++ b/jugendhackt/config.ini @@ -14,8 +14,7 @@ inout_type = t ;; Some font settings can have defaults, which can be overridden in the ;; 'title', 'speaker' and 'text' sections below. [default] -;; default font (either use 'fontfamily' or 'fontfile') -; fontfamily = arial +;; default font fontfile = SourceSansPro-Semibold.ttf ;; default font color fontcolor = #ffffff @@ -24,7 +23,6 @@ fontcolor = #ffffff ;; parameters are: ;; - in: start frame/ time ;; - out: end frame/ time -;; - fontfamily: font family ;; - fontfile: font file ;; - fontcolor: font color ;; - fontsize: font size (pixel) diff --git a/make-ffmpeg.py b/make-ffmpeg.py index 68d2bf2..94d1514 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -26,8 +26,6 @@ class TextConfig: y: int fontfile_path: str - fontfamily: str - fontsize: int fontcolor: str bordercolor: str = None # border is added, if a color is set @@ -35,7 +33,7 @@ class TextConfig: def uses_fontfile(self): return self.fontfile_path is not None - def parse(self, cparser_sect, default_fontfile, default_fontfamily, default_fontcolor): + def parse(self, cparser_sect, default_fontfile, default_fontcolor): self.inpoint = cparser_sect.getfloat('in') self.outpoint = cparser_sect.getfloat('out') self.x = cparser_sect.getint('x') @@ -44,18 +42,10 @@ class TextConfig: self.fontcolor = cparser_sect.get('fontcolor', default_fontcolor) fontfile = cparser_sect.get('fontfile', default_fontfile) - fontfamily = cparser_sect.get('fontfamily', default_fontfamily) - if fontfile != None and fontfamily is None: - 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): - error("Font file {} in Project Path is missing".format(self.fontfile_path)) - - elif fontfamily != None and fontfile is None: - self.fontfamily = fontfamily - - else: - error("Either provide a 'fontfamily' or 'fontfile', not both") + if not os.path.exists(self.fontfile_path): + error("Font file {} in Project Path is missing".format(self.fontfile_path)) self.fontsize = cparser_sect.getint('fontsize') self.bordercolor = cparser_sect.get('bordercolor', None) @@ -67,8 +57,6 @@ class TextConfig: font = ImageFont.truetype( self.fontfile_path, size=self.fontsize, encoding="unic") - # TODO: Make this work with font family as well! - return fit_text(text, (FRAME_WIDTH-self.x-100), font) def get_ffmpeg_filter(self, inout_type: str, fade_time: float, text: list[str]): @@ -78,21 +66,16 @@ class TextConfig: text_duration = self.outpoint - self.inpoint filter_str = "" for idx, line in enumerate(text): - filter_str += "drawtext=enable='between({},{},{})'".format( - inout_type, self.inpoint, self.outpoint) + filter_str += "drawtext=enable='between({},{},{})':x={}:y={}".format( + inout_type, self.inpoint, self.outpoint, self.x, self.y + (idx*self.fontsize)) - if self.uses_fontfile(): - filter_str += ":fontfile='{}'".format(self.fontfile_path) - else: - filter_str += ":font='{}'".format(self.fontfamily) + filter_str += ":fontfile='{}':fontsize={}:fontcolor={}:text={}".format( + self.fontfile_path, self.fontsize, self.fontcolor, ffmpeg_escape_str(line)) if self.bordercolor is not None: filter_str += ":borderw={}:bordercolor={}".format( self.fontsize / 30, self.bordercolor) - filter_str += ":fontsize={0}:fontcolor={1}:x={2}:y={3}:text={4}".format( - self.fontsize, self.fontcolor, self.x, self.y + (idx*self.fontsize), ffmpeg_escape_str(line)) - 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( fade_in_start_time=self.inpoint, @@ -142,15 +125,14 @@ def parse_config(filename) -> Config: defaults = cparser['default'] default_fontfile = defaults.get('fontfile', None) - default_fontfamily = defaults.get('fontfamily', None) default_fontcolor = defaults.get('fontcolor', "#ffffff") conf.title = TextConfig() - conf.title.parse(cparser['title'], default_fontfile, default_fontfamily, default_fontcolor) + conf.title.parse(cparser['title'], default_fontfile, default_fontcolor) conf.speaker = TextConfig() - conf.speaker.parse(cparser['speaker'], default_fontfile, default_fontfamily, default_fontcolor) + conf.speaker.parse(cparser['speaker'], default_fontfile, default_fontcolor) conf.text = TextConfig() - conf.text.parse(cparser['text'], default_fontfile, default_fontfamily, default_fontcolor) + conf.text.parse(cparser['text'], default_fontfile, default_fontcolor) conf.extra_text = cparser['text'].get('text', '') From 2451c0f25f66662ee5b17d1d17247b7cd0c31afc Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 2 Nov 2024 21:49:30 +0100 Subject: [PATCH 8/8] bump some versions in requirements.txt - lxml 5 works - svg.path has no breaking changes according to change log - cssutils has a v2, but didn't find a changelog --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index f8a9b74..efa6214 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pillow>=8.0.0 cssutils==1.0.2 -lxml==4.9.1 -svg.path==4.0.2 -Wand==0.6.5 +lxml~=5.3 +svg.path~=6.0 +Wand~=0.6.5