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)
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 <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 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):
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 output
return apply
# == Profile definitions ==
keepres = bool(environ.get("XCONV_KEEPRES", None))
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))

@ -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<id>\d+)(?:\((?P<lang>[^\)]+)\))?: (?P<type>\w+): (?P<codec>\w+)"
r"(?: \((?P<profile>[^\)]+)\))?\s*(?P<extra>.+)?"
r"Stream #0:(?P<id>\d+)(?:\((?P<lang>[^\)]+)\))?:\s+(?P<type>\w+):\s+(?P<codec>[\w_\d]+)"
r"(?:\s+\((?P<profile>[^\)]+)\))?(?:\s+(?P<extra>.+))?"
)
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)

Loading…
Cancel
Save