ffmpeg-skill 0.12.0 → 0.16.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -7
- package/SKILL.md +26 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +177 -2
- package/scripts/_common.py +112 -5
- package/scripts/_contract.py +128 -6
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +48 -5
- package/scripts/check.py +8 -0
- package/scripts/color.py +102 -22
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +26 -6
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +7 -5
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/look.py +13 -8
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +27 -10
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/scenes.py +8 -2
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/stabilize.py +22 -4
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
package/scripts/caption.py
CHANGED
|
@@ -60,7 +60,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
|
|
|
60
60
|
except MissingFpsError as e:
|
|
61
61
|
die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
|
|
62
62
|
except ValueError:
|
|
63
|
-
|
|
63
|
+
# TIME_RE matched (so m.group("text") is the real cue text, not the broken
|
|
64
|
+
# timestamp), but one of the two timestamps itself failed to parse (e.g. a
|
|
65
|
+
# malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
|
|
66
|
+
# burn the whole raw line, broken timestamp included, into the caption instead
|
|
67
|
+
# of just the text after it.
|
|
68
|
+
start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
|
|
64
69
|
else:
|
|
65
70
|
text = m.group("text").strip()
|
|
66
71
|
else:
|
|
@@ -165,6 +170,13 @@ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
|
165
170
|
def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
166
171
|
with open(path, "w", encoding="utf-8") as fh:
|
|
167
172
|
for i, (s, e, t) in enumerate(cues, 1):
|
|
173
|
+
# A blank line is SRT's own block separator (index/timecode/text, blank, next block).
|
|
174
|
+
# Cue text can contain one -- parse_text_cues() turns a bare "|" into "\n", so a source
|
|
175
|
+
# line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
|
|
176
|
+
# raw would split one cue into two malformed half-blocks (the second missing its own
|
|
177
|
+
# index/timecode). Collapse any run of blank lines within the cue text to a single
|
|
178
|
+
# newline so the cue's own text can never fake the format's block boundary.
|
|
179
|
+
t = re.sub(r"\n{2,}", "\n", t).strip("\n")
|
|
168
180
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
169
181
|
|
|
170
182
|
|
|
@@ -247,12 +259,20 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
247
259
|
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "",
|
|
248
260
|
"[V4+ Styles]",
|
|
249
261
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
250
|
-
f"Style: Default,{args.font},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
|
|
262
|
+
f"Style: Default,{ass_font_name(args.font)},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
|
|
251
263
|
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
252
264
|
]
|
|
253
265
|
lines = []
|
|
254
266
|
for start, end, text in cues:
|
|
255
267
|
text = text.replace("\n", "\\N")
|
|
268
|
+
# ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
|
|
269
|
+
# commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
|
|
270
|
+
# all effectively user-controlled) that happens to contain braces would otherwise be
|
|
271
|
+
# interpreted as those commands (\pos, \t, \fscx, ...), letting caption content reposition,
|
|
272
|
+
# rescale, or recolor itself or later text instead of just being read out. No caption needs
|
|
273
|
+
# a literal curly brace, so they're dropped outright, matching the "unneeded delimiter
|
|
274
|
+
# character -> drop it" call already made for font names (see ass_font_name()).
|
|
275
|
+
text = text.replace("{", "").replace("}", "")
|
|
256
276
|
fx = ""
|
|
257
277
|
if args.animate == "fade":
|
|
258
278
|
fx = "{\\fad(200,200)}"
|
|
@@ -302,6 +322,20 @@ def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
|
302
322
|
return f"&H{alpha:02X}{b}{g}{r}".upper()
|
|
303
323
|
|
|
304
324
|
|
|
325
|
+
def ass_font_name(name: str) -> str:
|
|
326
|
+
"""Sanitise a font name for embedding in an ASS [V4+ Styles] Style line (comma-delimited
|
|
327
|
+
fields, no quoting mechanism) and in a `force_style='...'` option list (comma-separated
|
|
328
|
+
Key=Value pairs, colon-separated from the rest of the -vf filter). No real font name uses
|
|
329
|
+
`, : \\ '`, so rather than chase a per-context escape (a Style line and a force_style list
|
|
330
|
+
have different delimiter rules), those characters -- and control characters, which are never
|
|
331
|
+
meaningful in a font name either -- are dropped outright, the same "no escape proven safe
|
|
332
|
+
everywhere it's used" call this codebase already makes for escape_drawtext()'s `'`/`%`."""
|
|
333
|
+
name = re.sub(r"[\x00-\x1f\x7f]", "", name)
|
|
334
|
+
for ch in ",:\\'":
|
|
335
|
+
name = name.replace(ch, "")
|
|
336
|
+
return name
|
|
337
|
+
|
|
338
|
+
|
|
305
339
|
def main() -> int:
|
|
306
340
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
307
341
|
ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
|
|
@@ -427,14 +461,23 @@ def main() -> int:
|
|
|
427
461
|
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
428
462
|
die(f"SRT file not found: {srt_path}")
|
|
429
463
|
codec = mux_subtitle_codec(output)
|
|
464
|
+
# Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
|
|
465
|
+
# language to build a multi-language set) -- copied byte-identical, distinct from the
|
|
466
|
+
# newly-added SRT's own codec below.
|
|
467
|
+
existing_subs = meta.get("subtitle_streams") or 0
|
|
430
468
|
maps = ["-map", "0:v:0"]
|
|
431
469
|
cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
|
|
432
470
|
if meta.get("audio"):
|
|
433
471
|
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
472
|
+
if existing_subs:
|
|
473
|
+
maps += ["-map", "0:s?"]
|
|
434
474
|
maps += ["-map", "1:0"]
|
|
435
|
-
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
|
475
|
+
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
|
476
|
+
for i in range(existing_subs):
|
|
477
|
+
cmd += [f"-c:s:{i}", "copy"]
|
|
478
|
+
cmd += [f"-c:s:{existing_subs}", codec]
|
|
436
479
|
if args.language:
|
|
437
|
-
cmd += ["-metadata:s:s:
|
|
480
|
+
cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
|
|
438
481
|
cmd += [output]
|
|
439
482
|
run(cmd)
|
|
440
483
|
result = probe(output, role="output")
|
|
@@ -462,7 +505,7 @@ def main() -> int:
|
|
|
462
505
|
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
463
506
|
die(f"SRT file not found: {srt_path}")
|
|
464
507
|
style = [
|
|
465
|
-
f"FontName={args.font}",
|
|
508
|
+
f"FontName={ass_font_name(args.font)}",
|
|
466
509
|
f"FontSize={args.size}",
|
|
467
510
|
f"PrimaryColour={ass_color(args.color)}",
|
|
468
511
|
f"OutlineColour={ass_color(args.outline_color)}",
|
package/scripts/check.py
CHANGED
|
@@ -162,6 +162,14 @@ def main() -> int:
|
|
|
162
162
|
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
|
|
163
163
|
reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
|
|
164
164
|
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
|
|
165
|
+
else:
|
|
166
|
+
# ffmpeg's loudnorm JSON didn't parse out of stderr (unexpected/garbled output).
|
|
167
|
+
# Dropping the loudness/true-peak rows silently here would report an overall PASS
|
|
168
|
+
# for a platform that has a loudness requirement, without ever having checked it --
|
|
169
|
+
# a false PASS is worse than noise. WARN so the gap is visible in the report.
|
|
170
|
+
row("loudness", "WARN", "could not measure", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "re-run check.py, or verify loudness manually",
|
|
171
|
+
reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
|
|
172
|
+
row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually")
|
|
165
173
|
elif args.platform in ("podcast",):
|
|
166
174
|
row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
|
|
167
175
|
else:
|
package/scripts/color.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"""Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
|
|
3
3
|
real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
|
|
4
4
|
colour tags without re-encoding, or apply typed primary colour correction
|
|
5
|
-
(exposure, contrast, saturation, white balance
|
|
5
|
+
(exposure, contrast, saturation, gamma, white balance, three-way
|
|
6
|
+
lift/gain, levels, curves).
|
|
6
7
|
|
|
7
8
|
Examples:
|
|
8
9
|
python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
|
|
@@ -13,21 +14,26 @@ Examples:
|
|
|
13
14
|
python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
|
|
14
15
|
python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
|
|
15
16
|
python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
|
|
17
|
+
python3 color.py flat.mp4 --correct --gamma 1.2 --lift 0.04 --gain -0.03 # three-way shadows/gamma/highlights
|
|
18
|
+
python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
|
|
16
19
|
"""
|
|
17
20
|
import argparse
|
|
18
21
|
import os
|
|
19
22
|
import sys
|
|
20
23
|
from typing import List
|
|
21
24
|
|
|
22
|
-
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
25
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args
|
|
23
26
|
|
|
24
27
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
25
28
|
|
|
26
29
|
# Typed primary correction: each flag is one option of one real, always-available libavfilter filter
|
|
27
30
|
# (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
|
|
28
31
|
# documents (`ffmpeg -h filter=<name>`), not the filter's full technical range. default is each filter's
|
|
29
|
-
# own documented no-op value, so every stage
|
|
30
|
-
# which flags were actually given.
|
|
32
|
+
# own documented no-op value, so every stage in this dict is always emitted and the chain never depends
|
|
33
|
+
# on which flags were actually given (exposure/temperature/tint/gamma/lift/gain). `colorlevels` and
|
|
34
|
+
# `curves` (see LEVELS and CURVES_PRESETS below) are the exception: they only add a term to the chain
|
|
35
|
+
# when the caller actually asks for them, because "levels 0..255 in, 0..255 out" and "no curve" are
|
|
36
|
+
# already the identity operation without emitting a no-op filter term for it.
|
|
31
37
|
CORRECTION = {
|
|
32
38
|
# flag default lo hi unit
|
|
33
39
|
"exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
|
|
@@ -35,8 +41,27 @@ CORRECTION = {
|
|
|
35
41
|
"saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
|
|
36
42
|
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
|
|
37
43
|
"tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
|
|
44
|
+
"gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
|
|
45
|
+
"lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
|
|
46
|
+
"gain": (0.0, -1.0, 1.0, "x"), # colorbalance highlights (rh=gh=bh); 0=unchanged
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
# colorlevels takes fractional 0.0..1.0 input/output black/white points; this tool exposes the
|
|
50
|
+
# familiar 8-bit 0..255 unit instead and divides by 255.0 when building the filter (same convention
|
|
51
|
+
# as the rest of CORRECTION: a human-friendly CLI unit formatted into the filter's own native unit).
|
|
52
|
+
LEVELS = {
|
|
53
|
+
# flag default lo hi
|
|
54
|
+
"levels_in_black": (0, 0, 255),
|
|
55
|
+
"levels_in_white": (255, 0, 255),
|
|
56
|
+
"levels_out_black": (0, 0, 255),
|
|
57
|
+
"levels_out_white": (255, 0, 255),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# curves filter's real built-in presets (`ffmpeg -h filter=curves`), excluding its own "none" (0):
|
|
61
|
+
# omitting --curves already gets that identity result without adding a filter term for it.
|
|
62
|
+
CURVES_PRESETS = ["color_negative", "cross_process", "darker", "increase_contrast", "lighter",
|
|
63
|
+
"linear_contrast", "medium_contrast", "negative", "strong_contrast", "vintage"]
|
|
64
|
+
|
|
40
65
|
|
|
41
66
|
def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
42
67
|
_, lo, hi, unit = CORRECTION[flag]
|
|
@@ -46,26 +71,65 @@ def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
|
46
71
|
return value
|
|
47
72
|
|
|
48
73
|
|
|
74
|
+
def _checked_levels(args: argparse.Namespace, flag: str) -> int:
|
|
75
|
+
_, lo, hi = LEVELS[flag]
|
|
76
|
+
value = getattr(args, flag)
|
|
77
|
+
if not (lo <= value <= hi):
|
|
78
|
+
die(f"--{flag.replace('_', '-')} {value} is outside {lo}..{hi} (8-bit units, scaled to colorlevels' own 0..1 range)")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
49
82
|
def correction_chain(args: argparse.Namespace) -> str:
|
|
50
|
-
"""
|
|
51
|
-
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint
|
|
52
|
-
contrast/saturation act on colour-balanced footage) -> contrast
|
|
53
|
-
adjacent stage
|
|
54
|
-
expressed as colorbalance's three midtone channels (gm=-tint,
|
|
55
|
-
midtones toward magenta and a negative one toward green
|
|
56
|
-
the same balanced-axis convention colour tools use
|
|
83
|
+
"""Always-present filter stages, in a fixed order chosen so each stage sees a picture already
|
|
84
|
+
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
|
|
85
|
+
lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
|
|
86
|
+
(the most creative-adjacent stage of the always-on chain). `tint` (-1 green .. +1 magenta) is not
|
|
87
|
+
a single ffmpeg option: it is expressed as colorbalance's three midtone channels (gm=-tint,
|
|
88
|
+
rm=bm=tint/2) so a positive tint shifts midtones toward magenta and a negative one toward green
|
|
89
|
+
without changing overall midtone lightness, the same balanced-axis convention colour tools use
|
|
90
|
+
for a one-dial tint control. `lift` and `gain` extend the same colorbalance call to the shadow
|
|
91
|
+
(rs=gs=bs=lift) and highlight (rh=gh=bh=gain) channels, giving a classic three-way shadows/
|
|
92
|
+
midtones/highlights correction in one filter invocation. `gamma` is folded into the same `eq`
|
|
93
|
+
term contrast/saturation already use, as `eq`'s own `gamma` option. Two further stages are
|
|
94
|
+
appended only when asked for, since their own identity value would otherwise add a no-op filter
|
|
95
|
+
term to the chain: `colorlevels` (--levels-*, 8-bit units scaled to the filter's 0..1 range) and
|
|
96
|
+
`curves` (--curves, one of the filter's own named presets)."""
|
|
57
97
|
exposure = _checked(args, "exposure")
|
|
58
98
|
contrast = _checked(args, "contrast")
|
|
59
99
|
saturation = _checked(args, "saturation")
|
|
60
100
|
temperature = _checked(args, "temperature")
|
|
61
101
|
tint = _checked(args, "tint")
|
|
102
|
+
gamma = _checked(args, "gamma")
|
|
103
|
+
lift = _checked(args, "lift")
|
|
104
|
+
gain = _checked(args, "gain")
|
|
105
|
+
in_black = _checked_levels(args, "levels_in_black")
|
|
106
|
+
in_white = _checked_levels(args, "levels_in_white")
|
|
107
|
+
out_black = _checked_levels(args, "levels_out_black")
|
|
108
|
+
out_white = _checked_levels(args, "levels_out_white")
|
|
109
|
+
if in_black >= in_white:
|
|
110
|
+
die(f"--levels-in-black {in_black} must be less than --levels-in-white {in_white}")
|
|
111
|
+
if out_black >= out_white:
|
|
112
|
+
die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
|
|
113
|
+
|
|
62
114
|
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
63
|
-
|
|
115
|
+
terms = [
|
|
64
116
|
f"exposure=exposure={exposure:g}",
|
|
65
117
|
f"colortemperature=temperature={temperature:g}",
|
|
66
|
-
f"colorbalance=rm={rm:g}:gm={gm:g}:bm={bm:g}",
|
|
67
|
-
f"eq=contrast={contrast:g}:saturation={saturation:g}",
|
|
68
|
-
]
|
|
118
|
+
f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
|
|
119
|
+
f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}",
|
|
120
|
+
]
|
|
121
|
+
if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
|
|
122
|
+
rimin, rimax = in_black / 255.0, in_white / 255.0
|
|
123
|
+
romin, romax = out_black / 255.0, out_white / 255.0
|
|
124
|
+
terms.append(
|
|
125
|
+
f"colorlevels=rimin={rimin:g}:gimin={rimin:g}:bimin={rimin:g}:"
|
|
126
|
+
f"rimax={rimax:g}:gimax={rimax:g}:bimax={rimax:g}:"
|
|
127
|
+
f"romin={romin:g}:gomin={romin:g}:bomin={romin:g}:"
|
|
128
|
+
f"romax={romax:g}:gomax={romax:g}:bomax={romax:g}"
|
|
129
|
+
)
|
|
130
|
+
if args.curves:
|
|
131
|
+
terms.append(f"curves=preset={args.curves}")
|
|
132
|
+
return ",".join(terms)
|
|
69
133
|
|
|
70
134
|
|
|
71
135
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
@@ -94,7 +158,7 @@ def main() -> int:
|
|
|
94
158
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
95
159
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
96
160
|
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
97
|
-
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint")
|
|
161
|
+
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint/--gamma/--lift/--gain/--levels-*/--curves")
|
|
98
162
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
99
163
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
100
164
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
@@ -105,6 +169,14 @@ def main() -> int:
|
|
|
105
169
|
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
106
170
|
ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
|
|
107
171
|
ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
|
|
172
|
+
ap.add_argument("--gamma", type=float, default=CORRECTION["gamma"][0], help="--correct: master gamma (eq filter's own gamma), 0.1..10, 1=unchanged (default 1)")
|
|
173
|
+
ap.add_argument("--lift", type=float, default=CORRECTION["lift"][0], help="--correct: shadows lift (colorbalance rs/gs/bs), -1..1, 0=unchanged (default 0)")
|
|
174
|
+
ap.add_argument("--gain", type=float, default=CORRECTION["gain"][0], help="--correct: highlights gain (colorbalance rh/gh/bh), -1..1, 0=unchanged (default 0)")
|
|
175
|
+
ap.add_argument("--levels-in-black", type=int, default=LEVELS["levels_in_black"][0], help="--correct: colorlevels input black point, 0..255 (default 0, unchanged)")
|
|
176
|
+
ap.add_argument("--levels-in-white", type=int, default=LEVELS["levels_in_white"][0], help="--correct: colorlevels input white point, 0..255 (default 255, unchanged)")
|
|
177
|
+
ap.add_argument("--levels-out-black", type=int, default=LEVELS["levels_out_black"][0], help="--correct: colorlevels output black point, 0..255 (default 0, unchanged)")
|
|
178
|
+
ap.add_argument("--levels-out-white", type=int, default=LEVELS["levels_out_white"][0], help="--correct: colorlevels output white point, 0..255 (default 255, unchanged)")
|
|
179
|
+
ap.add_argument("--curves", choices=CURVES_PRESETS, default=None, help="--correct: curves filter built-in preset (default: none, no curves term added)")
|
|
108
180
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
109
181
|
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
110
182
|
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
@@ -202,8 +274,16 @@ def main() -> int:
|
|
|
202
274
|
else:
|
|
203
275
|
if not os.path.exists(args.lut):
|
|
204
276
|
die(f"LUT not found: {args.lut}")
|
|
277
|
+
if not (0.0 <= args.lut_strength <= 1.0):
|
|
278
|
+
die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
|
|
205
279
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
|
206
|
-
if
|
|
280
|
+
if args.lut_strength == 0.0:
|
|
281
|
+
# 0 means "no LUT at all" -- without this branch, 0 (falling outside the open interval
|
|
282
|
+
# below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
|
|
283
|
+
# value did before the guard above existed: the one strength value documented to mean
|
|
284
|
+
# "don't grade it" instead silently graded it at 100%, the opposite of what was asked.
|
|
285
|
+
vf = "format=yuv420p"
|
|
286
|
+
elif args.lut_strength < 1.0:
|
|
207
287
|
# blend graded and original
|
|
208
288
|
vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
|
|
209
289
|
else:
|
|
@@ -212,16 +292,16 @@ def main() -> int:
|
|
|
212
292
|
tag = "lut"
|
|
213
293
|
|
|
214
294
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
215
|
-
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
216
|
-
|
|
295
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
296
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
217
297
|
r = probe(output, role="output")
|
|
218
298
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
219
299
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
300
|
+
extra = {"dropped_non_av_streams": dropped_streams}
|
|
220
301
|
if measurements is not None:
|
|
221
302
|
measurements["output"] = analyze_levels(output)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
emit(output)
|
|
303
|
+
extra["measurements"] = measurements
|
|
304
|
+
emit(output, **extra)
|
|
225
305
|
return 0
|
|
226
306
|
|
|
227
307
|
|
package/scripts/crop.py
CHANGED
|
@@ -38,6 +38,8 @@ def main() -> int:
|
|
|
38
38
|
add_common(ap)
|
|
39
39
|
args = ap.parse_args()
|
|
40
40
|
apply_common(args)
|
|
41
|
+
if args.fps is not None and args.fps <= 0:
|
|
42
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
41
43
|
|
|
42
44
|
if args.x < 0 or args.y < 0:
|
|
43
45
|
die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure black letterbox/pillarbox bars and report the crop rectangle that removes them.
|
|
3
|
+
|
|
4
|
+
Wraps FFmpeg's cropdetect filter: it samples frames, finds the largest
|
|
5
|
+
non-black rectangle common to (recent) frames, and reports it. This is a
|
|
6
|
+
measurement only -- it writes no file. Feed the reported {x, y, width,
|
|
7
|
+
height} to crop.py to actually remove the bars:
|
|
8
|
+
|
|
9
|
+
python3 cropdetect.py input.mp4
|
|
10
|
+
python3 crop.py input.mp4 --x 0 --y 140 --width 1920 --height 800
|
|
11
|
+
|
|
12
|
+
Distinct from fit.py --fit crop, which crops to a *target aspect ratio* it
|
|
13
|
+
computes itself (no black-bar detection involved) -- this tool instead
|
|
14
|
+
measures bars that are already baked into the source picture and tells you
|
|
15
|
+
where they are; it does not decide whether removing them is wanted (a
|
|
16
|
+
source with genuine letterboxed content, e.g. a scope-ratio film in a 16:9
|
|
17
|
+
frame, will "detect" that letterboxing as bars to strip, which is correct
|
|
18
|
+
for restoring the original frame but wrong if the letterboxing is part of
|
|
19
|
+
the intended presentation -- look at the frame before cropping it away).
|
|
20
|
+
|
|
21
|
+
--seconds controls how much of the file is sampled (default 10s, spread
|
|
22
|
+
across the file by --samples windows so a single black scene near the start
|
|
23
|
+
doesn't skew the result). Detected values fluctuate slightly frame to frame
|
|
24
|
+
even on a static border; the reported rectangle is the most common one seen.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
python3 cropdetect.py input.mp4
|
|
28
|
+
python3 cropdetect.py input.mp4 --seconds 30 --limit 0.15
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from collections import Counter
|
|
35
|
+
from typing import Dict, List, Tuple
|
|
36
|
+
|
|
37
|
+
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool
|
|
38
|
+
|
|
39
|
+
CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float) -> List[Tuple[int, int, int, int]]:
|
|
43
|
+
ffmpeg = require_tool("ffmpeg")
|
|
44
|
+
per_window = max(0.5, seconds / max(1, samples))
|
|
45
|
+
rects: List[Tuple[int, int, int, int]] = []
|
|
46
|
+
for i in range(samples):
|
|
47
|
+
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
48
|
+
start = max(0.0, start)
|
|
49
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
50
|
+
"-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
51
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
52
|
+
for m in CROP_RE.finditer(proc.stderr):
|
|
53
|
+
rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
|
|
54
|
+
return rects
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> int:
|
|
58
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
59
|
+
ap.add_argument("input")
|
|
60
|
+
ap.add_argument("--seconds", type=float, default=10.0, help="total seconds of footage to sample across the file (default 10)")
|
|
61
|
+
ap.add_argument("--samples", type=int, default=5, help="number of windows spread across the file (default 5)")
|
|
62
|
+
ap.add_argument("--limit", type=float, default=0.0941176, help="black-pixel threshold, 0..1 (default ~0.094, cropdetect's own default)")
|
|
63
|
+
ap.add_argument("--round", type=int, default=16, dest="round_to", help="the reported width/height are rounded to a multiple of this (default 16)")
|
|
64
|
+
add_common(ap)
|
|
65
|
+
args = ap.parse_args()
|
|
66
|
+
apply_common(args)
|
|
67
|
+
|
|
68
|
+
if args.seconds <= 0:
|
|
69
|
+
die(f"--seconds must be > 0, got {args.seconds:g}")
|
|
70
|
+
if args.samples <= 0:
|
|
71
|
+
die(f"--samples must be > 0, got {args.samples}")
|
|
72
|
+
if not 0 <= args.limit <= 1:
|
|
73
|
+
die(f"--limit must be 0..1, got {args.limit:g}")
|
|
74
|
+
if args.round_to <= 0:
|
|
75
|
+
die(f"--round must be > 0, got {args.round_to}")
|
|
76
|
+
|
|
77
|
+
meta = probe(args.input)
|
|
78
|
+
if not meta.get("video"):
|
|
79
|
+
die("input has no video stream")
|
|
80
|
+
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
81
|
+
duration = meta.get("duration") or 0.0
|
|
82
|
+
|
|
83
|
+
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration)
|
|
84
|
+
result: Dict = {"file": args.input, "source_width": sw, "source_height": sh}
|
|
85
|
+
if not rects:
|
|
86
|
+
result["crop"] = None
|
|
87
|
+
info("no crop bars detected (cropdetect produced no readings -- try --limit higher, or the source may already be full-frame)")
|
|
88
|
+
else:
|
|
89
|
+
w, h, x, y = Counter(rects).most_common(1)[0][0]
|
|
90
|
+
result["crop"] = {"width": w, "height": h, "x": x, "y": y}
|
|
91
|
+
result["confidence"] = round(Counter(rects).most_common(1)[0][1] / len(rects), 3)
|
|
92
|
+
if (w, h) == (sw, sh):
|
|
93
|
+
info(f"no bars detected: full {sw}x{sh} frame is already content")
|
|
94
|
+
else:
|
|
95
|
+
info(f"detected crop={w}:{h}:{x}:{y} (source {sw}x{sh}, confidence {result['confidence']:.0%}) -- "
|
|
96
|
+
f"crop.py {args.input} --x {x} --y {y} --width {w} --height {h}")
|
|
97
|
+
|
|
98
|
+
if args.json:
|
|
99
|
+
emit(None, **result)
|
|
100
|
+
else:
|
|
101
|
+
print_json(result)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
sys.exit(main())
|
package/scripts/cut.py
CHANGED
|
@@ -149,10 +149,14 @@ def main() -> int:
|
|
|
149
149
|
segments = parse_segments(args.segments)
|
|
150
150
|
else:
|
|
151
151
|
start = parse_time(args.start)
|
|
152
|
+
if start < 0:
|
|
153
|
+
die(f"--start must not be negative, got {args.start!r}")
|
|
152
154
|
if args.end and args.duration:
|
|
153
155
|
die("use --end or --duration, not both")
|
|
154
156
|
if args.end:
|
|
155
157
|
end = parse_time(args.end)
|
|
158
|
+
if end < 0:
|
|
159
|
+
die(f"--end must not be negative, got {args.end!r}")
|
|
156
160
|
elif args.duration:
|
|
157
161
|
end = start + parse_time(args.duration)
|
|
158
162
|
else:
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deinterlace interlaced source footage (old broadcast masters, DV, some camcorders).
|
|
3
|
+
|
|
4
|
+
Wraps FFmpeg's yadif filter. --mode frame (default) keeps the original frame
|
|
5
|
+
rate, blending each pair of fields back into one frame; --mode field instead
|
|
6
|
+
emits one frame per field, doubling the frame rate (smoother motion, the
|
|
7
|
+
usual choice for footage that will be watched at full quality). --parity
|
|
8
|
+
tells yadif which field came first when the container doesn't say so
|
|
9
|
+
correctly; --only-interlaced skips frames the source itself doesn't mark as
|
|
10
|
+
interlaced, leaving already-progressive frames untouched.
|
|
11
|
+
|
|
12
|
+
This tool does not detect whether the source needs deinterlacing at all --
|
|
13
|
+
that's a probe.py/look.py judgement (visible combing on motion in a contact
|
|
14
|
+
sheet). Running yadif on already-progressive footage is a harmless no-op in
|
|
15
|
+
practice but still re-encodes the whole file, so don't run this by default.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
python3 deinterlace.py old_tape.mov
|
|
19
|
+
python3 deinterlace.py broadcast.mxf --mode field --parity tff
|
|
20
|
+
python3 deinterlace.py mixed.mov --only-interlaced
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
26
|
+
|
|
27
|
+
MODES = {"frame": 0, "field": 1}
|
|
28
|
+
PARITIES = {"auto": -1, "tff": 0, "bff": 1}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> int:
|
|
32
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
33
|
+
ap.add_argument("input")
|
|
34
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_deint.<ext>)")
|
|
35
|
+
ap.add_argument("--mode", choices=list(MODES), default="frame",
|
|
36
|
+
help="frame (default): one output frame per input frame; field: one output frame per field, doubling fps")
|
|
37
|
+
ap.add_argument("--parity", choices=list(PARITIES), default="auto",
|
|
38
|
+
help="which field came first (default auto-detect from the stream)")
|
|
39
|
+
ap.add_argument("--only-interlaced", action="store_true",
|
|
40
|
+
help="only deinterlace frames the source marks as interlaced; leave the rest untouched")
|
|
41
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
42
|
+
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
43
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
44
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
45
|
+
add_common(ap)
|
|
46
|
+
args = ap.parse_args()
|
|
47
|
+
apply_common(args)
|
|
48
|
+
|
|
49
|
+
meta = probe(args.input)
|
|
50
|
+
if not meta.get("video"):
|
|
51
|
+
die("input has no video stream")
|
|
52
|
+
has_audio = bool(meta.get("audio"))
|
|
53
|
+
audio_streams = meta.get("audio_streams") or []
|
|
54
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
55
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
56
|
+
if args.audio_stream and not audio_streams:
|
|
57
|
+
die("--audio-stream needs an input with audio streams")
|
|
58
|
+
output = args.output or default_output(args.input, "deint")
|
|
59
|
+
|
|
60
|
+
vf = f"yadif=mode={MODES[args.mode]}:parity={PARITIES[args.parity]}:deint={1 if args.only_interlaced else 0}"
|
|
61
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
62
|
+
if has_audio:
|
|
63
|
+
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
64
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
65
|
+
if args.mode == "field":
|
|
66
|
+
v = meta["video"]
|
|
67
|
+
src_fps = v.get("fps") or 30.0
|
|
68
|
+
cmd += ["-fps_mode", "cfr", "-r", f"{src_fps * 2:g}"]
|
|
69
|
+
else:
|
|
70
|
+
cmd += cfr_args(meta)
|
|
71
|
+
if has_audio:
|
|
72
|
+
cmd += aac_args()
|
|
73
|
+
else:
|
|
74
|
+
cmd += ["-an"]
|
|
75
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
76
|
+
|
|
77
|
+
result = probe(output, role="output")
|
|
78
|
+
v = result["video"]
|
|
79
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps, mode={args.mode})")
|
|
80
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
sys.exit(main())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reduce video noise/grain with FFmpeg's hqdn3d filter.
|
|
3
|
+
|
|
4
|
+
For audio noise reduction use audio.py --denoise instead -- this tool only
|
|
5
|
+
touches the picture. --strength picks a tested preset (low/medium/high,
|
|
6
|
+
scaling hqdn3d's four spatial/temporal luma/chroma parameters together);
|
|
7
|
+
--luma-spatial/--chroma-spatial/--luma-temporal/--chroma-temporal override
|
|
8
|
+
any of the four individually when a preset isn't precise enough. Heavier
|
|
9
|
+
denoising trades away fine detail (skin texture, foliage, film grain) for a
|
|
10
|
+
cleaner-looking but softer image -- there is no setting that removes noise
|
|
11
|
+
"for free"; --strength high is a real quality trade-off, not just "better".
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
python3 denoise.py noisy_lowlight.mp4
|
|
15
|
+
python3 denoise.py grainy_scan.mov --strength high
|
|
16
|
+
python3 denoise.py source.mp4 --luma-spatial 6 --chroma-spatial 4 --luma-temporal 8 --chroma-temporal 6
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
22
|
+
|
|
23
|
+
# hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
|
|
24
|
+
# starting points its own documentation and common usage recommend (spatial then temporal,
|
|
25
|
+
# luma then chroma -- chroma tends to tolerate more smoothing before looking soft).
|
|
26
|
+
STRENGTH_PRESETS = {
|
|
27
|
+
"low": (2.0, 1.5, 3.0, 2.25),
|
|
28
|
+
"medium": (4.0, 3.0, 6.0, 4.5),
|
|
29
|
+
"high": (8.0, 6.0, 10.0, 7.5),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> int:
|
|
34
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
35
|
+
ap.add_argument("input")
|
|
36
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_denoise.<ext>)")
|
|
37
|
+
ap.add_argument("--strength", choices=list(STRENGTH_PRESETS), default="medium",
|
|
38
|
+
help="preset denoising amount (default medium); overridden per-parameter by the flags below")
|
|
39
|
+
ap.add_argument("--luma-spatial", type=float, help="spatial luma denoising strength (default from --strength)")
|
|
40
|
+
ap.add_argument("--chroma-spatial", type=float, help="spatial chroma denoising strength (default from --strength)")
|
|
41
|
+
ap.add_argument("--luma-temporal", type=float, help="temporal luma denoising strength (default from --strength)")
|
|
42
|
+
ap.add_argument("--chroma-temporal", type=float, help="temporal chroma denoising strength (default from --strength)")
|
|
43
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
44
|
+
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
45
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
46
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
47
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
48
|
+
add_common(ap)
|
|
49
|
+
args = ap.parse_args()
|
|
50
|
+
apply_common(args)
|
|
51
|
+
if args.fps is not None and args.fps <= 0:
|
|
52
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
53
|
+
|
|
54
|
+
ls, cs, lt, ct = STRENGTH_PRESETS[args.strength]
|
|
55
|
+
ls = args.luma_spatial if args.luma_spatial is not None else ls
|
|
56
|
+
cs = args.chroma_spatial if args.chroma_spatial is not None else cs
|
|
57
|
+
lt = args.luma_temporal if args.luma_temporal is not None else lt
|
|
58
|
+
ct = args.chroma_temporal if args.chroma_temporal is not None else ct
|
|
59
|
+
for name, val in (("--luma-spatial", ls), ("--chroma-spatial", cs), ("--luma-temporal", lt), ("--chroma-temporal", ct)):
|
|
60
|
+
if val < 0:
|
|
61
|
+
die(f"{name} must be >= 0, got {val:g}")
|
|
62
|
+
|
|
63
|
+
meta = probe(args.input)
|
|
64
|
+
if not meta.get("video"):
|
|
65
|
+
die("input has no video stream")
|
|
66
|
+
has_audio = bool(meta.get("audio"))
|
|
67
|
+
audio_streams = meta.get("audio_streams") or []
|
|
68
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
69
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
70
|
+
if args.audio_stream and not audio_streams:
|
|
71
|
+
die("--audio-stream needs an input with audio streams")
|
|
72
|
+
output = args.output or default_output(args.input, "denoise")
|
|
73
|
+
|
|
74
|
+
vf = f"hqdn3d={ls:g}:{cs:g}:{lt:g}:{ct:g}"
|
|
75
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
76
|
+
if has_audio:
|
|
77
|
+
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
78
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
79
|
+
cmd += cfr_args(meta, args.fps)
|
|
80
|
+
if has_audio:
|
|
81
|
+
cmd += aac_args()
|
|
82
|
+
else:
|
|
83
|
+
cmd += ["-an"]
|
|
84
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
85
|
+
|
|
86
|
+
result = probe(output, role="output")
|
|
87
|
+
v = result["video"]
|
|
88
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, strength={args.strength})")
|
|
89
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
sys.exit(main())
|