ffmpeg-skill 3.8.12 → 3.8.14

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 (44) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +1 -1
  3. package/docs/contract.md +11 -5
  4. package/mcp/server.py +0 -1
  5. package/package.json +5 -2
  6. package/references/flags.md +2 -2
  7. package/references/scripts.md +72 -27
  8. package/scripts/_common/asr.py +1 -1
  9. package/scripts/_common/color.py +7 -2
  10. package/scripts/_common/decision.py +40 -8
  11. package/scripts/_common/emit.py +38 -10
  12. package/scripts/_common/probe.py +34 -6
  13. package/scripts/_common/runner.py +76 -7
  14. package/scripts/_contract.py +24 -10
  15. package/scripts/_platforms.py +0 -5
  16. package/scripts/_smartcut.py +23 -5
  17. package/scripts/audio.py +86 -28
  18. package/scripts/background.py +1 -1
  19. package/scripts/caption.py +2 -3
  20. package/scripts/check.py +3 -3
  21. package/scripts/color.py +7 -1
  22. package/scripts/crop.py +3 -2
  23. package/scripts/cropdetect.py +1 -1
  24. package/scripts/cut.py +50 -17
  25. package/scripts/export.py +39 -6
  26. package/scripts/fit.py +4 -3
  27. package/scripts/freeze.py +1 -1
  28. package/scripts/graphics.py +2 -3
  29. package/scripts/insert.py +1 -1
  30. package/scripts/look.py +9 -4
  31. package/scripts/loop.py +3 -3
  32. package/scripts/loudness.py +20 -12
  33. package/scripts/multicam.py +56 -15
  34. package/scripts/overlay.py +1 -1
  35. package/scripts/probe.py +13 -2
  36. package/scripts/proxy.py +11 -2
  37. package/scripts/redact.py +5 -1
  38. package/scripts/scenes.py +82 -21
  39. package/scripts/silence.py +62 -16
  40. package/scripts/sphere.py +23 -3
  41. package/scripts/stabilize.py +5 -2
  42. package/scripts/sync.py +11 -6
  43. package/scripts/verify.py +82 -20
  44. package/scripts/waveform.py +12 -2
package/scripts/sync.py CHANGED
@@ -7,7 +7,9 @@ reduced to a loudness envelope, cross-correlated with an FFT implemented in
7
7
  Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
8
8
 
9
9
  Offset semantics: a positive offset means the SECOND input starts LATER
10
- than the reference, i.e. `second` must be shifted earlier by that amount.
10
+ than the reference (it began recording after it), so its sound is delayed by
11
+ that amount (its head padded) to line up; a negative offset means it started
12
+ earlier, and its first -offset seconds are dropped.
11
13
 
12
14
  This aligns two AUDIO tracks to each other; it does not check or guarantee
13
15
  lip sync (mouth movement matching the audio). It assumes each recording's
@@ -25,15 +27,13 @@ Examples:
25
27
  python3 sync.py cam.mp4 recorder.wav --fix-drift --replace-audio # long takes: fix clock drift too
