1114 lines
34 KiB
Python
Executable file
1114 lines
34 KiB
Python
Executable file
#! /usr/bin/env python3
|
|
# encoding: utf-8
|
|
|
|
import aux
|
|
from dataclasses import dataclass, field
|
|
import enum
|
|
import json
|
|
import multiprocessing # for cpu_count
|
|
import os
|
|
import over
|
|
import pathlib
|
|
import re
|
|
import tempfile
|
|
import time
|
|
import typing
|
|
import version
|
|
|
|
Command = over.cmd.Command
|
|
|
|
##
|
|
## Dynamic command templates: functions and classes
|
|
##
|
|
|
|
@dataclass
|
|
class CodecPar:
|
|
name: str
|
|
prefix: str
|
|
mandatory: bool
|
|
default: typing.Any
|
|
is_valid: typing.Callable
|
|
doc: str
|
|
|
|
def resolve(self, raw):
|
|
"""
|
|
Takes an argument from the user and resolves it into a command line parameter
|
|
and value.
|
|
|
|
E.g. "45" -> ["-crf", "45"]
|
|
|
|
Mandatory CodecPars will raise a ValueError if raw is None. Non-mandatory
|
|
CodecPars will substitude their default if None, and return an empty list
|
|
if neither mandatory nor have a default.
|
|
"""
|
|
|
|
if raw is None:
|
|
value = self.default
|
|
else:
|
|
value = raw
|
|
|
|
if value is None:
|
|
if self.mandatory:
|
|
raise ValueError("%s must be set" %(self.name))
|
|
else:
|
|
return []
|
|
|
|
if self.is_valid and not self.is_valid(value):
|
|
raise ValueError("%s is not a valid %s" %(value, self.name))
|
|
|
|
return [self.prefix, value]
|
|
|
|
class Codec:
|
|
def __init__(self, name: str, doc: str, prelude: list, parameters: list):
|
|
"""
|
|
Represents a portion of an ffmpeg incantation that defines a
|
|
particular codec and its parameters.
|
|
|
|
>>> c = Codec(
|
|
"x265",
|
|
["-c:v", "libx265"],
|
|
[
|
|
CodecPar("CRF", "-crf", True, None, StrIntInRange(0, 51), "Constant Rate Factor: 0 is lossless, 18-25 generally very good."),
|
|
CodecPar("SPEED", "-preset", True, "slow", IsEnum(x264_presets), "Encoder preset: one of " + ", ".join(x264_presets)),
|
|
...
|
|
]
|
|
)
|
|
>>> c.parse("x264:24,medium") # incorrect name, returns nothing
|
|
>>> c.parse("x265:20,superslow")
|
|
["-c:v", "libx265", "-crf", "20", "-preset", "superslow"]
|
|
|
|
:param name: Codec's name visible to the user
|
|
:param prelude: List of shell words that prefix the codec
|
|
:param parameters: List of CodecPar instances
|
|
"""
|
|
|
|
self.name = name
|
|
self.doc = doc
|
|
self.prelude = prelude
|
|
self.parameters = parameters
|
|
|
|
def parse(self, line):
|
|
"""
|
|
Parses the config line and produces a list of shell words.
|
|
|
|
If the codec name does not match this Codec, returns None.
|
|
"""
|
|
|
|
name, *rest = line.split(":")
|
|
args = rest[0].split(",") if rest else []
|
|
|
|
if name != self.name:
|
|
return None
|
|
|
|
cmd = self.prelude[:]
|
|
|
|
for i, parameter in enumerate(self.parameters):
|
|
if i < len(args):
|
|
arg = args[i]
|
|
else:
|
|
arg = None
|
|
|
|
part = parameter.resolve(arg)
|
|
cmd.extend(part)
|
|
|
|
return cmd
|
|
|
|
codec_par_colors = ["R", "r", "Y", "y", "C", "c"]
|
|
|
|
@dataclass
|
|
class CodecRouter:
|
|
"""
|
|
Holds a list of known codecs and their parameters. Takes a codec definition
|
|
line and returns (the codec's name, appropriate ffmpeg command part).
|
|
"""
|
|
|
|
color: str
|
|
codecs: list = field(default_factory=list)
|
|
|
|
def add_codec(self, codec):
|
|
self.codecs.append(codec)
|
|
|
|
def get_command(self, config):
|
|
for codec in self.codecs:
|
|
cmd = codec.parse(config)
|
|
|
|
if cmd:
|
|
return (codec.name, cmd)
|
|
|
|
def help(self, indent=0):
|
|
"""
|
|
Returns a help text for all codecs.
|
|
"""
|
|
|
|
lines = []
|
|
|
|
for codec in self.codecs:
|
|
lines.append("‣ <%s>%s<.>" %(self.color, codec.name))
|
|
lines.append(" %s" %(codec.doc))
|
|
|
|
usage = []
|
|
par_lines = []
|
|
|
|
for i, par in enumerate(codec.parameters):
|
|
usage.append("<%s>%s<.>" %(codec_par_colors[i], par.name))
|
|
par_line_parts = [" "]
|
|
|
|
par_line_parts.append("<%s>%s<.>: %s" %(codec_par_colors[i], par.name, par.doc))
|
|
|
|
if par.default is not None:
|
|
par_line_parts.append(" <K>(default %s)<.>" %(par.default))
|
|
|
|
if not par.mandatory:
|
|
par_line_parts.append(" <m>(optional)<.>")
|
|
|
|
par_lines.append("".join(par_line_parts))
|
|
|
|
if usage:
|
|
lines.append(" Incantation: <%s>%s<.>:%s" %(self.color, codec.name, ",".join(usage)))
|
|
else:
|
|
lines.append(" Incantation: <%s>%s<.>" %(self.color, codec.name))
|
|
|
|
lines.extend(par_lines)
|
|
lines.append("")
|
|
|
|
return "\n".join(" "*indent + line for line in lines)
|
|
|
|
# Validators
|
|
|
|
@dataclass
|
|
class StrIntInRange:
|
|
lowest: int
|
|
highest: int
|
|
|
|
def __call__(self, value):
|
|
try:
|
|
value = int(value)
|
|
except:
|
|
return False
|
|
|
|
return value >= self.lowest and value <= self.highest
|
|
|
|
@dataclass
|
|
class StrFloatInRange:
|
|
lowest: float
|
|
highest: float
|
|
|
|
def __call__(self, value):
|
|
try:
|
|
value = float(value)
|
|
except:
|
|
return False
|
|
|
|
return value >= self.lowest and value <= self.highest
|
|
|
|
@dataclass
|
|
class IsEnum:
|
|
items: list
|
|
|
|
def __call__(self, value):
|
|
return value in self.items
|
|
|
|
def IntK(value):
|
|
if value[-1].lower() == "k":
|
|
value = value[:-1]
|
|
|
|
try:
|
|
_ = int(value)
|
|
|
|
return True
|
|
|
|
except:
|
|
return False
|
|
|
|
##
|
|
## Dynamic command templates: codec definitions
|
|
##
|
|
|
|
x264_presets = [
|
|
"ultrafast",
|
|
"superfast",
|
|
"veryfast",
|
|
"faster",
|
|
"fast",
|
|
"medium",
|
|
"slow",
|
|
"slower",
|
|
"veryslow",
|
|
"placebo"
|
|
]
|
|
|
|
x264_tunes = [
|
|
"film",
|
|
"animation",
|
|
"grain",
|
|
"stillimage",
|
|
"fastdecode",
|
|
"zerolatency",
|
|
"psnr",
|
|
"ssim"
|
|
]
|
|
|
|
x265_tunes = [
|
|
"animation",
|
|
"grain",
|
|
"fastdecode",
|
|
"zerolatency",
|
|
"psnr",
|
|
"ssim"
|
|
]
|
|
|
|
codec_h265 = Codec(
|
|
"h265",
|
|
"HEVC encoded using a hardware encoder via VAAPI. Very good quality and faster-than-realtime times.",
|
|
["-vaapi_device", "/dev/dri/renderD128", "-filter:v", "format=nv12,hwupload", "-profile:v", "main", "-codec:v", "hevc_vaapi", "-rc_mode", "CQP"],
|
|
[
|
|
CodecPar(
|
|
"QP",
|
|
"-qp",
|
|
True,
|
|
None,
|
|
StrIntInRange(0, 52),
|
|
"Quality factor: 0 is lossless, 22-27 generally very good, 52 worst."
|
|
)
|
|
]
|
|
)
|
|
|
|
codec_x265 = Codec(
|
|
"x265",
|
|
"HEVC encoded using x265. Very good quality and decent encode times.",
|
|
["-codec:v", "libx265"],
|
|
[
|
|
CodecPar(
|
|
"CRF",
|
|
"-crf",
|
|
True,
|
|
None,
|
|
StrIntInRange(0, 51),
|
|
"Constant Rate Factor: 0 is lossless, 18-25 generally very good, 51 worst."
|
|
),
|
|
CodecPar(
|
|
"SPEED",
|
|
"-preset",
|
|
True,
|
|
"slow",
|
|
IsEnum(x264_presets),
|
|
"Encoder preset, one of " + ", ".join(x264_presets) + "."
|
|
),
|
|
CodecPar(
|
|
"TUNE",
|
|
"-tune",
|
|
False,
|
|
None,
|
|
IsEnum(x265_tunes),
|
|
"Optional tune hint, one of "+ ", ".join(x265_tunes) + "."
|
|
)
|
|
]
|
|
)
|
|
|
|
codec_x264 = Codec(
|
|
"x264",
|
|
"AVC encoded using x264. Decent quality, fast encodes and very portable.",
|
|
["-codec:v", "libx264"],
|
|
[
|
|
CodecPar(
|
|
"CRF",
|
|
"-crf",
|
|
True,
|
|
None,
|
|
StrIntInRange(0, 63),
|
|
"Constant Rate Factor: 0 is lossless, 20-28 generally very good, 63 worst."
|
|
),
|
|
CodecPar(
|
|
"SPEED",
|
|
"-preset",
|
|
True,
|
|
"slow",
|
|
IsEnum(x264_presets),
|
|
"Encoder preset, one of " + ", ".join(x264_presets) + "."
|
|
),
|
|
CodecPar(
|
|
"TUNE",
|
|
"-tune",
|
|
False,
|
|
None,
|
|
IsEnum(x264_tunes),
|
|
"Optional tune hint, one of "+ ", ".join(x264_tunes) + "."
|
|
)
|
|
]
|
|
)
|
|
|
|
codec_rav1e = Codec(
|
|
"rav1e",
|
|
"AV1 encoded using rav1e. Excellent quality, glacially slow encode times bordering on unusable.",
|
|
["-codec:v", "librav1e"],
|
|
[
|
|
CodecPar(
|
|
"QP",
|
|
"-qp",
|
|
True,
|
|
None,
|
|
StrIntInRange(0, 255),
|
|
"Quantizer: 0 is of highest quality, 80 very good, 100 decent, 255 the worst."
|
|
),
|
|
CodecPar(
|
|
"SPEED",
|
|
"-speed",
|
|
True,
|
|
None,
|
|
StrIntInRange(0, 10),
|
|
"Speed (at the expense of quality): 0 is the slowest, 10 fastest."
|
|
),
|
|
CodecPar(
|
|
"COLS",
|
|
"-tile-columns",
|
|
True,
|
|
"2",
|
|
StrIntInRange(1, 10),
|
|
"How many columns to tile the video into. More columns give faster encodes (and decodes) at slightly worse quality."
|
|
),
|
|
CodecPar(
|
|
"ROWS",
|
|
"-tile-rows",
|
|
True,
|
|
"2",
|
|
StrIntInRange(1, 10),
|
|
"How many rows to tile the video into. More rows give faster encodes (and decodes) at slightly worse quality."
|
|
)
|
|
]
|
|
)
|
|
|
|
codec_opus = Codec(
|
|
"opus",
|
|
"Opus audio via libopus VBR. Excellent overall.",
|
|
["-codec:a", "libopus", "-vbr", "on"],
|
|
[
|
|
CodecPar(
|
|
"ABR",
|
|
"-b:a",
|
|
True,
|
|
"96k",
|
|
IntK,
|
|
"Average bitrate in bps. The default gives very good results."
|
|
)
|
|
]
|
|
)
|
|
|
|
codec_vorbis = Codec(
|
|
"vorbis",
|
|
"Vorbis VBR audio via libvorbis.",
|
|
["-codec:a", "libvorbis"],
|
|
[
|
|
CodecPar(
|
|
"QUALITY",
|
|
"-q:a",
|
|
True,
|
|
"3.0",
|
|
StrFloatInRange(-1, 10),
|
|
"Target quality where -1 is the worst and 10 the best. The default gives very good results."
|
|
)
|
|
]
|
|
)
|
|
|
|
codec_pcm = Codec(
|
|
"pcm",
|
|
"Uncompressed PCM s16le audio.",
|
|
["-codec:a", "pcm_s16le"],
|
|
[]
|
|
)
|
|
|
|
codec_vcopy = Codec(
|
|
"copy",
|
|
"Directly copies the source video stream without encoding.",
|
|
["-codec:v", "copy"],
|
|
[]
|
|
)
|
|
|
|
codec_vdrop = Codec(
|
|
"drop",
|
|
"Drops the source video stream.",
|
|
["-vn"],
|
|
[]
|
|
)
|
|
|
|
codec_acopy = Codec(
|
|
"copy",
|
|
"Directly copies the source audio stream without encoding.",
|
|
["-codec:a", "copy"],
|
|
[]
|
|
)
|
|
|
|
codec_adrop = Codec(
|
|
"drop",
|
|
"Drops the source audio stream.",
|
|
["-an"],
|
|
[]
|
|
)
|
|
|
|
codec_scopy = Codec(
|
|
"copy",
|
|
"Copies over subtitles embedded in the source.",
|
|
["-codec:s", "copy"],
|
|
[]
|
|
)
|
|
|
|
codec_sdrop = Codec(
|
|
"drop",
|
|
"Drops all source subtitle tracks.",
|
|
["-sn"],
|
|
[]
|
|
)
|
|
|
|
video_codec_router = CodecRouter("M")
|
|
video_codec_router.add_codec(codec_h265)
|
|
video_codec_router.add_codec(codec_x265)
|
|
video_codec_router.add_codec(codec_x264)
|
|
video_codec_router.add_codec(codec_rav1e)
|
|
video_codec_router.add_codec(codec_vcopy)
|
|
video_codec_router.add_codec(codec_vdrop)
|
|
|
|
audio_codec_router = CodecRouter("C")
|
|
audio_codec_router.add_codec(codec_opus)
|
|
audio_codec_router.add_codec(codec_vorbis)
|
|
audio_codec_router.add_codec(codec_pcm)
|
|
audio_codec_router.add_codec(codec_acopy)
|
|
audio_codec_router.add_codec(codec_adrop)
|
|
|
|
subtitle_codec_router = CodecRouter("Y")
|
|
subtitle_codec_router.add_codec(codec_scopy)
|
|
subtitle_codec_router.add_codec(codec_sdrop)
|
|
|
|
##
|
|
## Static command templates
|
|
##
|
|
|
|
# see doc/command_assembler.png
|
|
command = over.types.ndict()
|
|
command.identify = Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "INFILE")
|
|
command.normalize_prepass = Command("ffmpeg", "CUT_FROM", "-i", "INFILE", "-threads", str(multiprocessing.cpu_count()), "CUT_TO", "MAP", "-filter:a", "loudnorm=i=-23.0:tp=-2.0:lra=7.0:print_format=json", "-vn", "-sn", "-f", "null", "/dev/null")
|
|
command.encode_generic = Command("ffmpeg", "FPS", "CUT_FROM", "-i", "INFILE", "-threads", str(multiprocessing.cpu_count()), "CUT_TO", "MAP", "VIDEO", "AUDIO", "SUBTITLE", "OUTFILE")
|
|
command.sub_vfilter = Command("-filter:v", "ARGS")
|
|
command.sub_afilter = Command("-filter:a", "ARGS", "-ar", "48k")
|
|
command.merge_chapters = Command("mkvmerge", "--chapters", "CHAPTERS_FILE", "-o", "OUTFILE", "INFILE")
|
|
command.force_yuv420p = Command("-pix_fmt", "yuv420p")
|
|
|
|
##
|
|
## UI helpers
|
|
##
|
|
|
|
@dataclass
|
|
class ListCodecs:
|
|
video_codecs: CodecRouter
|
|
audio_codecs: CodecRouter
|
|
subtitle_codecs: CodecRouter
|
|
|
|
def __call__(self, main):
|
|
print("Available codecs and their configuration:\n")
|
|
|
|
print(over.text.render("<W>--<G>video<.> codecs:"))
|
|
print(over.text.render(self.video_codecs.help(indent=2)))
|
|
|
|
print(over.text.render("<W>--<G>audio<.> codecs:"))
|
|
print(over.text.render(self.audio_codecs.help(indent=2)))
|
|
|
|
print(over.text.render("<W>--<G>subtitle<.> codecs:"))
|
|
print(over.text.render(self.subtitle_codecs.help(indent=2)))
|
|
|
|
main.exit(True)
|
|
|
|
list_codecs = ListCodecs(video_codec_router, audio_codec_router, subtitle_codec_router)
|
|
|
|
##
|
|
## Functions
|
|
##
|
|
|
|
def get_preset(presets, preset):
|
|
out = []
|
|
|
|
if not presets:
|
|
return
|
|
|
|
for atom in presets.split():
|
|
if not atom[0] == "@":
|
|
raise ValueError("invalid preset: " + atom)
|
|
|
|
name, data = atom[1:].split(":", 1)
|
|
|
|
if name != preset:
|
|
continue
|
|
|
|
for statement in data.split(";"):
|
|
key, value = statement.split("=", 1)
|
|
out.append((key, value))
|
|
|
|
return out
|
|
|
|
# --------------------------------------------------
|
|
|
|
factory_presets = " ".join([
|
|
"@share:video=x264:23,slow;audio=opus;container=mp4;ffmpeg-vfilter=scale=1280:trunc(ow/a/2)*2",
|
|
"@share-portrait:video=x264:23,slow;audio=opus;container=mp4;ffmpeg-vfilter=scale=720:trunc(ow/a/2)*2",
|
|
"@archive-sw-22:video=x265:22,slow;audio=opus;container=mkv",
|
|
"@archive-sw-24:video=x265:24,slow;audio=opus;container=mkv",
|
|
"@archive-sw-27:video=x265:27,slow;audio=opus;container=mkv",
|
|
"@archive-animation:video=x265:27,slow,animation;audio=opus;container=mkv",
|
|
"@archive-26:video=h265:26;audio=opus;container=mkv",
|
|
"@archive-28:video=h265:28;audio=opus;container=mkv"
|
|
])
|
|
|
|
# --------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
if not (over.version.major >= 3):
|
|
raise RuntimeError("dev-python/over-3.0 or newer is required")
|
|
|
|
main = over.app.Main("over-video", version.str, "GPL-3", features={"config_file": True})
|
|
main.log.format = "<@>: <i><t>"
|
|
main.add_option("presets", "Space-separated list of presets. See the [<W>Presets<.>] chapter for details.", str, [factory_presets], abbr="p", count=1)
|
|
main.add_option("preset", "Name of the selected preset.", str, abbr="P", count=1)
|
|
main.add_option("codecs", "List all available codecs and their usage.", lambda: list_codecs(main))
|
|
main.add_option("audio", "Audio codec incantation to use. See <W>--<G>codecs<.> for a list.", str, ["opus"], abbr="a", count=1)
|
|
main.add_option("video", "Video codec incantation to use. See <W>--<G>codecs<.> for a list.", str, abbr="v", count=1)
|
|
main.add_option("subtitle", "Subtitle codec incantation to use. See <W>--<G>codecs<.> for a list.", str, ["drop"], abbr="s", count=1)
|
|
main.add_option("container", "Override the target container, e.g. <M>mkv<.>, <M>mp4<.>, or anything else supported by ffmpeg. Over-video uses mkv by default but changes to an appropriate audio container if no video tracks will be in the final output.", str, abbr="c", count=1)
|
|
main.add_option("normalize", "Normalize the audio track without clipping. May use dynamic range compression.", bool, [True], abbr="N")
|
|
main.add_option("ffmpeg-vfilter", 'Raw ffmpeg -filter:v options, e.g. "<M>scale=1280:trunc(ow/a/2)*2,transpose=dir=1<.>"', str, abbr="F", count=1)
|
|
main.add_option("ffmpeg-afilter", 'Raw ffmpeg -filter:a options, e.g. "<M>pan=mono|c0=FL<.>"', str, abbr="f", count=1)
|
|
main.add_option("ffmpeg-map", "Raw ffmpeg <c>-map<.> options, e.g. <W>--<g>map<.> <M>0:1<.> <W>--<g>map<.> <M>0:2<.>. This is a drop-in fix until we get proper stream selection.", str, abbr="m", overwrite=False, count=1)
|
|
main.add_option("chapters", "Path to a Matroska chapters file. See [<W>Chapters<.>].", str, count=1)
|
|
main.add_option("cut", "Start timestamp and the duration of the portion to use. Uses native ffmpeg <c>-ss<.> and <c>-to<.> format, so it's either seconds from start or <M>[<HH>:]<MM>:<SS>[.<<m>...]<.>. Example: <W>--<g>cut<.> <M>25 10<.> uses 10 seconds of video starting at 25s, <W>--<g>cut<.> <M>1:10:45 13:9.5<.> uses video from 4245s to 5034.5s.", over.callback.strings, abbr="x", count=2)
|
|
main.add_option("fps", "Override input framerate.", float, count=1)
|
|
main.add_option("output", "Force an output filename. Note that this overrides <W>--<g>container<.> as we're relying on ffmpeg's container detection by reading the suffix.", str, [""], abbr="O", count=1)
|
|
main.add_option("force-5718", "Force bug #5718 workaround for <W>--<g>audio<.> <M>opus<.>.", bool)
|
|
main.add_option("move", "Move source file to this directory after conversion. Pass an empty string to disable.", str, ["processed"], abbr="M", count=1)
|
|
main.add_option("dump", "Print ffmpeg commands that would be executed. If <W>--<g>normalize<.> is in effect, the normalization pre-pass will still be performed so that the proper volume correction can be computed.", bool, [False], abbr="D", in_cfg_file=False)
|
|
main.add_option("identify", "Print the raw JSON output of ffprobe and exit.", bool, [False], abbr="I", in_cfg_file=False)
|
|
main.add_option("armed", "Perform the suggested action.", bool, [False], abbr="A", in_cfg_file=False)
|
|
|
|
main.add_doc("Description", ["A video converter meant to coerce all audio-video recordings into one format with consistent audio volume. It can also be used to extract audio from video files, resize video, or for very basic cutting."])
|
|
main.add_doc("Performance", ["Good bitstreams take obscene amounts of CPU time to produce. See /doc/codec-comparison.tsv for a table of various configs encoding a 1080p video.", "AV1 is currently unusable due to the amount of time it takes to produce a single frame."])
|
|
main.add_doc("Chapters", ["Over-video can add chapters to an MKV file. The definition is taken from a separate file with the following syntax:", "CHAPTER<m>xx<.>=HH:MM:SS.SSS\n CHAPTER<m>xx<.>NAME=chapter's name\n (...)", "(where <m>xx<.> is a counter starting from 01)"])
|
|
main.add_doc("Presets", ["The various settings can be saved into a named preset for repeated use. The <W>presets<.> key in the config file holds a space-separated list of preset atoms: @<y>name<.>:<r>option<.>=<m>value<.>;<r>option<.>=<m>value<.>.", "For example, with <W>--<G>presets<.> = \"@<y>anime<.>:<r>video<.>=<m>x265:22,slow,animation<.>;<r>audio<.>=<m>opus<.>;<r>container<.>=<m>mkv<.>\" stored in the config file, a rather verbose invocation with <W>--<G>video<.> <m>...<.>, <W>--<G>audio<.> <m>...<.>, and <W>--<G>container<.> <m>...<.> on the command line can be replaced with just <W>--<G>preset<.> <y>anime<.>. You can specify options on the command line to override parts of a preset."])
|
|
|
|
main.setup()
|
|
|
|
# --------------------------------------------------
|
|
# apply presets
|
|
options_from_preset = []
|
|
options_from_preset_overriden = []
|
|
|
|
if main.cfg.preset:
|
|
preset = get_preset(main.cfg.presets, main.cfg.preset)
|
|
|
|
if not preset:
|
|
main.log.fail("<W>--<G>preset<.> <R>%s<.> is unknown", main.cfg.preset)
|
|
main.exit(1)
|
|
|
|
for option_name, value in preset:
|
|
option = main.cfg.options[option_name]
|
|
|
|
if option.source == over.app.Option_sources.command_line:
|
|
options_from_preset_overriden.append(option_name)
|
|
else:
|
|
options_from_preset.append(option_name)
|
|
option.set_value([value], over.app.Option_sources.preset)
|
|
|
|
# --------------------------------------------------
|
|
# cfg checks
|
|
|
|
files = over.types.ndict()
|
|
audio_words = []
|
|
video_words = []
|
|
subtitle_words = []
|
|
files.container = "mkv"
|
|
|
|
if not main.cfg.audio:
|
|
main.log.fail("<W>--<G>audio<.> is not set")
|
|
main.exit(1)
|
|
elif main.cfg.audio in ("copy", "drop"):
|
|
audio_words.append("<c>%s<.>" %(main.cfg.audio))
|
|
else:
|
|
audio_words.append("<g>codec<.>=<M>%s<.>" %(main.cfg.audio))
|
|
|
|
if main.cfg.ffmpeg_afilter:
|
|
audio_words.append("<g>afilter<.>=<M>%s<.>" %(main.cfg.ffmpeg_afilter))
|
|
|
|
if main.cfg.normalize:
|
|
audio_words.append("<c>normalize<.>")
|
|
|
|
if not main.cfg.video:
|
|
main.log.fail("<W>--<G>video<.> is not set")
|
|
main.exit(1)
|
|
elif main.cfg.video in ("copy", "drop"):
|
|
video_words.append("<c>%s<.>" %(main.cfg.video))
|
|
else:
|
|
video_words.append("<g>codec<.>=<M>%s<.>" %(main.cfg.video))
|
|
|
|
if main.cfg.ffmpeg_vfilter:
|
|
video_words.append("<g>vfilter<.>=<M>%s<.>" %(main.cfg.ffmpeg_vfilter))
|
|
|
|
if not main.cfg.subtitle:
|
|
main.log.fail("<W>--<G>subtitle<.> is not set")
|
|
main.exit(1)
|
|
elif main.cfg.subtitle in ("copy", "drop"):
|
|
subtitle_words.append("<c>%s<.>" %(main.cfg.subtitle))
|
|
else:
|
|
subtitle_words.append("<g>codec<.>=<M>%s<.>" %(main.cfg.subtitle))
|
|
|
|
if main.cfg.video == "drop":
|
|
if main.cfg.audio.startswith("pcm"):
|
|
files.container = "wav"
|
|
elif main.cfg.audio.startswith("vorbis"):
|
|
files.container = "ogg"
|
|
elif main.cfg.audio.startswith("opus"):
|
|
files.container = "opus"
|
|
|
|
if main.cfg.container:
|
|
files.container = main.cfg.container
|
|
|
|
if main.cfg.chapters:
|
|
if files.container != "mkv":
|
|
main.log.fail("unable to use <W>--<G>chapters<.> with <R>%s<.> containers", files.container)
|
|
main.exit(1)
|
|
|
|
if not os.path.exists(main.cfg.chapters):
|
|
raise FileNotFoundError(main.cfg.chapters)
|
|
|
|
if options_from_preset or options_from_preset_overriden:
|
|
if options_from_preset:
|
|
note_preset = "<M>%s<.>" %("<.>, <M>".join(options_from_preset))
|
|
else:
|
|
note_preset = "<y>nothing<.>"
|
|
|
|
if options_from_preset_overriden:
|
|
note = "%s loaded from preset <Y>%s<.>; <y>%s<.> was overridden by command line" %(
|
|
note_preset,
|
|
main.cfg.preset,
|
|
"<.>, <r>".join(options_from_preset_overriden)
|
|
)
|
|
|
|
else:
|
|
note = "%s loaded from preset <Y>%s<.>" %(
|
|
note_preset,
|
|
main.cfg.preset
|
|
)
|
|
|
|
main.log.info("settings (%s)", note, end=":\n")
|
|
else:
|
|
main.log.info("settings", end=":\n")
|
|
|
|
main.log.info("video: %s", ", ".join(video_words))
|
|
main.log.info("audio: %s", ", ".join(audio_words))
|
|
main.log.info("subtitle: %s", ", ".join(subtitle_words))
|
|
main.log.info("container: <M>%s<.>", files.container)
|
|
|
|
if main.cfg.cut:
|
|
main.log.warn("cut: start at <M>%s<.>, take <M>%s<.>", main.cfg.cut[0], main.cfg.cut[1])
|
|
|
|
if main.cfg.move:
|
|
main.log.info("move source files to <W>%s<.>/", main.cfg.move)
|
|
|
|
if not audio_codec_router.get_command(main.cfg.audio):
|
|
main.log.fail("unknown <W>--<G>audio<.> codec: <R>%s<.>", main.cfg.audio)
|
|
main.exit(1)
|
|
|
|
if not video_codec_router.get_command(main.cfg.video):
|
|
main.log.fail("unknown <W>--<G>video<.> codec: <R>%s<.>", main.cfg.video)
|
|
main.exit(1)
|
|
|
|
if not subtitle_codec_router.get_command(main.cfg.subtitle):
|
|
main.log.fail("unknown <W>--<G>subtitle<.> codec: <R>%s<.>", main.cfg.subtitle)
|
|
main.exit(1)
|
|
|
|
if not main.targets:
|
|
main.log.warn("no files specified, nothing to do")
|
|
|
|
for tgt in main.targets:
|
|
print()
|
|
|
|
files.infile = aux.to_Path(tgt)
|
|
files.tmpfile = aux.to_Path(tempfile.mktemp(suffix="." + files.container, dir="."))
|
|
if main.cfg.output:
|
|
files.outfile = main.cfg.output
|
|
else:
|
|
files.outfile = files.infile.parent / (str(files.infile.stem) + "." + files.container)
|
|
files.move_infile_to = aux.to_Path(main.cfg.move) / files.infile.name if main.cfg.move else None
|
|
|
|
if (not os.path.exists(tgt)) or os.path.isdir(tgt):
|
|
main.log.fail("target <y>%s<.> <r>is not a readable file<.>, skipping", tgt)
|
|
continue
|
|
|
|
main.log.note("processing <W>%s<.>", tgt)
|
|
|
|
# --------------------------------------------------
|
|
# identify the input file
|
|
|
|
command.identify.reset()
|
|
command.identify.INFILE = "file:" + str(files.infile)
|
|
command.identify.run()
|
|
identify_raw = command.identify.get_all_output().decode("utf-8")
|
|
identify_dict = json.loads(identify_raw)
|
|
|
|
if main.cfg.identify:
|
|
print(identify_raw)
|
|
continue
|
|
|
|
info = over.types.ndict()
|
|
info.t_start = time.monotonic()
|
|
|
|
try:
|
|
info.duration = float(identify_dict["format"]["duration"])
|
|
|
|
video_streams = [s for s in identify_dict["streams"] if s["codec_type"] == "video"]
|
|
audio_streams = [s for s in identify_dict["streams"] if s["codec_type"] == "audio"]
|
|
|
|
amount_vs = len(video_streams)
|
|
amount_as = len(audio_streams)
|
|
|
|
if amount_vs > 1:
|
|
main.log.warn('detected <r>%d<.> video streams, picking the "best" one (see man 1 ffmpeg, section STREAM SELECTION)', amount_vs)
|
|
|
|
if amount_as > 1:
|
|
main.log.warn('detected <y>%d<.> audio streams, picking the "best" one (see man 1 ffmpeg, section STREAM SELECTION)', amount_as)
|
|
|
|
if video_streams:
|
|
# ffmpeg picks the stream with the highest pixel count and then the lowest index
|
|
video_streams.sort(key=lambda s: s["width"] * s["height"], reverse=True)
|
|
video = video_streams[0]
|
|
info.video_codec = video["codec_name"]
|
|
info.video_size_x = video["width"]
|
|
info.video_size_y = video["height"]
|
|
info.video_fps = over.text.Unit(aux.parse_fps(video["r_frame_rate"]), "Hz")
|
|
|
|
if "bit_rate" in video:
|
|
info.video_bitrate = over.text.Unit(video["bit_rate"], "b/s")
|
|
elif "tags" in video and "BPS" in video["tags"]:
|
|
info.video_bitrate = over.text.Unit(int(video["tags"]["BPS"]), "b/s")
|
|
elif "tags" in video and "BPS-eng" in video["tags"]:
|
|
info.video_bitrate = over.text.Unit(int(video["tags"]["BPS-eng"]), "b/s")
|
|
else:
|
|
info.video_bitrate = "<R>??<.>"
|
|
info.pixel_fmt = video["pix_fmt"]
|
|
|
|
if audio_streams:
|
|
# ffmpeg picks the stream with the most channels and then the lowest index
|
|
audio_streams.sort(key=lambda s: s["channels"], reverse=True)
|
|
audio = audio_streams[0]
|
|
info.audio_codec = audio["codec_name"]
|
|
info.audio_channels = audio["channels"]
|
|
info.audio_channel_layout = audio["channel_layout"] if "channel_layout" in audio else "don't care"
|
|
info.audio_samplerate = over.text.Unit(audio["sample_rate"], "Hz")
|
|
info.audio_language = audio["tags"]["language"] if "tags" in audio and "language" in audio["tags"] else "und"
|
|
info.audio_bitrate = over.text.Unit(audio["bit_rate"], "b/s") if "bit_rate" in audio else "<R>??<.>"
|
|
else:
|
|
info.audio_channel_layout = None
|
|
|
|
except:
|
|
main.log.fail("exception while reading identify_dict, dump follows")
|
|
print(identify_dict)
|
|
raise
|
|
|
|
original_filesize = over.text.Unit(files.infile.stat().st_size, "B", base=2)
|
|
original_bitrate = over.text.Unit(original_filesize.value * 8 / info.duration, "b/s")
|
|
main.log.info(" file: size=%s duration=%s bitrate=%s" %(
|
|
original_filesize,
|
|
over.text.timestamp_to_hms(info.duration),
|
|
original_bitrate
|
|
))
|
|
|
|
if video_streams:
|
|
main.log.info("<m>video<.>: size=<M>%d<.>x<M>%d<.> px, framerate=%s, codec=%s, bitrate=%s", info.video_size_x, info.video_size_y, info.video_fps, info.video_codec, info.video_bitrate)
|
|
else:
|
|
main.log.warn("<m>video<.>: <y>none<.>")
|
|
|
|
if audio_streams:
|
|
main.log.info("<c>audio<.>: channels=<C>%d<.>, samplerate=%s, codec=%s, bitrate=%s, language=%s", info.audio_channels, info.audio_samplerate, info.audio_codec, info.audio_bitrate, info.audio_language)
|
|
else:
|
|
main.log.warn("<c>audio<.>: <y>none<.>")
|
|
|
|
# prepare input stream mapping (which needs to be correct even for the normalization pre-pass)
|
|
if main.cfg.ffmpeg_map:
|
|
info.map_command = []
|
|
|
|
for m in main.cfg.ffmpeg_map:
|
|
info.map_command.append("-map")
|
|
info.map_command.append(m)
|
|
else:
|
|
info.map_command = None
|
|
|
|
# --------------------------------------------------
|
|
# normalization pre-pass
|
|
|
|
if audio_streams and main.cfg.normalize and (main.cfg.armed or main.cfg.dump) and (main.cfg.audio not in ["copy", "drop"]):
|
|
main.log.begin("running normalization pre-pass")
|
|
|
|
command.normalize_prepass.reset()
|
|
command.normalize_prepass.INFILE = "file:" + str(files.infile)
|
|
command.normalize_prepass.CUT_FROM = ["-ss", main.cfg.cut[0]] if main.cfg.cut else None
|
|
command.normalize_prepass.CUT_TO = ["-to", main.cfg.cut[1]] if main.cfg.cut else None
|
|
command.normalize_prepass.MAP = info.map_command
|
|
command.normalize_prepass.run(stderr=True)
|
|
|
|
pb = over.text.ProgressBar(
|
|
"§%a [§=a>§ A] §sa (Trem=§TA)",
|
|
{
|
|
"a": {
|
|
"unit": "s",
|
|
"top": info.duration,
|
|
"precision": 1,
|
|
"min_width_raw": 0,
|
|
"min_width_rate": 0,
|
|
"min_width_time": 0
|
|
}
|
|
}
|
|
)
|
|
|
|
pb.render()
|
|
output_buffer = []
|
|
|
|
while True:
|
|
time.sleep(.5)
|
|
|
|
out = command.normalize_prepass.get_output()
|
|
|
|
if out:
|
|
output_buffer.append(out)
|
|
|
|
if b"time=" in out:
|
|
t = aux.parse_time(out)
|
|
pb.set("a", t)
|
|
pb.render()
|
|
|
|
elif out is None:
|
|
break
|
|
|
|
pb.end(True)
|
|
|
|
output = b"".join(output_buffer)
|
|
|
|
# decode the JSON dump from loudnorm
|
|
if output.count(b"{") == 1 and output.count(b"}") == 1:
|
|
loudnorm_dict = json.loads(output[output.index(b"{"):].decode("ascii"))
|
|
info.loudnorm = over.types.ndict({k: aux.float_or_string(v) for k, v in loudnorm_dict.items()})
|
|
else:
|
|
main.log.fail("<R>unexpected ffmpeg output<.>, dump follows")
|
|
print(output.decode("utf-8"))
|
|
raise RuntimeError
|
|
|
|
main.log.done("detected true peak %.1f dB", info.loudnorm.input_tp)
|
|
|
|
info.loudnorm_afilter = "loudnorm=i=-23.0:lra=7.0:tp=-2.0:offset=0.0:measured_i=%.02f:measured_lra=%.02f:measured_tp=%.02f:measured_thresh=%.02f:linear=true" %(
|
|
info.loudnorm.input_i,
|
|
info.loudnorm.input_lra,
|
|
info.loudnorm.input_tp,
|
|
info.loudnorm.input_thresh
|
|
)
|
|
|
|
else:
|
|
info.loudnorm_afilter = None
|
|
|
|
# --------------------------------------------------
|
|
# main command assembly
|
|
encode_cmd = command.encode_generic
|
|
encode_cmd.reset()
|
|
encode_cmd.INFILE = "file:" + str(files.infile)
|
|
encode_cmd.OUTFILE = files.tmpfile
|
|
encode_cmd.FPS = ["-r", main.cfg.fps] if main.cfg.fps else None
|
|
encode_cmd.CUT_FROM = ["-ss", main.cfg.cut[0]] if main.cfg.cut else None
|
|
encode_cmd.CUT_TO = ["-to", main.cfg.cut[1]] if main.cfg.cut else None
|
|
encode_cmd.MAP = info.map_command
|
|
|
|
##
|
|
## audio
|
|
##
|
|
if main.cfg.ffmpeg_afilter:
|
|
info.afilter_command = command.sub_afilter
|
|
info.afilter_command.reset()
|
|
info.afilter_command.ARGS = main.cfg.ffmpeg_afilter
|
|
else:
|
|
info.afilter_command = None
|
|
|
|
audio_codec, audio_incantation = audio_codec_router.get_command(main.cfg.audio)
|
|
|
|
if audio_codec not in ["drop", "copy"]:
|
|
afilters = []
|
|
|
|
if main.cfg.ffmpeg_afilter:
|
|
afilters.append(main.cfg.ffmpeg_afilter)
|
|
|
|
if info.loudnorm_afilter:
|
|
afilters.append(info.loudnorm_afilter)
|
|
|
|
# workaround of https://trac.ffmpeg.org/ticket/5718
|
|
if audio_codec == "opus" and (info.audio_channel_layout == "5.1(side)" or main.cfg.force_5718):
|
|
main.log.warn("applying <y>#5718 workaround<.>")
|
|
afilters.append("channelmap=channel_layout=5.1")
|
|
|
|
if afilters:
|
|
afilter_command = command.sub_afilter
|
|
afilter_command.reset()
|
|
afilter_command.ARGS = ",".join(afilters)
|
|
|
|
audio_incantation.extend(afilter_command.dump())
|
|
|
|
encode_cmd.AUDIO = audio_incantation
|
|
|
|
##
|
|
## video
|
|
##
|
|
if main.cfg.ffmpeg_vfilter:
|
|
info.vfilter_command = command.sub_vfilter
|
|
info.vfilter_command.reset()
|
|
info.vfilter_command.ARGS = main.cfg.ffmpeg_vfilter
|
|
else:
|
|
info.vfilter_command = None
|
|
|
|
video_codec, video_incantation = video_codec_router.get_command(main.cfg.video)
|
|
|
|
if info.vfilter_command and video_codec not in ["drop", "copy"]:
|
|
video_incantation.extend(info.vfilter_command.dump())
|
|
|
|
if video_codec == "x264" and info.pixel_fmt in ["bgr24", "yuv422p"]:
|
|
main.log.warn("source pixel format <r>%s<.> is incompatible with x264, forcing <Y>yuv420p<.>", info.pixel_fmt)
|
|
video_incantation.extend(command.force_yuv420p.dump())
|
|
|
|
encode_cmd.VIDEO = video_incantation
|
|
|
|
##
|
|
## Subtitles
|
|
##
|
|
subtitle_codec, subtitle_incantation = subtitle_codec_router.get_command(main.cfg.subtitle)
|
|
|
|
encode_cmd.SUBTITLE = subtitle_incantation
|
|
|
|
# --------------------------------------------------
|
|
# run the command iff armed
|
|
|
|
if main.cfg.dump or main.cfg.armed:
|
|
cmd = " ".join(encode_cmd.dump(pretty=True))
|
|
|
|
if main.cfg.armed:
|
|
main.log.begin("executing <W>%s<.>", cmd)
|
|
else:
|
|
main.log.info("will execute <W>%s<.>", cmd)
|
|
else:
|
|
main.log.info("will encode into <W>%s<.>", files.tmpfile)
|
|
|
|
if main.cfg.armed:
|
|
pb = over.text.ProgressBar(
|
|
"§%a §rs [§=a>§ A] §sa (§ss) (Sest=§zs, Trem=§TA)",
|
|
{
|
|
"a": {
|
|
"unit": "s",
|
|
"top": int(info.duration),
|
|
"precision": 1,
|
|
"min_width_rate": 9,
|
|
"only_prefixes": ["µ", "m", ""]
|
|
},
|
|
"s": {
|
|
"unit": "B",
|
|
"base": 2,
|
|
"top": None, # size only becomes known in the subsequent updates
|
|
"precision": 1,
|
|
"min_width_raw": 9,
|
|
"min_width_rate": 11
|
|
}
|
|
}
|
|
)
|
|
|
|
encode_cmd.run(stderr=True)
|
|
|
|
while True:
|
|
time.sleep(.5)
|
|
|
|
out = encode_cmd.get_output()
|
|
|
|
if out:
|
|
if b"time=" in out:
|
|
t = aux.parse_time(out)
|
|
try:
|
|
pb.set("a", int(t))
|
|
except ZeroDivisionError:
|
|
pb.set("a", int(1.0))
|
|
|
|
elif out is None:
|
|
break
|
|
|
|
try:
|
|
pb.set("s", files.tmpfile.stat().st_size)
|
|
except FileNotFoundError: # a race condition with ffmpeg
|
|
pass
|
|
|
|
pb.render()
|
|
|
|
new_filesize = over.text.Unit(files.tmpfile.stat().st_size, "B", base=2)
|
|
new_bitrate = over.text.Unit(new_filesize.value * 8 / info.duration, "b/s")
|
|
|
|
pb.end(True)
|
|
|
|
if encode_cmd.returncode == 0:
|
|
main.log.done("success, %s (%s) -> %s (%s) in %s" %(
|
|
original_filesize,
|
|
original_bitrate,
|
|
new_filesize,
|
|
new_bitrate,
|
|
over.text.timestamp_to_hms(time.monotonic() - info.t_start)
|
|
))
|
|
else:
|
|
main.log.fail("<R>encoding failed<.>, ffmpeg returned <y>%d<.>", encode_cmd.returncode)
|
|
raise RuntimeError
|
|
|
|
# --------------------------------------------------
|
|
# inject chapters
|
|
|
|
if main.cfg.chapters:
|
|
if main.cfg.dump_commands or main.cfg.armed:
|
|
files.tmpfile_nochapters = aux.to_Path("nochapter-" + str(files.tmpfile)) # really pathlib?
|
|
|
|
command.merge_chapters.reset()
|
|
command.merge_chapters.CHAPTERS_FILE = main.cfg.chapters
|
|
command.merge_chapters.OUTFILE = files.tmpfile
|
|
command.merge_chapters.INFILE = files.tmpfile_nochapters
|
|
cmd = " ".join(command.merge_chapters.dump(pretty=True))
|
|
|
|
if main.cfg.armed:
|
|
main.log.info("moving <W>%s<.> -> <W>%s<.>", files.tmpfile, files.tmpfile_nochapters)
|
|
files.tmpfile.rename(files.tmpfile_nochapters)
|
|
|
|
main.log.info("executing <W>%s<.>", cmd)
|
|
command.merge_chapters.run()
|
|
_ = command.merge_chapters.get_all_output()
|
|
|
|
main.log.info("deleting <W>%s<.>", files.tmpfile_nochapters)
|
|
files.tmpfile_nochapters.unlink()
|
|
else:
|
|
main.log.info("will move <W>%s<.> -> <W>%s<.>", files.tmpfile, files.tmpfile_nochapters)
|
|
main.log.info("will execute <W>%s<.>", cmd)
|
|
main.log.info("will delete <W>%s<.>", files.tmpfile_nochapters)
|
|
else:
|
|
main.print("will add chapters from <W>%s<.>", main.cfg.chapters)
|
|
|
|
# --------------------------------------------------
|
|
# shuffle files around
|
|
|
|
if main.cfg.move:
|
|
move_to_dir = pathlib.Path(main.cfg.move)
|
|
|
|
if not move_to_dir.is_dir():
|
|
if main.cfg.armed:
|
|
main.log.info("creating directory <W>%s<.>", move_to_dir)
|
|
move_to_dir.mkdir()
|
|
else:
|
|
main.log.info("will create directory <W>%s<.>", move_to_dir)
|
|
|
|
if files.move_infile_to:
|
|
if main.cfg.armed:
|
|
main.log.info("moving <W>%s<.> -> <W>%s<.>", files.infile, files.move_infile_to)
|
|
files.infile.rename(files.move_infile_to)
|
|
else:
|
|
main.log.info("will move <W>%s<.> -> <W>%s<.>", files.infile, files.move_infile_to)
|
|
|
|
if main.cfg.armed:
|
|
main.log.info("moving <W>%s<.> -> <W>%s<.>", files.tmpfile, files.outfile)
|
|
files.tmpfile.rename(files.outfile)
|
|
else:
|
|
main.log.info("will move <W>%s<.> -> <W>%s<.>", files.tmpfile, files.outfile)
|