Dotfiles
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

420 lines
13 KiB

#!/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 <http://www.gnu.org/licenses/>.
"""
from advancedav import 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, dirname, splitext, exists, abspath
from os import environ, makedirs, mkdir
version_info = 0, 1, 3
# == 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
10 years ago
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
# == Extend AAV ==
import advancedav
class OutputFile(advancedav.OutputFile):
def change_format(self, format=None, ext=None):
# Diverge from decorated format.
# Watch out for args.genout!!
if format:
self.container = format
if ext:
self.name = splitext(self.name)[0] + "." + ext
class SimpleTask(advancedav.SimpleTask):
output_factory = OutputFile
# == 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():
(task.map_stream(s)
.set(codec="libx264",
tune=("fastdecode", "animation"),
profile="main",
preset="fast")
.downscale(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.set(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.set(max_samples="all") # Monkey's insane preset is insane.
(task.map_stream(stream)
.set(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)
.set(codec="libopus",
vbr="on")
# Defines
.apply(defines, bitrate="b"))
# Output format
if "ogg" in defines:
task.change_format("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)
.set(codec="libopus",
vbr="on",
b="32k",
ac="1",
application="voip"))
if "stereo" in defines:
out.set(ac="2",
b="36k")
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):
task.change_format(
defines["format"] if "format" in defines else None,
defines["fext"] if "fext" in defines and args.genout else None)
return all(task.map_stream(s) for s in task.iter_streams())
# == 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")
multimode = files.add_mutually_exclusive_group()
multimode.add_argument("-m", "--merge", help="Merge streams from all inputs instead of mapping each input to an output", action="store_true")
multimode.add_argument("-C", "--concat", help="Concatenate streams from inputs instead of mapping", action="store_true")
files.add_argument("-u", "--update", help="Only work on files that don't already exist", action="store_true")
files.add_argument("-c", "--create-parents", help="Create containing folders if they don't exist", action="store_true")
files.add_argument("-cc", "--create-folder", help="Create the output folder if it doesn't 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]
if (args.create_parents or args.create_folder) and not isdir(dirname(args.output)):
makedirs(dirname(args.output))
if args.create_folder and not isdir(args.output):
mkdir(args.output)
args.genout = isdir(args.output)
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 = advancedav.SimpleAV(ffmpeg=args.ffmpeg, ffprobe=args.ffprobe)
if args.quiet:
aav.global_conv_args = "-loglevel", "warning"
aav.global_args += "-hide_banner",
# Collect Tasks
tasks = []
print("\033[35mCollecting Tasks..\033[0m")
if args.merge:
task = SimpleTask(aav, make_outfile(args, profile, args.inputs[0]), profile.container)
for input in args.inputs:
task.add_input(input)
tasks.append(task)
elif args.concat:
import tempfile, os
tmp = tempfile.NamedTemporaryFile(mode="w", delete=False)
with tmp:
tmp.write("ffconcat version 1.0\n")
tmp.write("# XConv concat file\n")
for f in map(abspath, args.inputs):
print("\033[36m Concatenating %s\033[0m" % basename(f))
tmp.write("file '%s'\n" % f)
task = SimpleTask(aav, make_outfile(args, profile, args.inputs[0]), profile.container)
options(task.add_input(tmp.name),
f="concat",
safe="0")
tasks.append(task)
else:
if not args.genout and len(args.inputs) > 1:
print("\033[31mOutput path '%s' is not a directory.\033[0m" % args.output)
return -1
for input in args.inputs:
out = make_outfile(args, profile, input)
if args.update and exists(out):
continue
task = SimpleTask(aav, out, profile.container)
task.add_input(input)
tasks.append(task)
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()
# Clean up
if args.concat:
os.unlink(tmp.name)
return 0
if __name__ == "__main__":
import sys
sys.exit(main(sys.argv))