xconv: Add profiles for flac, audiobooks

master
Taeyeon Mori 8 years ago
parent 88cd0b90f0
commit c6fe3da852
  1. 314
      bin/xconv
  2. 20
      lib/python/advancedav.py

@ -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) 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. 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 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 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 <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
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 abc import ABCMeta, abstractmethod
from itertools import chain from itertools import chain
from functools import wraps
from os.path import isdir, join as build_path, basename, splitext, exists from os.path import isdir, join as build_path, basename, splitext, exists
from os import environ 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 == # == Profile Decorators ==
profiles = {} profiles = {}
def profile(f): 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 profiles[f.__name__] = f
return f return f
def output(container="matroska", ext="mkv"): def description(desc):
def output(f): """ 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.container = container
f.ext = "mkv" if container == "matroska" else container
if ext:
f.ext = ext f.ext = ext
return f return f
return output return apply
# == Profile definitions == def defines(**defs):
keepres = bool(environ.get("XCONV_KEEPRES", None)) """ 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 @profile
@description("First Video H.264 Main fastdecode animation, max 1280x800; Audio AAC; Keep subtitles")
@output(container="matroska", ext="mkv") @output(container="matroska", ext="mkv")
def laptop(task): 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 # add first video stream
for s in task.iter_video_streams(): for s in task.iter_video_streams():
print(s, s.codec)
os = task.map_stream(s) os = task.map_stream(s)
os.options["codec"] = "libx264" options(os,
if not keepres: codec="libx264",
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 tune=("fastdecode", "animation"),
os.options["tune"] = "fastdecode", "animation" profile="main",
os.options["profile"] = "main" preset="fast")
os.options["preset"] = "fast" downscale(os, 1280, 800)
break break
# Add all audio streams (reencode to aac if necessary) # Add all audio streams (reencode to aac if necessary)
for s in task.iter_audio_streams(): for s in task.iter_audio_streams():
@ -75,30 +172,149 @@ def laptop(task):
os.options["codec"] = "aac" os.options["codec"] = "aac"
# add all subtitle and attachment streams # add all subtitle and attachment streams
for s in chain(task.iter_subtitle_streams(), task.iter_attachment_streams()): for s in chain(task.iter_subtitle_streams(), task.iter_attachment_streams()):
os = task.map_stream(s) task.map_stream(s)
# go # go
return True 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 == # == 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): def parse_args(argv):
parser = ArgumentParser(prog=argv[0]) 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 = parser.add_argument_group("Files")
files.add_argument("inputs", nargs="+", help="The input file(s)") files.add_argument("inputs", nargs="+", help="The input file(s)")
files.add_argument("output", help="The output filename or directory") 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.") files.add_argument("-m", "--merge", help="Merge streams from all inputs instead of mapping each input to an output", action="store_true")
parser.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")
parser.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 = parser.add_argument_group("Programs")
progs.add_argument("--ffmpeg", default="ffmpeg", help="Path to the ffmpeg executable") progs.add_argument("--ffmpeg", default="ffmpeg", help="Path to the ffmpeg executable")
progs.add_argument("--ffprobe", default="ffprobe", help="Path to the ffprobe executable") progs.add_argument("--ffprobe", default="ffprobe", help="Path to the ffprobe executable")
return parser.parse_args(argv[1:]) return parser.parse_args(argv[1:])
def make_outfile(args, infile): def make_outfile(args, profile, infile):
if args.genout: if args.genout:
if hasattr(args.profile, "ext"): if hasattr(profile, "ext"):
return build_path(args.output, ".".join((splitext(basename(infile))[0], args.profile.ext))) return build_path(args.output, ".".join((splitext(basename(infile))[0], profile.ext if profile.ext else "bin")))
else: else:
return build_path(args.output, basename(infile)) return build_path(args.output, basename(infile))
else: else:
@ -107,11 +323,13 @@ def make_outfile(args, infile):
def main(argv): def main(argv):
import logging import logging
logging.basicConfig(level=logging.DEBUG)
# Parse commandline
args = parse_args(argv) 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) args.genout = isdir(args.output)
if not args.genout: if not args.genout:
@ -119,12 +337,22 @@ def main(argv):
print("Output path '%s' is not a directory." % args.output) print("Output path '%s' is not a directory." % args.output)
return -1 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) aav = SimpleAV(ffmpeg=args.ffmpeg, ffprobe=args.ffprobe)
if args.quiet:
aav.global_conv_args = "-loglevel", "warning"
# Collect Tasks
tasks = [] tasks = []
print("\033[35mCollecting Tasks..\033[0m")
if args.merge: 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: for input in args.inputs:
task.add_input(input) task.add_input(input)
@ -133,19 +361,38 @@ def main(argv):
else: else:
for input in args.inputs: for input in args.inputs:
out = make_outfile(args, input) out = make_outfile(args, profile, input)
if args.update and exists(out): if args.update and exists(out):
continue 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) task.add_input(input)
tasks.append(task) 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: for task in tasks:
if not args.profile(task): print("\033[32m Applying profile for '%s'\033[0m" % basename(task.name), end="\033[K\r")
print("Failed to apply profile for '%s'" % basename(task.name)) 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 return 1
print("\033[35mExecuting Tasks..\033[0m\033[K")
# Commit
for task in tasks: for task in tasks:
print("\033[32m Processing '%s'\033[0m" % basename(task.name))
task.commit() task.commit()
return 0 return 0
@ -154,4 +401,3 @@ def main(argv):
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
sys.exit(main(sys.argv)) sys.exit(main(sys.argv))

@ -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) 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. 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 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 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" __all__ = "AdvancedAVError", "AdvancedAV", "SimpleAV"
version_info = 2, 0, 1
# Constants # Constants
DEFAULT_CONTAINER = "matroska" DEFAULT_CONTAINER = "matroska"
@ -240,8 +242,8 @@ class InputFile(File):
# -- Probe streams # -- Probe streams
_reg_probe_streams = re.compile( _reg_probe_streams = re.compile(
r"Stream #0:(?P<id>\d+)(?:\((?P<lang>[^\)]+)\))?: (?P<type>\w+): (?P<codec>\w+)" r"Stream #0:(?P<id>\d+)(?:\((?P<lang>[^\)]+)\))?:\s+(?P<type>\w+):\s+(?P<codec>[\w_\d]+)"
r"(?: \((?P<profile>[^\)]+)\))?\s*(?P<extra>.+)?" r"(?:\s+\((?P<profile>[^\)]+)\))?(?:\s+(?P<extra>.+))?"
) )
def _initialize_streams(self, probe: str=None) -> Iterator: def _initialize_streams(self, probe: str=None) -> Iterator:
@ -507,6 +509,10 @@ class Task:
for input_ in self.inputs: for input_ in self.inputs:
yield from input_.data_streams yield from input_.data_streams
def iter_streams(self) -> Iterator:
for input_ in self.inputs:
yield from input_.streams
# -- FFmpeg # -- FFmpeg
@staticmethod @staticmethod
def argv_options(options: Mapping, qualifier: str=None) -> Iterator: 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 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): def __init__(self, *, ffmpeg="ffmpeg", ffprobe="ffprobe", logger=None, ffmpeg_output=True):
if logger is None: if logger is None:
import logging import logging
@ -728,7 +738,7 @@ class SimpleAV(AdvancedAV):
:type args: Iterable[str] :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) self.to_debug("Running Command: %s", argv)
@ -741,7 +751,7 @@ class SimpleAV(AdvancedAV):
:type args: Iterable[str] :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) self.to_debug("Running Command: %s", argv)

Loading…
Cancel
Save