Last active
March 10, 2023 13:44
-
-
Save archagon/5234347 to your computer and use it in GitHub Desktop.
A quick and dirty script to convert GDC Vault videos to a pleasant mobile viewing format.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This script continues the work of gdc-downloader.py and actually combines the video and audio | |
# into a single video file. The underlay.png file specifies the dimensions of the video. | |
# Personally, I use an all-black 1024x768 rectangle for optimal iPad viewing. | |
# As with gdc-downloader.py, code quality is crappy and quickly assembled to work for my | |
# nefarious purposes. | |
# Usage is as follows: | |
# | |
# gdc-encoder.py [video name] [video path] [output name] [GDC name] | |
# | |
# Example call: python gdc-encoder.py CoolGDCVideo2011 ~/Videos/GDC/CoolVideo/ CoolGDCVideoIpad "GDC 2012E" | |
# You need to have ffmpeg installed in order for this script to work. I recommend macports. If you | |
# port install ffmpeg with the +nonfree option, you'll get access to the faac encoder instead of the | |
# default experimental one. | |
# If you want chapter or Media Kind support for optimal iPhone/iPad viewing pleasure, | |
# be sure to install SublerCLI and point the constant below to the binary. (Note: this currently | |
# doesn't work for me, so I do this step manually with the Subler GUI.) | |
# It seems that chapter marks only work if the Media Kind (which you can set in Subler) is | |
# set to one of the Movie types. If the Media Kind is set to TV Show, then you can't use | |
# the chapter marks in Apple's iOS video player. | |
############# | |
# Constants # | |
############# | |
subler_path = "/This/Is/A/Test/Path/SublerCLI" | |
underlay_path = "/Users/archagon/Dropbox/Programming" | |
underlay_name = "underlay.png" | |
language_codes = {"en":"eng", "jp":"jpn"} | |
######## | |
# Code # | |
######## | |
import sys | |
import os | |
import subprocess | |
import re | |
import datetime | |
import time | |
import dateutil.parser as parser | |
from xml.dom import minidom | |
def error(message): | |
print "[gdc-downloader] Error: " + message | |
sys.exit(1) | |
def message(msg): | |
print "[gdc-downloader] Message: " + msg | |
def assert_file_exists(filename, path): | |
abs_path = os.path.join(os.path.abspath(path), filename) | |
if not os.path.exists(abs_path): | |
error(abs_path + " not found") | |
def check_dependencies(): | |
assert_file_exists(underlay_name, underlay_path) | |
# TODO: check ffmpeg, subler | |
def seconds_from_HHMMSS(string): | |
split_string = string.split(':') | |
return int(split_string[0]) * 3600 + int(split_string[1]) * 60 + int(split_string[2]) | |
def seconds_to_HHMMSS(seconds_total): | |
seconds = seconds_total % 60 | |
minutes = (seconds_total / 60) % 60 | |
hours = (seconds_total / (60 * 60)) | |
output_string = "%02d" % hours | |
output_string += ":" | |
output_string += "%02d" % minutes | |
output_string += ":" | |
output_string += "%02d" % seconds | |
return output_string | |
def parse_metadata(filename_xml, season_name): | |
metadata = {} | |
xml_file = open(filename_xml, "r") | |
xml = xml_file.read() | |
xml_file.close() | |
parsed_xml = minidom.parseString(xml) | |
if not parsed_xml: | |
return metadata | |
metadata_xml = parsed_xml.getElementsByTagName("metadata") | |
if len(metadata_xml) != 1: | |
return metadata | |
metadata_xml = metadata_xml[0] | |
# these tags will go directly into the metadata | |
if (len(metadata_xml.getElementsByTagName("title")) == 1): | |
title = metadata_xml.getElementsByTagName("title")[0].firstChild.nodeValue | |
metadata["title"] = title | |
if (len(metadata_xml.getElementsByTagName("speaker")) == 1): | |
artist = metadata_xml.getElementsByTagName("speaker")[0].firstChild.nodeValue | |
metadata["artist"] = artist | |
if (len(metadata_xml.getElementsByTagName("date")) == 1): | |
date = metadata_xml.getElementsByTagName("date")[0].firstChild.nodeValue | |
date_parsed = (parser.parse(date)) | |
metadata["date"] = date_parsed.isoformat() | |
if (len(metadata_xml.getElementsByTagName("id")) == 1): | |
id = metadata_xml.getElementsByTagName("id")[0].firstChild.nodeValue | |
metadata["episode_id"] = id | |
# these tags are for calculating the start offset in the script; convert from HH:MM:SS to seconds | |
if (len(metadata_xml.getElementsByTagName("startTime")) == 1): | |
start_time = metadata_xml.getElementsByTagName("startTime")[0].firstChild.nodeValue | |
metadata["!start_time"] = seconds_from_HHMMSS(start_time) | |
if (len(metadata_xml.getElementsByTagName("endTime")) == 1): | |
end_time = metadata_xml.getElementsByTagName("endTime")[0].firstChild.nodeValue | |
metadata["!end_time"] = seconds_from_HHMMSS(end_time) | |
# a few global tags | |
# metadata["album_artist"] = season_name | |
metadata["album"] = season_name | |
metadata["show"] = season_name | |
metadata["network"] = "Game Developers Conference" | |
# chapters for later subler step | |
metadata["!chapters"] = "" | |
chapters_xml = parsed_xml.getElementsByTagName("chapters") | |
if len(chapters_xml) != 1: | |
return metadata | |
chapters_xml = chapters_xml[0] | |
chapters_string = "" | |
chapters = chapters_xml.getElementsByTagName("chapter") | |
index = 0 | |
for chapter in chapters: | |
time = None | |
title = None | |
if (len(chapter.getElementsByTagName("time")) == 1): | |
time_sec = seconds_from_HHMMSS(chapter.getElementsByTagName("time")[0].firstChild.nodeValue) | |
# offset chapters by startTime (since we'll be cropping the video) | |
if metadata["!start_time"]: | |
time_sec = time_sec - metadata["!start_time"] | |
# make sure there's no negative time | |
if time_sec < 0: | |
time_sec = 0 | |
time = seconds_to_HHMMSS(time_sec) | |
# we need an entry for time 0 for iOS compliance | |
if index == 0 and time_sec != 0: | |
chapters_string += "00:00:00.000 Start\n" | |
if (len(chapter.getElementsByTagName("title")) == 1): | |
title = chapter.getElementsByTagName("title")[0].firstChild.nodeValue | |
if time and title: | |
chapters_string += time + ".000 " | |
chapters_string += title | |
chapters_string += '\n' | |
index += 1 | |
metadata["!chapters"] = chapters_string | |
return metadata | |
def does_ffmpeg_have_enc(enc): | |
info_args = ["ffmpeg", "-encoders"] | |
info_call = subprocess.Popen(info_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
info_return = info_call.wait() | |
info_out, info_err = info_call.communicate() | |
if info_return != 0: | |
warning("ffmpeg returned a non-zero error code") | |
for line in info_out.splitlines(): | |
if line.find(" " + enc + " ") != -1: | |
return True | |
return False | |
# audio is a list with pairs of audiocode and filepath | |
def ffmpeg_combine(speaker, slides, output, audio, metadata): | |
args = ["ffmpeg"] | |
# speaker input (default audio) | |
args.append("-i") | |
args.append(speaker) | |
# slides input | |
args.append("-i") | |
args.append(slides) | |
# underlay input | |
args.append("-loop") | |
args.append("1") | |
args.append("-i") | |
args.append(os.path.join(os.path.abspath(underlay_path), underlay_name)) | |
# optional audio inputs | |
if audio: | |
for [audio_code, audio_file] in audio: | |
args.append("-i") | |
args.append(audio_file) | |
# overlay the two videos over the underlay via complex filtergraph | |
args.append("-filter_complex") | |
# filtergraph = "\"" | |
filtergraph = "[2:0] [0:0] overlay=20:main_h/2-overlay_h/2 [overlay];" | |
filtergraph += "[overlay] [1:0] overlay=main_w-overlay_w-20:main_h/2-overlay_h/2 [output]" | |
# filtergraph += "\"" | |
args.append(filtergraph) | |
# map video + metadata | |
args.append("-map") | |
args.append("[output]:v") | |
args.append("-metadata:s:0") | |
args.append("language=eng") | |
# map original audio + metadata | |
args.append("-map") | |
args.append("0:a") | |
args.append("-metadata:s:1") | |
args.append("language=eng") | |
# args.append("-metadata:s:1") | |
# args.append("title=Default Audio") | |
# map optional audio + metadata | |
if audio: | |
current_input_stream = 3 | |
current_output_stream = 2 | |
for [audio_code, audio_file] in audio: | |
args.append("-map") | |
args.append(str(current_input_stream) + ":a") | |
args.append("-metadata:s:" + str(current_output_stream)) | |
args.append("language=" + audio_code) | |
current_input_stream += 1 | |
current_output_stream += 1 | |
# map the global metadata | |
if metadata: | |
for key in metadata: | |
if not key.startswith("!"): | |
args.append("-metadata") | |
args.append(key + "=" + metadata[key]) | |
# use experimental aac if libfaac is unavailable | |
if not does_ffmpeg_have_enc("libfaac"): | |
args.append("-strict") | |
args.append("experimental") | |
# up the frequency so it plays on iDevices | |
args.append("-ar") | |
args.append("44100") | |
# crop the video in accordance with the start and end times | |
if "!start_time" in metadata: | |
seconds = metadata["!start_time"] | |
args.append("-ss") | |
args.append(str(seconds)) | |
if "!start_time" in metadata and "!end_time" in metadata: | |
start_seconds = metadata["!start_time"] | |
end_seconds = metadata["!end_time"] | |
args.append("-t") | |
args.append(str(end_seconds - start_seconds)) | |
# args.append("-shortest") | |
args.append(output) | |
# ffmpeg is a hairy beast | |
message("ffmpeg call: " + str(args)) | |
try: | |
retval = subprocess.call(args, stdin=None) | |
except Exception, e: | |
error("ffmpeg error") | |
def create_chapters_file(chapters_file, chapters_data): | |
chapters = open(chapters_file, "w") | |
chapters.write(chapters_data) | |
chapters.close() | |
# def subler_process(filename_input, filename_output, chapters_file): | |
# subler_abs_path = os.path.abspath(subler_path) | |
# subler_args = [subler_abs_path] | |
# subler_args.append("-source") | |
# subler_args.append(filename_input) | |
# subler_args.append("-dest") | |
# subler_args.append(filename_output) | |
# subler_args.append("-optimize") | |
# subler_args.append("-metadata") | |
# subler_args.append("{Media Kind:TV Show}") | |
# print subler_args | |
# try: | |
# retval = subprocess.call(subler_args, stdin=None) | |
# except Exception, e: | |
# error("subler error: " + str(e)) | |
# # TODO: remove old vid | |
def encode_gdc_video(dir, name, out, gdc_name): | |
dest_path = os.path.abspath(dir) | |
filename_xml = os.path.join(os.path.abspath(dir), name + ".xml") | |
filename_slides = os.path.join(os.path.abspath(dir), name + "-slide.flv") | |
filename_speaker = os.path.join(os.path.abspath(dir), name + "-speaker.flv") | |
filename_output = os.path.join(os.path.abspath(dir), out + ".m4v") | |
filename_subler_output = os.path.join(os.path.abspath(dir), out + "-subl.m4v") | |
chapters_file = os.path.join(os.path.abspath(dir), out + "-chapters.txt") | |
assert_file_exists(name + ".xml", dir) | |
audios = [["en", os.path.join(os.path.abspath(dir), name + "-audio-en.flv")], ["jp", os.path.join(os.path.abspath(dir), name + "-audio-jp.flv")]] | |
for pair in audios: | |
code = pair[0] | |
if code in language_codes: | |
pair[0] = language_codes[code] | |
# Step 0: Check dependencies. | |
check_dependencies() | |
# Step 1: Parse the metadata. | |
metadata = parse_metadata(filename_xml, gdc_name) | |
# Step 2: Combine all the tracks into one m4v file in ffmpeg. | |
combined_file = ffmpeg_combine(filename_speaker, filename_slides, filename_output, audios, metadata) | |
# Step 3: Subler niceification. | |
if subler_path: | |
# Step 1: Create chapters file. | |
create_chapters_file(chapters_file, metadata["!chapters"]) | |
# Step 2: Subler it. | |
# TODO: SublerCLI currently throws an error when I try to use it | |
# subler_process(filename_output, filename_subler_output, chapters_file) | |
message("All done!") | |
if __name__ == "__main__": | |
if len(sys.argv) == 5: | |
try: | |
encode_gdc_video(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]) | |
except KeyboardInterrupt: | |
error("program interrupted") | |
else: | |
error("invalid number of arguments") | |
# TODO: proper inputs | |
# TODO: get rid of test paths | |
# TODO: correct video dimensions | |
# TODO: chapters don't show up? |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment