xconv: move it to a package

master
Taeyeon Mori 7 years ago
parent 04c609c152
commit a1fe0e7401
  1. 26
      lib/python/xconv/__init__.py
  2. 30
      lib/python/xconv/__main__.py
  3. 171
      lib/python/xconv/app.py
  4. 196
      lib/python/xconv/cmdline.py
  5. 123
      lib/python/xconv/profile.py
  6. 65
      lib/python/xconv/profileman.py
  7. 55
      lib/python/xconv/profiles/audiobook.py
  8. 14
      lib/python/xconv/profiles/flac.py
  9. 26
      lib/python/xconv/profiles/laptop.py
  10. 20
      lib/python/xconv/profiles/opus.py
  11. 13
      lib/python/xconv/profiles/remux.py

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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/>.
"""
version_info = 0, 2, 0

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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 .app import main
from sys import argv, exit
exit(main(argv))

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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 .profileman import load_profile
from .cmdline import parse_args, version
import advancedav
from os.path import isdir, join as build_path, basename, dirname, splitext, exists, abspath
from os import environ, makedirs, mkdir
from shutil import copyfile
from pathlib import Path
# == Extend AAV ==
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
# == App ==
def make_outfile(args, profile, infile):
if not args.output_filename:
if hasattr(profile, "ext"):
return build_path(args.output_directory, ".".join((splitext(basename(infile))[0], profile.ext if profile.ext else "bin")))
else:
return build_path(args.output_directory, 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 = load_profile(args.profile)
print("\033[36mXConv %s (c) Taeyeon Mori\033[0m" % version)
print("\033[34mProfile: %s\033[0m" % args.profile)
if args.create_directory:
makedirs(args.output_directory, exist_ok=True)
if not args.output_filename and not isdir(args.output_directory):
print("\033[31mOutput location '%s' is not a directory.\033[0m" % args.output_directory)
return -1
# 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:
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)
# Copy files
if args.copy_files:
print("\033[35mCopying Files..\033[0m\033[K")
for file in args.copy_files:
print("\033[32m Copying '%s'\033[0m\033[K" % basename(file))
copyfile(file, build_path(args.output_directory, basename(file)))
print("\033[35mDone.\033[0m\033[K")
return 0

@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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/>.
-----------------------------------------------------------
Commandline Parsing
"""
from .profileman import load_all_profiles, load_profile
from . import version_info
from advancedav import version_info as aav_version_info
from argparse import ArgumentParser, Action
from pathlib import Path
from os.path import basename
version = "%s (AdvancedAV %s)" % (".".join(map(str, version_info)), ".".join(map(str, aav_version_info)))
# == Support code ==
class TerminalAction(Action):
def __init__(self, option_strings, dest, nargs=0, default=None, **kwargs):
super().__init__(option_strings, dest, nargs=nargs, default=default or {}, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
self.run(parser, *values)
parser.exit()
class ProfilesAction(TerminalAction):
def run(self, parser):
print("Available Profiles:")
for name, profile in sorted(load_all_profiles().items()):
print(" %-20s %s" % (name, profile.description if profile.description else ""))
class ProfileInfoAction(TerminalAction):
def __init__(self, option_strings, dest, nargs=1, default=None, **kwargs):
super().__init__(option_strings, dest, nargs=nargs, default=default or {}, **kwargs)
def run(self, parser, profile_name):
profile = load_profile(profile_name)
print("Profile '%s':" % profile_name)
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)
class DefineAction(Action):
def __init__(self, option_strings, dest, nargs=1, default=None, **kwargs):
super().__init__(option_strings, dest, nargs=nargs, 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
class ExtendAction(Action):
def __init__(self, option_strings, dest, nargs="+", default=None, **kwargs):
super().__init__(option_strings, dest, nargs=nargs, default=default, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
items = getattr(namespace, self.dest) or []
items.extend(values)
setattr(namespace, self.dest, items)
def parse_args(argv):
prog = basename(argv[0])
if prog == "__main__.py":
prog = "python -m xconv"
parser = ArgumentParser(prog=prog,
usage="""%(prog)s [-h | -l | -i PROFILE]
%(prog)s [option]... -p PROFILE [-DNAME[=VALUE]]... [-B] [-T] input output
%(prog)s [option]... -p PROFILE [-DNAME[=VALUE]]... -M [-T] inputs... output
%(prog)s [option]... -p PROFILE [-DNAME[=VALUE]]... -C [-T] inputs... output
%(prog)s [option]... -p PROFILE [-DNAME[=VALUE]]... [-B] inputs... directory
%(prog)s [option]... -p PROFILE [-DNAME[=VALUE]]... [-B] -t directory inputs...""",
description="""FFmpeg wrapper based on AdvancedAV""")
parser.add_argument("-V", "--version", help="Show version and quit", action="version",
version="""XConv %s""" % version)
# Available Options
parser.add_argument("-v", "--verbose", help="Enable verbose output", action="store_true")
parser.add_argument("-q", "--quiet", help="Be less verbose", action="store_true")
profile = parser.add_argument_group("Profile")
profile.add_argument("-l", "--list-profiles", help="List profiles and quit", action=ProfilesAction)
profile.add_argument("-i", "--profile-info", help="Give info about a profile and quit", metavar="PROFILE", action=ProfileInfoAction)
profile.add_argument("-p", "--profile", help="Specify the profile", metavar="PROFILE", required=True)
profile.add_argument("-D", "--define", help="Define an option to be used by the profile", metavar="NAME[=VALUE]", action=DefineAction)
mode = parser.add_argument_group("Mode").add_mutually_exclusive_group()
mode.add_argument("-B", "--batch", help="Batch process every input file into an output file (default)", action="store_true")
mode.add_argument("-M", "--merge", help="Merge streams from all inputs", action="store_true")
mode.add_argument("-C", "--concat", help="Concatenate streams from inputs", action="store_true")
files = parser.add_argument_group("Files")
files.add_argument("inputs", help="The input file(s)", nargs="+")
files.add_argument("output", help="The output filename or directory (unless -t is given)", nargs="?") # always empty
files.add_argument("-u", "--update", help="Only work on files that don't already exist", action="store_true")
files.add_argument("-c", "--create-directory", help="Create directories if they don't exist", action="store_true")
target = files.add_mutually_exclusive_group()
target.add_argument("-t", "--target-directory", help="Output into a directory", metavar="DIRECTORY", type=Path)
target.add_argument("-T", "--no-target-directory", help="Treat output as a normal file", action="store_true")
files.add_argument("-S", "--subdirectory", help="Work in a subdirectory of here and -t (use glob patterns for inputs)")
files.add_argument("-K", "--copy-files", help="Copy all following files unmodified", metavar="FILE", action=ExtendAction)
progs = parser.add_argument_group("Programs")
progs.add_argument("--ffmpeg", help="Path to the ffmpeg executable", default="ffmpeg")
progs.add_argument("--ffprobe", help="Path to the ffprobe executable", default="ffprobe")
# Parse arguments
args = parser.parse_args(argv[1:])
# Figure out output path
# ----------------------
# Fill in args.output
# args.output will never be filled in by argparse, since inputs consumes everything
if args.target_directory:
args.output = args.target_directory
elif len(args.inputs) < 2:
parser.error("Neither --target-directory nor output is given")
else:
args.output = Path(args.inputs.pop(-1))
if args.subdirectory:
subdir = Path(args.subdirectory)#.resolve()
outdir = Path(args.output, args.subdirectory)#.resolve()
if outdir.exists() and not outdir.is_dir():
parser.error("--subdirectory only works with output directories. '%s' exists and isn't a directory")
inputs = args.inputs
args.inputs = []
for pattern in inputs:
args.inputs.extend(subdir.glob(pattern))
files = args.copy_files
args.copy_files = []
for pattern in files:
args.copy_files.extend(subdir.glob(pattern))
args.output_directory = args.output = outdir
args.output_filename = None
else:
# Check if we're outputting to a directory
multiple_outputs = args.copy_files or not (args.merge or args.concat) and len(args.inputs) > 1
if args.target_directory or args.output.is_dir() or multiple_outputs:
if args.no_target_directory:
if multiple_outputs:
parser.error("Passed --no-target-directory, but operation would have multiple outputs. (See --merge or --concat)")
else:
parser.error("Passed --no-target-directory, but '%s' is an existing directory." % args.output)
args.output_filename = None
args.output_directory = args.output
else:
args.output_filename = args.output.name
args.output_directory = args.output.parent
return args

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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/>.
-----------------------------------------------------------
Decorators for defining xconv profiles
"""
from .profileman import index
from functools import wraps
__all__ = [
"profile",
"description",
"output",
"defines",
"features",
"singleaudio"
]
# == 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 ==
def profile(f):
"""
Define a XConv Profile
Note: Should be outermost decorator
"""
__defaults(f,
description=None,
container=None,
ext=None,
defines={},
features={})
index[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

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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 .profiles import __path__ as profilepaths
from importlib import import_module
from pathlib import Path
# == Profile Index ==
index = {}
def load_profile(name):
if name in index:
return index[name]
try:
import_module(".profiles.%s" % name, __package__)
except ImportError as e:
print(e)
pass
if name in index:
return index[name]
load_all_profiles()
if name in index:
return index[name]
raise ImportError("Could not find XConv profile '%s'" % name)
def load_all_profiles():
for location in profilepaths:
for mod in (x for x in Path(location).iterdir() if x.is_file() and x.suffix == ".py"):
try:
import_module(".profiles.%s" % mod.stem, __package__)
except ImportError:
pass
return index

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
xconv ffmpeg 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-2017 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/>.
-----------------------------------------------------------
Opus Audiobook profile
"""
from ..profile import *
@profile
@description("Encode Opus Audiobook")
@output(container="ogg", ext="ogg")
@defines(stereo="Use two channels",
bitrate="Use custom target bitrate",
voip="Use voice optimization",
fancy="Use 48kbps stereo (For dramatic audiobooks with a lot of music and effects)")
@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")
if "fancy" in defines:
out.set(ac="2",
b="48k",
application="audio")
if "bitrate" in defines:
out.set(b=defines["bitrate"])
return True

@ -0,0 +1,14 @@
from ..profile import *
@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

@ -0,0 +1,26 @@
from ..profile import *
@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

@ -0,0 +1,20 @@
from ..profile import *
@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

@ -0,0 +1,13 @@
from ..profile import *
@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())
Loading…
Cancel
Save