26
28
  """
27
29
  import argparse
28
- import cmath
29
- import json
30
30
  import math
31
31
  import os
32
32
  import sys
33
33
  from typing import Dict, List
34
34
 
35
35
  from _common.runner import fail_if_classified
36
- from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
+ from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
39
39
  from _common.timeline import output_timecode_args
@@ -161,9 +161,14 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
161
161
 
162
162
  scores.append((val, lag))
163
163
  if val > best_val:
164
- second = best_val
165
164
  best_val, best_lag = val, lag
166
- elif val > second and abs(lag - best_lag) > 5:
165
+ # The runner-up is the best other PEAK more than 5 lags from the winner (A126). The running
166
+ # "previous best" this used to take was usually the winner's own neighbour, one lag away and
167
+ # on the same slope, so an exact match read 0.59 and could not be told from a tie (0.50).
168
+ by_lag = dict((lag, val) for val, lag in scores)
169
+ for val, lag in scores:
170
+ if abs(lag - best_lag) > 5 and val > second and \
171
+ val >= by_lag.get(lag - 1, -float("inf")) and val >= by_lag.get(lag + 1, -float("inf")):
167
172
  second = val
168
173
  # confidence: peak similarity, penalised when a distant runner-up is nearly as good
169
174
  conf = max(0.0, min(1.0, best_val))
package/scripts/verify.py CHANGED
@@ -18,7 +18,6 @@ import argparse
18
18
  import contextlib
19
19
  import io
20
20
  import json
21
- import os
22
21
  import subprocess
23
22
  import sys
24
23
  import tempfile
@@ -26,10 +25,12 @@ import time
26
25
  from pathlib import Path
27
26
  from typing import Any, Dict, List, Optional, Tuple
28
27
 
29
- from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe
28
+ from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
30
29
 
31
30
  HERE = Path(__file__).resolve().parent
32
- MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
31
+ # _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
32
+ # and a folder walk skipped .ts, .3gp, .wmv, .gif, .ogg, .opus, .aif/.aiff, .caf and .wma (#467 A218)
33
+ MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")}
33
34
 
34
35
 
35
36
  def unique_stems(files: List[Path]) -> Dict[Path, str]:
@@ -39,18 +40,31 @@ def unique_stems(files: List[Path]) -> Dict[Path, str]:
39
40
  resolve to the identical output prefix. Each file's own steps still ran correctly in isolation,
40
41
  but with --keep the second file's outputs silently overwrote the first file's on disk, and the
41
42
  report showed PASS for both without ever flagging the collision (same bug class already fixed
42
- in batch.py). Disambiguate every colliding stem with a stable per-collision index instead."""
43
+ in batch.py). Disambiguate every colliding stem with a stable per-collision index instead.
44
+
45
+ A stem is compared case-folded (clip.mp4 and Clip.MOV share one name on a case-insensitive
46
+ disk), and an indexed name skips any stem already taken, a file's own included: through 3.8.8
47
+ two clip.mp4 and a clip_1.mp4 gave "clip_1" twice (audit A121)."""
43
48
  counts: Dict[str, int] = {}
44
49
  for f in files:
45
- counts[f.stem] = counts.get(f.stem, 0) + 1
50
+ counts[f.stem.casefold()] = counts.get(f.stem.casefold(), 0) + 1
51
+ taken = {f.stem.casefold() for f in files if counts[f.stem.casefold()] == 1}
46
52
  seen: Dict[str, int] = {}
47
53
  stems: Dict[Path, str] = {}
48
54
  for f in files:
49
- if counts[f.stem] > 1:
50
- seen[f.stem] = seen.get(f.stem, 0) + 1
51
- stems[f] = f"{f.stem}_{seen[f.stem]}"
52
- else:
55
+ key = f.stem.casefold()
56
+ if counts[key] == 1:
53
57
  stems[f] = f.stem
58
+ continue
59
+ n = seen.get(key, 0)
60
+ while True:
61
+ n += 1
62
+ name = f"{f.stem}_{n}"
63
+ if name.casefold() not in taken:
64
+ break
65
+ seen[key] = n
66
+ taken.add(name.casefold())
67
+ stems[f] = name
54
68
  return stems
55
69
 
56
70
 
@@ -96,6 +110,20 @@ def _probe_capturing(path: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, An
96
110
  STATE.json = was_json
97
111
 
98
112
 
113
+ # The shared flags given to verify.py that every step receives as well (set in main())
114
+ FORWARD: Dict[str, bool] = {"overwrite": False, "fast": False}
115
+
116
+
117
+ def _forwarded(argv: List[str]) -> List[str]:
118
+ """--overwrite and --fast as the caller gave them to verify.py: through 3.8.8 neither reached
119
+ a step, so a rerun into the same --out failed every step on its own earlier output (audit
120
+ A119). The steps' --timeout is verify's own per-step limit (step()'s `timeout`)."""
121
+ out = ["--overwrite"] if FORWARD["overwrite"] else []
122
+ if FORWARD["fast"] and "--fast" not in argv:
123
+ out.append("--fast")
124
+ return out
125
+
126
+
99
127
  def step(name: str, argv: List[str], timeout: float) -> Dict:
100
128
  """Run one step and keep what its tool said: `ok` (it completed), and on a failure the tool's
101
129
  own `kind`, `reason` and `message` (its --json failure document), `error` being that message
@@ -113,7 +141,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
113
141
  r.update(kind="verification", reason="hdr_lost", message=r["error"])
114
142
  return r
115
143
  try:
116
- proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
144
+ proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
117
145
  text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
118
146
  except subprocess.TimeoutExpired:
119
147
  msg = f"timeout after {timeout:.0f}s"
@@ -146,7 +174,7 @@ def main() -> int:
146
174
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
147
175
  ap.add_argument("paths", nargs="+", help="media files and/or folders")
148
176
  ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
149
- ap.add_argument("--keep", action="store_true", help="keep the outputs")
177
+ ap.add_argument("--keep", action="store_true", help="keep the outputs (without --out: in a new folder, named as out_dir and on stderr)")
150
178
  ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
151
179
  ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
152
180
  ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds (default 600; 0 = no limit, as everywhere else)")
@@ -168,6 +196,7 @@ def main() -> int:
168
196
  # fault ("9 of 11 verification steps failed") rather than the flag's
169
197
  die(f"--seconds must be more than 0 (the length of the test cut), got {args.seconds:g}")
170
198
  files = collect(args.paths)
199
+ FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
171
200
  tmp = None
172
201
  if args.out:
173
202
  # made first, so a --report inside a new --out folder passes the guard below (#413 review)
@@ -177,20 +206,27 @@ def main() -> int:
177
206
  # a file this run writes itself (#408): not one of the media it verifies (a folder's
178
207
  # files included), nor an existing file without --overwrite
179
208
  guard_sidecar(args.report, *args.paths, *map(str, files))
180
- if not args.out:
209
+ if not args.out and args.keep:
210
+ # --keep without --out: a folder of its own that outlives the run, named in the document
211
+ # (`out_dir`) and on stderr; through 3.8.8 it was the temporary directory, deleted at exit
212
+ # whatever --keep said, and never named (audit A120)
213
+ outdir = Path(tempfile.mkdtemp(prefix="ffskill_verify_"))
214
+ elif not args.out:
181
215
  tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
182
216
  outdir = Path(tmp.name)
217
+ kept_dir = None if tmp else str(outdir)
183
218
 
184
219
  stem_for = unique_stems(files)
185
- results = []
220
+ # every file's plan is made before any step runs, so the names all of them will write are
221
+ # checked against every input first (below)
222
+ jobs: List[Tuple[Path, Dict, List[Tuple[str, List[str]]], Optional[Path]]] = []
186
223
  for f in files:
187
- info(f"=== {f}")
188
224
  entry: Dict = {"file": str(f), "steps": [], "skipped": []}
189
225
  meta, failure = _probe_capturing(str(f))
190
226
  if meta is None:
191
227
  entry["steps"].append(dict({"step": "probe", "ok": False, "seconds": 0, "error": _cell(failure.get("message", ""))}, **failure))
192
228
  entry["skipped"].append({"step": "*", "reason": "input_unreadable", "message": "no other step runs on a file probe cannot read"})
193
- results.append(entry)
229
+ jobs.append((f, entry, [], None))
194
230
  continue
195
231
  entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
196
232
  entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
@@ -202,7 +238,8 @@ def main() -> int:
202
238
  cut = f"{stem}_cut.mp4"
203
239
  seg_end = min(dur, args.seconds) if dur else args.seconds
204
240
  fast = ["--fast"]
205
- plan = []
241
+ plan: List[Tuple[str, List[str]]] = []
242
+ cues: Optional[Path] = None
206
243
  skipped: List[Dict[str, str]] = entry["skipped"]
207
244
 
208
245
  def skip(step_name: str, reason: str, message: str) -> None:
@@ -212,7 +249,6 @@ def main() -> int:
212
249
  plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
213
250
  plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
214
251
  cues = outdir / f"{stem_for[f]}_cues.txt"
215
- cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
216
252
  plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
217
253
  if not args.quick:
218
254
  plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
@@ -245,6 +281,30 @@ def main() -> int:
245
281
  else:
246
282
  for n in ("loudness measure", "silence list", "audio downmix"):
247
283
  skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
284
+ jobs.append((f, entry, plan, cues))
285
+
286
+ # A name verify generates can be another input's (clip.mp4's cut is clip_cut.mp4, which can be
287
+ # a file of the same folder): each step checks its output against its own input only, so with
288
+ # --overwrite that input was replaced and its own steps then read the replacement. Every name
289
+ # the run will write -- step outputs, caption's sidecars, the cue file -- against every input,
290
+ # through a link or another case of the name included, before the first is written.
291
+ every_input = [str(f) for f in files]
292
+ for f, _entry, plan, cues in jobs:
293
+ written = [("cue file", str(cues))] if cues else []
294
+ for name, argv in plan:
295
+ if "-o" in argv:
296
+ out = argv[argv.index("-o") + 1]
297
+ written.append((name, out))
298
+ if argv[0] == "caption.py":
299
+ written += [(name, str(Path(out).with_suffix(ext))) for ext in (".srt", ".ass")]
300
+ for name, out in written:
301
+ refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
302
+
303
+ results = []
304
+ for f, entry, plan, cues in jobs:
305
+ info(f"=== {f}")
306
+ if cues:
307
+ cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
248
308
  for name, argv in plan:
249
309
  r = step(name, argv, args.timeout)
250
310
  entry["steps"].append(r)
@@ -275,14 +335,16 @@ def main() -> int:
275
335
  info(f"wrote {args.report}")
276
336
  if not args.json:
277
337
  print(report)
278
- if tmp and not args.keep:
338
+ if tmp:
279
339
  tmp.cleanup()
340
+ if kept_dir:
341
+ info(f"step outputs kept in {kept_dir}")
280
342
  notes = (["--dry-run is ignored by verify.py: its steps ran and wrote their outputs"] if ignored_dry_run else [])
281
343
  if failed:
282
344
  die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
283
- report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
345
+ report=args.report, files=results, failed=failed, total=total, out_dir=kept_dir, **({"notes": notes} if notes else {}))
284
346
  if args.json:
285
- emit(None, report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
347
+ emit(None, report=args.report, files=results, failed=failed, total=total, out_dir=kept_dir, **({"notes": notes} if notes else {}))
286
348
  return 0
287
349
 
288
350
 
@@ -43,6 +43,7 @@ from _common import (add_common, apply_common, aac_args, default_output, die, dr
43
43
  fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
44
44
  from _common.emit import absorb_stage_hw, note, warn
45
45
  from _common.decision import length_output_t
46
+ from _common.probe import AUDIO_MEDIA_EXT
46
47
 
47
48
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
48
49
 
@@ -50,7 +51,7 @@ WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
50
51
  def main() -> int:
51
52
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
52
53
  ap.add_argument("input")
53
- ap.add_argument("-o", "--output", help="output file (default: <name>_waveform.<ext>)")
54
+ ap.add_argument("-o", "--output", help="output file, a video (default: <name>_waveform.<ext>, .mp4 for an audio input; an audio extension is refused)")
54
55
  ap.add_argument("--style", choices=["waveform", "spectrum"], default="waveform", help="waveform (default) or spectrum visualization")
55
56
  ap.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
56
57
  ap.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
@@ -170,7 +171,16 @@ def main() -> int:
170
171
  die(f"{args.input} is silent (audio stream {args.audio_stream} peaks at {silent_peak:g} dBFS, at or below "
171
172
  f"--silence-threshold {args.silence_threshold:g}): the visualisation would be a flat line",
172
173
  kind="input", hint="check the audio, or pass --on-silent warn to render it anyway")
173
- output = args.output or default_output(args.input, "waveform")
174
+ # A137: the output always holds a picture, so an audio input's extension is not the default:
175
+ # talk.wav made talk_waveform.wav, which the WAV muxer refused (kind ffmpeg), and talk.m4a an
176
+ # H.264 video named .m4a
177
+ in_ext = os.path.splitext(args.input)[1].lower()
178
+ output = args.output or default_output(args.input, "waveform", "mp4" if in_ext in AUDIO_MEDIA_EXT else None)
179
+ # every audio-only extension this skill reads, not only those it writes (is_audio_output():
180
+ # .aif, .aiff, .caf and .wma are not there, and passed this to fail in ffmpeg, or in a dry run not at all)
181
+ if os.path.splitext(output)[1].lower() in AUDIO_MEDIA_EXT:
182
+ die(f"{output}: waveform.py writes a video, and {os.path.splitext(output)[1].lower()} holds sound only; "
183
+ "name a video file (.mp4, .mov, .mkv)", kind="input")
174
184
 
175
185
  # The visualisation's own band. Without --image/--position it fills the frame, which is what
176
186
  # every 1.15 command line did, so the graph below is byte-identical when no new flag is given.