ffmpeg-skill 0.12.5 → 0.16.13
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 +19 -6
- package/SKILL.md +14 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +160 -1
- package/scripts/_common.py +57 -3
- package/scripts/_contract.py +56 -5
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +37 -3
- package/scripts/check.py +8 -0
- package/scripts/color.py +9 -1
- 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 +14 -3
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +1 -1
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +7 -2
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/scripts/batch.py
CHANGED
|
@@ -36,6 +36,13 @@ from _common import STATE, add_common, apply_common, die, emit, info
|
|
|
36
36
|
|
|
37
37
|
HERE = Path(__file__).resolve().parent
|
|
38
38
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
39
|
+
# recipe steps name the script to run as plain, untrusted JSON -- run_step() joins it onto HERE
|
|
40
|
+
# with the `/` operator, which silently ignores the left side when the right side is itself an
|
|
41
|
+
# absolute path (Path("/scripts") / "/tmp/evil.py" == Path("/tmp/evil.py")), and does nothing to
|
|
42
|
+
# stop a "../" traversal either. Without this allowlist, a batch.json a caller didn't author
|
|
43
|
+
# themselves (from a template, a shared config, anywhere) could name any Python file on disk and
|
|
44
|
+
# have it executed with the caller's own privileges on every matching media file.
|
|
45
|
+
ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswith("_")}
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
def file_key(path: Path) -> str:
|
|
@@ -51,11 +58,27 @@ def file_key(path: Path) -> str:
|
|
|
51
58
|
|
|
52
59
|
|
|
53
60
|
def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
54
|
-
|
|
61
|
+
# A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
|
|
62
|
+
# (export preset, captions, everything) live in the file at that path, not in this dict.
|
|
63
|
+
# Hashing only `recipe` meant editing project.json's content (without touching batch.json
|
|
64
|
+
# itself) left the key, and so every cache hit, unchanged: a preset swapped from "copy" to
|
|
65
|
+
# "x" (a real re-encode) still served the old cached output. Fold the referenced file's own
|
|
66
|
+
# content into the key so a content change invalidates the cache like any other edit would.
|
|
67
|
+
project_content = ""
|
|
68
|
+
if recipe.get("project"):
|
|
69
|
+
try:
|
|
70
|
+
project_content = Path(recipe["project"]).read_text(encoding="utf-8")
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
|
|
55
74
|
|
|
56
75
|
|
|
57
76
|
def run_step(argv: List[str]) -> bool:
|
|
58
|
-
|
|
77
|
+
script = argv[0]
|
|
78
|
+
if script not in ALLOWED_STEP_SCRIPTS:
|
|
79
|
+
die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
80
|
+
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
81
|
+
cmd = [sys.executable, str(HERE / script)] + argv[1:]
|
|
59
82
|
if STATE["fast"]:
|
|
60
83
|
cmd.append("--fast")
|
|
61
84
|
if STATE["dry_run"]:
|
|
@@ -68,10 +91,20 @@ def run_step(argv: List[str]) -> bool:
|
|
|
68
91
|
return True
|
|
69
92
|
|
|
70
93
|
|
|
71
|
-
def
|
|
94
|
+
def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
|
|
72
95
|
suffix = recipe.get("suffix", "_out")
|
|
96
|
+
# By default final_ext falls back to each source's OWN extension, so files that only differ
|
|
97
|
+
# by extension don't collide -- but a recipe that fixes "ext" (e.g. converting a folder of
|
|
98
|
+
# mixed .mp4/.mov masters to one format) makes every source with the same stem land on the
|
|
99
|
+
# same final path, e.g. clip.mp4 and clip.mov both -> clip_out.mp4. process() has no collision
|
|
100
|
+
# detection of its own; see one_pass()'s pre-flight check, which uses this same computation
|
|
101
|
+
# to catch that before any file is actually processed (and the earlier one silently clobbered).
|
|
73
102
|
final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
|
|
74
|
-
|
|
103
|
+
return outdir / f"{src.stem}{suffix}.{final_ext}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
|
|
107
|
+
final = final_path(src, recipe, outdir)
|
|
75
108
|
t0 = time.time()
|
|
76
109
|
if recipe.get("project"):
|
|
77
110
|
proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
|
|
@@ -140,6 +173,18 @@ def main() -> int:
|
|
|
140
173
|
def one_pass() -> List[Dict[str, Any]]:
|
|
141
174
|
results = []
|
|
142
175
|
files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
176
|
+
# Two different sources can compute the same final path (most often a fixed recipe "ext"
|
|
177
|
+
# collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
|
|
178
|
+
# processing anything, rather than letting the later one silently overwrite the earlier
|
|
179
|
+
# one's finished output with the cache still recording both as "ok".
|
|
180
|
+
by_final: Dict[Path, List[Path]] = {}
|
|
181
|
+
for src in files:
|
|
182
|
+
by_final.setdefault(final_path(src, recipe, outdir), []).append(src)
|
|
183
|
+
collisions = {dst: srcs for dst, srcs in by_final.items() if len(srcs) > 1}
|
|
184
|
+
if collisions:
|
|
185
|
+
detail = "; ".join(f"{dst.name} <- {', '.join(s.name for s in srcs)}" for dst, srcs in collisions.items())
|
|
186
|
+
die(f"{len(collisions)} output filename collision(s) in this batch -- rename the sources, "
|
|
187
|
+
f"or add a distinguishing \"suffix\"/\"ext\" per run, or split into separate globs: {detail}")
|
|
143
188
|
for src in files:
|
|
144
189
|
key = f"{file_key(src)}:{rkey}"
|
|
145
190
|
hit = cache.get(key)
|
|
@@ -152,7 +197,15 @@ def main() -> int:
|
|
|
152
197
|
results.append(r)
|
|
153
198
|
if r["ok"] and not STATE["dry_run"]:
|
|
154
199
|
cache[key] = r
|
|
155
|
-
|
|
200
|
+
# write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
|
|
201
|
+
# a concurrent manual run) could leave a truncated file that json.loads() above
|
|
202
|
+
# then silently treats as "no cache" (a ValueError -> {}), discarding every prior
|
|
203
|
+
# entry. Write to a sibling temp file and rename into place: same-directory
|
|
204
|
+
# renames are atomic on POSIX and os.replace() is atomic on Windows too, so a
|
|
205
|
+
# reader only ever sees the old complete file or the new complete file.
|
|
206
|
+
tmp = cache_path.parent / f"{cache_path.name}.tmp{os.getpid()}"
|
|
207
|
+
tmp.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
|
208
|
+
os.replace(tmp, cache_path)
|
|
156
209
|
return results
|
|
157
210
|
|
|
158
211
|
results = one_pass()
|
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)")
|
|
@@ -471,7 +505,7 @@ def main() -> int:
|
|
|
471
505
|
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
472
506
|
die(f"SRT file not found: {srt_path}")
|
|
473
507
|
style = [
|
|
474
|
-
f"FontName={args.font}",
|
|
508
|
+
f"FontName={ass_font_name(args.font)}",
|
|
475
509
|
f"FontSize={args.size}",
|
|
476
510
|
f"PrimaryColour={ass_color(args.color)}",
|
|
477
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
|
@@ -274,8 +274,16 @@ def main() -> int:
|
|
|
274
274
|
else:
|
|
275
275
|
if not os.path.exists(args.lut):
|
|
276
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")
|
|
277
279
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
|
278
|
-
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:
|
|
279
287
|
# blend graded and original
|
|
280
288
|
vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
|
|
281
289
|
else:
|
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())
|
package/scripts/export.py
CHANGED
|
@@ -26,7 +26,7 @@ import sys
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import Dict, List
|
|
28
28
|
|
|
29
|
-
from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
29
|
+
from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
|
|
30
30
|
|
|
31
31
|
PRESETS: Dict[str, Dict] = {
|
|
32
32
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
@@ -63,6 +63,7 @@ def main() -> int:
|
|
|
63
63
|
return 0
|
|
64
64
|
if not args.input or not args.preset:
|
|
65
65
|
die("input and --preset are required (or use --list)")
|
|
66
|
+
validate_color(args.pad_color, "--pad-color")
|
|
66
67
|
|
|
67
68
|
p = PRESETS[args.preset]
|
|
68
69
|
meta = probe(args.input)
|
package/scripts/fit.py
CHANGED
|
@@ -37,7 +37,7 @@ import sys
|
|
|
37
37
|
from fractions import Fraction
|
|
38
38
|
from typing import List
|
|
39
39
|
|
|
40
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
|
|
40
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
|
|
41
41
|
|
|
42
42
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
43
43
|
|
|
@@ -114,6 +114,7 @@ def main() -> int:
|
|
|
114
114
|
die(f"--crop-x must be 0..1, got {args.crop_x}")
|
|
115
115
|
if not 0.0 <= args.crop_y <= 1.0:
|
|
116
116
|
die(f"--crop-y must be 0..1, got {args.crop_y}")
|
|
117
|
+
validate_color(args.pad_color, "--pad-color")
|
|
117
118
|
|
|
118
119
|
meta = probe(args.input)
|
|
119
120
|
if not meta.get("video"):
|
|
@@ -193,8 +194,18 @@ def main() -> int:
|
|
|
193
194
|
out_h = even(args.height)
|
|
194
195
|
out_w = even(out_h * ratio) if ratio else args.height
|
|
195
196
|
elif ratio and src_ratio:
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
# No explicit --width/--height: size the canvas to the new aspect without exceeding
|
|
198
|
+
# the source's own resolution in either dimension. A narrower/taller target than the
|
|
199
|
+
# source (e.g. 9:16 from a 16:9 source) must be bounded by the source's HEIGHT, not
|
|
200
|
+
# its width -- bounding by width there multiplies the height by src_ratio/ratio (a
|
|
201
|
+
# 1920x1080 source asked for 9:16 used to come out 1920x3414, a ~3.16x upscale in
|
|
202
|
+
# both fit=pad and fit=crop, entirely unrequested).
|
|
203
|
+
if ratio <= src_ratio:
|
|
204
|
+
out_h = even(sh)
|
|
205
|
+
out_w = even(out_h * ratio)
|
|
206
|
+
else:
|
|
207
|
+
out_w = even(sw)
|
|
208
|
+
out_h = even(out_w / ratio)
|
|
198
209
|
else:
|
|
199
210
|
out_w, out_h = even(sw), even(sh)
|
|
200
211
|
if args.fit == "crop":
|