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

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import platform
from PIL import ImageFont
import schedulelib
ssl._create_default_https_context = ssl._create_unverified_context
FRAME_WIDTH = 1920
@ -34,31 +35,32 @@ class TextConfig:
return self.fontfile_path is not None
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')
self.y = cparser_sect.getint('y')
self.w = cparser_sect.getint('w')
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.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())
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)
self.fontsize = cparser_sect.getint("fontsize")
self.bordercolor = cparser_sect.get("bordercolor", None)
def fit_text(self, text: str) -> str:
if not text:
return ""
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))
return fitted
@ -66,21 +68,21 @@ class TextConfig:
if not text:
return ""
filter_str = ""
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:
filter_str+=f":boxw={self.w}"
print(f"{text}, {type(text)}")
filter_str += f":boxw={self.w}"
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:
filter_str += ":borderw={}:bordercolor={}".format(
self.fontsize / 30, self.bordercolor)
self.fontsize / 30, self.bordercolor
)
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(
@ -88,7 +90,8 @@ class TextConfig:
fade_in_end_time=self.inpoint + fade_time,
fade_out_start_time=self.outpoint - fade_time,
fade_out_end_time=self.outpoint,
fade_duration=fade_time)
fade_duration=fade_time,
)
filter_str += ","
@ -120,34 +123,34 @@ def parse_config(filename) -> Config:
cparser = ConfigParser()
cparser.read(filename)
meta = cparser['meta']
conf.schedule = meta.get('schedule')
infile = PurePath(args.project, meta.get('template'))
meta = cparser["meta"]
conf.schedule = meta.get("schedule")
infile = PurePath(args.project, meta.get("template"))
conf.template_file = str(infile)
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)
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)
default_fontcolor = defaults.get('fontcolor', "#ffffff")
defaults = cparser["default"]
default_fontfile = defaults.get("fontfile", None)
default_fontcolor = defaults.get("fontcolor", "#ffffff")
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.parse(cparser['speaker'], default_fontfile, default_fontcolor)
conf.speaker.parse(cparser["speaker"], default_fontfile, default_fontcolor)
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
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':
if conf.alpha and conf.fileext != ".mov":
error("Alpha can only be rendered with .mov source files")
if not args.project:
@ -169,7 +172,7 @@ def error(err_str):
def describe_event(event):
return "#{}: {}".format(event['id'], event['title'])
return "#{}: {}".format(event["id"], event["title"])
def event_print(event, message):
@ -184,20 +187,20 @@ def fit_text(string: str, max_width: int, font: ImageFont) -> list[str]:
w = 0
line = []
for word in split_line:
new_line = line + [word.rstrip(':')]
new_line = line + [word.rstrip(":")]
w = font.getlength(" ".join(new_line))
if w > max_width:
lines.append(' '.join(line))
lines.append(" ".join(line))
line = []
line.append(word.rstrip(':'))
line.append(word.rstrip(":"))
if word.endswith(':'):
lines.append(' '.join(line))
if word.endswith(":"):
lines.append(" ".join(line))
line = []
if line:
lines.append(' '.join(line))
lines.append(" ".join(line))
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
# 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"\\\'")
return text
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_mov = str(PurePath(args.project, event_id + '.mov'))
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']))
event_print(event, "skipping " + str(event["id"]))
return
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
event_title = str(event['title'])
event_personnames = str(event['personnames'])
event_title = str(event["title"])
event_personnames = str(event["personnames"])
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('Speaker: ', speakers)
print("Title: ", title)
print("Speaker: ", speakers)
if platform.system() == 'Windows':
ffmpeg_path = './ffmpeg.exe'
if platform.system() == "Windows":
ffmpeg_path = "./ffmpeg.exe"
else:
ffmpeg_path = 'ffmpeg'
ffmpeg_path = "ffmpeg"
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)
videofilters = [
conf.title.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, title),
conf.speaker.get_ffmpeg_filter(conf.inout_type, conf.fade_duration, speakers),
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:
cmd += ['-vcodec', 'prores_ks', '-pix_fmt', 'yuva444p10le', '-profile:v',
'4444', '-shortest', '-movflags', 'faststart', '-f', 'mov', outfile_mov]
cmd += [
"-vcodec",
"prores_ks",
"-pix_fmt",
"yuva444p10le",
"-profile:v",
"4444",
"-shortest",
"-movflags",
"faststart",
"-f",
"mov",
outfile_mov,
]
else:
cmd += ['-shortest', '-c:v', 'qtrle', '-movflags',
'faststart', '-f', 'mov', outfile_mov]
cmd += [
"-shortest",
"-c:v",
"qtrle",
"-movflags",
"faststart",
"-f",
"mov",
outfile_mov,
]
else:
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]
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)
subprocess.check_call(cmd,
stderr=subprocess.STDOUT,
)
subprocess.check_call(
cmd,
stderr=subprocess.STDOUT,
)
return event_id
@ -272,59 +316,102 @@ def enqueue_job(conf: Config, event):
if __name__ == "__main__":
# Parse arguments
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/",
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
''')
""",
)
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,
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='''
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='''
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='''
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='''
parser.add_argument(
"--force",
action="store_true",
default=False,
help="""
Force render if file exists.
''')
""",
)
args = parser.parse_args()
if (args.skip is None):
if args.skip is None:
args.skip = []
config = parse_config(PurePath(args.project, 'config.ini'))
config = parse_config(PurePath(args.project, "config.ini"))
if args.debug:
persons = ['Thomas Roth', 'Dmitry Nedospasov', 'Josh Datko',]
events = [{
'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',
}]
persons = [
"Thomas Roth",
"Dmitry Nedospasov",
"Josh Datko",
]
events = [
{
"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:
events = list(schedulelib.events(config.schedule))
@ -341,18 +428,20 @@ if __name__ == "__main__":
print("enqueuing {} jobs".format(len(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
if args.rooms and event['room'] not in args.rooms:
print("skipping room %s (%s)" % (event['room'], event['title']))
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")
event_print(
event, "job was not enqueued successfully, skipping postprocessing"
)
continue
print('all done')
print("all done")