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.
- package/README.md +2 -1
- package/SKILL.md +1 -1
- package/docs/contract.md +11 -5
- package/mcp/server.py +0 -1
- package/package.json +5 -2
- package/references/flags.md +2 -2
- package/references/scripts.md +72 -27
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/color.py +7 -2
- package/scripts/_common/decision.py +40 -8
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +76 -7
- package/scripts/_contract.py +24 -10
- package/scripts/_platforms.py +0 -5
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/background.py +1 -1
- package/scripts/caption.py +2 -3
- package/scripts/check.py +3 -3
- package/scripts/color.py +7 -1
- package/scripts/crop.py +3 -2
- package/scripts/cropdetect.py +1 -1
- package/scripts/cut.py +50 -17
- package/scripts/export.py +39 -6
- package/scripts/fit.py +4 -3
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +2 -3
- package/scripts/insert.py +1 -1
- package/scripts/look.py +9 -4
- package/scripts/loop.py +3 -3
- package/scripts/loudness.py +20 -12
- package/scripts/multicam.py +56 -15
- package/scripts/overlay.py +1 -1
- package/scripts/probe.py +13 -2
- package/scripts/proxy.py +11 -2
- package/scripts/redact.py +5 -1
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sphere.py +23 -3
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +11 -6
- package/scripts/verify.py +82 -20
- 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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/scripts/waveform.py
CHANGED
|
@@ -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
|
-
|
|
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.
|