diff --git a/doc/coniuga.py b/doc/coniuga.py new file mode 100755 index 0000000..da511cf --- /dev/null +++ b/doc/coniuga.py @@ -0,0 +1,967 @@ +#! /bin/env python +# encoding: utf-8 +# +# Res Tranceliī +# +# X - ducentī sexāgintā quattuor videōnis audiōnisque convertator +# +# Usage: coniuga.py files [options] +# + +import ellib, os, re, shutil, stat, sys, time +ellib._debug = True + +### +# Actus Nullus: Parse command line, define commands, classes and methods +### + +print + +args = ellib.CmdParser([ + "title", "t", 1, False, None, + "order", "O", 1, False, None, # semicolon-separated list of internal TIDs in the desired order + "force-duration", "D", 1, False, None, # force lenght of the video, used in local calculations only + "language", "l", 1, True, None, # TID:data format + "track-name", "ř", 1, True, None, # TID:data format + "sub-charset", "s", 1, True, None, # TID:data format + "fps", "f", 1, True, None, # TID:data format + "video-rate", "v", 1, True, None, # TID:data format + "audio-quality","a", 1, True, None, # TID:data format + "output", "o", 1, False, None, + "normalize", "n", 1, True, None, # TID:data format + "armed", "A", 0, False, False, + "resize", "r", 1, True, None, # TID:WxH + "crop", "C", 1, True, None, # TID:top:bottom:left:right (in px) + "aspect", "r", 1, True, None, # TID:data format + "recalc-aspect","+", 1, True, None, # track number to force aspect ratio recalculation for + "ffm-option", "P", 1, True, None, # additional params for ffmpeg, TID:data format + "downmix", "Ž", 1, True, None, # number of channels to downmix audio to, TID:data format + "mkv-option", "P", 1, True, None, # additional params for mkvmerge, TID:data format + "cut", "T", 1, False, None, # Start at:Duration format + "roundto", "d", 1, True, None, # TID:data + "normalize-all","N", 0, False, True, # + "video-rate-all", "V", 1, False, "600:200:800", # default values (overriden by specific values) + "audio-quality-all", "A", 1, False, "1", # + "roundto-all", "R", 1, False, "2", # + "cleanup", "c", 0, False, True, + "command-only", "X", 0, False, False, + "ignore", "e", 1, True, None, # track number to ignore + "chapters", "H", 1, False, None, # str + "log", "L", 0, False, True, # logging into ~/.coniuga/date - title.log +]) +argc = args.config + +if args.errors: + ellib.say("Exiting due to errors on command line.", 3) + for error in args.errors: + print " %s" %(error) + sys.exit(1) + +get_demuxers_command = 'mkvmerge --list-types spoo' +tracklist_command = 'mkvmerge -i "%s"' +alternate_tracklist_command = 'ffmpeg -i "%s" -vn -an spoo' +media_identify_command = 'mplayer -endpos 0 -vo null -ao null -identify "%s"' + +class world: + """Global settings""" + types = {0: 'video', 1: 'audio', 2: 'subtitle'} + tracks = [] + order = [] + log = [] + time = time.time() + +def log(*lines): + """Add data to logs.""" + for line in lines: + world.log.append(re.sub("", "", line)) + +def write_log(): + """Dump everything we know into the log.""" + logdir = "%s/.coniuga" %(os.getenv("HOME")) + if not os.path.isdir(logdir): + ellib.say("Creating log directory %s" %(logdir), 1) + os.mkdir(logdir) + + name = re.sub(".mkv$", "", argc.output) + logfile = "%s/%s - %s.log" %(logdir, time.strftime("%Y%m%d.%H%M%S"), name) # FIXME + + log = "%s\n\n%s" %(' '.join(sys.argv), '\n'.join(world.log)) + + file = open(logfile, 'w') + file.write(log) + file.close() + + ellib.say("Log saved: %s" %(logfile), 1) + +def get_demuxers(): + pipe = os.popen(get_demuxers_command) + data = pipe.read() + pipe.close() + + return re.findall(' (\w{2,4})\s{2,4}\w', data)[1:] + +class Track: + def __init__(self, filename, id_in_file, type, original_filename=None): + self.active = True + self.filename = filename + self.original_filename = original_filename + self.id_in_file = id_in_file # (real, ffmpeg) + self.name = "" + self.language = None # str + self.type = type # 0 = video, 1 = audio, 2 = subtitle + self.duration = 0.0 + self.proc_opts_hidden = [] # Primary options are added into these before a list is printed + self.cont_opts_hidden = [] # then they are merged into those below + self.proc_opts = [] + self.cont_opts = [] + self.cut = None # (start, duration) + + # flags, data, etc. + temp_filename_basis = "%s_%s" %(self.filename.replace("/", "-"), self.id_in_file[0]) + if self.type == 0: + self.temp_files = ["video-pass1-%s.x264" %(temp_filename_basis), "video-pass2-%s.x264" %(temp_filename_basis)] + self.resolution = None # [W, H] + self.orig_resolution = None # (W, H) + self.aspect = None # float + self.ro_aspect = False # True == readonly, don't recalc + self.fps = None # str + self.bitrate = tuple(map(int, argc.video_rate_all.split(":"))) # (nominal, variance, maximal) + self.resize = None # (W, H) + self.crop = None # (T, B, L, R) + if argc.roundto_all not in ["2", "16"]: + ellib.say("Option roundto-all must be either 2 or 16.", 3) + sys.exit(1) + self.roundto = int(argc.roundto_all) + elif self.type == 1: + self.temp_files = ["audio-%s.pcm" %(temp_filename_basis), "audio-%s.vorbis" %(temp_filename_basis)] + self.quality = float(argc.audio_quality_all) # float <-1; 10> + self.normalize = argc.normalize_all # bool + self.downmix = None # channels to downmix to; None means leave it be + elif self.type == 2: + self.charset = None # str + self.temp_file = "subtitle-%s.text" %(temp_filename_basis) + + def __cmp__(self, other): + return cmp(self.type, other.type) + +def get_tracks(filename): + # ffmpeg's an idiot incapable of grasping the preposterous idea that some filenames just HAVE a colon in their names, so we gotta hack around it + if ":" in filename and False: + original_filename = filename + + filename = filename.replace(":", "_") + while os.path.exists(filename): + filename = "_" + filename + + os.symlink(original_filename, filename) + else: + original_filename = None + + suffix = filename.split(".")[-1].lower() + + pipes = os.popen3(alternate_tracklist_command %(filename)) + ffmpeg_data = pipes[2].read() + map(file.close, pipes) + + tracks = [] + with_ffmpeg = True + if suffix in get_demuxers(): + # analyse with mkvmerge + pipe = os.popen(tracklist_command %(filename)) + data = pipe.read() + pipe.close() + + for tid, type in re.findall('Track ID (\d+): (\w+)', data): + with_ffmpeg = False + tid = int(tid) + if suffix.lower() in ["mkv", "mov"]: + tid -= 1 + if original_filename: + tracks.append(Track(filename, (tid, tid), {'video': 0, 'audio': 1, 'subtitles': 2}[type], original_filename=original_filename)) + else: + tracks.append(Track(filename, (tid, tid), {'video': 0, 'audio': 1, 'subtitles': 2}[type])) + + # post pro: id_in_file for ffmpeg has to skip subtitles + for track in tracks: + if track.type == 2: + track.id_in_file = (track.id_in_file[0], None) + + index = tracks.index(track) + for following_track in tracks[index+1:]: + following_track.id_in_file = (following_track.id_in_file[0], following_track.id_in_file[1] - 1) + + if with_ffmpeg: + # analyse with ffmpeg + for tid, type in re.findall(' Stream #\d+.(\d+)[^:]*: ([^:]+):', ffmpeg_data): + if type in ['Audio', 'Video']: + tracks.append(Track(filename, (int(tid), int(tid)), {'Video': 0, 'Audio': 1}[type])) + else: + ellib.say("Unknown track %s type %s from file %s" %(tid, type, filename)) + + for track in tracks: + if track.type in [0, 1]: + hours, mins, duration = re.findall("Duration: (.+?),", ffmpeg_data)[0].split(":") + track.duration = float(duration) + int(mins)*60 + int(hours)*3600 + + if track.type == 0: + #resolution, track.fps = re.findall("Stream #0.%s.+, (\d+x\d+).* (\d+.\d+)" %(track.id_in_file[1]), ffmpeg_data)[0] + #resolution, track.fps = re.findall("Stream #0.%s.+, (\d+x\d+),.* (\d+.\d+) fps" %(track.id_in_file[1]), ffmpeg_data)[0] + # =media-video/ffmpeg-0.4.9_p20081014 + if "tbr" in ffmpeg_data and "tbn" in ffmpeg_data and "tbc" in ffmpeg_data: + resolution, track.fps = re.findall("Stream #0.%s.+, (\d+x\d+).* ([0-9./]+) tbr" %(track.id_in_file[1]), ffmpeg_data)[0] + else: + resolution, track.fps = re.findall("Stream #0.%s.+, (\d+x\d+).* (\d+.\d+)" %(track.id_in_file[1]), ffmpeg_data)[0] + + if track.fps.startswith("29.9"): + track.fps = "30000/1001" + elif track.fps.startswith("23.9"): + track.fps = "24000/1001" + + track.resolution = map(int, resolution.split("x")) + + # we still need that state-of-the-junk MPlayer to capture aspect ratio + pipes = os.popen3(media_identify_command %(filename)) + data = pipes[1].read() + map(file.close, pipes) + + aspect = re.findall("ID_VIDEO_ASPECT=(.+)", data) + if (aspect and float(aspect[-1]) > 0) and not (str(tracks.index(track)) in argc.recalc_aspect): + track.aspect = float(aspect[-1]) + else: + track.aspect = float(track.resolution[0]) / track.resolution[1] + return tracks + +def list_track(tid, track, indent): + type = world.types[track.type].rjust(8) + filename = track.filename.ljust(indent) + id_in_file = track.id_in_file + if track.proc_opts: + proc_opts = "ffmpeg(%s)" %(' '.join(track.proc_opts)) + else: + proc_opts = "" + if track.cont_opts: + cont_opts = "mkvmerge(%s)" %(' '.join(track.cont_opts)) + else: + cont_opts = "" + + if track.active: + flags = "" + if track.language: + flags += "lang:%s " %(track.language) + + if track.name: + flags += "name:%s " %(track.name) + + if track.type == 0: + flags += "bitrate:%s:%s:%s" %(track.bitrate) + + # check bitrate if sufficient + if "/" in track.fps: + fps = map(float, track.fps.split("/")) + fps = fps[0] / fps[1] + else: + print + print track.fps + fps = float(track.fps) + minimal_bitrate = (track.resolution[0] * track.resolution[1] * fps) / 18432 + track_bitrate = (track.bitrate[0] + track.bitrate[1]) / 2 + if minimal_bitrate - track_bitrate > 150: # insufficient + flags += ":low:+%s " %(int(minimal_bitrate - track_bitrate)) + elif int(track_bitrate) - minimal_bitrate > 600: # excessive + flags += ":high:-%s "%(int(track_bitrate - minimal_bitrate)) + else: + flags += ":OK " + + flags += "fps:%s " %(track.fps) + if track.resolution: + x, y = track.orig_resolution[0], track.orig_resolution[1] + bad = False + if x % 2 == 1: + x = "%s" %(x) + if y % 2 == 1: + y = "%s" %(y) + + flags += "size:%sx%s " %(x, y) + if track.crop: + flags += "crop:%s:%s:%s:%s " %(track.crop) + if track.resize or track.crop: + flags += "resize:%sx%s " %(track.resolution[0], track.resolution[1]) + if track.aspect: + flags += "aspect:%s " %(track.aspect) + elif track.type == 1: + flags += "quality:%s " %(track.quality) + if track.normalize: + flags += "normalize " + elif track.type == 2: + if track.charset: + flags += "char:%s " %(track.charset) + if track.type in [0, 1]: + if track.cut: + flags += "cut:%s:%s " %(track.cut) + else: + flags = "TRACK IGNORED" + + output = "%s: %s from %s %s/%s %s %s %s" %(str(tid).rjust(3), type, filename, id_in_file[0], id_in_file[1], flags, proc_opts, cont_opts) + + log(output) + + print ellib.style(output) + +def list_tracks(): + ellib.say("Duration: %s" %(world.tracks[0].duration)) + + print + + print ellib.style("TID: type from filename position in file/without subtitles flags") + + filename_len = 0 + for track in world.tracks: + if len(track.filename) > filename_len: + try: + filename_len = len(track.filename.decode(sys.stdin.encoding)) + except: + filename_len = len(track.filename) + + if world.order: + order = world.order + else: + order = range(len(world.tracks)) + + for tid in order: + try: + list_track(tid, world.tracks[tid], filename_len) + except IndexError: + ellib.say("Track %s is not defined." %(tid), 3) + print + + if argc.chapters: + ellib.say("Reading chapters from file %s." %(argc.chapters)) + print + + # estimates (in kB) + total_size = 0.0 + for track in world.tracks: + if not track.active: + continue + + if track.type == 0: + total_size += track.duration * (track.bitrate[0] + track.bitrate[2]) / 2 / 8 + elif track.type == 1: + total_size += track.duration * 10 # approximate kB/s for vorbis q=1 + + if total_size > 40960: + total_size = "%s MB" %(int(total_size / 1024)) + else: + total_size = "%s kB" %(int(total_size)) + + total_size_str = "Estimated file size is %s." %(total_size) + log("") + log(total_size_str) + ellib.say(total_size_str) + print + +def parse_group(data, accepted=[0, 1, 2]): + tid, data = data.split(":", 1) + + try: + tid = int(tid) + except ValueError: + ellib.say("%s is not a valid Track ID." %(tid), 3) + sys.exit(1) + + try: + track = world.tracks[tid] + except IndexError: + ellib.say("Track %s is not defined." %(tid), 3) + sys.exit(1) + + if track.type not in accepted: + accept_names = [] + for type in accepted: + accept_names.append("%s" %(world.types[type])) + accept_names = ' or '.join(accept_names) + ellib.say("Track %s is not a valid %s track." %(tid, accept_names), 3) + sys.exit(1) + return (tid, track, data) + +def pad(n, padme = False, padto = 5): + if type(n) == float: + n = round(n, 2) + n = str(n) + if "." in n: # it's a float -> make sure it has two decimals. Pad if necessary. + if len(n.split(".")[1]) == 1: + n = n + "0" + elif len(n) == 1: # clock + n = "0" + n + + if padme: + padtimes = padto - len(n) + n = "0"*padtimes + n + + return n + +def strftime(secs, return_string=True): + hours = int(secs) / 3600 + secs = secs % 3600 + mins = int(secs) / 60 + secs = int(secs % 60) + if return_string: + return "%s:%s:%s" %(pad(hours), pad(mins), pad(secs)) + else: + return (hours, mins, secs) + +class ProgressBar: + def __init__(self, lenght = 30): + self.lenght = lenght - 3 # minus [, > and ] + self.filesize = 0 + self.buffer = [] # stores file sizes, used to compute ETA + self.buffer_len = 8 + self.start = time.time() + self.last_size = 0 + def draw(self, percent, filesize = None, additional = ""): + # filesize == current filesize + # self.filesize == projected filesize + if not filesize or filesize > self.filesize: # assume 100% + filesize = self.filesize + if percent > 1: + percent = 1.0 + current = int(self.lenght * percent) + + # maintain a graph of speeds (kB/s for each update) + curr_time = time.time() + if not self.buffer: + last_time = self.start + else: + last_time = self.buffer[-1][1] + + speed = (filesize - self.last_size) / (curr_time - last_time) + self.buffer.append([speed, curr_time]) + + self.last_size = filesize + + if len(self.buffer) > self.buffer_len: + self.buffer.pop(0) # delete the oldest one + + # average speed + suma = 0.0 + for i in range(len(self.buffer)): + suma += self.buffer[i][0] + average = suma / len(self.buffer) + + # come up with an ETA based on average speed + if average > 0: + remaining_size = self.filesize - filesize + ETA = strftime(remaining_size / average) + else: + ETA = "never" + + if percent == 1: + arrow = "===" + else: + arrow = ">" + + sys.stdout.write("\r%s%% [%s%s%s] %s/%sMB (%s kB/s) ETA: %s %s " %(pad(percent*100), current * "=", arrow, (self.lenght - current) * " ", pad(filesize/1048576.0), pad(self.filesize/1048576.0), pad(average/1024.0, True), ETA, additional)) + sys.stdout.flush() + +def ffmpeg(command, temp_file, track): + # purge temps, ffmpeg is kinda sensitive :o) + try: + os.remove(temp_file) + except: + pass + + pipes = os.popen3(command) + bar = ProgressBar() + + while True: + #time.sleep(1) + chunk = pipes[2].read(256) + if not chunk: + break # we're done I guess + + # split output by '\r's, parse the one before last (it's surely complete and most up to date) + try: + line = chunk.split('\r')[-2] + # data: (frame, size in kB, time in seconds, bitrate in kb/s) - all floats + secs, bitrate = map(float, re.findall('size=\s*\d+kB time=\s*(\d+.\d+) bitrate=\s*(\d+.\d)kbits/s', line)[0]) + + # estimate target file size + percent = secs / track.duration + if percent == 0: + percent = 0.01 + current_size = os.lstat(temp_file)[stat.ST_SIZE] + target_size = current_size / percent + + bar.filesize = target_size + # draw progress bar + + additional = "Kbps: %s" %(bitrate) + bar.draw(percent, current_size, additional) + + except IndexError: # it's not converting yet + try: + #print line + pass + except: + pass + try: + bar.draw(1, target_size, bitrate) + except: + pass + map(file.close, pipes) + print + +def oggenc(command, temp_file): + pipes = os.popen3(command) + bar = ProgressBar() + + while True: + chunk = pipes[2].read(128) + if not chunk: + break # we're done I guess + + try: + line = chunk.split('\r')[-2] + perc_units, perc_frac = re.findall("(\d+)[.,](\d+)%.+\d+m\d+s remaining", line)[0] + + if len(perc_frac) == 1: + perc_frac += "0" + percent = (int(perc_units) + float(perc_frac)/100) / 100 + if percent == 0: + percent = 0.01 + + current_size = os.lstat(temp_file)[stat.ST_SIZE] + target_size = current_size / percent + + bar.filesize = target_size + # draw progress bar + bar.draw(percent, current_size) + except IndexError: # it's not converting yet + pass + try: + bar.draw(1, target_size) + except: + pass + map(file.close, pipes) + print + +### +# Actus Primus: Analyse input files, create track objects, interpret gathered intel, bring order into chaos +### + +# gather basic intel on our files +for filename in args.targets: + if os.path.exists(filename): + world.tracks += get_tracks(filename) + else: + ellib.say("File %s does not exist." %(filename), 3) + sys.exit(1) + +if not world.tracks: + ellib.say("Specify at least one file containing video, audio, or subtitles.", 3) + sys.exit(1) + +# sort video -> audio -> subs +world.tracks.sort() + +# React on user input +if argc.order: + world.order = map(int, argc.order.split(":")) + +# Container options +if not argc.title: + if world.tracks[0].original_filename: + filename = world.tracks[0].original_filename + else: + filename = world.tracks[0].filename + + if "." in filename: + argc.title = '.'.join(filename.split(".")[:-1]) + else: + argc.title = filename + + ellib.say("Setting title to %s as none was specified." %(argc.title)) + +if not argc.output: + argc.output = "%s.mkv" %(argc.title) + argc.output = ellib.charfilter(argc.output, {'/': '-'}) + ellib.say("Setting output filename to %s as none was specified." %(argc.output)) + +for opt in argc.language: + tid, track, data = parse_group(opt) + track.language = data + +for opt in argc.track_name: + tid, track, data = parse_group(opt) + track.name = data + +for opt in argc.sub_charset: + tid, track, data = parse_group(opt, [2]) + track.charset = data + +for opt in argc.fps: + tid, track, data = parse_group(opt, [0]) + track.fps = data + track.proc_opts_hidden.append("-r %s" %(data)) + +# Processor options +if argc.force_duration: + for track in world.tracks: + track.duration = float(argc.force_duration) + +if argc.cut: + for track in world.tracks: + if track.type in [0, 1]: + track.cut = tuple(map(float, argc.cut.split(":"))) + +for opt in argc.normalize: + if ":" in opt: + tid, track, data = parse_group(opt, [1]) + if data.lower() in ["true", "1", "yes"]: + track.normalize = True + else: + track.normalize = False + else: + if opt.lower() in ["true", "1", "yes"]: + data = True + else: + data = False + for track in world.tracks: + if track.type == 1: + track.normalize = data + +for opt in argc.aspect: + tid, track, data = parse_group(opt, [0]) + if ":" in data or "/" in data: + data = map(float, re.findall("(.+)[:/](.+)", data)[0]) + aspect = data[0] / data[1] + else: + aspect = float(data) + + track.aspect = aspect + track.ro_aspect = True # readonly, don't recalc + +for opt in argc.ffm_option: + tid, track, data = parse_group(opt, [0, 1]) + track.proc_opts.append(data) + +for opt in argc.downmix: + tid, track, data = parse_group(opt, [1]) + track.downmix = data + +for opt in argc.mkv_option: + tid, track, data = parse_group(opt) + track.cont_opts.append(data) + +for opt in argc.audio_quality: + tid, track, data = parse_group(opt, [1]) + track.quality = float(data) + +for opt in argc.video_rate: + tid, track, data = parse_group(opt, [0]) + track.bitrate = tuple(map(int, data.split(":"))) + +for opt in argc.resize: + tid, track, data = parse_group(opt, [0]) + if "x" not in data: + ellib.say("Resize parameter %s of track %s is not valid." %(data, tid), 3) + sys.exit(1) + track.resize = tuple(map(int, data.split("x"))) + +for opt in argc.crop: + tid, track, data = parse_group(opt, [0]) + if data.count(":") is not 3: + ellib.say("Crop parameter %s of track %s is not valid." %(data, tid), 3) + sys.exit(1) + track.crop = tuple(map(int, data.split(":"))) + for crop in track.crop: + if crop%2 == 1: + ellib.say("Crop values from track %s are not divisible by 2." %(tid), 3) + sys.exit(1) + +for opt in argc.roundto: + tid, track, data = parse_group(opt, [0]) + if data == "16": + track.roundto = 16 + else: + ellib.say("Roundto parameter of track %s must be either 16 or 2." %(tid), 3) + +for tid in argc.ignore: + try: + tid = int(tid) + except ValueError: + ellib.say("%s is not a valid Track ID." %(tid), 3) + sys.exit(1) + + if tid < len(world.tracks): + world.tracks[tid].active = False + +if world.order: + order = world.order +else: + order = range(len(world.tracks)) + +# understand and apply all parameters +for tid in order: + track = world.tracks[tid] + + if track.language: + #track.cont_opts_hidden.append("--language %s:%s" %(track.id_in_file[0], track.language)) + track.cont_opts_hidden.append("--language 0:%s" %(track.language)) + if track.type in [0, 1]: + if track.cut: + track.proc_opts_hidden.append("-ss %s -t %s" %(track.cut)) + if track.type == 0: + ## Size and aspect ratio + + # 1) determine real size + if abs(float(track.resolution[0]) / track.resolution[1] - track.aspect) > 0.05: + if track.aspect >= 1: + track.resolution[0] = int(round(track.aspect * track.resolution[1])) + else: + track.resolution[1] = int(round(track.resolution[0] / track.aspect)) + + # save original sizes for the list + track.orig_resolution = tuple(track.resolution) + + # 2) determine percentages - we'll use them to resize later + if track.resize: + perc_x = float(track.resize[0]) / track.resolution[0] + perc_y = float(track.resize[1]) / track.resolution[1] + + # 3) substract crop values (comes in TBLR format) + if track.crop: + track.resolution[0] -= sum(track.crop[2:4]) + track.resolution[1] -= sum(track.crop[0:2]) + + # 4) resize them if necessary + if track.resize: + track.resolution[0] *= perc_x + track.resolution[1] *= perc_y + + # if we were resizing or cropping, calculate a new aspect ratio + if (track.resize or track.crop) and not track.ro_aspect: + track.aspect = track.resolution[0] / float(track.resolution[1]) + track.proc_opts_hidden.append("-aspect %s" %(track.aspect)) + + # 5) round sizes + track.resolution[0] = int(round(track.resolution[0] / float(track.roundto))) * track.roundto + track.resolution[1] = int(round(track.resolution[1] / float(track.roundto))) * track.roundto + + ## ffmpeg options + # crop + if track.crop: + track.proc_opts_hidden.append('-vf crop=%d:%d:%d:%d' %(track.orig_resolution[0] - track.crop[2] - track.crop[3], + track.orig_resolution[1] - track.crop[0] - track.crop[1], + track.crop[2], + track.crop[0])) + + # resize + if track.resize: + track.proc_opts_hidden.append("-s %sx%s" %(track.resolution[0], track.resolution[1])) + + + # mkvmerge options + if track.fps: + #track.cont_opts_hidden.append("--default-duration %s:%sfps" %(track.id_in_file[0], track.fps)) + track.cont_opts_hidden.append("--default-duration 0:%sfps" %(track.fps)) + elif track.type == 2: + if track.charset: + #track.cont_opts_hidden.append("--sub-charset %s:%s" %(track.id_in_file[0], track.charset)) + track.cont_opts_hidden.append("--sub-charset 0:%s" %(track.charset)) + +# print all we know +list_tracks() + +if not argc.armed: # if we're just planning we don't wanna go any further. Exiting nicely. + for track in world.tracks:# part 2A of our "Why ffmpeg's A Total Moron™" hack - remove the file if not armed + if track.original_filename and os.path.exists(track.filename): # in case a previous iteration removed the file + os.remove(track.filename) + sys.exit(0) +else: + # merge hidden and additional options + for track in world.tracks: + track.proc_opts = track.proc_opts_hidden + track.proc_opts + track.cont_opts = track.cont_opts_hidden + track.cont_opts + +### +# Actus Secundus: The hard part - transcode (crop, resize, ...) video, transcode audio +### + +ffmpeg_command = 'ffmpeg -i "%s" %s -map 0:%s:0 "%s"' # input parameters ffmpeg_stream_id output +ffmpeg_video_parameters = '-pass %s %s -an -vcodec libx264 -nr 400 -b %sk -bt %sk -maxrate %sk -coder 1 -flags +loop -partitions +parti4x4+partp8x8+partb8x8 -me_method 5 -subq 1 -trellis 0 -me_range 16 -g 250 -keyint_min 25 -sc_threshold 40 -i_qfactor 0.71 -rc_eq "blurCplx^(1-qComp)" -qmin 10 -qmax 51 -qdiff 4 -f rawvideo' # passnum additional_options bitrate_nominal bitrate_variance bitrate_maximal +ffmpeg_audio_parameters = '-vn -acodec pcm_s16le %s -f wav %s' +audio_normalize_command = 'normalize "%s"' +audio_encode_command = 'oggenc -q 1 "%s" -o "%s"' + +# make us a nice temp directory +world.temp = "coniuga-temp_%s" %(argc.output) +if argc.command_only: + print 'mkdir "%s"' %(world.temp) + print 'cd "%s"' %(world.temp) + print +else: + ellib.say("Creating temp directory %s" %(world.temp), 1) + os.mkdir(world.temp) + os.chdir(world.temp) + +# adjust filenames to reflect the change of current working directory +for track in world.tracks: + track.filename = "../%s" %(track.filename) + +for tid in order: + track = world.tracks[tid] + if not track.active: + continue + + if track.type == 2: # Subtitles do not require processing in this act. Just copy them over. + if argc.command_only: + print 'cp "%s" "%s"' %(track.filename, track.temp_file) + print + else: + shutil.copy(track.filename, track.temp_file) + + if track.type == 0: # videō + for passnum in ["1", "2"]: + # compose the command + additional_processor_parameters = ' '.join(track.proc_opts) + + # passnum additional_options bitrate_nominal bitrate_variance bitrate_maximal + processor_parameters = ffmpeg_video_parameters %((passnum, additional_processor_parameters) + track.bitrate) + + main_command = ffmpeg_command %(track.filename, processor_parameters, track.id_in_file[1], track.temp_files[int(passnum) - 1]) + if argc.command_only: + print main_command + print + else: + ellib.say("Transcoding video track %s, pass %s..." %(tid, passnum), 1) + ffmpeg(main_command, track.temp_files[int(passnum) - 1], track) + elif track.type == 1: # audiō + additional_processor_parameters = ' '.join(track.proc_opts) + + if track.downmix: + processor_parameters = ffmpeg_audio_parameters %("-ac %s" %(track.downmix), additional_processor_parameters) + else: + processor_parameters = ffmpeg_audio_parameters %("", additional_processor_parameters) + + main_command = ffmpeg_command %(track.filename, processor_parameters, track.id_in_file[1], track.temp_files[0]) + if argc.command_only: + print main_command + print + else: + ellib.say("Extracting audio track %s..." %(tid), 1) + ffmpeg(main_command, track.temp_files[0], track) + + if track.normalize: + if argc.command_only: + print audio_normalize_command %(track.temp_files[0]) + print + else: + ellib.say("Normalizing audio track %s..." %(tid), 1) + pipes = os.popen3(audio_normalize_command %(track.temp_files[0])) + pipes[2].read() + + if argc.command_only: + print audio_encode_command %(track.temp_files[0], track.temp_files[1]) + print + else: + ellib.say("Encoding audio track %s..." %(tid), 1) + oggenc(audio_encode_command %(track.temp_files[0], track.temp_files[1]), track.temp_files[1]) + +### +# Actus Tertius: Müxing. +### + +mkvmerge_command = 'mkvmerge -o "../%s" --title "%s"' # output, title, tracks + +merge_command = [mkvmerge_command %(argc.output, argc.title)] + +for tid in order: + track = world.tracks[tid] + if not track.active: + continue + + if track.type in [0, 1]: + file = track.temp_files[1] + else: + file = track.temp_file + + if track.cont_opts: + track_command = '%s "%s"' %(' '.join(track.cont_opts), file) + else: + track_command = '"%s"' %(file) + + merge_command.append(track_command) + +if argc.chapters: + merge_command.append('--chapters "../%s"' %(argc.chapters)) + +merge_command = ' '.join(merge_command) + +if argc.command_only: + print merge_command + print +else: + retval = os.system(merge_command) + if retval != 0: + ellib.say("mkvmerge, halting without cleanup (use manual merging)...", 3) + sys.exit(2) + +### +# Actus Quartus: Cleanup. +### +if argc.cleanup: + files = ["ffmpeg2pass-0.log", "x264_2pass.log", "x264_2pass.log.mbtree"] + for track in world.tracks: + # part 2B of our "Why ffmpeg's A Total Moron™" hack - remove the file if armed + if track.original_filename: + try: + os.remove("../"+track.filename) + except: + pass + + if not track.active: + continue + if track.type in [0, 1]: + files.extend(track.temp_files) + else: + files.append(track.temp_file) + to_remove = [] + for file in files: + if argc.command_only: + to_remove.append(file) + else: + try: + os.remove(file) + ellib.say("Removed temp file %s" %(file), 1) + except OSError: + ellib.say("Expected temp file %s not present." %(file)) + if argc.command_only: + print 'rm -f "%s"' %('" "'.join(to_remove)) + print 'cd ..' + print 'rmdir "%s"' %(world.temp) + else: + os.chdir("..") + try: + os.rmdir(world.temp) + ellib.say("Removed temp directory %s" %(world.temp), 1) + except OSError: + ellib.say("Expected temp dir %s not present, what's going on ???" %(file)) + + if not argc.command_only: + time_taken = time.time() - world.time + + final_size = os.lstat(argc.output)[stat.ST_SIZE] / 1024 + if final_size > 40960: + final_size = "%s MB" %(int(final_size / 1024)) + else: + final_size = "%s kB" %(int(final_size)) + final_size_str = "Resulting file size is %s." %(final_size) + ellib.say(final_size_str) + time_taken_str = "Process took %s hours, %s minutes and %s seconds." %(strftime(time_taken, False)) + ellib.say(time_taken_str, 2) + + log("") + log(time_taken_str, final_size_str) + write_log() + print diff --git a/doc/ellib.py b/doc/ellib.py new file mode 100755 index 0000000..d86baa9 --- /dev/null +++ b/doc/ellib.py @@ -0,0 +1,366 @@ +#! /bin/env python +# -*- coding: utf-8 -*- + +import os, pickle, re, sys, time, traceback + +__version = '1.9.2' + +class CmdParser: + class config: + pass + def __init__(self, mapping, source = sys.argv[1:]): + # pools for variable quadruples + p_long = [] + p_short = [] + p_type = [] + p_plural = {} + p_default = {} + # Legacy checks for pre-1.7 or pre-1.9 programs -- it's a CRAP, so i had them neutered :] + #l_mapping = len(mapping) + #if l_mapping%3 == 0 and l_mapping%4 != 0: + #maptype = 3 + #say("ellib.CmdParser: Pre-1.7 or 1.9 program detected, running in legacy mode", timestamp = True, icon = "DBG") + #else: + #maptype = 5 + maptype = 5 + + for i in range(0, len(mapping), maptype): + p_long.append(mapping[i]) + p_short.append(mapping[i+1]) + p_type.append(mapping[i+2]) + p_plural[mapping[i]] = mapping[i+3] + p_default[mapping[i]] = mapping[i+4] + + # Switches and targets arrays. + self.switches = {} + for name in p_long: + if p_plural[name]: + self.switches[name] = [] + else: + self.switches[name] = None + self.targets = [] + self.errors = [] + + + # Behold, the Parser !!! + delka = len(source) + mapa = range(delka) + for i in range(delka): + if i in mapa: + arg = source[i] + if arg[:2] == '--' and arg[:5] != '--no-': + if arg[2:] in p_long: + if p_type[p_long.index(arg[2:])] == 0: + if p_plural[arg[2:]]: + self.switches[arg[2:]].append(True) + else: + self.switches[arg[2:]] = True + else: + try: + if p_plural[arg[2:]]: + self.switches[arg[2:]].append(source[i+1]) + else: + self.switches[arg[2:]] = source[i+1] + mapa.remove(i+1) + except: + self.errors.append(arg) + else: + self.errors.append(arg) + + elif arg[:5] == '--no-': + if arg[5:] in p_long: + if p_type[p_long.index(arg[5:])] == 0: + if p_plural[arg[5:]]: + self.switches[arg[5:]].append(False) + else: + self.switches[arg[5:]] = False + else: + self.errors.append(arg) + + elif arg[0] in ['-', '+']: + for x in arg[1:]: + if x in p_short: + longname = p_long[p_short.index(x)] + if p_type[p_short.index(x)] == 0: + if arg[0] == '-': + value = False + else: + value = True + if p_plural[longname]: + self.switches[longname].append(value) + else: + self.switches[longname] = value + else: + try: + if p_plural[longname]: + self.switches[longname].append(source[i+1]) + else: + self.switches[longname] = source[i+1] + mapa.remove(i+1) + except: + self.errors.append(arg[0]+x) + else: + self.errors.append(arg[0]+x) + + else: + self.targets.append(arg) + + # Look for empty fields and fill them with default values if possible + for name in self.switches: + if name in p_default.keys() and p_default[name] != None and self.switches[name] in [[], None]: + if type(p_default[name]) == list: + self.switches[name] = p_default[name] + else: + if p_plural[name]: + self.switches[name] = [p_default[name]] + else: + self.switches[name] = p_default[name] + # Fill the self.switches dictionary into self.config variables for easier access + name2 = name.replace(' ', '_').replace('-', '_').replace('+', '_') + setattr(self.config, name2, self.switches[name]) + + # if there's a "--debug" somewhere on the line, run a command dump + if '--debug' in source: + _debug = True + say("Command dump", 1) + for name in self.switches: + say("%s: %s" %(name, self.switches[name])) + say("Command dump", 2) + +class Db: + """Třída velmi špatné databáze: + * při inicializaci se vytvoří databáze se jménem name, pokud neexistuje. Otevře databázi. + * metoda read() - přečte databázi + * metoda write() - zapíše (přepíše) databázi + * metoda remove() - odstraní řádek + """ + def __init__(self, filename): + """Otevře databázi, pokud neexistuje, vytvoří ji.""" + self.filename = filename + if os.path.isfile(self.filename): + try: file = open(self.filename, 'r+') + except IOError: file = open(self.filename, 'r') + else: + say("Vytvářím databázi %s \r" %(self.filename), 1, 1) + file = open(self.filename, 'w') + file.write('None') + say("Databáze %s vytvořena." %(self.filename), 2) + file.close() + try: file = open(self.filename, 'r+') + except IOError: file = open(self.filename, 'r') + self.file = file + + def read(self): + """Low level funkce; vrátí databázi""" + self.file.seek(0) + try: return pickle.load(self.file) + except: + return [] + + def write(self, object): + """Low level funkce; zapíše databázi""" + self.file.seek(0) + retval = pickle.dump(object, self.file) + self.file.flush() + return retval + + def remove(self, ID): + """Odstraní řádek z databáze.""" + database = self.read() + del database[ID] + return self.write(database) + + def add(self, line): + """Zapíše řádek do databáze""" + database = self.read() + database.append(line) + return self.write(database) + +class Help: + def __init__(self, rawtext): + self.rawtext = rawtext+'\n[[' # a kinky workaround indeed :) + self.blocks = {} + self.order = [] + self.mkindex() + def mkindex(self): + chunk = re.findall('\[\[(.+?)\]\]\n(.+?)(?=\[\[)', self.rawtext, re.S) + for section in chunk: + if len(section) == 2: # caption and text + self.order.append(section[0]) + self.blocks[section[0]] = section[1] + else: + say("Help section is of wrong lenght, please report a bug (provide the original help text if possible)", timestamp = True, icon = "FAIL") + def __call__(self, caption = None): + if caption in self.blocks.keys(): + return style(self.blocks[caption]) + else: + tosend = "" + for cap in self.order: + tosend += "[%s]\n%s" %(cap, self.blocks[cap]) + return style(tosend) + def help(self, caption = None): + return self.__call__(caption) + +# output engine +colortags = { +'': '\x1b[32;01m', +'': '\x1b[33;01m', +'': '\x1b[31;01m', +'': '\x1b[34;01m', +'': '\x1b[01m', +'': '\x1b[39;49;00m' +} + +badchars = { +'\"': '\\"', +'\x00': '', +'/': '-' +} + +def charfilter(text, sada = badchars): + for badchar in sada.keys(): + text = text.replace(badchar, sada[badchar]) + return text + +# R.I.P, you served us well. May this line make us remember you for ages. +# Eram, non sum, non misero... +#def style(text): + #return re.compile('').sub(lambda text: colortags[text.group()], text) + +def style(text): + tags = re.findall('', text) + for tag in tags: + try: + text = text.replace(tag, colortags[tag]) + except KeyError: + pass + return text + +def say(text, mode = 0, breakline = True, timestamp = False, icon = ""): + if isinstance(text, unicode) and sys.stdout.encoding: + text = text.encode(sys.stdout.encoding) + text = str(text) + if _debug: + timestamp = True + icons = _icons_debug + else: + icons = _icons + if mode in range(1, len(icons)) and not icon: + icon = icons[mode] + elif not icon: + icon = icons[0] + if breakline == 1: + br = '\n' + else: + br = '' + if timestamp: + text = style(time.strftime('%Y-%m-%d %H:%M:%S ')+icon+' -- '+text)+br + else: + text = style(icon+' '+text)+br + if _log: log(text) + sys.stdout.write(text) + sys.stdout.flush() + +def countdown(units, text = "", secs = 1): + ticks = range(units) + ticks.reverse() + try: + for i in ticks: + say(text+str(i+1)+' \r', 1, 0) + time.sleep(secs) + say(text+'0 ', 2) + return True + except (EOFError, KeyboardInterrupt): + say(text+'0 ', 3) + return False + +def log(text, logfile = None): + if not logfile: + logfile = _logfile + if not text: + return False + if text[-1] != '\n': + text += '\n' + fd = open(logfile, 'a') + fd.write(text) + fd.close() + +def join(array, separator=' '): + return separator.join(array) + +def logger(status, prefix = '.'): + global _log, _logfile + _prefix = prefix + + if status: + _log = True + # Check if prefix is a directory: if it's a file, log into ".", if it doesn't exist, create it. + if not os.path.isdir(_prefix) and os.path.exists(_prefix): + _prefix = '.' + elif not os.path.exists(_prefix): + os.mkdir(_prefix) + _logfile = _prefix + '/' + time.strftime('%Y-%m-%d %H:%M:%S')+'.log' + if prefix != _prefix: say("%s exists, but is not a directory." %(prefix), 3) + say("Logging into %s" %(_logfile), 1) + else: + say("Logging into %s" %(_logfile), 2) + _log = False + +class File: + def __init__(self, name, mode = 'r'): + self.name = name + self.mode = mode + self.fd = open(name, mode) + self.data = '' + def read(self): + self.data = self.fd.read() + def write(self): + self.fd.write(self.data) + def close(self): + self.fd.close() + +def coredump(loc): + import code + c = code.InteractiveConsole(loc) + c.interact() + +class Autoloader: + def __init__(self): + self.original_handler = sys.excepthook + + def register(self, deact=False): + if not deact: + sys.excepthook = self + else: + sys.excepthook = self.original_handler + + def __call__(self, exctype, value, trace): + if exctype == NameError: + module_name = charfilter(value.args[0], {"name '": "", "' is not defined": ""}) + #retval = _exec("import %s" % module_name, trace) + + command_locals = trace.tb_frame.f_locals + command_globals = trace.tb_frame.f_globals + try: # retval relates to import success only + exec "import %s" % module_name in command_locals, command_globals + retval = True + say("Autoloaded module %s" %(module_name), timestamp=True, icon="INFO") + exec trace.tb_frame.f_code in command_locals, command_globals + retval = True + except ImportError: + retval = False + except: + traceback.print_exc() + + if exctype != NameError or not retval: + traceback.print_exception(exctype, value, trace) + +autoloader = Autoloader() + +# Ellib setup + +_debug = False +_log = False +_icons = [" ", ">>> ", " * ", " * "] +_icons_debug = ["INFO", "EXEC", "DONE", "FAIL"] +_logfile = time.strftime('%Y-%m-%d %H:%M:%S')+".log"