over-video/over-video.py
2023-05-29 11:21:33 +02:00

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)