diff --git a/.gitignore b/.gitignore index 87676a5..b5f2439 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ *.mp4 *.dv *.ts +*.mkv *.mov +/*/*.png /*.png *.pyc schedule.de.xml diff --git a/cccamp19/config.ini b/cccamp19/config.ini index 838379d..c1bbba0 100644 --- a/cccamp19/config.ini +++ b/cccamp19/config.ini @@ -1,4 +1,5 @@ [default] +schedule = https://fahrplan.events.ccc.de/camp/2019/Fahrplan/schedule.xml template = cccamp19_talks_intro_1080p.mov [title] diff --git a/forumoe19/__init__.py b/forumoe19/__init__.py index b85bffc..906ec4c 100644 --- a/forumoe19/__init__.py +++ b/forumoe19/__init__.py @@ -124,28 +124,35 @@ def tasks(queue, args, idlist, skiplist): queue.put(Rendertask( infile = 'intro.svg', outfile = str(event['id'])+".ts", - sequence = introFrames, parameters = { '$ID': event['id'], '$TITLE': event['title'], '$SUBTITLE': event['subtitle'], '$SPEAKER': event['personnames'] } - )) + ).animated(introFrames)) # place a task for the outro into the queue if not "out" in skiplist: queue.put(Rendertask( infile = 'outro.svg', - outfile = 'outro.ts', - sequence = outroFrames - )) + outfile = 'outro.ts' + ).animated(outroFrames)) for person in persons(scheduleUrl, personmap, taglinemap): queue.put(Rendertask( infile = 'insert.svg', outfile = "insert_{}.mkv".format(person['person'].replace("/", "_")), - sequence = bbFrames, + parameters = { + '$PERSON': person['person'], + '$TAGLINE': person['tagline'], + } + ).animated(bbFrames)) + + for person in persons(scheduleUrl, personmap, taglinemap): + queue.put(Rendertask( + infile = 'insert.svg', + outfile = "insert_{}.png".format(person['person'].replace("/", "_")), parameters = { '$PERSON': person['person'], '$TAGLINE': person['tagline'], diff --git a/make-ffmpeg.py b/make-ffmpeg.py index 0b13287..6d048f3 100755 --- a/make-ffmpeg.py +++ b/make-ffmpeg.py @@ -9,25 +9,21 @@ 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/ https://url/to/schedule.xml", + 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('schedule', action="store", metavar='Schedule-URL', type=str, nargs='?', help=''' - URL or Path to your schedule.xml - ''') - 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. Schedule-URL can be left blank when - used with --debug + not parsing or accessing a schedule. This argument must not be used together with --id Usage: ./make-ffmpeg.py yourproject/ --debug ''') @@ -71,30 +67,6 @@ def error(str): parser.print_help() sys.exit(1) - -if not (os.path.exists(os.path.join(args.project, 'config.ini'))): - error("config.ini file in Project Path is missing") - -if not args.project: - error("The Project Path is a required argument") - -if not args.debug and not args.schedule: - error("Either specify --debug or supply a schedule") - -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(renderlib.events(args.schedule)) - parser = ConfigParser() parser.read(os.path.join(os.path.dirname(args.project), 'config.ini')) template = parser['default']['template'] @@ -131,6 +103,31 @@ 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 = parser['default']['schedule'] + +if not (os.path.exists(os.path.join(args.project, 'config.ini'))): + error("config.ini file in Project Path is missing") + +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(renderlib.events(schedule)) + def describe_event(event): return "#{}: {}".format(event['id'], event['title']) @@ -202,6 +199,9 @@ def enqueue_job(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('"', '') t = fit_title(event_title) s = fit_speaker(event_personnames) @@ -220,7 +220,8 @@ def enqueue_job(event): videofilter += "drawtext=enable='between(n,{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) if fileformat == '.mov': - 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) + cmd = 'ffmpeg -y -i "{0}" -vf "{1}" -shortest -c:v qtrle -movflags faststart -f mov "{2}"'.format(infile, videofilter, outfile) + #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}" -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) if args.debug: diff --git a/renderlib.py b/renderlib.py index d29221f..0f99adf 100644 --- a/renderlib.py +++ b/renderlib.py @@ -1,4 +1,3 @@ -#!/usr/bin/python3 # vim: tabstop=4 shiftwidth=4 expandtab import os @@ -7,11 +6,9 @@ import re import glob import shutil import errno -from lxml import etree -from xml.sax.saxutils import escape as xmlescape -import cssutils -import logging import subprocess +from svgtemplate import SVGTemplate +from lxml import etree from urllib.request import urlopen from wand.image import Image @@ -20,10 +17,6 @@ fps = 25 debug = True args = None -cssutils.ser.prefs.lineSeparator = ' ' -cssutils.log.setLevel(logging.FATAL) - - def loadProject(projectname): sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), projectname)) return __import__(projectname) @@ -40,20 +33,31 @@ def easeDelay(easer, delay, t, b, c, d, *args): class Rendertask: - def __init__(self, infile, sequence, parameters={}, outfile=None, workdir='.'): + def __init__(self, infile, parameters={}, outfile=None, workdir='.', sequence=None): if isinstance(infile, list): self.infile = infile[0] # self.audiofile = infile[1] else: self.infile = infile self.audiofile = None - self.sequence = sequence self.parameters = parameters self.outfile = outfile self.workdir = workdir + self.sequence = sequence # deprecated, use animated() + + def animated(self, sequence): + atask = self + atask.sequence = sequence + return atask + + def is_animated(self): + return self.sequence != None def fromtupel(tuple): - return Rendertask(tuple[0], tuple[2], tuple[3], tuple[1]) + task = Rendertask(tuple[0], tuple[2], tuple[1]) + if len(tuple) > 3: + task = task.animated(tuple[3]) + return task def ensure(input): if isinstance(input, tuple): @@ -63,7 +67,6 @@ class Rendertask: else: return None - # try to create all folders needed and skip, they already exist def ensurePathExists(path): try: @@ -78,40 +81,24 @@ def ensureFilesRemoved(pattern): for f in glob.glob(pattern): os.unlink(f) +def renderFrame(infile, task, outfile): + width = 1920 + height = 1080 + infile = '{0}/.gen.svg'.format(task.workdir) + if args.imagemagick: + # invoke imagemagick to convert the generated svg-file into a png inside the .frames-directory + with Image(filename=infile) as img: + with img.convert('png') as converted: + converted.save(filename=outfile) + else: + # invoke inkscape to convert the generated svg-file into a png inside the .frames-directory + cmd = 'cd {0} && inkscape --export-background=white --export-background-opacity=0 --export-width={1} --export-height={2} --export-png="{3}" "{4}" 2>&1 >/dev/null'.format(task.workdir, width, height, outfile, infile) + errorReturn = subprocess.check_output(cmd, shell=True, universal_newlines=True, stderr=subprocess.STDOUT) + if errorReturn != '': + print("inkscape exitted with error\n" + errorReturn) + # sys.exit(42) -def rendertask(task): - global args - # in debug mode we have no thread-worker which prints its progress - if debug: - print("generating {0} from {1}".format(task.outfile, task.infile)) - - if args.skip_frames and 'only_rerender_frames_after' not in task.parameters: - if os.path.isdir(os.path.join(task.workdir, '.frames')): - shutil.rmtree(os.path.join(task.workdir, '.frames')) - - # make sure a .frames-directory exists in out workdir - ensurePathExists(os.path.join(task.workdir, '.frames')) - - # open and parse the input file - with open(os.path.join(task.workdir, task.infile), 'r') as fp: - svgstr = fp.read() - for key in task.parameters.keys(): - svgstr = svgstr.replace(key, xmlescape(str(task.parameters[key]))) - - parser = etree.XMLParser(huge_tree=True) - svg = etree.fromstring(svgstr.encode('utf-8'), parser) - - # if '$subtitle' in task.parameters and task.parameters['$subtitle'] == '': - # child = svg.findall(".//*[@id='subtitle']")[0] - # child.getparent().remove(child) - - # frame-number counter - frameNr = 0 - - # iterate through the animation seqence frame by frame - # frame is a ... tbd - cache = {} - for frame in task.sequence(task.parameters): +def cachedRenderFrame(frame, frameNr, task, cache): skip_rendering = False # skip first n frames, to speed up rerendering during debugging if 'only_rerender_frames_after' in task.parameters: @@ -135,58 +122,35 @@ def rendertask(task): framedir = task.workdir + "/.frames/" shutil.copyfile("{0}/{1:04d}.png".format(framedir, cache[frame]), "{0}/{1:04d}.png".format(framedir, frameNr)) - frameNr += 1 - continue + return elif not skip_rendering: cache[frame] = frameNr - # apply the replace-pairs to the input text, by finding the specified xml-elements by thier id and modify thier css-parameter the correct value - for replaceinfo in frame: - (id, type, key, value) = replaceinfo + with SVGTemplate(task) as svg: + svg.replacetext() + svg.transform(frame) + svgfile = svg.write() - for el in svg.findall(".//*[@id='" + id.replace("'", "\\'") + "']"): - if type == 'style': - style = cssutils.parseStyle(el.attrib['style'] if 'style' in el.attrib else '') - style[key] = str(value) - el.attrib['style'] = style.cssText - - elif type == 'attr': - el.attrib[key] = str(value) - - elif type == 'text': - el.text = str(value) - - if not skip_rendering: - # open the output-file (named ".gen.svg" in the workdir) - with open(os.path.join(task.workdir, '.gen.svg'), 'w') as fp: - # write the generated svg-text into the output-file - fp.write(etree.tostring(svg, encoding='unicode')) - - if task.outfile.endswith('.ts') or task.outfile.endswith('.mov') or task.outfile.endswith('.mkv'): - width = 1920 - height = 1080 - else: - width = 1024 - height = 576 - - if args.imagemagick: - # invoke imagemagick to convert the generated svg-file into a png inside the .frames-directory - infile = '{0}/.gen.svg'.format(task.workdir) - outfile = '{0}/.frames/{1:04d}.png'.format(task.workdir, frameNr) - with Image(filename=infile) as img: - with img.convert('png') as converted: - converted.save(filename=outfile) - else: - # invoke inkscape to convert the generated svg-file into a png inside the .frames-directory - cmd = 'cd {0} && inkscape --export-background=white --export-background-opacity=0 --export-width={2} --export-height={3} --export-png=$(pwd)/.frames/{1:04d}.png $(pwd)/.gen.svg 2>&1 >/dev/null'.format(task.workdir, frameNr, width, height) - errorReturn = subprocess.check_output(cmd, shell=True, universal_newlines=True, stderr=subprocess.STDOUT) - if errorReturn != '': - print("inkscape exitted with error\n" + errorReturn) - # sys.exit(42) + outfile = '{0}/.frames/{1:04d}.png'.format(task.workdir, frameNr) + renderFrame(svgfile, task, outfile) # increment frame-number frameNr += 1 + +def rendertask_image(task): + with SVGTemplate(task) as svg: + svg.replacetext() + svgfile = svg.write() + renderFrame(svgfile, task, task.outfile) + +def rendertask_video(task): + # iterate through the animation sequence frame by frame + # frame is a ... tbd + cache = {} + for frameNr, frame in enumerate(task.sequence(task.parameters)): + cachedRenderFrame(frame, frameNr, task, cache) + if args.only_frame: task.outfile = '{0}.frame{1:04d}.png'.format(task.outfile, args.only_frame) @@ -231,15 +195,33 @@ def rendertask(task): if r != 0: sys.exit() +def rendertask(task): + global args + # in debug mode we have no thread-worker which prints its progress + if debug: + print("generating {0} from {1}".format(task.outfile, task.infile)) + + if args.skip_frames and 'only_rerender_frames_after' not in task.parameters: + if os.path.isdir(os.path.join(task.workdir, '.frames')): + shutil.rmtree(os.path.join(task.workdir, '.frames')) + + # make sure a .frames-directory exists in out workdir + ensurePathExists(os.path.join(task.workdir, '.frames')) + + if task.is_animated(): + rendertask_video(task) + else: + rendertask_image(task) + if not debug: print("cleanup") # remove the generated svg ensureFilesRemoved(os.path.join(task.workdir, '.gen.svg')) + + # Download the Events-Schedule and parse all Events out of it. Yield a tupel for each Event - - def downloadSchedule(scheduleUrl): print("downloading schedule") @@ -322,7 +304,7 @@ def events(scheduleUrl, titlemap={}): 'personnames': ', '.join(personnames), 'room': room.attrib['name'], 'track': event.find('track').text, - #'url': event.find('url').text + #'url': event.find('url').text } diff --git a/suselabs18/__init__.py b/suselabs18/__init__.py new file mode 100644 index 0000000..0b4687e --- /dev/null +++ b/suselabs18/__init__.py @@ -0,0 +1,129 @@ +#!/usr/bin/python + +from renderlib import * +from easing import * + +# URL to Schedule-XML +scheduleUrl = 'https://live.ber.c3voc.de/releases/suselabs18/schedule.xml' + +def bounce(i, min, max, frames): + if i == frames - 1: + return 0 + + if i <= frames/2: + return easeInOutQuad(i, min, max, frames/2) + else: + return max - easeInOutQuad(i - frames/2, min, max, frames/2) + +def introFrames(parameters): + # 3 Sekunde Text Fadein + frames = 1*fps + for i in range(0, frames): + yield ( + ('textblock', 'style', 'opacity', "%.4f" % easeLinear(i, 0, 1, frames)), + ('fadeout', 'style', 'opacity', "%.4f" % 0), + ) + + # 4 Sekunden stehen lassen + frames = 4*fps + for i in range(0, frames): + yield ( + ('fadeout', 'style', 'opacity', "%.4f" % 0), + ) + + # 1 Sekunde Fade to black layer + frames = 1*fps + for i in range(0, frames): + yield ( + ('fadeout', 'style', 'opacity', "%.4f" % easeOutCubic(i, 0, 1, frames)), + ) + +def pauseFrames(parameters): + frames = 3*fps + colors = ['#21A4D4', '#73BA25', '#6DA741', '#35B9AB', '#00A489', '#173F4F'] + yield ( + ('pause_bg', 'style', 'fill', "%s" % '#173F4F'), + ('pause_bg', 'attr', 'opacity', '%.4f' % 1.0), + ) + for i in range(0, len(colors)): + z = 0 + for z in range(0,frames): + yield ( + ('pause_bg_alt', 'style', 'fill', "%s" % colors[i]), + ('pause_bg_alt', 'attr', 'opacity', '%.4f' % easeLinear(z, 0.0, 1.0, frames)), + ) + yield ( + ('pause_bg', 'style', 'fill', "%s" % colors[i]), + ('pause_bg', 'attr', 'opacity', '%.4f' % 1.0), + ) + +def outroFrames(p): + # 5 Sekunden stehen bleiben + frames = 5*fps + for i in range(0, frames): + yield [] + +def debug(): + render( + 'intro.svg', + '../intro.ts', + introFrames, + { + '$ID': 4711, + '$TITLE': "Long Long Long title is LONG", + '$SUBTITLE': 'Long Long Long Long subtitle is LONGER', + '$SPEAKER': 'Long Name of Dr. Dr. Prof. Dr. Long Long' + } + ) + + render( + 'pause.svg', + '../pause.ts', + pauseFrames + ) + + render( + 'outro.svg', + '../outro.ts', + outroFrames + ) + +def tasks(queue, args, idlist, skiplist): + # iterate over all events extracted from the schedule xml-export + for event in events(scheduleUrl): + if not (idlist==[]): + if 000000 in idlist: + print("skipping id (%s [%s])" % (event['title'], event['id'])) + continue + if int(event['id']) not in idlist: + print("skipping id (%s [%s])" % (event['title'], event['id'])) + continue + + # generate a task description and put it into the queue + queue.put(Rendertask( + infile = 'intro.svg', + outfile = str(event['id'])+".ts", + sequence = introFrames, + parameters = { + '$ID': event['id'], + '$TITLE': event['title'], + '$SUBTITLE': event['subtitle'], + '$SPEAKER': event['personnames'] + } + )) + + # place a task for the outro into the queue + if not "out" in skiplist: + queue.put(Rendertask( + infile = 'outro.svg', + outfile = 'outro.ts', + sequence = outroFrames + )) + + # place the pause-sequence into the queue + if not "pause" in skiplist: + queue.put(Rendertask( + infile = 'pause.svg', + outfile = 'pause.ts', + sequence = pauseFrames + )) diff --git a/suselabs18/artwork/intro.svg b/suselabs18/artwork/intro.svg new file mode 100644 index 0000000..fee1795 --- /dev/null +++ b/suselabs18/artwork/intro.svg @@ -0,0 +1,3378 @@ + + + +image/svg+xml + +$TITLE$SPEAKER$TITLE2018 + \ No newline at end of file diff --git a/suselabs18/artwork/outro.svg b/suselabs18/artwork/outro.svg new file mode 100644 index 0000000..0be25ef --- /dev/null +++ b/suselabs18/artwork/outro.svg @@ -0,0 +1,436 @@ + + + +image/svg+xml + +SUSE Labs Conference 2018was brought to you by: +Video +Team +c3voc.de +2018 + \ No newline at end of file diff --git a/suselabs18/artwork/pause.svg b/suselabs18/artwork/pause.svg new file mode 100644 index 0000000..e601797 --- /dev/null +++ b/suselabs18/artwork/pause.svg @@ -0,0 +1,180 @@ + + + +image/svg+xmlPause + \ No newline at end of file diff --git a/svgtemplate.py b/svgtemplate.py new file mode 100644 index 0000000..f6797b0 --- /dev/null +++ b/svgtemplate.py @@ -0,0 +1,54 @@ +# vim: tabstop=4 shiftwidth=4 expandtab +import builtins +import cssutils +import logging +import os +from lxml import etree +from xml.sax.saxutils import escape as xmlescape + +cssutils.ser.prefs.lineSeparator = ' ' +cssutils.log.setLevel(logging.FATAL) + +class SVGTemplate: + def __init__(self, task): + self.task = task + + def __enter__(self): + with builtins.open(os.path.join(self.task.workdir, self.task.infile), 'r') as fp: + self.svgstr = fp.read() + return self + + def write(self): + # open the output-file (named ".gen.svg" in the workdir) + outfile = os.path.join(self.task.workdir, '.gen.svg') + with builtins.open(outfile, 'w') as fp: + # write the generated svg-text into the output-file + fp.write(self.svgstr) + return outfile + + def replacetext(self): + for key in self.task.parameters.keys(): + self.svgstr = self.svgstr.replace(key, xmlescape(str(self.task.parameters[key]))) + + def transform(self, frame): + parser = etree.XMLParser(huge_tree=True) + svg = etree.fromstring(self.svgstr.encode('utf-8'), parser) + # apply the replace-pairs to the input text, by finding the specified xml-elements by their id and modify their css-parameter the correct value + for replaceinfo in frame: + (id, type, key, value) = replaceinfo + for el in svg.findall(".//*[@id='" + id.replace("'", "\\'") + "']"): + if type == 'style': + style = cssutils.parseStyle(el.attrib['style'] if 'style' in el.attrib else '') + style[key] = str(value) + el.attrib['style'] = style.cssText + elif type == 'attr': + el.attrib[key] = str(value) + elif type == 'text': + el.text = str(value) + # if '$subtitle' in task.parameters and task.parameters['$subtitle'] == '': + # child = svg.findall(".//*[@id='subtitle']")[0] + # child.getparent().remove(child) + self.xmlstr = etree.tostring(svg, encoding='unicode') + + def __exit__(self, exception_type, exception_value, traceback): + pass