Created
July 16, 2013 03:05
-
-
Save robmathers/6005451 to your computer and use it in GitHub Desktop.
Converts mkvs with h.264 video into m4v containers. Needs mp4box, mediainfo, ffmpeg and probably some other command line tools installed (most of which should be installable via homebrew).
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
#!/usr/bin/env python | |
import sys | |
import os | |
import subprocess | |
from pymediainfo import MediaInfo | |
from glob import glob | |
import shlex | |
from fractions import Fraction | |
import ssatosrt | |
import atexit | |
# Error codes: | |
# 3: No MKV found | |
# 4: Video track error | |
# 5: Audio track error | |
# 6: Missing tools | |
# Configuration | |
includeSurroundTrack = True | |
chooseExistingDefaultTrack = False # can lead to choosing ac3 over aac if True | |
mkvFilename = os.path.abspath(sys.argv[1]) | |
filenameNoExt = os.path.splitext(mkvFilename)[0] | |
# Function to escape characters in glob filename, from http://bugs.python.org/msg147434 | |
def escape_glob(path): | |
import re | |
transdict = { | |
'[': '[[]', | |
']': '[]]', | |
'*': '[*]', | |
'?': '[?]', | |
} | |
rc = re.compile('|'.join(map(re.escape, transdict))) | |
return rc.sub(lambda m: transdict[m.group(0)], path) | |
# Function to test if executables exist, from http://stackoverflow.com/questions/377017/test-if-executable-exists-in-python | |
def which(program): | |
import os | |
def is_exe(fpath): | |
return os.path.isfile(fpath) and os.access(fpath, os.X_OK) | |
fpath, fname = os.path.split(program) | |
if fpath: | |
if is_exe(program): | |
return program | |
else: | |
for path in os.environ["PATH"].split(os.pathsep): | |
path = path.strip('"') | |
exe_file = os.path.join(path, program) | |
if is_exe(exe_file): | |
return exe_file | |
return None | |
# Set handler to remove files | |
def cleanUpFiles(): | |
filesToRemove = glob(escape_glob(filenameNoExt) + '_*') + [filenameNoExt + '.264'] + [filenameNoExt + '.chapters.txt'] | |
for path in filesToRemove: | |
try: | |
os.remove(path) | |
except OSError as err: | |
if err.errno == 2: | |
pass | |
else: | |
raise | |
# Register cleanUpFiles to run on exit | |
atexit.register(cleanUpFiles) | |
mkvInfo = MediaInfo.parse(mkvFilename) | |
generalTrack = [track for track in mkvInfo.tracks if track.track_type == 'General'][0] | |
if generalTrack.format != 'Matroska': | |
print 'Input file is not MKV' | |
sys.exit(3) | |
if len([track for track in mkvInfo.tracks if track.track_type == 'Video']) == 0: | |
print 'No video track found' | |
sys.exit(4) | |
elif len([track for track in mkvInfo.tracks if track.track_type == 'Video']) > 1: | |
print 'More than one video track found' | |
sys.exit(4) | |
videoTrack = [track for track in mkvInfo.tracks if track.track_type == 'Video'][0] | |
if videoTrack.codec_family != 'AVC': | |
print 'Video is not h.264' | |
sys.exit(4) | |
audioTracks = [track for track in mkvInfo.tracks if track.track_type == 'Audio'] | |
if len(audioTracks) < 1: | |
print 'No audio tracks found' | |
sys.exit(5) | |
# Parse Audio Tracks | |
for track in audioTracks: | |
if track.codec_family == 'AAC': | |
# keep all aac tracks | |
track.include = True | |
track.encode = False | |
elif len(audioTracks) == 1: | |
# keep and encode a track if it is the only track (and by virtue of condition above, non-acc) | |
track.include = True | |
track.encode = True | |
elif track.title == 'Commentary': | |
# encode commentary tracks to aac, but don't include originals | |
track.include = False | |
track.encode = True | |
elif len([aTrack for aTrack in audioTracks if aTrack.codec_family == 'AAC' and aTrack.title != 'Commentary']) > 0: | |
# if there are other non-commentary aac tracks, include original, don't encode | |
track.include = True | |
track.encode = False | |
else: | |
# encode and include original for anything else | |
track.include = True | |
track.encode = True | |
if track.include and track.codec_family != 'AAC' and track.channel_s <= 2: | |
# don't include non-aac stereo tracks | |
track.include = False | |
# Determine default track | |
if len(audioTracks) == 1: | |
defaultAudioTrack = audioTracks[0] | |
else: | |
defaultAudioTrack = None | |
if defaultAudioTrack == None: | |
if len([track for track in audioTracks if track.default == 'Yes']) == 1 and chooseExistingDefaultTrack: | |
# take the existing default, if there is one | |
defaultAudioTrack = [track for track in audioTracks if track.default == 'Yes'][0] | |
else: | |
if [track for track in audioTracks if track.codec_family == 'AAC' and 'Commentary' not in track.title]: | |
# if there is a non-commentary AAC track | |
defaultAudioTrack = [track for track in audioTracks if track.codec_family == 'AAC' and 'Commentary' not in track.title][0] | |
elif [track for track in audioTracks if 'Commentary' not in track.title]: | |
# take the first non-commentary track | |
defaultAudioTrack = [track for track in audioTracks if 'Commentary' not in track.title][0] | |
else: | |
# take the first audio track if all else fails | |
defaultAudioTrack = audioTracks[0] | |
# Get subtitle tracks | |
subtitleTracksToExtract = [track for track in mkvInfo.tracks if track.track_type=='Text' and track.language in ['en'] and track.codec_id in ['S_TEXT/UTF8', 'S_TEXT/SSA', 'S_TEXT/ASS']] | |
# Extract tracks | |
extractCmd = ['mkvextract', 'tracks', mkvFilename, str(videoTrack.track_id) + ':' + filenameNoExt + '.264'] | |
for track in [aTrack for aTrack in audioTracks if aTrack.encode or aTrack.include]: | |
extractCmd += [str(track.track_id) + ':' + filenameNoExt + '_' + str(track.track_id) + '.' + track.codec_family] | |
for track in subtitleTracksToExtract: | |
if track.codec_id == 'S_TEXT/UTF8': | |
subExtension = '.srt' | |
else: | |
subExtension = '.sub' | |
extractCmd += [str(track.track_id) + ':' + filenameNoExt + '_' + str(track.track_id) + subExtension] | |
subprocess.call(extractCmd) | |
# Get chapters | |
if len([track for track in mkvInfo.tracks if track.track_type == 'Menu']) > 0: | |
chaptersFile = open(filenameNoExt + '.chapters.txt', 'w') | |
chaptersExtract = subprocess.Popen(['mkvextract', 'chapters', mkvFilename, '-s'], stdout = chaptersFile) | |
chaptersExtract.wait() | |
chaptersFile.close() | |
chaptersExist = True | |
else: | |
chaptersExist = False | |
# Process audio | |
audioTracksToMux = [] | |
def encodeAAC(track): | |
if which('neroAacEnc') != None: | |
decoderParameters = shlex.split('-acodec pcm_s16le -ac 2 -f wav -') | |
decoder = ['ffmpeg', '-i', filenameNoExt + '_' + str(track.track_id) + '.' + track.codec_family] + decoderParameters | |
encoderParameters = shlex.split('neroAacEnc -lc -br 160000 -ignorelength -if - -of') | |
encoder = encoderParameters + [filenameNoExt + '_' + str(track.track_id) + '.AAC'] | |
decoderProc = subprocess.Popen(decoder, stdout = subprocess.PIPE) | |
encoderProc = subprocess.Popen(encoder, stdin = decoderProc.stdout) | |
decoderProc.stdout.close() | |
encoderProc.wait() | |
elif which('afconvert') != None: | |
decoderParameters = shlex.split('-acodec pcm_s16le -ac 2 -f wav') | |
decoder = ['ffmpeg', '-i', filenameNoExt + '_' + str(track.track_id) + '.' + track.codec_family] + decoderParameters + [filenameNoExt + '_' + str(track.track_id) + '.wav'] | |
subprocess.call(decoder) | |
print 'Starting encode with afconvert...' | |
encoderParameters = shlex.split('afconvert -b 160000 -f m4af') | |
encoder = encoderParameters + [filenameNoExt + '_' + str(track.track_id) + '.wav', filenameNoExt + '_' + str(track.track_id) + '.AAC'] | |
subprocess.call(encoder) | |
else: | |
print 'No AAC encoder found' | |
sys.exit(6) | |
def encodeDTS(track): | |
# Sample DTS to AC3 command: | |
# dcadec -o wavall "$pathNoExt".dts | aften -v 0 - "$pathNoExt".ac3 | |
if which('aften') != None: | |
if which('dcadec') != None: | |
# do decoding here | |
decoder = ['dcadec', '-o', 'wavall', filenameNoExt + '_' + str(track.track_id) + '.' + track.codec_family] | |
print 'no dcadec yet' | |
else: | |
# ffmpeg | |
decoder = ['ffmpeg', '-i', filenameNoExt + '_' + str(track.track_id) + '.' + track.codec_family] + shlex.split('-acodec pcm_s16le -f wav -') | |
encoder = ['aften', '-v', '0', '-', filenameNoExt + '_' + str(track.track_id) + '.AC3'] | |
# pipe commands together | |
decoderProc = subprocess.Popen(decoder, stdout = subprocess.PIPE) | |
encoderProc = subprocess.Popen(encoder, stdin = decoderProc.stdout) | |
decoderProc.stdout.close() | |
encoderProc.wait() | |
else: | |
print 'No AC3 encoder found' | |
sys.exit(6) | |
print 'Tracks to encode:' | |
print [track for track in audioTracks if track.encode] | |
print 'Tracks to include:' | |
print [track for track in audioTracks if track.include] | |
for track in audioTracks: | |
# Protect against errors on non-existent title items | |
if track.title == None: | |
track.title = '' | |
if track.encode: | |
encodeAAC(track) | |
track.encodeFile = filenameNoExt + '_' + str(track.track_id) + '.AAC' | |
# Set track titles | |
if not 'Commentary' in track.title: | |
track.encodeTitle = 'Stereo' | |
else: | |
track.encodeTitle = track.title | |
if track.include: | |
if track.codec_family in ['AAC', 'AC3', 'DTS']: | |
if track.codec_family == 'DTS': | |
encodeDTS(track) | |
track.includeFile = filenameNoExt + '_' + str(track.track_id) + '.AC3' | |
else: | |
track.includeFile = filenameNoExt + '_' + str(track.track_id) + '.' + track.codec_family | |
# Set track titles | |
if not 'Commentary' in track.title: | |
if track.channel_s == 1: | |
track.includeTitle = 'Mono' | |
elif track.channel_s == 2: | |
track.includeTitle = 'Stereo' | |
else: | |
track.includeTitle = 'Surround' | |
else: | |
track.includeTitle = track.title | |
else: | |
track.includeFile = None | |
print 'WARNING: Didn\'t include audio track', str(track.track_id), 'because non-AC3/AAC isn\'t supported currently.' | |
# Get Frame Rate | |
if videoTrack.frame_rate != None: | |
fps = videoTrack.frame_rate | |
elif videoTrack.original_frame_rate != None: | |
fps = videoTrack.original_frame_rate | |
else: | |
print "Couldn't find original FPS, assuming 23.976 fps" | |
fps = 23.976 | |
# Build mp4 | |
mp4Cmd = ['MP4Box', '-new', filenameNoExt + '.m4v', '-add', filenameNoExt + '.264', '-fps', fps] | |
# Make PAR corrections if necessary | |
if float(videoTrack.pixel_aspect_ratio) != 1: | |
darValues = videoTrack.other_display_aspect_ratio[0].split(':') | |
dar = Fraction(int(darValues[0]), int(darValues[1])) | |
sar = Fraction(videoTrack.width, videoTrack.height) | |
par = dar/sar | |
# video is always track one, so PAR adjustment is set for that | |
mp4Cmd += ['-par', '1=' + str(par.numerator) + ':' + str(par.denominator)] | |
# Add audio tracks | |
titleList = [] # For adding audio titles after building mp4 | |
# Add default track first | |
if defaultAudioTrack.encode: | |
mp4Cmd += ['-add', defaultAudioTrack.encodeFile + ':name=' + defaultAudioTrack.encodeTitle + ':group=1'] | |
if defaultAudioTrack.language == None: | |
mp4Cmd[-1] += ':lang=en' | |
else: | |
mp4Cmd[-1] += ':lang=' + defaultAudioTrack.language | |
# Add to title list | |
titleList += [defaultAudioTrack.encodeTitle] | |
if defaultAudioTrack.include: | |
mp4Cmd += ['-add', defaultAudioTrack.includeFile + ':name=' + defaultAudioTrack.includeTitle + ':group=1'] | |
if defaultAudioTrack.language == None: | |
mp4Cmd[-1] += ':lang=en' | |
else: | |
mp4Cmd[-1] += ':lang=' + defaultAudioTrack.language | |
# disable track if there's already an encoded version | |
if defaultAudioTrack.encode: | |
mp4Cmd[-1] += ':disable' | |
# Add to title list | |
titleList += [defaultAudioTrack.includeTitle] | |
# Add other tracks | |
for track in [aTrack for aTrack in audioTracks if aTrack != defaultAudioTrack]: | |
# Add encoded tracks | |
if track.encode: | |
mp4Cmd += ['-add', track.encodeFile + ':name=' + track.encodeTitle + ':group=1'] | |
# Set language | |
if track.language == None: | |
mp4Cmd[-1] += ':lang=en' | |
else: | |
mp4Cmd[-1] += ':lang=' + track.language | |
mp4Cmd[-1] += ':disable' | |
# Add to title list | |
titleList += [track.encodeTitle] | |
if track.include and track.includeFile != None: | |
mp4Cmd += ['-add', track.includeFile + ':name=' + track.includeTitle + ':group=1'] | |
# Set language | |
if track.language == None: | |
mp4Cmd[-1] += ':lang=en' | |
else: | |
mp4Cmd[-1] += ':lang=' + track.language | |
mp4Cmd[-1] += ':disable' | |
# Add to title list | |
titleList += [track.includeTitle] | |
# Add subtitles | |
for track in subtitleTracksToExtract: | |
subsFile = filenameNoExt + '_' + str(track.track_id) | |
if track.codec_id != 'S_TEXT/UTF8': | |
ssatosrt.main(subsFile + '.sub', subsFile + '.srt') | |
mp4Cmd += ['-add', subsFile + '.srt' + ':hdlr=sbtl:lang=en:group=2:layer=-1'] | |
# Write MP4 | |
subprocess.call(mp4Cmd) | |
# Write audio track titles | |
titleIndexOffset = 2 # audio tracks start at track 2 | |
for title in titleList: | |
subprocess.call(['mp4track', '--track-id', str(titleList.index(title) + titleIndexOffset), '--udtaname', title, filenameNoExt + '.m4v']) | |
# Write chapters | |
if chaptersExist: | |
subprocess.call(['mp4chaps', '-i', filenameNoExt + '.m4v']) |
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
# Converts SSA and ASS subtitles to SRT format. Only supports UTF-8. | |
# Output may differ from Aegisub's exporter. | |
# | |
# Requires Python 2.7+. Python 3.0 updates by RiCON. | |
# | |
# Copyright 2010, 2012 by Poor Coding Standards. All Rights Reserved | |
# from http://doom10.org/index.php?topic=916.0 | |
# fixed to use unicode() for python 2.6 compatibility | |
import codecs | |
import os.path | |
import sys | |
from copy import copy | |
from datetime import datetime | |
class SSADialogueEvent(object): | |
'''Container for a single line of an SSA script.''' | |
def __init__(self, line): | |
'''Reads timecodes and text from line.''' | |
try: | |
parts = line.split(': ', 1) | |
eventType = parts[0] | |
eventBody = parts[1] | |
if not eventType == 'Dialogue': | |
raise ValueError('Not a dialogue event: %s' % line) | |
fields = eventBody.split(',', 9) | |
start = fields[1] | |
end = fields[2] | |
text = fields[-1] | |
except IndexError: | |
raise ValueError('Parsing error: %s' % line) | |
self.start = datetime.strptime(start, '%H:%M:%S.%f') | |
self.end = datetime.strptime(end, '%H:%M:%S.%f') | |
self.text = text | |
def convert_tags(self): | |
'''Returns SRT-styled text. Does not inherit from styles.''' | |
equivs = {'i1':'<i>', 'i0':'</i>', 'b1':'<b>', 'b0':'</b>', \ | |
'u1':'<u>', 'u0':'</u>', 's1':'<s>', 's0':'</s>'} | |
# Parse the text one character at a time, looking for {}. | |
parsed = [] | |
currentTag = [] | |
tagIsOpen = False | |
for i in self.text: | |
if not tagIsOpen: | |
if i != '{': | |
parsed.append(i) | |
else: | |
tagIsOpen = True | |
else: | |
if i != '}': | |
currentTag.append(i) | |
else: | |
tagIsOpen = False | |
tags = ''.join(currentTag).split('\\') | |
for j in tags: | |
if j in equivs: | |
parsed.append(equivs[j]) | |
currentTag = [] | |
line = ''.join(parsed) | |
# Replace SSA literals with the corresponding ASCII characters. | |
line = line.replace('\\N', '\n').replace('\\n', '\n').replace('\\h', ' ') | |
return line | |
def out_srt(self, index): | |
'''Converts event to an SRT subtitle.''' | |
# datetime stores microseconds, but SRT/SSA use milliseconds. | |
srtStart = self.start.strftime('%H:%M:%S.%f')[0:-3].replace('.', ',') | |
srtEnd = self.end.strftime('%H:%M:%S.%f')[0:-3].replace('.', ',') | |
srtEvent = str(index) + '\r\n' \ | |
+ srtStart + ' --> ' + srtEnd + '\r\n' \ | |
+ self.convert_tags() + '\r\n' | |
return srtEvent | |
def __repr__(self): | |
return self.out_srt(1) | |
def resolve_stack(stack, out, tcNext): | |
'''Resolves cases of overlapping events, as SRT does not allow them.''' | |
stack.sort(key=cmp_to_key(end_cmp)) | |
stackB = [stack.pop(0)] | |
# Combines lines with identical timing. | |
while stack: | |
prevEvent = stackB[-1] | |
currEvent = stack.pop(0) | |
if prevEvent.end == currEvent.end: | |
prevEvent.text += '\\N' + currEvent.text | |
else: | |
stackB.append(currEvent) | |
while stackB: | |
top = stackB[0] | |
combinedText = '\\N'.join([i.text for i in stackB]) | |
if top.end <= tcNext: | |
stackB[0].text = combinedText | |
out.append(stackB.pop(0)) | |
for i in stackB: | |
i.start = top.end | |
else: | |
final = copy(top) | |
final.text = combinedText | |
final.end = tcNext | |
out.append(final) | |
# Copy back to stack, which is from a different namespace. | |
for i in stackB: | |
i.start = tcNext | |
break | |
return stackB | |
def cmp_to_key(mycmp): | |
'''Convert a cmp= function into a key= function.''' | |
class K(object): | |
def __init__(self, obj, *args): | |
self.obj = obj | |
def __lt__(self, other): | |
return mycmp(self.obj, other.obj) < 0 | |
def __gt__(self, other): | |
return mycmp(self.obj, other.obj) > 0 | |
def __eq__(self, other): | |
return mycmp(self.obj, other.obj) == 0 | |
def __le__(self, other): | |
return mycmp(self.obj, other.obj) <= 0 | |
def __ge__(self, other): | |
return mycmp(self.obj, other.obj) >= 0 | |
def __ne__(self, other): | |
return mycmp(self.obj, other.obj) != 0 | |
return K | |
# Comparison functions for sorting. | |
start_cmp = lambda a, b: (a.start > b.start) - (a.start < b.start) | |
end_cmp = lambda a, b: (a.end > b.end) - (a.end < b.end) | |
def main(infile, outfile): | |
'''Convert the SSA/ASS file infile into the SRT file outfile.''' | |
stream = codecs.open(infile, 'r', 'utf8') | |
sink = codecs.open(outfile, 'w', 'utf8') | |
# HACK: Handle UTF-8 files with Byte-Order Markers. | |
if stream.read(1) == unicode(codecs.BOM_UTF8, "utf8"): | |
stream.seek(3) | |
else: | |
stream.seek(0) | |
# Parse the stream one line at a time. | |
events = [] | |
for i in stream: | |
text = i.strip() | |
try: | |
events.append(SSADialogueEvent(text)) | |
except ValueError: | |
continue | |
events.sort(key=cmp_to_key(start_cmp)) | |
stack = [] | |
merged = [] | |
while events: | |
currEvent = events.pop(0) | |
if not stack: | |
stack.append(currEvent) | |
continue | |
if currEvent.start != stack[-1].start: | |
stack = resolve_stack(stack, merged, currEvent.start) | |
stack.append(currEvent) | |
else: | |
if stack: | |
stack = resolve_stack(stack, merged, stack[-1].end) | |
# Write the file. SRT requires each event to be numbered. | |
index = 1 | |
sink.write(unicode(codecs.BOM_UTF8, 'utf8')) | |
for i in merged: | |
# The overlap resolution can create zero-length lines. | |
if i.start != i.end: | |
sink.write(i.out_srt(index) + '\r\n') | |
index += 1 | |
stream.close() | |
sink.close() | |
# Read command line arguments. | |
if __name__ == "__main__": | |
try: | |
infile = sys.argv[1] | |
try: | |
outfile = sys.argv[2] | |
except IndexError: | |
outfile = os.path.splitext(infile)[0] + '.srt' | |
except: | |
script_name = os.path.basename(sys.argv[0]) | |
sys.stderr.write('Usage: {0} infile [outfile]\n'.format(script_name)) | |
sys.exit(2) | |
main(infile, outfile) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I'm having an issue with pymediainfo. I installed it from pip, but got this error: