#!/usr/bin/env python3 """ xconv ffpmeg wrapper based on AdvancedAV ----------------------------------------------------------- AdvancedAV helps with constructing FFmpeg commandline arguments. It can automatically parse input files with the help of FFmpeg's ffprobe tool (WiP) and allows programatically mapping streams to output files and setting metadata on them. ----------------------------------------------------------- Copyright (c) 2015-2016 Taeyeon Mori This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ from advancedav import SimpleAV, version_info as aav_version_info from argparse import ArgumentParser, Action from abc import ABCMeta, abstractmethod from itertools import chain from functools import wraps from os.path import isdir, join as build_path, basename, splitext, exists from os import environ version_info = 0, 1, 1 # == Misc. helpers == def __update(f, name, update): if hasattr(f, name): getattr(f, name).update(update); else: setattr(f, name, update) def __defaults(obj, **defs): for k, v in defs.items(): if not hasattr(obj, k): setattr(obj, k, v) # == Profile Decorators == profiles = {} def profile(f): """ Define a XConv Profile Note: Should be outermost decorator """ __defaults(f, description=None, container=None, ext=None, defines={}, features={}) profiles[f.__name__] = f return f def description(desc): """ Add a profile description """ def apply(f): f.description = desc return f return apply def output(container=None, ext=None): """ Add output file information """ def apply(f): if container: f.container = container f.ext = "mkv" if container == "matroska" else container if ext: f.ext = ext return f return apply def defines(**defs): """ Document supported defines with description """ def apply(f): __update(f, "defines", defs) return f return apply def features(**features): """ Set opaque feature flags """ def apply(f): __update(f, "features", features) return f return apply def singleaudio(profile): """ Operate on a single audio stream (The first one found) The stream will be passed to the decorated function in the "stream" keyword """ @wraps(profile) def wrapper(task, **kwds): try: audio_stream = next(task.iter_audio_streams()) except StopIteration: print("No audio track in '%s'" % "', '".join(map(lambda x: x.name, task.inputs))) return False return profile(task, stream=audio_stream, **kwds) __update(wrapper, "features", {"singleaudio": None}) return wrapper # == Utilities for Profiles == def downscale(outstream, width, height): # Scale while keeping aspect ratio; never upscale. outstream.options["filter_complex"] = "scale=iw*min(1\,min(%i/iw\,%i/ih)):-1" % (width, height) def apply_defines(defines, object, *names, **maps): # Apply defines to task/stream options maps = (maps or {}) for name in names: maps[name] = name for define, option in maps.items(): if define in defines: object.options[option] = defines[define] def options(object, **options): object.options.update(options) return object def change_format(outfile, format=None, ext=None): # Diverge from decorated format. # Watch out for args.genout!! if format: outfile.container = format if ext: outfile.name = splitext(outfile.name)[0] + "." + ext # == Profile definitions == @profile @description("First Video H.264 Main fastdecode animation, max 1280x800; Audio AAC; Keep subtitles") @output(container="matroska", ext="mkv") def laptop(task): # add first video stream for s in task.iter_video_streams(): os = task.map_stream(s) options(os, codec="libx264", tune=("fastdecode", "animation"), profile="main", preset="fast") downscale(os, 1280, 800) break # Add all audio streams (reencode to aac if necessary) for s in task.iter_audio_streams(): os = task.map_stream(s) if s.codec != "aac": os.options["codec"] = "aac" # add all subtitle and attachment streams for s in chain(task.iter_subtitle_streams(), task.iter_attachment_streams()): task.map_stream(s) # go return True @profile @description("Save the first audio track as FLAC.") @output(ext="flac") @singleaudio def flac(task, stream): if stream.codec == "ape": stream.file.options["max_samples"] = "all" # Monkey's insane preset is insane. options(task.map_stream(stream), codec="flac", compression_level="10") return True @profile @description("Encode Opus Audio") @output(ext="mka", container="matroska") @defines(ogg="Use Ogg/Opus output container", bitrate="Target bitrate (Default 96k)") @features(argshax=None) @singleaudio def opus(task, stream, defines, args): os = task.map_stream(stream) options(os, codec="libopus", vbr="on") # options apply_defines(defines, os, bitrate="b") # Output format if "ogg" in defines: change_format(task.output, "ogg", "opus" if args.genout else None) return True @profile @description("Encode Opus Audiobook") @output(container="ogg", ext="ogg") @defines(stereo="Keep stereo channels") @singleaudio def audiobook(task, stream, defines): out = task.map_stream(stream) options(out, codec="libopus", vbr="on", b="24k", ac="1", application="voip", frame_duration="40") if "stereo" in defines: options(out, ac="2", b="32k") return True @profile @description("Copy all streams to a new container") @defines(format="Container format", fext="File extension") @features(argshax=None) def remux(task, defines, args): change_format(task.output, defines["format"] if "format" in defines else None, defines["fext"] if "fext" in defines and args.genout else None) all(task.map_stream(s) for s in task.iter_streams()) return True # == Support code == class ProfilesAction(Action): def __call__(self, parser, *a, **b): print("Available Profiles:") for name, profile in sorted(profiles.items()): print(" %-20s %s" % (name, profile.description if profile.description else "")) parser.exit() class ProfileInfoAction(Action): def __call__(self, parser, args, *a, **b): if not args.profile: print("-i must come after -p") parser.exit() # todo profile = profiles[args.profile] print("Profile '%s':" % args.profile) if profile.description: print(" Description: %s" % profile.description) output_info = [] if profile.container: output_info.append("Format: %s" % profile.container) if profile.ext: output_info.append("File extension: %s" % profile.ext) if output_info: print(" Output: %s" % "; ".join(output_info)) if profile.features: print(" Flags: %s" % ", ".join("%s(%r)" % (k, v) if v is not None else k for k, v in profile.features.items())) if profile.defines: print(" Supported defines:") for define in sorted(profile.defines.items()): print(" %s: %s" % define) parser.exit() class DefineAction(Action): def __init__(self, option_strings, dest, nargs=None, default=None, **kwargs): super().__init__(option_strings, dest, nargs=1, default=default or {}, **kwargs) def __call__(self, parser, namespace, values, option_string=None): value = values[0] dest = getattr(namespace, self.dest) if "=" in value: k, v = value.split("=") dest[k] = v else: dest[value] = True def parse_args(argv): parser = ArgumentParser(prog=argv[0]) parser.add_argument("-v", "--verbose", help="Enable verbose output", action="store_true") parser.add_argument("-q", "--quiet", help="Be less verbose", action="store_true") files = parser.add_argument_group("Files") files.add_argument("inputs", nargs="+", help="The input file(s)") files.add_argument("output", help="The output filename or directory") files.add_argument("-m", "--merge", help="Merge streams from all inputs instead of mapping each input to an output", action="store_true") files.add_argument("-u", "--update", help="Only work on files that don't already exist", action="store_true") profile = parser.add_argument_group("Profile") profile.add_argument("-p", "--profile", choices=profiles.keys(), required=True, help="Specify the profile. See the source code.") profile.add_argument("-l", "--list-profiles", action=ProfilesAction, nargs=0, help="List profiles and exit") profile.add_argument("-i", "--profile-info", action=ProfileInfoAction, nargs=0, help="Give info about a profile") profile.add_argument("-D", "--define", help="Define an option to be used by the profile", action=DefineAction, metavar="NAME[=VALUE]") progs = parser.add_argument_group("Programs") progs.add_argument("--ffmpeg", default="ffmpeg", help="Path to the ffmpeg executable") progs.add_argument("--ffprobe", default="ffprobe", help="Path to the ffprobe executable") return parser.parse_args(argv[1:]) def make_outfile(args, profile, infile): if args.genout: if hasattr(profile, "ext"): return build_path(args.output, ".".join((splitext(basename(infile))[0], profile.ext if profile.ext else "bin"))) else: return build_path(args.output, basename(infile)) else: return args.output def main(argv): import logging # Parse commandline args = parse_args(argv) logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) profile = profiles[args.profile] args.genout = isdir(args.output) if not args.genout: if len(args.inputs) > 1: print("Output path '%s' is not a directory." % args.output) return -1 print("\033[36mXConv %s (AdvancedAV %s) (c) Taeyeon Mori\033[0m" % (".".join(map(str, version_info)), ".".join(map(str, aav_version_info)))) print("\033[34mProfile: %s\033[0m" % args.profile) # Initialize AAV aav = SimpleAV(ffmpeg=args.ffmpeg, ffprobe=args.ffprobe) if args.quiet: aav.global_conv_args = "-loglevel", "warning" # Collect Tasks tasks = [] print("\033[35mCollecting Tasks..\033[0m") if args.merge: task = aav.create_job(make_outfile(args, profile, args.inputs[0])) for input in args.inputs: task.add_input(input) tasks.append(task) else: for input in args.inputs: out = make_outfile(args, profile, input) if args.update and exists(out): continue task = aav.create_job(out, profile.container) task.add_input(input) tasks.append(task) task.options["hide_banner"] = None print("\033[35mPreparing Tasks..\033[0m") # Prepare profile parameters pkw = {} if profile.defines: pkw["defines"] = args.define if profile.features: if "argshax" in profile.features: pkw["args"] = args # Apply profile for task in tasks: print("\033[32m Applying profile for '%s'\033[0m" % basename(task.name), end="\033[K\r") res = profile(task, **pkw) if not res: print("\033[31m Failed to apply profile for '%s'\033[0m\033[K" % basename(task.name)) return 1 print("\033[35mExecuting Tasks..\033[0m\033[K") # Commit for task in tasks: print("\033[32m Processing '%s'\033[0m" % basename(task.name)) task.commit() return 0 if __name__ == "__main__": import sys sys.exit(main(sys.argv))