def title_screen(): """ Write the title on a black background @see https://ffmpeg.org/ffmpeg-filters.html#drawtext-1 ffmpeg -f lavfi -i color=c=black:s=1920x1080:d=0.5 \ -vf "drawtext=fontsize=30:fontcolor=white:fontfile=/path/font.ttf:text='Title':x=(w-text_w)/2:y=(h-text_h)/2" \ -frames:v 1 output.png :return: """ date = settings.TODAY.split('-') date = date[2] + '-' + date[1] + '-' + date[0] draw_text = 'drawtext=fontsize=' + str( settings.FONT_SIZE ) + ':fontcolor=white:fontfile=\'' + settings.FONT + '\'' cmd = ffmpeg.get_command() cmd += '-f lavfi ' cmd += '-i color=c=black:s=1920x1080:d=0.5 ' cmd += '-vf "' + draw_text + ':text=\'' + date + '\':x=(w-text_w)/2:y=((h-text_h)/2)+50" ' cmd += '-frames:v 1 ' + settings.DIR_DATA_DATE + '0.png' os.system(cmd) log.info('TITLE SCREEN SAVED.')
def cleanup(data, video_parts, credits_path): """ Delete temporary files :param data: :param video_parts: :param credits_path: :return: """ # delete rescaled images # (0.png, the title screen, is kept for archival) for i, word_data in data.items(): if os.path.exists(settings.DIR_DATA_DATE + str(i + 1) + '-out.png'): os.remove(settings.DIR_DATA_DATE + str(i + 1) + '-out.png') elif os.path.exists(settings.DIR_DATA_DATE + str(i + 1) + '-out.gif'): os.remove(settings.DIR_DATA_DATE + str(i + 1) + '-out.gif') # delete temporary gif color palette file, if it exists if os.path.exists(settings.DIR_DATA_DATE + 'palette.png'): os.remove(settings.DIR_DATA_DATE + 'palette.png') # delete credits video os.remove(credits_path) # delete temporary lossless parts for part in video_parts: os.remove(part) log.info('TEMPORARY DATA DELETED.')
def get(url, word_idx): """ using urlopen to avoid download errors and get content-type although APIs return image types without checking for HTTP errors, one could use 'urllib.request.urlretrieve(url, path)' :param url: :param word_idx: :return: """ try: log.info('Requesting image: ' + str(word_idx + 1)) req = urllib.request.urlopen(url) except urllib.request.URLError as e: log.warning('Image request failed ' + str(e.reason) + ' ' + url) return False except OSError as e: log.warning('Image request failed: ' + type(e).__name__ + ': ' + str(e.strerror) + ' [' + str(e.errno) + '] ' + url) return False content_type = req.info()['Content-Type'] extension = get_extension(content_type) if extension: return { 'name': word_idx + 1, 'image': req, 'extension': extension, 'type': content_type } return False
def run(): from wdpkp import setup from wdpkp import requests from wdpkp.utils import log from wdpkp.movie import image from wdpkp.movie import selection from wdpkp.movie import ffmpeg from wdpkp.movie import text from wdpkp.utils import export # from wdpkp.movie import subtitles # Initialise setup.configure() log.info('PROGRAM START.') log.time('start') # Get input words = setup.get_words() # Make requests and parse responses data = requests.get(words) log.results(data, 'results-full') # Edit the movie data = selection.loop(data) log.results(data, 'results-selection') # adapt images to HD size image.resize_images(data) # make title & credit screens text.title_screen() credits_path = text.credit_screen(data) # srt file, will be read on movie editing # subtitles.create(data) # image > movie editing video_paths = ffmpeg.edit(data) # add the credits and subtitles master = ffmpeg.merge(video_paths, credits_path) # small = ffmpeg.merge_small(video_paths, credits_path) # delete temporary files setup.cleanup(data, video_paths, credits_path) # copy video to HTTP server export.send(master) # export.send(small) log.info('ALL DONE!') log.time('end')
def credit_screen(data): """ Build ffmpeg command for the rolling credit screen: for each line append a drawtext command @see http://stackoverflow.com/questions/11058479/ffmpeg-how-does-moving-overlay-text-command-work ffmpeg -y -f lavfi -i color=c=black:s=1920x1080:d=10 \ -vf drawtext="fontfile='/path/font.ttf':text='Rolling':fontsize=20:fontcolor=white:x=(w-text_w)/2:y=h-20*t" \ output.mp4 :return: """ log.info('CREATING ROLLING CREDITS...') data = _credit_string_list(data) credits_total = len(data) count = -1 duration = 96 cmd = ffmpeg.get_command() cmd += ' -f lavfi -i color=c=black:s=1920x1080:r=24:d=' + str( duration) + ' -vf "' draw_txt_cmds = [] for i in range(0, credits_total): count += 1 credit_sub_lines = [] for j in range(0, len(data[i])): count += 1 credit_sub_lines.append(_credit_line(count, data[i][j])) draw_txt_cmds.append(','.join(credit_sub_lines)) # append last line after a space # 'concept Tessa Groenewoud; code Arnaud Coolsaet' draw_txt_cmds.append(_credit_line_last(count)) # join all the drawtext commands cmd += ','.join(draw_txt_cmds) + '" ' # append output path '{today}/{last-frame-number}.mp4' credits_path = settings.DIR_DATA_DATE + 'credits.mp4' # lossless H264 export cmd += '-an -c:v libx264 -r 24 -preset ultrafast -qp 0 ' + credits_path # using unicode in drawtext text subprocess.call(cmd.encode('utf8'), shell=True) log.info('ROLLING CREDITS SAVED.') return credits_path
def configure(): env_path = settings.DIR + '/.env' load_dotenv(dotenv_path=env_path) os.mkdir(settings.DIR_DATA_DATE) os.mkdir(settings.DIR_VIDEO_DATE) log.configure() settings.URL_BLACKLIST = re.compile(settings.URL_BLACKLIST) log.info('PROGRAM CONFIG OK.')
def save(downloaded): """ Saves image as 'idx.ext' based on HTTP Content-Type :param downloaded: :return: """ log.info('Saving image: ' + str(downloaded['name']) + downloaded['extension']) path = settings.DIR_DATA_DATE + str( downloaded['name']) + downloaded['extension'] with open(path, 'wb') as output: output.write(downloaded['image'].read()) return path
def merge(video_parts, credits_video): """ Merge video, credit video + 3s of black. Previously subtitles were added here through an external .srt file, by the sync was perfect (some timings being very short), the subtitles are now burnt in each separate image. For the record, previous filter_complex: -filter_complex "[0:v][1:v][2:v] concat=n=3:v=1[v0];\ [v0]subtitles=filename=/subtitles.srt:fontsdir=/path/:force_style=\'FontName=Helvetica Neue\,FontSize=16\'[v1]" \ -map "[v1]" :param video_parts: :param credits_video: :return: """ time = datetime.datetime.now() # subtitle_file = settings.DIR_VIDEO_DATE + 'wdpkp-' + settings.TODAY + '.srt' filename = settings.DIR_VIDEO_DATE + 'wdpkp-' + settings.TODAY + '.mp4' log.info('MERGING VIDEO PARTS...') cmd = get_command() # concatenate the video parts num_parts = len(video_parts) stream_ids = '' for i, part in enumerate(video_parts): stream_ids += '[' + str(i) + ':v]' cmd += ' -i "' + part + '"' # add credits + 3s of black # add metadata stream_ids += '[' + str(num_parts) + ':v][' + str(num_parts + 1) + ':v]' cmd += ' -i "' + credits_video + '"' \ + ' -f lavfi -i "color=c=black:s=1920x1080:r=24:d=3" ' \ + ' -filter_complex "' + stream_ids + ' concat=n=' + str(num_parts + 2) + ':v=1[v1]"' \ + ' -map "[v1]"' \ + ' -metadata title="' + settings.TITLE + '" -metadata year="' + str(time.year) + '"' \ + ' -metadata author="Tessa Groenewoud" ' \ + ' -an -pix_fmt yuv420p -r 24 -movflags faststart ' + filename # + '[v0]subtitles=filename=' + subtitle_file + ':fontsdir=' \ # + settings.FONT_DIR + ':force_style=\'FontName=Helvetica Neue\,FontSize=16\'[v1]"' \ os.system(cmd) log.info('MASTER VIDEO SAVED : ' + filename) return filename
def get(words): """ Call APIs synchronously, re-invoke the method if requests failed :param words: :return: """ global times_relaunched failed = 0 for i, val in words.items(): # only continue for previously failed requests if 'api' in words[i]: continue # choose a controller controller = _choose_controller(val['word']) # construct the URL param url = controller.get_url(val['word']) headers = controller.get_http_headers() # make the request response = _make_request(url, headers) # handle response if response: controller_name = controller.__name__.split('.')[-1] # get the returned image URLs parsed = controller.parse(response) if parsed: words[i]['api'] = controller_name words[i]['urls'] = parsed else: log.warning('Nothing parsed from ' + controller_name) failed = 1 else: # failed HTTP requests are logged above failed = 1 # re-request for failed responses if failed and settings.ALLOW_RELAUNCH and settings.RELAUNCH_TIMES >= times_relaunched: times_relaunched += 1 log.info('Relaunching failed requests...') get(words) return words
def _make_request(url, headers): """ Handle API requests and errors :param url: :param headers: :return: """ log.info('FETCHING: ' + url) req = urllib.request.Request(url, None, headers) try: req = urllib.request.urlopen(req) encoding = req.info().get_content_charset('utf-8') data = req.read() return json.loads(data.decode(encoding)) except urllib.request.URLError as e: log.warning('REQUEST FAILED: ' + str(e.reason)) return False
def _edit_part(data, part): """ Our main still image merging function :param data: :return: """ time_codes = get_time_codes() video_path = settings.DIR_VIDEO_DATE + 'wdpkp-' + settings.TODAY + '-part-' + part + '.mp4' num_input_streams = len(data.items()) log.info('START EDITING VIDEO PART ' + part + '...') cmd = get_command() if part == '1': # extract title image stream num_input_streams += 1 cmd += _img_demux(time_codes[0], settings.DIR_DATA_DATE + '0.png') for i, word_data in data.items(): if word_data['type'] != 'image/gif': filename = settings.DIR_DATA_DATE + str(i + 1) + '-out.png' # extract image stream, for non gif images cmd += _img_demux(time_codes[i + 1], filename) else: filename = settings.DIR_DATA_DATE + str(i + 1) + '-out.gif' # extract image stream, for gif images cmd += _img_demux_gif(time_codes[i + 1], filename) # says: 'stitch all images together and map to one video stream' cmd += "-filter_complex 'concat=n=" + str( num_input_streams) + ":v=1[v]' -map '[v]' " # lossless H264 encoding cmd += "-an -c:v libx264 -preset ultrafast -qp 0 -pix_fmt yuv420p -r 24 " + video_path os.system(cmd) log.info('VIDEO PART ' + part + ' EDITED & SAVED.') return video_path
def merge_small(video_parts, credits_video): time = datetime.datetime.now() filename = settings.DIR_VIDEO_DATE + 'wdpkp-' + settings.TODAY + '-320x180.mp4' log.info('MERGING SMALL VIDEO...') cmd = get_command() # concatenate the video parts num_parts = len(video_parts) stream_ids = '' scale_filters = '' for i, part in enumerate(video_parts): scale_filters += '[' + str(i) + ':v]scale=320x180[v' + str(i) + '];' stream_ids += '[v' + str(i) + ']' cmd += ' -i "' + part + '"' # add credits + 3s of black # add metadata scale_filters += '[' + str(num_parts) + ':v]scale=320x180[v' + str( num_parts) + '];' stream_ids += '[v' + str(num_parts) + ']' stream_ids += '[' + str(num_parts + 1) + ':v]' cmd += ' -i "' + credits_video + '"' \ + ' -f lavfi -i "color=c=black:s=320x180:r=24:d=3" ' \ + ' -filter_complex "' + scale_filters + stream_ids + 'concat=n=' + str(num_parts + 2) + ':v=1[v1]"' \ + ' -map "[v1]"' \ + ' -metadata title="' + settings.TITLE + '" -metadata year="' + str(time.year) + '"' \ + ' -metadata author="Tessa Groenewoud" ' \ + ' -an -pix_fmt yuv420p -r 24 -crf 18 -movflags faststart ' + filename os.system(cmd) log.info('SMALL VIDEO SAVED : ' + filename) return filename
def send(file): log.info('EXPORTING VIDEO...') os.system('scp -q ' + file + ' ' + os.getenv("EXPORT_USER") + '@' + os.getenv("EXPORT_HOST") + ':' + os.getenv("EXPORT_DIR"))
def resize_images(data): """ Resize and fit each image in a HD frame, by padding it with black where necessary, this makes the subsequent ffmpeg command to create the full video much simpler. The subtitle text is also burned in here per image (keeping the sync between image and text perfect. Because some pictures have very short timings, ffmpeg couldn't synchronize perfectly using a subtitle file, which was merged on the full video.) Several quality tests were done, jpg had color space issues (black wasn't black), tiff is quite heavy to process... function uses png, below are the test commands: ## png > +/- 3.1MB ffmpeg -i in.jpg -filter:v "scale=iw*min(1920/iw\,1080/ih):ih*min(1920/iw\,1080/ih), \ pad=1920:1080:(1920-iw*min(1920/iw\,1080/ih))/2:(1080-ih*min(1920/iw\,1080/ih))/2:black, \ setsar=1/1, setdar=16/9" \ out.png ## jpg > quality -q:v 1 (max) - 31 (min) > padded black is not 100% black ffmpeg -i in.jpg -filter:v "scale=iw*min(1920/iw\,1080/ih):ih*min(1920/iw\,1080/ih), \ pad=1920:1080:(1920-iw*min(1920/iw\,1080/ih))/2:(1080-ih*min(1920/iw\,1080/ih))/2:0x000000, \ setsar=1/1, setdar=16/9" \ -q:v 1 -pix_fmt yuvj420p out.jpg ## jpg > better ffmpeg -f lavfi -i color=c=black:s=1920x1080 -i in.jpg \ -filter_complex "[1:v]scale=..., setsar=1/1, setdar=16/9[ovr];[ovr]blend=all_mode='normal'[v]" \ -map '[v]' -q:v 1 -pix_fmt yuvj420p out.jpg ## tiff > best > +/- 6MB ffmpeg -i in.jpg -filter:v "scale=..., setsar=1/1, setdar=16/9" -compression_algo raw -pix_fmt rgb24 out.tiff @see http://superuser.com/questions/881783/convert-from-avi-to-uncompressed-tiff-using-ffmpeg :param data: :return: """ draw_text = 'drawtext=fontsize=40:fontcolor=white:borderw=2:fontfile=\'' + settings.FONT_BOLD + '\'' cmd = '' log.info('RESCALING IMAGES...') for i, word_data in data.items(): if word_data['type'] == 'image/gif': # save as gif filename = settings.DIR_DATA_DATE + str(i + 1) + '-out.gif' cmd += _resize_gif_cmd(word_data['file']) \ + '[v0]' + draw_text + ':text=\'' + word_data['punc'] + '\':x=(w-text_w)/2:y=(h-110)[v1]"' \ + ' -map "[v1]" -pix_fmt rgb8 ' + filename + ';' else: # save as png filename = settings.DIR_DATA_DATE + str(i + 1) + '-out.png' cmd += ffmpeg.get_command() \ + ' -i ' + word_data['file'] \ + ' -filter_complex "' + _get_scale_args() + '[v0];' \ + '[v0]' + draw_text + ':text=\'' + word_data['punc'] + '\':x=(w-text_w)/2:y=(h-110)[v1]"' \ + ' -map "[v1]" -pix_fmt rgb24 ' + filename + ';' # store tmp path data[i].update({'tmp': filename}) # run all rescaling commands at once subprocess.call(cmd.encode('utf8'), shell=True) log.info('IMAGES RESCALED.')