diff --git a/bin/xconv b/bin/xconv index 629c00a..0278d87 100755 --- a/bin/xconv +++ b/bin/xconv @@ -7,7 +7,7 @@ xconv ffpmeg wrapper based on AdvancedAV 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 Taeyeon Mori + 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 @@ -23,50 +23,147 @@ xconv ffpmeg wrapper based on AdvancedAV along with this program. If not, see . """ -from advancedav import SimpleAV +from advancedav import SimpleAV, version_info as aav_version_info -from argparse import ArgumentParser +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 output(container="matroska", ext="mkv"): - def output(f): - f.container = container - f.ext = ext +def description(desc): + """ Add a profile description """ + def apply(f): + f.description = desc return f - return output + return apply -# == Profile definitions == -keepres = bool(environ.get("XCONV_KEEPRES", None)) +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): - # enable experimental aac codec - task.options["strict"] = "-2" - print(list(task.iter_video_streams()), list(task.iter_audio_streams()), list(task.iter_subtitle_streams())) # add first video stream for s in task.iter_video_streams(): - print(s, s.codec) os = task.map_stream(s) - os.options["codec"] = "libx264" - if not keepres: - os.options["vf"] = "scale='if(gt(a,16/10),1280,-1)':'if(gt(a,16/10),-1,800)'" # scale to 1280x800, keeping the aspect ratio - os.options["tune"] = "fastdecode", "animation" - os.options["profile"] = "main" - os.options["preset"] = "fast" + 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(): @@ -75,30 +172,149 @@ def laptop(task): os.options["codec"] = "aac" # add all subtitle and attachment streams for s in chain(task.iter_subtitle_streams(), task.iter_attachment_streams()): - os = task.map_stream(s) + 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") - parser.add_argument("-p", "--profile", choices=profiles.keys(), required=True, help="Specify the profile. See the source code.") - parser.add_argument("-m", "--merge", help="Merge streams from all inputs instead of mapping each input to an output", action="store_true") - parser.add_argument("-u", "--update", help="Only work on files that don't already exist", action="store_true") + 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, infile): +def make_outfile(args, profile, infile): if args.genout: - if hasattr(args.profile, "ext"): - return build_path(args.output, ".".join((splitext(basename(infile))[0], args.profile.ext))) + 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: @@ -107,11 +323,13 @@ def make_outfile(args, infile): def main(argv): import logging - logging.basicConfig(level=logging.DEBUG) + # Parse commandline args = parse_args(argv) - args.profile = profiles[args.profile] + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) + + profile = profiles[args.profile] args.genout = isdir(args.output) if not args.genout: @@ -119,12 +337,22 @@ def main(argv): 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, args.inputs[0])) + task = aav.create_job(make_outfile(args, profile, args.inputs[0])) for input in args.inputs: task.add_input(input) @@ -133,19 +361,38 @@ def main(argv): else: for input in args.inputs: - out = make_outfile(args, input) + out = make_outfile(args, profile, input) if args.update and exists(out): continue - task = aav.create_job(out, args.profile.container if hasattr(args.profile, "container") else None) + 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: - if not args.profile(task): - print("Failed to apply profile for '%s'" % basename(task.name)) + 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 @@ -154,4 +401,3 @@ def main(argv): if __name__ == "__main__": import sys sys.exit(main(sys.argv)) - diff --git a/lib/python/advancedav.py b/lib/python/advancedav.py index f796c61..fb072e2 100644 --- a/lib/python/advancedav.py +++ b/lib/python/advancedav.py @@ -6,7 +6,7 @@ AdvancedAV FFmpeg commandline generator v2.0 [Library Edition] 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 2014-2015 Taeyeon Mori + Copyright 2014-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 @@ -33,6 +33,8 @@ from collections.abc import Iterable, Mapping, Sequence, Iterator, MutableMappin __all__ = "AdvancedAVError", "AdvancedAV", "SimpleAV" +version_info = 2, 0, 1 + # Constants DEFAULT_CONTAINER = "matroska" @@ -240,8 +242,8 @@ class InputFile(File): # -- Probe streams _reg_probe_streams = re.compile( - r"Stream #0:(?P\d+)(?:\((?P[^\)]+)\))?: (?P\w+): (?P\w+)" - r"(?: \((?P[^\)]+)\))?\s*(?P.+)?" + r"Stream #0:(?P\d+)(?:\((?P[^\)]+)\))?:\s+(?P\w+):\s+(?P[\w_\d]+)" + r"(?:\s+\((?P[^\)]+)\))?(?:\s+(?P.+))?" ) def _initialize_streams(self, probe: str=None) -> Iterator: @@ -507,6 +509,10 @@ class Task: for input_ in self.inputs: yield from input_.data_streams + def iter_streams(self) -> Iterator: + for input_ in self.inputs: + yield from input_.streams + # -- FFmpeg @staticmethod def argv_options(options: Mapping, qualifier: str=None) -> Iterator: @@ -703,6 +709,10 @@ class SimpleAV(AdvancedAV): It uses the python logging module for messages and expects the ffmpeg/ffprobe executables as arguments """ + global_args = () + global_conv_args = () + global_probe_args = () + def __init__(self, *, ffmpeg="ffmpeg", ffprobe="ffprobe", logger=None, ffmpeg_output=True): if logger is None: import logging @@ -728,7 +738,7 @@ class SimpleAV(AdvancedAV): :type args: Iterable[str] """ - argv = tuple(itertools.chain((self._ffmpeg,), args)) + argv = tuple(itertools.chain((self._ffmpeg,), self.global_args, self.global_conv_args, args)) self.to_debug("Running Command: %s", argv) @@ -741,7 +751,7 @@ class SimpleAV(AdvancedAV): :type args: Iterable[str] """ - argv = tuple(itertools.chain((self._ffprobe,), args)) + argv = tuple(itertools.chain((self._ffprobe,), self.global_args, self.global_probe_args, args)) self.to_debug("Running Command: %s", argv)