ffmpeg-skill 1.4.1 → 1.4.3
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/package.json +1 -1
- package/references/process-pitfalls.md +47 -0
- package/scripts/_common.py +118 -24
- package/scripts/color.py +24 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
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",
|
|
@@ -180,3 +180,50 @@ admin with Contents: read/write on this repo -- whose "Repository admin" bypass
|
|
|
180
180
|
push triggers workflows (GITHUB_TOKEN's do not), so the bump commit carries `[skip ci]`; the
|
|
181
181
|
tag, Release and npm publish all happen in the originating run. Rotate the PAT before it
|
|
182
182
|
expires or the next release fails at the same step, cleanly, before anything is published.
|
|
183
|
+
|
|
184
|
+
### A literal `[skip ci]` anywhere in a PR body skips every workflow on the squash merge
|
|
185
|
+
|
|
186
|
+
Found on the merge of #170 (2026-09-11). The PR body quoted the new bump-commit message
|
|
187
|
+
verbatim, including `[skip ci]`; a squash merge copies the PR body into the merge commit, and
|
|
188
|
+
GitHub honours the marker wherever it appears in the commit message. Nothing ran on `main` for
|
|
189
|
+
that merge -- no tests, no CodeQL, no release -- and the release only happened when the next PR
|
|
190
|
+
merged. Describe the marker in words in PR bodies and commit messages ("the skip-CI marker"),
|
|
191
|
+
or wrap it so it does not match, and after any merge that touches CI check that the push
|
|
192
|
+
actually triggered the expected runs.
|
|
193
|
+
|
|
194
|
+
### Two merges minutes apart: the first release run bumps on a stale main and its push is rejected
|
|
195
|
+
|
|
196
|
+
Found on 1.4.2 (2026-09-11). #167 (fix) merged, then #171 (docs) a minute later while the
|
|
197
|
+
release run for #167 was still bumping. The concurrency group serialises the runs, but a run
|
|
198
|
+
checks out the SHA that triggered it, so the first run's bump commit sat behind #171's merge
|
|
199
|
+
and `git push origin HEAD:main` was rejected as non-fast-forward. The second run (for #171)
|
|
200
|
+
then found both PRs unreleased and published 1.4.2 correctly, so nothing was lost -- one red
|
|
201
|
+
run and a confusing timeline. release.yml now checks out `ref: main` and rebases the bump on
|
|
202
|
+
main right before pushing. Lesson: a workflow that pushes to the branch that triggered it must
|
|
203
|
+
start from the branch tip, not from the triggering commit.
|
|
204
|
+
|
|
205
|
+
### "Clean up the partial output on failure" deleted the user's file
|
|
206
|
+
|
|
207
|
+
Found by the 1.4.2 review (2026-09-12), present since #78 (2026-09-07). The cleanup that removes a
|
|
208
|
+
0-byte stray after a failed encode keyed on "output path exists after failure", which is also
|
|
209
|
+
true of a deliverable that was there before the run and that ffmpeg never opened (a bad filter
|
|
210
|
+
argument fails at graph init, before the muxer touches the output -- on 6.1+). The --overwrite
|
|
211
|
+
consent added in #163 guards the success path only; the failure path had its own delete. The
|
|
212
|
+
first fix (snapshot size/mtime, leave an unchanged file alone) passed on 6.1 and failed in the
|
|
213
|
+
5.1.1 CI job: FFmpeg 5.x opens (truncates) the output during option parsing, before any filter
|
|
214
|
+
initialises, so ffmpeg itself had already destroyed the file. The fix that holds on every
|
|
215
|
+
version is to never let ffmpeg write to an existing path: run against a hidden sibling temp
|
|
216
|
+
file and os.replace() it over the original on success only. Lessons: a destructive step must
|
|
217
|
+
know whether it created the thing it is about to destroy, "exists" is not that knowledge; and
|
|
218
|
+
"the tool fails before touching the file" is a version-specific fact, never a guarantee. And the second review found what the first one -- which had
|
|
219
|
+
just written the overwrite guard next to this code -- did not: a reviewer who wrote the fix
|
|
220
|
+
reads the file they fixed, not the one beside it.
|
|
221
|
+
|
|
222
|
+
### --timeout only worked when ffmpeg was talking
|
|
223
|
+
|
|
224
|
+
Same review. The --progress runner iterated the progress pipe and compared the clock per
|
|
225
|
+
line, so the one case the timeout exists for (a deadlocked ffmpeg, which prints nothing)
|
|
226
|
+
never reached the comparison. The non-progress path used subprocess.run(timeout=) and was
|
|
227
|
+
fine, and the test only exercised that path. Lesson: a deadline belongs on a clock the loop
|
|
228
|
+
wakes up to check, never on the arrival of the thing you are waiting for; and a test for
|
|
229
|
+
"hang" must use a shim that actually hangs silently, not one that fails fast.
|
package/scripts/_common.py
CHANGED
|
@@ -199,8 +199,8 @@ class Context:
|
|
|
199
199
|
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
200
200
|
"""
|
|
201
201
|
|
|
202
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
|
|
203
|
-
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
|
|
202
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
203
|
+
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
204
204
|
|
|
205
205
|
def __init__(self) -> None:
|
|
206
206
|
self.reset()
|
|
@@ -215,6 +215,7 @@ class Context:
|
|
|
215
215
|
self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
|
|
216
216
|
self.overwrite = False # --overwrite: an existing output may be replaced
|
|
217
217
|
self.written: set = set() # output paths this process has written itself
|
|
218
|
+
self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
|
|
218
219
|
|
|
219
220
|
# mapping-style access kept for backwards compatibility
|
|
220
221
|
def __getitem__(self, key: str) -> Any:
|
|
@@ -300,8 +301,19 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
300
301
|
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
301
302
|
return
|
|
302
303
|
try:
|
|
303
|
-
if os.path.exists(output):
|
|
304
|
-
|
|
304
|
+
if not os.path.exists(output):
|
|
305
|
+
return
|
|
306
|
+
# A file that was already there before this command ran is someone's deliverable, not
|
|
307
|
+
# our partial. If ffmpeg died before opening it (bad filter argument, unreadable input:
|
|
308
|
+
# the common case) it is byte-for-byte what it was, so leave it alone. Only when ffmpeg
|
|
309
|
+
# did open and truncate it (size or mtime changed) is what remains a partial of ours,
|
|
310
|
+
# and the original is already gone either way; then removing it is still right.
|
|
311
|
+
before = STATE.preexisting.get(os.path.realpath(output))
|
|
312
|
+
if before is not None:
|
|
313
|
+
st = os.stat(output)
|
|
314
|
+
if (st.st_size, st.st_mtime_ns) == before:
|
|
315
|
+
return
|
|
316
|
+
os.remove(output)
|
|
305
317
|
except OSError:
|
|
306
318
|
pass
|
|
307
319
|
|
|
@@ -348,7 +360,7 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
|
348
360
|
2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
|
|
349
361
|
process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
|
|
350
362
|
output = cmd[-1]
|
|
351
|
-
if
|
|
363
|
+
if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
|
|
352
364
|
return
|
|
353
365
|
try:
|
|
354
366
|
exists = os.path.isfile(output)
|
|
@@ -357,6 +369,13 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
|
357
369
|
return
|
|
358
370
|
if not exists or real in STATE.written:
|
|
359
371
|
return
|
|
372
|
+
try:
|
|
373
|
+
st = os.stat(output)
|
|
374
|
+
STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
|
|
375
|
+
except OSError:
|
|
376
|
+
pass
|
|
377
|
+
if STATE.overwrite:
|
|
378
|
+
return
|
|
360
379
|
if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
|
|
361
380
|
die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
|
|
362
381
|
info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
|
|
@@ -380,12 +399,40 @@ def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
|
|
|
380
399
|
code=124, kind="timeout")
|
|
381
400
|
|
|
382
401
|
|
|
402
|
+
def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
|
|
403
|
+
"""When the output path already holds someone's file, run ffmpeg against a hidden sibling
|
|
404
|
+
temp path and move it over the original only on success.
|
|
405
|
+
|
|
406
|
+
ffmpeg's -y truncates the output the moment it opens it, and *when* it opens it depends on
|
|
407
|
+
the version: 6.1+ initialises the filter graph first (a bad LUT fails before the file is
|
|
408
|
+
touched), 5.x opens the output during option parsing, before any filter runs, so the same
|
|
409
|
+
bad LUT leaves a 0-byte file where the deliverable was. No amount of post-failure cleanup
|
|
410
|
+
can undo that; the only way to keep an existing file safe across a failed run is for ffmpeg
|
|
411
|
+
never to write to it. Same directory, same extension (the muxer is chosen by it), hidden
|
|
412
|
+
name, so nothing else changes for the encoder. Returns (command to execute, final path,
|
|
413
|
+
temp path); (cmd, None, None) when no staging is needed."""
|
|
414
|
+
output = cmd[-1]
|
|
415
|
+
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
416
|
+
return list(cmd), None, None
|
|
417
|
+
try:
|
|
418
|
+
if not os.path.isfile(output) or os.path.realpath(output) in STATE.written:
|
|
419
|
+
return list(cmd), None, None
|
|
420
|
+
except OSError:
|
|
421
|
+
return list(cmd), None, None
|
|
422
|
+
d, base = os.path.split(output)
|
|
423
|
+
stem, ext = os.path.splitext(base)
|
|
424
|
+
tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
|
|
425
|
+
return list(cmd[:-1]) + [tmp], output, tmp
|
|
426
|
+
|
|
427
|
+
|
|
383
428
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
384
429
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
385
430
|
|
|
386
431
|
ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
|
|
387
432
|
(a fake successful CompletedProcess is returned so scripts can keep planning), and run
|
|
388
|
-
with a progress readout under --progress. ffprobe and other tools always run.
|
|
433
|
+
with a progress readout under --progress. ffprobe and other tools always run. An output
|
|
434
|
+
path that already exists is written through a temp file and replaced only on success
|
|
435
|
+
(see _stage_existing_output), so a failed run never costs the caller the file that was there.
|
|
389
436
|
"""
|
|
390
437
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
391
438
|
if is_ffmpeg:
|
|
@@ -396,9 +443,22 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
396
443
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
397
444
|
if STATE.dry_run and is_ffmpeg:
|
|
398
445
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
446
|
+
exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
|
|
447
|
+
if STATE.progress and is_ffmpeg and exec_cmd[-1] != "-":
|
|
448
|
+
proc = _run_with_progress(exec_cmd, check)
|
|
449
|
+
else:
|
|
450
|
+
proc = _run_captured(exec_cmd, check)
|
|
451
|
+
if final and tmp:
|
|
452
|
+
if proc.returncode == 0:
|
|
453
|
+
try:
|
|
454
|
+
os.replace(tmp, final)
|
|
455
|
+
except OSError as e:
|
|
456
|
+
_cleanup_partial_output(exec_cmd)
|
|
457
|
+
die(f"could not replace {final} with the new output: {e}", kind="output")
|
|
458
|
+
_remember_output(cmd)
|
|
459
|
+
else:
|
|
460
|
+
_cleanup_partial_output(exec_cmd)
|
|
461
|
+
return proc
|
|
402
462
|
|
|
403
463
|
|
|
404
464
|
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
@@ -455,22 +515,57 @@ def _progress_line(done: float, total: float, elapsed: float) -> str:
|
|
|
455
515
|
|
|
456
516
|
|
|
457
517
|
def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
458
|
-
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
|
|
518
|
+
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
|
|
519
|
+
|
|
520
|
+
The time limit is checked on a clock, not per progress line: a deadlocked ffmpeg (the very
|
|
521
|
+
case --timeout exists for) prints nothing, so a loop that only looked at the deadline when a
|
|
522
|
+
line arrived waited on it forever. Reader threads drain both pipes; the main loop wakes at
|
|
523
|
+
least twice a second to compare the clock against the limit."""
|
|
524
|
+
import queue
|
|
525
|
+
import threading
|
|
459
526
|
import time
|
|
460
527
|
total = STATE.duration_hint or 0.0
|
|
461
528
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
462
529
|
t0 = time.time()
|
|
463
530
|
limit = _limit_for(cmd)
|
|
464
531
|
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
532
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
533
|
+
lines: "queue.Queue[Optional[str]]" = queue.Queue()
|
|
534
|
+
err_chunks: List[str] = []
|
|
535
|
+
|
|
536
|
+
def pump_out() -> None:
|
|
537
|
+
for line in proc.stdout: # type: ignore[union-attr]
|
|
538
|
+
lines.put(line)
|
|
539
|
+
lines.put(None)
|
|
540
|
+
|
|
541
|
+
def pump_err() -> None:
|
|
542
|
+
err_chunks.append(proc.stderr.read()) # type: ignore[union-attr]
|
|
543
|
+
|
|
544
|
+
threading.Thread(target=pump_out, daemon=True).start()
|
|
545
|
+
err_thread = threading.Thread(target=pump_err, daemon=True)
|
|
546
|
+
err_thread.start()
|
|
465
547
|
last = ""
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
548
|
+
|
|
549
|
+
def clear_line() -> None:
|
|
550
|
+
if last:
|
|
551
|
+
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
552
|
+
|
|
553
|
+
def timed_out() -> None:
|
|
554
|
+
proc.kill()
|
|
555
|
+
proc.wait()
|
|
556
|
+
clear_line()
|
|
557
|
+
_timed_out(cmd, limit or 0)
|
|
558
|
+
|
|
559
|
+
while True:
|
|
560
|
+
remaining = (limit - (time.time() - t0)) if limit else None
|
|
561
|
+
if remaining is not None and remaining <= 0:
|
|
562
|
+
timed_out()
|
|
563
|
+
try:
|
|
564
|
+
line = lines.get(timeout=min(0.5, remaining) if remaining is not None else 0.5)
|
|
565
|
+
except queue.Empty:
|
|
566
|
+
continue
|
|
567
|
+
if line is None:
|
|
568
|
+
break
|
|
474
569
|
if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
|
|
475
570
|
try:
|
|
476
571
|
done = int(line.split("=")[1]) / 1_000_000
|
|
@@ -482,13 +577,12 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
482
577
|
sys.stderr.flush()
|
|
483
578
|
last = msg
|
|
484
579
|
try:
|
|
485
|
-
|
|
580
|
+
proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
|
|
486
581
|
except subprocess.TimeoutExpired:
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
582
|
+
timed_out()
|
|
583
|
+
err_thread.join()
|
|
584
|
+
err = "".join(err_chunks)
|
|
585
|
+
clear_line()
|
|
492
586
|
if proc.returncode == 0:
|
|
493
587
|
_remember_output(cmd)
|
|
494
588
|
if proc.returncode != 0:
|
package/scripts/color.py
CHANGED
|
@@ -112,26 +112,46 @@ def correction_chain(args: argparse.Namespace) -> str:
|
|
|
112
112
|
die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
|
|
113
113
|
|
|
114
114
|
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
115
|
-
|
|
115
|
+
rgb = [
|
|
116
116
|
f"exposure=exposure={exposure:g}",
|
|
117
117
|
f"colortemperature=temperature={temperature:g}",
|
|
118
118
|
f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
|
|
119
|
-
f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}",
|
|
120
119
|
]
|
|
120
|
+
terms = [_rgb_stage(rgb), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
|
|
121
|
+
rgb2 = []
|
|
121
122
|
if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
|
|
122
123
|
rimin, rimax = in_black / 255.0, in_white / 255.0
|
|
123
124
|
romin, romax = out_black / 255.0, out_white / 255.0
|
|
124
|
-
|
|
125
|
+
rgb2.append(
|
|
125
126
|
f"colorlevels=rimin={rimin:g}:gimin={rimin:g}:bimin={rimin:g}:"
|
|
126
127
|
f"rimax={rimax:g}:gimax={rimax:g}:bimax={rimax:g}:"
|
|
127
128
|
f"romin={romin:g}:gomin={romin:g}:bomin={romin:g}:"
|
|
128
129
|
f"romax={romax:g}:gomax={romax:g}:bomax={romax:g}"
|
|
129
130
|
)
|
|
130
131
|
if args.curves:
|
|
131
|
-
|
|
132
|
+
rgb2.append(f"curves=preset={args.curves}")
|
|
133
|
+
if rgb2:
|
|
134
|
+
terms.append(_rgb_stage(rgb2))
|
|
132
135
|
return ",".join(terms)
|
|
133
136
|
|
|
134
137
|
|
|
138
|
+
def _rgb_stage(filters: list) -> str:
|
|
139
|
+
"""Wrap a run of RGB-only filters in explicit, matching YUV<->RGB conversions.
|
|
140
|
+
|
|
141
|
+
exposure/colortemperature/colorbalance/colorlevels/curves take RGB, so libavfilter inserts a
|
|
142
|
+
swscale conversion on each side. Left to itself, the way in honours the frame's colour tag
|
|
143
|
+
(bt709 on any camera or export.py file) while the way back uses swscale's default matrix
|
|
144
|
+
(bt601): on a bt709-tagged source an all-defaults --correct came out 26 dB PSNR from its
|
|
145
|
+
input and ~8 % less saturated (#159). Untagged sources never showed it because both legs
|
|
146
|
+
then fall back to bt601 and cancel. Pinning both legs to the same matrix restores the
|
|
147
|
+
identity on every source (39 dB, the same as an untagged one always got); bt601 on both
|
|
148
|
+
sides measured better than bt709 on both (34 dB) because swscale's 601 path round-trips
|
|
149
|
+
8-bit 4:2:0 more exactly. The matrix here is only the working space of the conversion pair,
|
|
150
|
+
never a tag: the output carries the encoder's BT.709 tags as before."""
|
|
151
|
+
return ("scale=in_color_matrix=bt601,format=gbrpf32le," + ",".join(filters)
|
|
152
|
+
+ ",scale=out_color_matrix=bt601,format=yuv420p")
|
|
153
|
+
|
|
154
|
+
|
|
135
155
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
136
156
|
v = meta["video"]
|
|
137
157
|
trc = v.get("color_transfer") or "smpte2084"
|