ffmpeg-skill 2.22.5 → 2.23.0

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 CHANGED
@@ -270,7 +270,7 @@ These are the rules the skill file gives the agent and the code enforces.
270
270
  |---|---|
271
271
  | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
272
272
  | `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
273
- | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence and short audio-dropout rows |
273
+ | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows |
274
274
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
275
275
 
276
276
  **Orchestration**
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 (`2.22.5`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.23.0`) | 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`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.22.5", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.23.0", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
@@ -206,7 +206,8 @@ rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user).
206
206
  The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
207
207
  the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
208
208
  (opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
209
- from one decode pass, and `dropouts` (2.17) from one audio-only pass; without it the row set is unchanged. Thresholds:
209
+ from one decode pass, `dropouts` (2.17) from one audio-only pass, and `flashes` and `first frame` (2.23) from one
210
+ video-only pass and a one-frame decode; without it the row set is unchanged. Thresholds:
210
211
  `docs/design-decisions.md` ("check.py --content").
211
212
 
212
213
  ### Dry run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.22.5",
3
+ "version": "2.23.0",
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",
@@ -1142,7 +1142,12 @@ duration), FAIL when it covers >= 95%, i.e. the whole video) and `silence` (shar
1142
1142
  -50 dBFS: WARN > 50%, FAIL >= 95%); since 2.17 one more audio-only pass adds `dropouts`
1143
1143
  (gaps of digital silence, <= -80 dBFS for 20 ms to 1 s with sound on both sides -- lost audio
1144
1144
  at a join, a glitched decode; WARN when there is any, never FAIL, because a noise gate makes
1145
- them on purpose). A render project enables it with `"check": {"platform":
1145
+ them on purpose); since 2.23 one more video pass adds `flashes` (single frames that differ from
1146
+ both neighbours by >= 12/255 mean luma while the neighbours differ by at most a quarter of that
1147
+ -- a stray frame at a cut, a decode glitch; WARN when there is any, with the first five times,
1148
+ never FAIL, because a camera flash or a strobe is real) and a one-frame decode adds `first frame`
1149
+ (WARN when it is one flat colour, luma spread < 4/255: the frame a feed's autoplay shows first).
1150
+ A render project enables it with `"check": {"platform":
1146
1151
  "reels", "content": true}`.
1147
1152
 
1148
1153
  ### batch.py — same recipe over a folder, cached
@@ -12,7 +12,7 @@ import re
12
12
  import statistics
13
13
  from fractions import Fraction
14
14
  from typing import Any, Dict, List, Optional, Sequence, Tuple
15
- from _common.emit import die
15
+ from _common.emit import die, warn
16
16
  from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
17
17
 
18
18
 
@@ -605,6 +605,15 @@ CONTENT_DROPOUT_MIN, CONTENT_DROPOUT_MAX = 0.02, 1.0 # seconds
605
605
  CONTENT_DROPOUT_EDGE = 0.05 # a gap this close to the start or end is a lead-in/out, not a dropout
606
606
  CONTENT_DROPOUT_PAD = 2.0 # zeros appended before the dropout pass; > MAX, so a tail that runs into it never counts
607
607
 
608
+ # 2.23: a flash is one frame that differs from both of its neighbours while they match each other
609
+ # (a stray frame from another shot, a white or black frame at a join, a decode glitch). Measured on
610
+ # 64x36 grey frames as the mean absolute luma difference (0-255): moving footage stays at a few
611
+ # units, a cut or a flash moves the whole frame by tens.
612
+ CONTENT_FLASH_JUMP = 12.0 # both steps into and out of the frame are at least this
613
+ CONTENT_FLASH_RETURN = 0.25 # ...and the frames either side differ by at most this share of the smaller step
614
+ CONTENT_FLAT_STD = 4.0 # first frame: luma standard deviation (0-255) below this is one flat colour
615
+ _DIFF_W, _DIFF_H = 64, 36
616
+
608
617
  _BLACK_RE = re.compile(r"black_start:\s*([0-9.]+)\s+black_end:\s*([0-9.]+)")
609
618
  _FREEZE_RE = re.compile(r"lavfi\.freezedetect\.freeze_(start|end):\s*([0-9.]+)")
610
619
 
@@ -619,7 +628,7 @@ def detect_content(path: str, duration: float, *, video: bool = True, audio: boo
619
628
  ffmpeg = require_tool("ffmpeg")
620
629
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path]
621
630
  if video:
622
- cmd += ["-map", "0:v:0", "-vf", f"blackdetect=d={CONTENT_MIN_SPAN}:pix_th={CONTENT_BLACK_PIX_TH},"
631
+ cmd += ["-map", "0:V:0", "-vf", f"blackdetect=d={CONTENT_MIN_SPAN}:pix_th={CONTENT_BLACK_PIX_TH},"
623
632
  f"freezedetect=n={CONTENT_FREEZE_NOISE}:d={CONTENT_FREEZE_MIN}"]
624
633
  if audio:
625
634
  cmd += ["-map", "0:a:0", "-af", f"silencedetect=noise={SILENT_PEAK_DBFS:g}dB:d={CONTENT_SILENCE_MIN}"]
@@ -684,6 +693,83 @@ def _detect_dropouts(path: str, end: float) -> "List[Tuple[float, float]]":
684
693
  return gaps # a gap still open at the end is a tail, never a dropout
685
694
 
686
695
 
696
+ def detect_flashes(path: str) -> "Optional[List[int]]":
697
+ """Frame indexes of single-frame flashes in the first video stream: frame n differs from n-1 and
698
+ from n+1 by at least CONTENT_FLASH_JUMP while n-1 and n+1 differ by at most
699
+ CONTENT_FLASH_RETURN of the smaller step. One video-only pass; ffmpeg reduces each frame to
700
+ three 16-bit numbers (|f[n]-f[n-1]| and the two clipped halves of f[n]-f[n-2]): 6 bytes a
701
+ frame in the captured output (1.3 MB for an hour at 60 fps), read in place. The caller turns an index into a time;
702
+ the comparison itself needs no frame rate. None when no frame decoded or the pass failed
703
+ (nothing was compared; a failure is also a flash_pass_failed warning)."""
704
+ if dry_run_input_pending(path):
705
+ return []
706
+ graph = (f"[0:V:0]scale={_DIFF_W}:{_DIFF_H}:flags=area,format=gray16le,split=3[a][b][c];"
707
+ "[a]tblend=all_mode=difference,scale=1:1:flags=area[d];"
708
+ "[b]tmix=frames=3:weights='-1 0 1':scale=1,scale=1:1:flags=area[p];"
709
+ "[c]tmix=frames=3:weights='1 0 -1':scale=1,scale=1:1:flags=area[q];"
710
+ "[d][p][q]hstack=inputs=3")
711
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
712
+ "-filter_complex", graph, "-fps_mode", "passthrough", "-f", "rawvideo", "-"],
713
+ check=False, text=False, record=True)
714
+ if proc.returncode != 0:
715
+ # an unmeasured row, not the end of the report: the other content rows stand
716
+ warn("flash detection failed: " + (proc.stderr.decode(errors="replace").strip().splitlines() or ["ffmpeg exited " + str(proc.returncode)])[-1][:300],
717
+ "flash_pass_failed")
718
+ return None
719
+ # tblend has no output for frame 0, so record r is frame r+1: a zero record in front puts
720
+ # every value at its own frame's index. passthrough keeps the rawvideo muxer from padding a
721
+ # constant rate, which repeated records over timestamp gaps (a repeat is a zero step, and a
722
+ # flash before a gap went unreported)
723
+ if len(proc.stdout) < 6:
724
+ return None
725
+ data = bytes(6) + proc.stdout
726
+ n = len(data) // 6
727
+
728
+ def record(i: int) -> "Tuple[float, float]":
729
+ """(|f[i] - f[i-1]|, |f[i] - f[i-2]|), 0-255; read from the buffer, never kept per frame."""
730
+ d, p, q = (int.from_bytes(data[i * 6 + k:i * 6 + k + 2], "little") / 256.0 for k in (0, 2, 4))
731
+ return d, p + q
732
+
733
+ flashes = []
734
+ into = record(1)[0] if n > 1 else 0.0
735
+ for i in range(1, n - 1):
736
+ out, back = record(i + 1)
737
+ if into >= CONTENT_FLASH_JUMP and out >= CONTENT_FLASH_JUMP and back <= CONTENT_FLASH_RETURN * min(into, out):
738
+ flashes.append(i)
739
+ into = out
740
+ return flashes
741
+
742
+
743
+ def presentation_times(path: str, start: float) -> "List[float]":
744
+ """The presentation time of every frame of the first video stream (attached pictures
745
+ excluded), in order: its packets' pts sorted, those before the stream's own start (an edit
746
+ list's hidden pre-roll, never shown) dropped. From packets, so no decode. [] when unreadable,
747
+ also when one packet has no pts: skipping it would shift every later time onto the wrong frame."""
748
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "V:0", "-show_entries", "packet=pts_time",
749
+ "-of", "csv=p=0", path], quiet=True, check=False)
750
+ times = []
751
+ for line in (proc.stdout or "").split() if proc.returncode == 0 else []:
752
+ try:
753
+ times.append(float(line.split(",")[0]))
754
+ except ValueError:
755
+ return []
756
+ return sorted(t for t in times if t >= start - 1e-6)
757
+
758
+
759
+ def first_frame_stats(path: str) -> "Optional[Tuple[float, float]]":
760
+ """(mean, standard deviation) of the first frame's luma, 0-255, at 64x36; None when it does
761
+ not decode or the input is a pending dry-run placeholder."""
762
+ if dry_run_input_pending(path):
763
+ return None
764
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
765
+ "-map", "0:V:0", "-frames:v", "1", "-vf", f"scale={_DIFF_W}:{_DIFF_H}:flags=area,format=gray",
766
+ "-f", "rawvideo", "-"], check=False, text=False, record=True)
767
+ px = list(proc.stdout[:_DIFF_W * _DIFF_H])
768
+ if proc.returncode != 0 or len(px) < _DIFF_W * _DIFF_H:
769
+ return None
770
+ return statistics.fmean(px), statistics.pstdev(px)
771
+
772
+
687
773
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
688
774
 
