ffmpeg-skill 3.2.3 → 3.3.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
@@ -20,6 +20,7 @@ No cloud · No API keys · Python standard library</p>
20
20
 
21
21
  <p align="center">
22
22
  <a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
23
+ <a href="https://scorecard.dev/viewer/?uri=github.com/kajisho5/ffmpeg-skill"><img src="https://api.scorecard.dev/projects/github.com/kajisho5/ffmpeg-skill/badge" alt="OpenSSF Scorecard"></a>
23
24
  <a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
24
25
  <a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-6.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 6.1, 7.1, 8 and 9 tested in CI"></a>
25
26
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
@@ -595,6 +596,16 @@ claude plugin marketplace add kajisho5/ffmpeg-skill # older v
595
596
  claude plugin install ffmpeg-skill@ffmpeg-skill # then install from it
596
597
  ```
597
598
 
599
+ The same repository installs as a plugin in two more hosts (checked against Codex CLI 0.167 and Gemini CLI 0.68):
600
+
601
+ ```bash
602
+ codex plugin marketplace add kajisho5/ffmpeg-skill # Codex: add the marketplace,
603
+ codex plugin add ffmpeg-skill@ffmpeg-skill # then the plugin
604
+ gemini extensions install https://github.com/kajisho5/ffmpeg-skill # Gemini CLI
605
+ ```
606
+
607
+ Those hosts find a plugin's skill only at `skills/ffmpeg-skill/SKILL.md`, a generated copy of the root `SKILL.md` (`.github/scripts/build_plugin_skill.py`; a test fails when it is stale). A Cursor plugin manifest (`.cursor-plugin/plugin.json`) is in the repository for the Cursor Marketplace; it follows Cursor's reference and has not been through their review yet.
608
+
598
609
  The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
599
610
 
600
611
  Without Node: clone this repository and copy `SKILL.md`, `package.json`, `scripts/`, `templates/`, `references/`, `docs/` and `mcp/` into your agent's skills directory (the same set `npx ffmpeg-skill` installs; without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown).
@@ -629,7 +640,7 @@ FFmpeg itself:
629
640
 
630
641
  ## Stability
631
642
 
632
- 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
643
+ 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. Releases are batched: related fixes and features ship together as one version rather than one version per merged pull request, so a dependent project sees fewer, larger steps. Pin a tagged version, not `main`. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
633
644
 
634
645
  ## Development
635
646
 
package/bin/install.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * npx ffmpeg-skill --uninstall # remove from the selected targets
15
15
  * npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
16
16
  * npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
17
+ * npx ffmpeg-skill mcp # stdio MCP server (python3 mcp/server.py); registry clients pass `mcp`
17
18
  *
18
19
  * Already installed? re-run `npx ffmpeg-skill` to refresh ~/.claude/skills/ffmpeg-skill
19
20
  * Copies are not updated automatically.
@@ -98,7 +99,8 @@ function warnMissingPython(platform, spawn, warn) {
98
99
  }
99
100
 
100
101
  function main() {
101
- // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
102
+ // `contract` / `doctor` are answered by scripts/_contract.py. `mcp` is the stdio server
103
+ // (server.json's packageArguments). Everything else installs.
102
104
  if (args[0] === 'contract' || args[0] === 'doctor') {
103
105
  const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
104
106
  if (py.missing) {
@@ -107,6 +109,14 @@ if (args[0] === 'contract' || args[0] === 'doctor') {
107
109
  }
108
110
  process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
109
111
  }
112
+ if (args[0] === 'mcp') {
113
+ const py = runWithPython([path.join(ROOT, 'mcp', 'server.py'), ...args.slice(1)], process.platform, spawnSync);
114
+ if (py.missing) {
115
+ console.error(`error: could not find a Python 3.10+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.10+ and ensure it is on PATH.`);
116
+ process.exit(127);
117
+ }
118
+ process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
119
+ }
110
120
 
111
121
  const home = os.homedir();
112
122
  const targets = [];
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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.2.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.3.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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.2.3", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.3.0", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -918,6 +918,19 @@ that is so: once a tool has written its deliverable and runs a further step on i
918
918
  says it is kept without the step that was stopped, and the document carries it as `output`
919
919
  (3.1.6, #415).
920
920
 
921
+ **Source carry-over.** Every tool that writes a media file compares what its first input carried
922
+ besides the picture and the sound with the file it wrote, and says so in `source_carry:
923
+ {kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
924
+ `audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
925
+ `timecode`. The key is present only when the source had at least one of them, so a result for a
926
+ plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
927
+ or more inputs have a picture or sound (which one's items the output should carry is the tool's
928
+ choice). An item bound to a stream the output does not have at all is not compared (a sound-only
929
+ extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
930
+ it. A dropped `subtitles`, `chapters`, `cover_art` or `audio_channels` also records one
931
+ `source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
932
+ `dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
933
+
921
934
  **Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
922
935
  skipped -- is recorded as it happens (a stderr line too) and lands in the document as
923
936
  `diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.2.3",
3
+ "version": "3.3.0",
4
+ "mcpName": "io.github.kajisho5/ffmpeg-skill",
4
5
  "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
6
  "keywords": [
6
7
  "ffmpeg",
@@ -54,6 +54,13 @@ on a stream copy as on a re-encode
54
54
  (`cut.py` reports it: `dropped_audio_streams`, an `audio_tracks_dropped` warning). Keep ProRes masters
55
55
  at source colour: `export.py --preset prores` does not retag.
56
56
 
57
+ What else the source carried is compared on every write: `source_carry: {kept, dropped}` lists
58
+ subtitles, chapters, a cover picture, surround channels, `creation_time`, the audio language tag
59
+ and the start timecode, and a lost subtitle track, chapter list, cover picture or surround layout
60
+ is a `source_items_dropped` warning. Read it before telling the user the file is complete; to
61
+ carry the items over, use the tool that keeps them (`export.py` keeps subtitle tracks,
62
+ `metadata.py` writes chapters and tags).
63
+
57
64
  Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
58
65
  colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
59
66
  do not tag BT.709 on BT.2020 pixels.
@@ -69,8 +69,11 @@ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
69
69
  return bool(_RGB_PIX_FMT_RE.match(str(((meta or {}).get("video") or {}).get("pix_fmt") or "")))
70
70
 
71
71
 
72
- _MATRIX_FILTER_RE = re.compile(r"^(?:colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709|(?:format=rgb24,)?%s)$"
73
- % ("|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
72
+ # An odd-sized BT.601 source is padded to even for the colorspace filter and cropped back to its
73
+ # own size after it (bt709_matrix_filter): both wrap the conversion in the one -vf the merge finds.
74
+ _EVEN_PAD = "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0,"
75
+ _MATRIX_FILTER_RE = re.compile(r"^(?:(?:%s)?colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709(?:,crop=iw-[01]:ih-[01]:0:0:exact=1)?|(?:format=rgb24,)?%s)$"
76
+ % (re.escape(_EVEN_PAD), "|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
74
77
  _INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
75
78
 
76
79
 
@@ -102,7 +105,19 @@ def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
102
105
  return ("format=rgb24," if v.get("pix_fmt") == "pal8" else "") + rgb_to_bt709_filter()
103
106
  if matrix not in NON_BT709_MATRICES:
104
107
  return None
105
- return f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
108
+ # the colorspace filter refuses an odd width or height outright ("Invalid odd size"), and a
109
+ # 4:2:0 JPEG can have one (a phone screenshot, #437). The frame is padded by one row and/or
110
+ # column, converted, and cropped back to its own size: no source pixel is lost or resampled
111
+ # (a 4:2:0 frame's chroma planes are the same size either way), so a caller's own crop or
112
+ # scale sees the frame it measured. Nothing changes for an even-sized source. FFmpeg rotates
113
+ # a rotated source before the graph, so the odd axis is read after the same swap.
114
+ w, h = int(v.get("width") or 0), int(v.get("height") or 0)
115
+ if int(v.get("rotation") or 0) % 180 == 90:
116
+ w, h = h, w
117
+ conv = f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
118
+ if w % 2 or h % 2:
119
+ return f"{_EVEN_PAD}{conv},crop=iw-{w % 2}:ih-{h % 2}:0:0:exact=1"
120
+ return conv
106
121
 
107
122
 
108
123
  def bt709_label_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
@@ -163,6 +163,74 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
163
163
  ctx.notes.append(note_line)
164
164
 
165
165
 
166
+ # source_carry: the items compared, in report order. The first group is content a viewer or
167
+ # listener notices missing and raises the warning; the second is tags, listed but never warned
168
+ # about (every camera file has a creation_time, so a warning for it would fire on every run).
169
+ CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
170
+ CARRY_TAGS = ("creation_time", "audio_language", "timecode")
171
+ _CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
172
+ "audio_channels": "audio channels"}
173
+
174
+
175
+ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
176
+ """What the first input carried besides its picture and sound, and whether the written file
177
+ still has it (#401): {"kept": [...], "dropped": [...]}, or None when the source had none of
178
+ it or the two files are not comparable. Measured on the written file, so the tool, encoder or
179
+ FFmpeg version that dropped it does not matter. A report, not a check: `verified` is not
180
+ touched. Not compared: two or more picture or sound sources (which one's items the output
181
+ should carry is the tool's choice), an output that is not a media file, and items bound to a
182
+ stream the output does not have at all (a sound-only extract has no subtitle track or
183
+ timecode to lose; a picture-only output has no audio channels). Audio channels are an item
184
+ only for a surround source (more than two)."""
185
+ from .probe import MEDIA_EXT, _STILL_EXT, carry_inventory
186
+ first = getattr(STATE, "first_input", None)
187
+ if not first or not output:
188
+ return None
189
+ # a tool that reads its own output back (cut.py measures the cut it wrote) probed it as an
190
+ # input too: that is not a second source
191
+ own = os.path.realpath(output)
192
+ if any(len(set(getattr(STATE, name, None) or ()) - {own}) > 1 for name in ("video_inputs", "audio_inputs")):
193
+ return None
194
+ ext = os.path.splitext(output)[1].lower()
195
+ if ext not in MEDIA_EXT or ext in _STILL_EXT:
196
+ return None # a still, a contact sheet or an animated GIF holds none of these
197
+ src, out = carry_inventory(first[0]), carry_inventory(output)
198
+ if not src or not out or os.path.realpath(first[0]) == os.path.realpath(output):
199
+ return None
200
+ kept: List[str] = []
201
+ dropped: List[str] = []
202
+ for item in CARRY_CONTENT + CARRY_TAGS:
203
+ if not src.get(item):
204
+ continue
205
+ if item == "audio_channels" and src[item] <= 2:
206
+ continue # mono and stereo are what every tool writes; only a surround layout can be folded down
207
+ if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
208
+ continue
209
+ if item in ("audio_channels", "audio_language") and not out["has_audio"]:
210
+ continue
211
+ (kept if out.get(item, 0) >= src[item] else dropped).append(item)
212
+ if not kept and not dropped:
213
+ return None
214
+ said = set()
215
+ if extra.get("dropped_non_av_streams") or any(w.get("code") == "non_av_streams_dropped" for w in ctx.fallbacks):
216
+ said.add("subtitles") # the tool's own field already says the track went
217
+ loud = [i for i in dropped if i in CARRY_CONTENT and i not in said]
218
+ if loud:
219
+ parts = []
220
+ for i in loud:
221
+ if i == "audio_channels":
222
+ parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
223
+ else:
224
+ parts.append("its " + _CARRY_NAMES[i])
225
+ warn("the source carried " + ", ".join(parts) + " that this output does not have "
226
+ "(source_carry lists everything kept and dropped)",
227
+ "source_items_dropped", ctx=ctx, dropped=loud)
228
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
229
+ if note_line and note_line not in ctx.notes:
230
+ ctx.notes.append(note_line)
231
+ return {"kept": kept, "dropped": dropped}
232
+
233
+
166
234
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
167
235
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
168
236
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -710,6 +778,9 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
710
778
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
711
779
  _report_dropped_audio_tracks(meta, ctx)
712
780
  _report_dropped_hdr10_metadata(meta, ctx)
781
+ carry = source_carry(output, ctx, extra)
782
+ if carry and "source_carry" not in extra:
783
+ extra["source_carry"] = carry
713
784
  if ctx.json:
714
785
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
715
786
  if meta:
@@ -402,9 +402,30 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
402
402
  (out.get("video") or {}).get("hdr10_static"))
403
403
  if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
404
404
  STATE.source_timecode = (out["timecode"]["value"], duration)
405
+ # what a source carries besides its picture and sound, for emit()'s source_carry comparison:
406
+ # kept beside the probe, not in it, so no probe document changes
407
+ lang = (audio.get("tags") or {}).get("language") if audio else None
408
+ _CARRY[os.path.realpath(path)] = {
409
+ "subtitles": len(subs),
410
+ "chapters": len(raw.get("chapters") or []),
411
+ "cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
412
+ "creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
413
+ "audio_language": 1 if lang and lang.lower() != "und" else 0,
414
+ "timecode": 1 if out["timecode"].get("value") else 0,
415
+ "audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
416
+ "has_video": bool(video), "has_audio": bool(audio),
417
+ }
405
418
  return out
406
419
 
407
420
 
421
+ _CARRY: Dict[str, Dict[str, Any]] = {}
422
+
423
+
424
+ def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
425
+ """The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
426
+ return _CARRY.get(os.path.realpath(path)) if path else None
427
+
428
+
408
429
  def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
409
430
  """A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
410
431
  a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
@@ -1067,9 +1067,14 @@ class _OutputLock:
1067
1067
  def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
1068
1068
  """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
1069
1069
  encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
1070
- with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
1070
+ with an even-dimension scale appended to its -vf chain (or a new -vf when the command had
1071
1071
  none); None when the failure is something else or the graph is a -filter_complex the
1072
- caller has to fix itself."""
1072
+ caller has to fix itself.
1073
+
1074
+ Appended, not prepended: what the encoder refuses is the frame the chain hands it, and a
1075
+ chain can make an even frame odd again (squaring non-square pixels to 636x1401, a conversion
1076
+ that restores the source's own size). Through 3.2.3 the scale went first, so caption.py on an
1077
+ odd-sized clip retried and failed the same way (#438)."""
1073
1078
  if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
1074
1079
  return None
1075
1080
  if "-filter_complex" in cmd:
@@ -1077,7 +1082,7 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
1077
1082
  new = list(cmd)
1078
1083
  if "-vf" in new:
1079
1084
  i = new.index("-vf") + 1
1080
- new[i] = EVEN_SCALE + "," + new[i]
1085
+ new[i] = new[i] + "," + EVEN_SCALE
1081
1086
  return new
1082
1087
  if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
1083
1088
  return None
@@ -472,6 +472,7 @@ DIAGNOSTIC_CODES = {
472
472
  # fonts and text
473
473
  "emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
474
474
  "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
475
+ "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
475
476
  "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
476
477
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
477
478
  "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
@@ -614,6 +615,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
614
615
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
615
616
  "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
616
617
  "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
618
+ "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode. Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
617
619
  "diagnostics": DIAGNOSTICS_SCHEMA}
618
620
  extra: Dict[str, Any] = {}
619
621
  if name == "check":
@@ -75,6 +75,11 @@ __all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_
75
75
  "text_width_em"]
76
76
 
77
77
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
78
+ # libass reads a force_style Alignment in the legacy SSA numbering (1-3 bottom, 5-7 top, 9-11
79
+ # middle), not the v4+ numpad one a Style line uses: only the bottom row is the same number in
80
+ # both. Through 3.2.3 the SRT burn passed the numpad value, so --position top landed mid-left,
81
+ # center top-left, top-left top-right (#436, measured on libass 0.17.1 and FFmpeg 9.0.2).
82
+ FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
78
83
 
79
84
  TIME_RE = re.compile(
80
85
  r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
@@ -1750,7 +1755,7 @@ def main() -> int:
1750
1755
  f"Outline={args.outline:g}",
1751
1756
  f"Shadow={args.shadow:g}",
1752
1757
  f"Bold={-1 if args.bold else 0}",
1753
- f"Alignment={ALIGN[args.position]}",
1758
+ f"Alignment={FORCE_STYLE_ALIGN[args.position]}",
1754
1759
  f"MarginV={args.margin}",
1755
1760
  ]
1756
1761
  force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")
@@ -27,7 +27,7 @@ import sys
27
27
  import tempfile
28
28
  from pathlib import Path
29
29
 
30
- from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs
30
+ from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -63,7 +63,18 @@ def main() -> int:
63
63
  trf_arg = escape_filter_path(trf)
64
64
 
65
65
  ffmpeg = require_tool("ffmpeg")
66
- detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
66
+ # vid.stab asserts on an odd frame (ffmpeg aborts in pass 1, #438), so both passes see an
67
+ # even one. Recorded like the runner's own odd-size retry: the output is a pixel narrower
68
+ # or shorter than the source.
69
+ v = meta["video"]
70
+ w, h = int(v.get("width") or 0), int(v.get("height") or 0)
71
+ if int(v.get("rotation") or 0) % 180 == 90:
72
+ w, h = h, w
73
+ even = f"{EVEN_SCALE}," if (w % 2 or h % 2) else ""
74
+ if even:
75
+ record_fallback("source has odd dimensions; scaling to even before stabilizing (vid.stab needs it)",
76
+ "odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
77
+ detect_vf = f"{even}vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
67
78
  if args.tripod:
68
79
  # A frame number, not a boolean: frame 1 is the standard reference for "lock to
69
80
  # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
@@ -78,7 +89,7 @@ def main() -> int:
78
89
  die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
79
90
 
80
91
  crop_mode = {"keep": 0, "black": 1}[args.crop]
81
- transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
92
+ transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
82
93
  if args.tripod:
83
94
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
84
95
  # camera path makes no sense once every frame is locked to one fixed reference.