Skip to content

Instantly share code, notes, and snippets.

@robmathers
Created July 16, 2013 03:05
Show Gist options
  • Save robmathers/6005451 to your computer and use it in GitHub Desktop.
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).
#!/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'])
# 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)
@BreakerOfHalos
Copy link

I'm having an issue with pymediainfo. I installed it from pip, but got this error:

Traceback (most recent call last):
  File "~/Downloads/gist6005451-ba8d7108c9b2946a2916f47897d4e7c8467b6063/mkvconverter.py", line 6, in <module>
    from pymediainfo import MediaInfo
ImportError: No module named pymediainfo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment