Last active
October 12, 2015 22:08
-
-
Save archagon/4094122 to your computer and use it in GitHub Desktop.
Quickly converts h264 videos into an iOS-compatible format by only re-encoding the audio. (I wasn't aware of any software that did this when I first wrote the script. Recently, I discovered that MP4tools does the job quite nicely, and additionally Subler works great for retrieving metadata and converting subtitles. As a result, I'm abandoning wo…
This file contains 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/python | |
# This script takes a video and remuxes it as an iOS-compatible video, re-encoding the audio if necessary. | |
# Note that the video WILL NOT be re-encoded. The script will fail if the video is not h264. | |
# | |
# Requires ffmpeg, ffprobe, and afconvert to be installed. | |
import re | |
import os | |
import sys | |
import subprocess | |
script_name_color = "\033[1;33m" | |
script_color_reset = "\033[0m" | |
def colorise(text): | |
return script_name_color + text + script_color_reset | |
def error(msg): | |
script_name = os.path.basename(__file__) | |
full_msg = colorise("[%s]") + " Error: %s" | |
print full_msg % (script_name, msg) | |
sys.exit(1) | |
def warning(msg): | |
script_name = os.path.basename(__file__) | |
full_msg = colorise("[%s]") + " Warning: %s" | |
print full_msg % (script_name, msg) | |
def message(msg): | |
script_name = os.path.basename(__file__) | |
full_msg = colorise("[%s]") + " %s" | |
print full_msg % (script_name, msg) | |
def file_exists(filename): | |
try: | |
with open(filename) as f: | |
return True | |
except IOError as e: | |
return False | |
filename = None | |
output_name = None | |
output_path = None | |
# TODO: keep existing audio flag; maybe different modes for remux, convert, keep all? | |
metadata = None | |
##################################################################### | |
# Step 0: Check that video file exists and setup initial variables. # | |
##################################################################### | |
if (len(sys.argv) < 2 or len(sys.argv) > 3): | |
error("invalid number of arguments") | |
filename = sys.argv[1] | |
if (len(sys.argv) == 3): | |
output_path = sys.argv[2] | |
output_name = os.path.split(output_path)[1] | |
output_path = os.path.split(output_path)[0] | |
else: | |
output_path = filename | |
output_name = os.path.splitext(os.path.split(output_path)[1])[0] + ".ios" + ".mp4" | |
output_path = os.path.split(output_path)[0] | |
message("outputting as " + output_name + " to " + (output_path if len(output_path) > 0 else os.getcwd())) | |
if not file_exists(filename): | |
error("file does not exist") | |
########################### | |
# Step 1: Get video info. # | |
########################### | |
info_args = ["ffprobe", "-show_streams", filename] | |
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("ffprobe returned a non-zero error code") | |
metadata = [] | |
info_in_stream = False | |
for line in info_out.splitlines(): | |
if not info_in_stream: | |
match = re.match(r"\s*\[STREAM\]\s*", line) | |
if match: | |
info_in_stream = True | |
metadata.append({}) | |
else: | |
match = re.match(r"\s*\[[/]STREAM\]\s*", line) | |
if match: | |
info_in_stream = False | |
else: | |
match = re.match(r"\s*codec[_]name\s*[=]\s*(.*)\s*", line) | |
if match: | |
metadata[-1]["codec_name"] = match.group(1) | |
continue | |
match = re.match(r"\s*codec[_]type\s*[=]\s*(.*)\s*", line) | |
if match: | |
metadata[-1]["codec_type"] = match.group(1) | |
continue | |
match = re.match(r"\s*TAG[:]language\s*[=]\s*(.*)\s*", line) | |
if match: | |
metadata[-1]["language"] = match.group(1) | |
match = re.match(r"\s*channels\s*[=]\s*(.*)\s*", line) | |
if match: | |
metadata[-1]["channels"] = int(match.group(1)) | |
# TODO: streams have "index" field; does it matter? | |
has_non_h264 = False | |
for i in range(len(metadata)): | |
stream = metadata[i] | |
if stream["codec_type"] == "audio": | |
output = "stream %d has %s with codec %s (%d channels)" + (" and language %s" if stream["language"] else "") | |
output_parameters = (i, stream["codec_type"], stream["codec_name"], stream["channels"]) + ((stream["language"],) if stream["language"] else ()) | |
message(output % output_parameters) | |
else: | |
output = "stream %d has %s with codec %s" + (" and language %s" if stream["language"] else "") | |
output_parameters = (i, stream["codec_type"], stream["codec_name"]) + ((stream["language"],) if stream["language"] else ()) | |
message(output % output_parameters) | |
if stream["codec_type"] == "video" and stream["codec_name"] != "h264": | |
has_non_h264 = True | |
if has_non_h264: | |
error("file has non-h264 video stream") | |
################################################################ | |
# Step 2: If audio is not AAC, demux audio and convert to AAC. # | |
################################################################ | |
# yes, I know that ffmpeg has aac converting but I wanted to use the native (non-reverse-engineered) afconvert | |
# note that if the audio is aac with more than 2 channels, there may be audible quality loss from transcoding aac -> aac | |
stream_directory_name = os.path.splitext(output_name)[0] + "." + "streams" | |
stream_directory_filename = os.path.join(output_path, stream_directory_name) | |
if not os.path.exists(stream_directory_filename): | |
os.makedirs(stream_directory_filename) | |
# TODO: remove this directory after next step | |
for i in range(len(metadata)): | |
stream = metadata[i] | |
# TODO: check for frequency | |
# TODO: I'd prefer to use the afconvet demuxer, but I can't seem to get it to work; | |
# last I tried, it only left the back channels and ignored the front channels | |
if stream["codec_type"] == "audio" and (stream["codec_name"] != "aac" or stream["channels"] > 2): | |
message("demuxing audio stream %d..." % (i)) | |
demux_name = os.path.splitext(output_name)[0] + "." + str(i) + ".wav" | |
demux_filename = os.path.join(stream_directory_filename, demux_name) | |
demux_audio_args = ["ffmpeg", "-async", "1", "-i", filename, "-ac", "2", "-y", demux_filename] | |
demux_audio_call = subprocess.Popen(demux_audio_args) | |
demux_audio_return = demux_audio_call.wait() | |
if demux_audio_return != 0: | |
warning("ffmpeg audio demux returned a non-zero error code") | |
if not file_exists(demux_filename): | |
error("demuxed audio file does not exist for stream " + str(i)) | |
message("re-encoding audio stream %d..." % (i)) | |
audio_aac_name = "__" + os.path.splitext(filename)[0] + "." + str(i) + ".m4a" | |
encode_args = ["afconvert", "--verbose", "--data", "aac", "--bitrate", "256000", "--quality", "127", "--strategy", "2", demux_filename, audio_aac_name] | |
encode_call = subprocess.Popen(encode_args) | |
encode_return = encode_call.wait() | |
if encode_return != 0: | |
warning("afconvert audio encode returned a non-zero error code") | |
stream["aac_name"] = audio_aac_name | |
os.remove(demux_filename) | |
################################################ | |
# Step 3: Run ffmpeg to create the final file. # | |
################################################ | |
message("creating final file...") | |
args_input = ["ffmpeg", "-i", filename] | |
args_map = [] | |
args_last = ["-vcodec", "copy", "-acodec", "copy", "-scodec", "copy", "-y", output_name] | |
non_aac_streams_found = 0 | |
for i in range(len(metadata)): | |
stream = metadata[i] | |
if "aac_name" in stream: | |
non_aac_streams_found += 1 | |
args_input += ["-i", stream["aac_name"]] | |
args_map += ["-map", str(non_aac_streams_found)+":0"] | |
else: | |
# TODO: make subtitles work | |
if stream["codec_type"] != "subtitle": | |
args_map += ["-map", "0:"+str(i)] | |
args = args_input + args_map + args_last | |
output_call = subprocess.Popen(args) | |
output_return = output_call.wait() | |
if output_return != 0: | |
warning("ffmpeg remux returned a non-zero error code") | |
for i in range(len(metadata)): | |
stream = metadata[i] | |
if "aac_name" in stream: | |
os.remove(stream["aac_name"]) | |
message("all done!") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment