ffmpeg-skill 1.4.12 → 1.4.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/SKILL.md +1 -0
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common.py +141 -18
- package/scripts/batch.py +15 -0
- package/scripts/caption.py +3 -1
- package/scripts/color.py +6 -0
- package/scripts/cut.py +10 -1
- package/scripts/export.py +5 -3
- package/scripts/graphics.py +2 -0
- package/scripts/insert.py +3 -0
- package/scripts/look.py +12 -3
- package/scripts/loudness.py +9 -2
- package/scripts/multicam.py +2 -0
- package/scripts/pad.py +8 -2
- package/scripts/redact.py +5 -1
- package/scripts/render.py +17 -5
- package/scripts/report.py +4 -1
- package/scripts/reverse.py +3 -0
- package/scripts/sync.py +2 -0
- package/scripts/verify.py +6 -0
package/SKILL.md
CHANGED
|
@@ -278,6 +278,7 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
|
|
|
278
278
|
-40 LUFS or below is room tone, wind or nothing; raising it 25 dB raises the
|
|
279
279
|
noise, not the content. Leave the level, say so, and offer music or narration.
|
|
280
280
|
- Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
|
|
281
|
+
- Captions burned at an intermediate size and then upscaled by `export.py` come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to 1080x1920). Fit to the delivery size first (`fit.py --width 1080 --height 1920`), then caption, then export.
|
|
281
282
|
- Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
|
|
282
283
|
- `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y` toward the subject, or a reframe is often the honest answer — a silent centre crop is a guess, not a decision.
|
|
283
284
|
- Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.14`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.14", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.14",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/scripts/_common.py
CHANGED
|
@@ -258,9 +258,9 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
258
258
|
g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
|
|
259
259
|
if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
|
|
260
260
|
g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
|
|
261
|
-
help=f"kill
|
|
261
|
+
help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
|
|
262
262
|
g.add_argument("--overwrite", action="store_true",
|
|
263
|
-
help="allow replacing an output
|
|
263
|
+
help="allow replacing an existing output (warned today, refused from 2.0)")
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -461,6 +461,112 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
461
461
|
parent = os.path.dirname(os.path.abspath(output))
|
|
462
462
|
if not os.path.isdir(parent):
|
|
463
463
|
die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
|
|
464
|
+
if not os.access(parent, os.W_OK):
|
|
465
|
+
die(f"output directory {parent!r} is not writable")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _pid_dead(pid: int) -> bool:
|
|
472
|
+
"""True only when the process is known not to exist. POSIX: signal 0. Windows: OpenProcess
|
|
473
|
+
fails with ERROR_INVALID_PARAMETER (87) for a pid that is not in use; any other outcome
|
|
474
|
+
(a handle, or access denied) means it is live. Unknown is treated as live."""
|
|
475
|
+
if os.name != "nt":
|
|
476
|
+
try:
|
|
477
|
+
os.kill(pid, 0)
|
|
478
|
+
except ProcessLookupError:
|
|
479
|
+
return True
|
|
480
|
+
except OSError:
|
|
481
|
+
pass
|
|
482
|
+
return False
|
|
483
|
+
try:
|
|
484
|
+
import ctypes
|
|
485
|
+
k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
486
|
+
handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
487
|
+
if handle:
|
|
488
|
+
k32.CloseHandle(handle)
|
|
489
|
+
return False
|
|
490
|
+
return k32.GetLastError() == 87
|
|
491
|
+
except Exception:
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class _OutputLock:
|
|
496
|
+
"""Two runs writing the same output at once used to both report `completed` while one of
|
|
497
|
+
them described the other's file (sweep F1). A lock file next to the output, created with
|
|
498
|
+
O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
|
|
499
|
+
whose pid is dead (POSIX) or older than an hour is stale and taken over."""
|
|
500
|
+
def __init__(self, output: str) -> None:
|
|
501
|
+
self.path: Optional[str] = None
|
|
502
|
+
self.fd: Optional[int] = None
|
|
503
|
+
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
504
|
+
return
|
|
505
|
+
d, base = os.path.split(os.path.abspath(output))
|
|
506
|
+
self.path = os.path.join(d, f".{base}.ffskill-lock")
|
|
507
|
+
|
|
508
|
+
def __enter__(self) -> "_OutputLock":
|
|
509
|
+
if not self.path:
|
|
510
|
+
return self
|
|
511
|
+
for attempt in (0, 1):
|
|
512
|
+
try:
|
|
513
|
+
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
514
|
+
os.write(self.fd, str(os.getpid()).encode())
|
|
515
|
+
return self
|
|
516
|
+
except FileExistsError:
|
|
517
|
+
if attempt == 0 and self._stale():
|
|
518
|
+
try:
|
|
519
|
+
os.remove(self.path)
|
|
520
|
+
except OSError:
|
|
521
|
+
pass
|
|
522
|
+
continue
|
|
523
|
+
die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
|
|
524
|
+
f"(lock {self.path}); wait for it or choose a different --output/-o path")
|
|
525
|
+
except OSError:
|
|
526
|
+
return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
|
|
527
|
+
return self
|
|
528
|
+
|
|
529
|
+
def _stale(self) -> bool:
|
|
530
|
+
try:
|
|
531
|
+
pid = int(open(self.path).read().strip() or "0")
|
|
532
|
+
if pid > 0 and _pid_dead(pid):
|
|
533
|
+
return True
|
|
534
|
+
import time
|
|
535
|
+
return time.time() - os.path.getmtime(self.path) > 3600
|
|
536
|
+
except (OSError, ValueError):
|
|
537
|
+
return True
|
|
538
|
+
|
|
539
|
+
def __exit__(self, *exc: Any) -> None:
|
|
540
|
+
if self.fd is not None:
|
|
541
|
+
try:
|
|
542
|
+
os.close(self.fd)
|
|
543
|
+
except OSError:
|
|
544
|
+
pass
|
|
545
|
+
if self.path:
|
|
546
|
+
try:
|
|
547
|
+
os.remove(self.path)
|
|
548
|
+
except OSError:
|
|
549
|
+
pass
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
553
|
+
"""An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
|
|
554
|
+
encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
|
|
555
|
+
with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
|
|
556
|
+
none); None when the failure is something else or the graph is a -filter_complex the
|
|
557
|
+
caller has to fix itself."""
|
|
558
|
+
if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
|
|
559
|
+
return None
|
|
560
|
+
if "-filter_complex" in cmd:
|
|
561
|
+
return None
|
|
562
|
+
new = list(cmd)
|
|
563
|
+
if "-vf" in new:
|
|
564
|
+
i = new.index("-vf") + 1
|
|
565
|
+
new[i] = EVEN_SCALE + "," + new[i]
|
|
566
|
+
return new
|
|
567
|
+
if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
|
|
568
|
+
return None
|
|
569
|
+
return new[:-1] + ["-vf", EVEN_SCALE, new[-1]]
|
|
464
570
|
|
|
465
571
|
|
|
466
572
|
def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
@@ -555,24 +661,41 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
555
661
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
556
662
|
if STATE.dry_run and is_ffmpeg:
|
|
557
663
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
proc =
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
664
|
+
with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
|
|
665
|
+
exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
|
|
666
|
+
proc = _execute(exec_cmd)
|
|
667
|
+
if proc.returncode != 0 and is_ffmpeg:
|
|
668
|
+
retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
|
|
669
|
+
if retry is not None:
|
|
670
|
+
info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
|
|
671
|
+
STATE.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
|
|
672
|
+
proc = _execute(retry)
|
|
673
|
+
elif "not divisible by 2" in (proc.stderr or ""):
|
|
674
|
+
die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
|
|
675
|
+
"cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
|
|
676
|
+
kind="input")
|
|
677
|
+
if proc.returncode != 0 and check:
|
|
678
|
+
_fail(exec_cmd, proc.returncode, proc.stderr or "")
|
|
679
|
+
if final and tmp:
|
|
680
|
+
if proc.returncode == 0:
|
|
681
|
+
try:
|
|
682
|
+
os.replace(tmp, final)
|
|
683
|
+
except OSError as e:
|
|
684
|
+
_cleanup_partial_output(exec_cmd)
|
|
685
|
+
die(f"could not replace {final} with the new output: {e}", kind="output")
|
|
686
|
+
_remember_output(cmd)
|
|
687
|
+
else:
|
|
568
688
|
_cleanup_partial_output(exec_cmd)
|
|
569
|
-
die(f"could not replace {final} with the new output: {e}", kind="output")
|
|
570
|
-
_remember_output(cmd)
|
|
571
|
-
else:
|
|
572
|
-
_cleanup_partial_output(exec_cmd)
|
|
573
689
|
return proc
|
|
574
690
|
|
|
575
691
|
|
|
692
|
+
def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
|
|
693
|
+
"""One attempt, never exiting on failure (run() decides after its retries)."""
|
|
694
|
+
if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
|
|
695
|
+
return _run_with_progress(exec_cmd, False)
|
|
696
|
+
return _run_captured(exec_cmd, False)
|
|
697
|
+
|
|
698
|
+
|
|
576
699
|
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
|
|
577
700
|
"""Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
|
|
578
701
|
silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
|
|
@@ -840,7 +963,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
840
963
|
|
|
841
964
|
|
|
842
965
|
def shell_quote(s: str) -> str:
|
|
843
|
-
if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
|
|
966
|
+
if not s or any(ch in s for ch in " \t\n\r\\\"';|&<>()[]{}$*?"):
|
|
844
967
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
845
968
|
return s
|
|
846
969
|
|
|
@@ -1466,7 +1589,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
1466
1589
|
# signalstats reports in the source bit depth; normalise everything to an 8-bit scale
|
|
1467
1590
|
scale = 1.0
|
|
1468
1591
|
if ymax > 255 or yavg > 255:
|
|
1469
|
-
scale = 1 / 4.0 if ymax <= 1023 else 1 / 16.0
|
|
1592
|
+
scale = 1 / 4.0 if ymax <= 1023 else (1 / 16.0 if ymax <= 4095 else 1 / 256.0) # 10 / 12 / 16-bit
|
|
1470
1593
|
ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
|
|
1471
1594
|
# 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
|
|
1472
1595
|
lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
|
package/scripts/batch.py
CHANGED
|
@@ -156,8 +156,23 @@ def main() -> int:
|
|
|
156
156
|
if not outdir.is_absolute():
|
|
157
157
|
outdir = folder / outdir
|
|
158
158
|
work = Path(args.work) if args.work else outdir / ".work"
|
|
159
|
+
# the children refuse an output whose directory does not exist, under --dry-run too, so the
|
|
160
|
+
# directories are created for the plan as well -- and removed again afterwards when a dry
|
|
161
|
+
# run created them and left them empty (a plan leaves nothing behind, sweep F15)
|
|
162
|
+
created = [d for d in (outdir, work) if not d.exists()]
|
|
159
163
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
160
164
|
work.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
if STATE.dry_run and created:
|
|
166
|
+
import atexit
|
|
167
|
+
|
|
168
|
+
def _remove_empty_dirs() -> None:
|
|
169
|
+
for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
|
|
170
|
+
try:
|
|
171
|
+
if not any(d.iterdir()):
|
|
172
|
+
d.rmdir()
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
atexit.register(_remove_empty_dirs)
|
|
161
176
|
cache_path = outdir / ".ffskill_cache.json"
|
|
162
177
|
cache: Dict[str, Any] = {}
|
|
163
178
|
if cache_path.exists() and not args.force:
|
package/scripts/caption.py
CHANGED
|
@@ -450,7 +450,9 @@ def main() -> int:
|
|
|
450
450
|
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
451
451
|
args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
|
|
452
452
|
args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
|
|
453
|
-
|
|
453
|
+
# a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
|
|
454
|
+
# to be applied anyway and then refused as "animation is burn only" -- ignore it there
|
|
455
|
+
args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
|
|
454
456
|
args.highlight_color = color_hex(args.highlight_color or bc.get("primary", "FFD200"))
|
|
455
457
|
if args.brand and bcap.get("bold") and not args.bold:
|
|
456
458
|
args.bold = True
|
package/scripts/color.py
CHANGED
|
@@ -285,6 +285,9 @@ def main() -> int:
|
|
|
285
285
|
output = args.output or default_output(args.input, "sdr")
|
|
286
286
|
tag = "sdr"
|
|
287
287
|
elif args.correct:
|
|
288
|
+
if v.get("hdr") and not args.force:
|
|
289
|
+
die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
|
|
290
|
+
f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
|
|
288
291
|
vf = correction_chain(args)
|
|
289
292
|
output = args.output or default_output(args.input, "correct")
|
|
290
293
|
tag = "correct"
|
|
@@ -292,6 +295,9 @@ def main() -> int:
|
|
|
292
295
|
# "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
|
|
293
296
|
measurements = {"input": analyze_levels(args.input)}
|
|
294
297
|
else:
|
|
298
|
+
if v.get("hdr") and not args.force:
|
|
299
|
+
die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
|
|
300
|
+
f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
|
|
295
301
|
if not os.path.exists(args.lut):
|
|
296
302
|
die(f"LUT not found: {args.lut}")
|
|
297
303
|
if not (0.0 <= args.lut_strength <= 1.0):
|
package/scripts/cut.py
CHANGED
|
@@ -32,6 +32,8 @@ from typing import List, Tuple
|
|
|
32
32
|
|
|
33
33
|
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
34
|
|
|
35
|
+
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
36
|
+
DROPPED_STREAMS: List[str] = []
|
|
35
37
|
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
36
38
|
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
37
39
|
NEAREST_KEYFRAMES: list = []
|
|
@@ -115,6 +117,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
115
117
|
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
116
118
|
if is_audio_output(dst) or not meta.get("video"):
|
|
117
119
|
cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
|
|
120
|
+
# ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
|
|
121
|
+
# trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
|
|
122
|
+
# sweep F2), so the re-encode carries video/audio only and the result says so
|
|
123
|
+
cmd += ["-sn", "-dn"]
|
|
124
|
+
if meta.get("subtitle_streams") or meta.get("data_streams"):
|
|
125
|
+
DROPPED_STREAMS.append(dst)
|
|
118
126
|
cmd += encode_args(meta, dst, crf, preset) + ["-avoid_negative_ts", "make_zero", dst]
|
|
119
127
|
elif audio_only:
|
|
120
128
|
# output-side seek: an input seek on a video file lands on the previous video keyframe and on
|
|
@@ -130,7 +138,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
130
138
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
131
139
|
if not reencode and tolerance >= 0 and not STATE.dry_run:
|
|
132
140
|
got = probe(dst).get("duration") or 0.0
|
|
133
|
-
if abs(got - dur)
|
|
141
|
+
if abs(got - dur) >= tolerance: # a snap of exactly the tolerance is not "within" it (sweep F20)
|
|
134
142
|
near = keyframes_near(src, start)
|
|
135
143
|
alt = ""
|
|
136
144
|
if near:
|
|
@@ -230,6 +238,7 @@ def main() -> int:
|
|
|
230
238
|
info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
|
|
231
239
|
+ ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
|
|
232
240
|
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
241
|
+
dropped_non_av_streams=bool(DROPPED_STREAMS),
|
|
233
242
|
requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
|
|
234
243
|
requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
|
|
235
244
|
requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
|
package/scripts/export.py
CHANGED
|
@@ -68,8 +68,10 @@ def main() -> int:
|
|
|
68
68
|
meta = probe(args.input)
|
|
69
69
|
if not meta.get("video"):
|
|
70
70
|
die("input has no video stream")
|
|
71
|
+
notes: List[str] = []
|
|
71
72
|
if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
|
|
72
|
-
|
|
73
|
+
notes.append("source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
|
|
74
|
+
info("warning: " + notes[-1])
|
|
73
75
|
has_audio = bool(meta.get("audio"))
|
|
74
76
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
|
75
77
|
out_ext = Path(output).suffix.lstrip(".").lower()
|
|
@@ -92,7 +94,7 @@ def main() -> int:
|
|
|
92
94
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
93
95
|
run(cmd)
|
|
94
96
|
info(f"wrote {output}")
|
|
95
|
-
emit(output)
|
|
97
|
+
emit(output, **({"notes": notes} if notes else {}))
|
|
96
98
|
return 0
|
|
97
99
|
|
|
98
100
|
if vf:
|
|
@@ -121,7 +123,7 @@ def main() -> int:
|
|
|
121
123
|
result = probe(output, role="output")
|
|
122
124
|
v = result["video"]
|
|
123
125
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
|
|
124
|
-
emit(output)
|
|
126
|
+
emit(output, **({"notes": notes} if notes else {}))
|
|
125
127
|
return 0
|
|
126
128
|
|
|
127
129
|
|
package/scripts/graphics.py
CHANGED
|
@@ -98,6 +98,8 @@ def main() -> int:
|
|
|
98
98
|
|
|
99
99
|
extra_inputs: List[str] = []
|
|
100
100
|
fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
|
|
101
|
+
if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
|
|
102
|
+
die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
|
|
101
103
|
if args.template == "lower-third":
|
|
102
104
|
if not args.name:
|
|
103
105
|
die("lower-third needs --name")
|
package/scripts/insert.py
CHANGED
|
@@ -65,6 +65,9 @@ def main() -> int:
|
|
|
65
65
|
meta = probe(args.input)
|
|
66
66
|
if not meta.get("video"):
|
|
67
67
|
die("input has no image/video stream")
|
|
68
|
+
if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
|
|
69
|
+
die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
|
|
70
|
+
f"For a clip use broll.py (cutaway) or cut.py/join.py")
|
|
68
71
|
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
69
72
|
ratio = sw / sh
|
|
70
73
|
|
package/scripts/look.py
CHANGED
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import List
|
|
17
17
|
|
|
18
|
-
from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
18
|
+
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
19
19
|
|
|
20
20
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
21
|
|
|
@@ -102,19 +102,28 @@ def main() -> int:
|
|
|
102
102
|
n = cols * rows
|
|
103
103
|
if not dur:
|
|
104
104
|
die("cannot build a contact sheet without a known duration")
|
|
105
|
+
frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
|
|
106
|
+
if n > frames_avail:
|
|
107
|
+
# a 2x2 sheet from a one-frame clip: tile waits for frames that never come and writes nothing
|
|
108
|
+
cols, rows = min(cols, frames_avail), 1
|
|
109
|
+
n = cols * rows
|
|
110
|
+
info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
|
|
105
111
|
step = dur / n
|
|
106
112
|
tile_w = max(2, (args.width // cols) // 2 * 2)
|
|
107
113
|
out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
|
|
108
114
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
109
115
|
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
|
|
110
116
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
111
|
-
|
|
117
|
+
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
118
|
+
seek = 0.0 if frames_avail < 4 else step / 2
|
|
119
|
+
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
|
|
112
120
|
run(cmd)
|
|
113
121
|
outputs.append(out)
|
|
114
122
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|
|
115
123
|
|
|
116
124
|
for o in outputs:
|
|
117
|
-
|
|
125
|
+
if STATE.dry_run or os.path.exists(o):
|
|
126
|
+
info(f"wrote {o}")
|
|
118
127
|
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
|
|
119
128
|
if len(outputs) > 1 and not args.json:
|
|
120
129
|
for o in outputs:
|
package/scripts/loudness.py
CHANGED
|
@@ -64,12 +64,19 @@ def main() -> int:
|
|
|
64
64
|
if stats.get("silent"):
|
|
65
65
|
info("audio is silent (integrated loudness -inf); nothing to normalise")
|
|
66
66
|
if args.measure_only:
|
|
67
|
-
|
|
67
|
+
if STATE.json:
|
|
68
|
+
emit(None, measured={"silent": True, "input_i": "-inf"})
|
|
69
|
+
else:
|
|
70
|
+
print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
|
|
68
71
|
return 0
|
|
69
72
|
die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
|
|
70
73
|
info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
|
|
71
74
|
if args.measure_only:
|
|
72
|
-
|
|
75
|
+
measured = {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
|
|
76
|
+
if STATE.json:
|
|
77
|
+
emit(None, measured=measured) # the contract's document shape (status, commands), not a bare dict
|
|
78
|
+
else:
|
|
79
|
+
print(json.dumps(measured, indent=2))
|
|
73
80
|
return 0
|
|
74
81
|
|
|
75
82
|
output = args.output or default_output(args.input, "loudnorm")
|
package/scripts/multicam.py
CHANGED
|
@@ -73,6 +73,8 @@ def main() -> int:
|
|
|
73
73
|
add_common(ap)
|
|
74
74
|
args = ap.parse_args()
|
|
75
75
|
apply_common(args)
|
|
76
|
+
if args.analyze_seconds > 900:
|
|
77
|
+
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
76
78
|
if args.fps is not None and args.fps <= 0:
|
|
77
79
|
die(f"--fps must be positive, got {args.fps:g}")
|
|
78
80
|
|
package/scripts/pad.py
CHANGED
|
@@ -16,7 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main() -> int:
|
|
@@ -57,7 +57,13 @@ def main() -> int:
|
|
|
57
57
|
cmd += aac_args()
|
|
58
58
|
else:
|
|
59
59
|
cmd += ["-an"]
|
|
60
|
-
|
|
60
|
+
if args.start > 0:
|
|
61
|
+
# a stream-copied subtitle track keeps its timestamps and would fire --start seconds early
|
|
62
|
+
# (sweep F3); drop it and say so, as freeze --mode insert and fit --method speed do
|
|
63
|
+
run(cmd + [output])
|
|
64
|
+
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
65
|
+
else:
|
|
66
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
61
67
|
|
|
62
68
|
result = probe(output, role="output")
|
|
63
69
|
v = result["video"]
|
package/scripts/redact.py
CHANGED
|
@@ -74,7 +74,11 @@ def main() -> int:
|
|
|
74
74
|
output = args.output or default_output(args.input, "redact")
|
|
75
75
|
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
|
|
76
76
|
if args.mode == "blur":
|
|
77
|
-
|
|
77
|
+
# boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
|
|
78
|
+
# so they get their own (halved) radius; a 30 px region with the default 20 used to fail
|
|
79
|
+
radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
|
|
80
|
+
chroma = max(1, min(radius // 2, min(args.width, args.height) // 4 - 1))
|
|
81
|
+
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
78
82
|
else:
|
|
79
83
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
80
84
|
fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
package/scripts/render.py
CHANGED
|
@@ -141,7 +141,16 @@ def main() -> int:
|
|
|
141
141
|
# shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
|
|
142
142
|
# made unique per process, since it's the one that's also auto-deleted at the end.
|
|
143
143
|
work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
|
|
144
|
-
|
|
144
|
+
try:
|
|
145
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
except OSError as e:
|
|
147
|
+
die(f"cannot create the work directory {work}: {e}")
|
|
148
|
+
if not args.keep and not args.work:
|
|
149
|
+
# a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
|
|
150
|
+
# auto-named directory is ours alone, so remove it on every exit path
|
|
151
|
+
import atexit
|
|
152
|
+
import shutil
|
|
153
|
+
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
145
154
|
frame = proj.get("frame") or {}
|
|
146
155
|
trans = proj.get("transition") or {}
|
|
147
156
|
brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
|
|
@@ -151,6 +160,8 @@ def main() -> int:
|
|
|
151
160
|
parts: List[str] = []
|
|
152
161
|
for i, c in enumerate(clips):
|
|
153
162
|
src = rel(c["src"])
|
|
163
|
+
if not os.path.exists(src):
|
|
164
|
+
die(f"clip {i}: source not found: {src}") # under --dry-run too: a plan for a missing file is no plan
|
|
154
165
|
if not STATE.dry_run:
|
|
155
166
|
probe(src)
|
|
156
167
|
needs_cut = c.get("in") is not None or c.get("out") is not None
|
|
@@ -168,10 +179,11 @@ def main() -> int:
|
|
|
168
179
|
spd = float(c["speed"])
|
|
169
180
|
if not (spd > 0) or spd != spd or spd == float("inf"):
|
|
170
181
|
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
183
|
+
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
|
|
184
|
+
fitted = str(work / f"clip{i:02d}_speed.mp4")
|
|
185
|
+
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
186
|
+
part = fitted
|
|
175
187
|
parts.append(part)
|
|
176
188
|
stages_done.append("clips")
|
|
177
189
|
current = parts[0]
|
package/scripts/report.py
CHANGED
|
@@ -158,7 +158,10 @@ def main() -> int:
|
|
|
158
158
|
if STATE.dry_run:
|
|
159
159
|
info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
|
|
160
160
|
else:
|
|
161
|
-
|
|
161
|
+
try:
|
|
162
|
+
Path(output).write_text(doc, encoding="utf-8")
|
|
163
|
+
except OSError as e:
|
|
164
|
+
die(f"cannot write {output}: {e}", kind="output")
|
|
162
165
|
info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
|
|
163
166
|
emit(None, report=output, check=chk)
|
|
164
167
|
if not args.json:
|
package/scripts/reverse.py
CHANGED
|
@@ -34,6 +34,9 @@ def main() -> int:
|
|
|
34
34
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
35
35
|
|
|
36
36
|
output = args.output or default_output(args.input, "reverse")
|
|
37
|
+
if (meta.get("duration") or 0) > 60:
|
|
38
|
+
info(f"warning: reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
|
|
39
|
+
f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)")
|
|
37
40
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
|
|
38
41
|
if has_audio:
|
|
39
42
|
cmd += ["-af", "areverse"]
|
package/scripts/sync.py
CHANGED
|
@@ -213,6 +213,8 @@ def main() -> int:
|
|
|
213
213
|
add_common(ap)
|
|
214
214
|
args = ap.parse_args()
|
|
215
215
|
apply_common(args)
|
|
216
|
+
if args.analyze_seconds > 900:
|
|
217
|
+
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
216
218
|
|
|
217
219
|
for p in (args.reference, args.second):
|
|
218
220
|
if not probe(p).get("audio"):
|
package/scripts/verify.py
CHANGED
|
@@ -70,12 +70,15 @@ def collect(paths: List[str]) -> List[Path]:
|
|
|
70
70
|
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
71
71
|
t0 = time.time()
|
|
72
72
|
if argv[0] == "__check_hdr__":
|
|
73
|
+
was_json, STATE.json = STATE.json, False # probe()'s die() would print a JSON document of its own
|
|
73
74
|
try:
|
|
74
75
|
v = probe(argv[1]).get("video") or {}
|
|
75
76
|
ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
|
|
76
77
|
err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
|
|
77
78
|
except SystemExit:
|
|
78
79
|
ok, err = False, "output missing"
|
|
80
|
+
finally:
|
|
81
|
+
STATE.json = was_json
|
|
79
82
|
return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
|
|
80
83
|
try:
|
|
81
84
|
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
|
@@ -113,12 +116,15 @@ def main() -> int:
|
|
|
113
116
|
for f in files:
|
|
114
117
|
info(f"=== {f}")
|
|
115
118
|
entry: Dict = {"file": str(f), "steps": []}
|
|
119
|
+
was_json, STATE.json = STATE.json, False # same: one JSON document per run, printed at the end
|
|
116
120
|
try:
|
|
117
121
|
meta = probe(str(f))
|
|
118
122
|
except SystemExit:
|
|
119
123
|
entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
|
|
120
124
|
results.append(entry)
|
|
121
125
|
continue
|
|
126
|
+
finally:
|
|
127
|
+
STATE.json = was_json
|
|
122
128
|
entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
|
|
123
129
|
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")}
|
|
124
130
|
entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
|