689
775
 
@@ -495,6 +495,7 @@ DIAGNOSTIC_CODES = {
495
495
  # degraded or partial results (part 2)
496
496
  "colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
497
497
  "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
498
+ "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
498
499
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
499
500
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
500
501
  "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
package/scripts/check.py CHANGED
@@ -25,17 +25,20 @@ duration that is black: WARN > 10%, FAIL >= 95%), frozen (longest frozen span: W
25
25
  max(3 s, 30% of the duration), FAIL when the whole video is frozen) and silence (share
26
26
  below -50 dBFS: WARN > 50%, FAIL >= 95%); and, from one more audio-only pass (2.17),
27
27
  dropouts (gaps of digital silence, <= -80 dBFS for 20 ms to 1 s with sound on both sides:
28
- WARN when there is any). Without it the row set is unchanged.
28
+ WARN when there is any); and, from one more video pass and a one-frame decode (2.23), flashes
29
+ (single frames that differ from both neighbours while they match each other: WARN when there is
30
+ any) and first frame (one flat colour, the frame a feed's autoplay shows first: WARN). Without it
31
+ the row set is unchanged.
29
32
  """
30
33
  import argparse
31
34
  import json
32
35
  import re
33
36
  import sys
34
37
  from fractions import Fraction
35
- from typing import Any, Dict, List
38
+ from typing import Any, Dict, List, Optional
36
39
 
37
40
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
38
- from _common.probe import SILENT_PEAK_DBFS, detect_content, measured_level_dbfs
41
+ from _common.probe import CONTENT_FLAT_STD, SILENT_PEAK_DBFS, detect_content, detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs
39
42
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
40
43
 
41
44
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
@@ -68,6 +71,22 @@ FROZEN_FAIL = 0.95 # a frozen span covering this share =
68
71
  SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
69
72
 
70
73
 
74
+ def flash_times(flashes: List[int], v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
75
+ """The flashes row's first five times on look.py's timeline (seconds from the container
76
+ start). A flash frame's own presentation time when `pts` has it (presentation_times(): exact,
77
+ gaps in the timestamps included); otherwise index / average rate plus the video's lead over
78
+ the container, marked "~" (approximate), and `frame N` when there is no frame rate either."""
79
+ origin = float(meta.get("start_time") or 0.0)
80
+ fps = float(v.get("fps") or 0.0)
81
+ lead = max(0.0, float(v.get("start_time") or 0.0) - origin)
82
+
83
+ def one(n: int) -> str:
84
+ if pts and n < len(pts):
85
+ return f"{max(0.0, pts[n] - origin):.2f}s"
86
+ return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
87
+ return ", ".join(one(n) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
88
+
89
+
71
90
  def _span_total(spans: Any) -> float:
72
91
  return sum(max(0.0, e - s) for s, e in spans)
73
92
 
@@ -91,7 +110,7 @@ def main() -> int:
91
110
  ap.add_argument("--max-mb", type=float, help="override max file size in MB")
92
111
  ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
93
112
  ap.add_argument("--content", action="store_true",
94
- help="also check the content (one decode pass, plus an audio-only one): black, frozen, silence and dropouts rows")
113
+ help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
95
114
  add_common(ap)
96
115
  args = ap.parse_args()
97
116
  apply_common(args)
@@ -117,7 +136,8 @@ def main() -> int:
117
136
  v, a = meta.get("video") or {}, meta.get("audio") or {}
118
137
  rows: List[Dict[str, Any]] = []
119
138
 
120
- JUDGEMENT = {"duration", "aspect", "loudness", "true peak", "fps", "resolution"}
139
+ # first frame / flashes: a fade from black or a strobe can be the edit, so neither is a mechanical fix
140
+ JUDGEMENT = {"duration", "aspect", "loudness", "true peak", "fps", "resolution", "first frame", "flashes"}
121
141
 
122
142
  def row(name: str, status: str, value: Any, expect: Any, fix: str = "", reason: str = "") -> None:
123
143
  # "format" rows are safe to fix mechanically; "judgement" rows change the content
@@ -284,6 +304,32 @@ def main() -> int:
284
304
  f"longest {longest:.1f}s", f"<= {limit:.1f}s frozen",
285
305
  "look.py the frozen span: a still title card is fine, a stuck decode is not",
286
306
  reason="a picture that never changes is usually a stuck frame, not the edit")
307
+ if v and not dry_run_input_pending(args.input): # a plan's not-yet-written input: no rows, as above
308
+ first = first_frame_stats(args.input)
309
+ if first is None:
310
+ row("first frame", "WARN", "could not measure", f"spread >= {CONTENT_FLAT_STD:g} (not one flat colour)",
311
+ "look.py the first frame", reason="the first frame did not decode, so whether it is flat is unknown")
312
+ else:
313
+ mean, spread = first
314
+ flat = spread < CONTENT_FLAT_STD
315
+ row("first frame", "WARN" if flat else "PASS", f"luma {mean:.0f}, spread {spread:.1f}",
316
+ f"spread >= {CONTENT_FLAT_STD:g} (not one flat colour)",
317
+ "a fade from black is a choice; where autoplay or the preview shows the first frame, start on "
318
+ "a picture (cut.py --start a few frames in, or move a title card to frame 0)" if flat else "",
319
+ reason="a feed shows the first frame before anything plays, and a flat one reads as a broken upload" if flat else "")
320
+ flashes = detect_flashes(args.input)
321
+ pts = presentation_times(args.input, float(v.get("start_time") or 0.0)) if flashes else None
322
+ at = flash_times(flashes or [], v, meta, pts)
323
+ if flashes is None:
324
+ row("flashes", "WARN", "could not measure", "no single frame unlike both its neighbours",
325
+ "look.py the video: no frame decoded, so nothing was compared",
326
+ reason="the video did not decode, so whether it has flashes is unknown")
327
+ else:
328
+ row("flashes", "WARN" if flashes else "PASS", f"{len(flashes)}" + (f": {at}" if flashes else ""),
329
+ "no single frame unlike both its neighbours",
330
+ "look.py at those times: a stray frame at a cut or join is an edit error (re-cut with cut.py "
331
+ "--accurate, or join.py again); a camera flash or a strobe in the footage is fine" if flashes else "",
332
+ reason="one frame that matches neither neighbour is usually a frame left over at a cut, or a decode glitch" if flashes else "")
287
333
  if "silence" in cd and span:
288
334
  share = _span_total(cd["silence"]) / span
289
335
  row("silence", "FAIL" if share >= SILENCE_FAIL else ("WARN" if share > SILENCE_WARN else "PASS"),