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.
Files changed (60) hide show
  1. package/README.md +19 -6
  2. package/SKILL.md +14 -2
  3. package/bin/install.js +12 -3
  4. package/mcp/server.py +8 -0
  5. package/package.json +2 -2
  6. package/references/scripts.md +160 -1
  7. package/scripts/_common.py +57 -3
  8. package/scripts/_contract.py +56 -5
  9. package/scripts/background.py +11 -2
  10. package/scripts/batch.py +58 -5
  11. package/scripts/caption.py +37 -3
  12. package/scripts/check.py +8 -0
  13. package/scripts/color.py +9 -1
  14. package/scripts/crop.py +2 -0
  15. package/scripts/cropdetect.py +106 -0
  16. package/scripts/cut.py +4 -0
  17. package/scripts/deinterlace.py +85 -0
  18. package/scripts/denoise.py +94 -0
  19. package/scripts/export.py +2 -1
  20. package/scripts/fit.py +14 -3
  21. package/scripts/freeze.py +108 -0
  22. package/scripts/graphics.py +1 -1
  23. package/scripts/grid.py +142 -0
  24. package/scripts/join.py +4 -1
  25. package/scripts/loop.py +80 -0
  26. package/scripts/multicam.py +10 -2
  27. package/scripts/overlay.py +7 -2
  28. package/scripts/pad.py +68 -0
  29. package/scripts/redact.py +100 -0
  30. package/scripts/render.py +17 -3
  31. package/scripts/silence.py +6 -3
  32. package/scripts/speedramp.py +123 -0
  33. package/scripts/sphere.py +126 -0
  34. package/scripts/straighten.py +97 -0
  35. package/scripts/verify.py +25 -2
  36. package/scripts/waveform.py +92 -0
  37. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  59. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  60. 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
- return hashlib.sha1(json.dumps(recipe, sort_keys=True).encode()).hexdigest()[:12]
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
- cmd = [sys.executable, str(HERE / argv[0])] + argv[1:]
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 process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
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
- final = outdir / f"{src.stem}{suffix}.{final_ext}"
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
- cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
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()
@@ -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
- start, end, text = cursor, cursor + auto_seconds, line.strip()
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 0 < args.lut_strength < 1:
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
- out_w = even(sw if ratio <= src_ratio else sh * ratio)
197
- out_h = even(out_w / ratio)
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":