ffmpeg-skill 1.4.13 → 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/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common.py +137 -14
- package/scripts/batch.py +15 -0
- 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/pad.py +8 -2
- package/scripts/redact.py +5 -1
- package/scripts/render.py +12 -1
- package/scripts/report.py +4 -1
- package/scripts/verify.py +6 -0
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
|
@@ -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
|
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/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/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
|
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/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")}
|