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.
403 lines
12 KiB
403 lines
12 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 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))
|
|
|