ffmpeg-skill 1.0.4 → 1.1.1

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
@@ -312,12 +312,13 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
312
312
 
313
313
  ## FFmpeg compatibility
314
314
 
315
- The tools need FFmpeg 5.0 or later. The capability parser has been run against the listings of these builds:
315
+ The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
316
316
 
317
317
  | FFmpeg | `-filters` row layout | Source |
318
318
  |---|---|---|
319
+ | 5.1.1 | three flag characters, same as 6.x | johnvansickle.com static build on the Linux CI runner |
319
320
  | 6.1.1 | three flag characters: `..C acompressor A->A` | Ubuntu 24.04 apt, captured |
320
- | 7.x | same as 6.x | constructed fixture (no capture at hand) |
321
+ | 7.1.x | same as 6.x | Debian trixie apt in a CI container (plus a constructed fixture in tests/) |
321
322
  | 8.1.2 | two flag characters: `TS aap AA->A`, three-character legend, `------` separator | Homebrew on the macOS CI runner, captured |
322
323
  | 9.0.1 | same as 8.x, CRLF | gyan.dev build on the Windows CI runner, captured |
323
324
 
@@ -356,6 +357,14 @@ npx ffmpeg-skill --dir ./my-skills
356
357
  npx ffmpeg-skill --uninstall # remove from the selected targets (--codex also clears the older ~/.codex/skills location)
357
358
  ```
358
359
 
360
+ As a Claude Code plugin (no Node needed, updates with `claude plugin update`):
361
+
362
+ ```bash
363
+ claude plugin install kajisho5/ffmpeg-skill
364
+ ```
365
+
366
+ The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`; the manifest is [.claude-plugin/plugin.json](.claude-plugin/plugin.json) and its version follows every release automatically.
367
+
359
368
  Without Node: clone this repository and copy `SKILL.md`, `scripts/`, `references/` and `mcp/` into your agent's skills directory.
360
369
 
361
370
  After installing:
@@ -381,6 +390,10 @@ FFmpeg itself:
381
390
 
382
391
  `doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...) has no timeout: a legitimate `--accurate` re-encode of a long file can genuinely take a long time, so bounding it would risk killing real work. `-nostdin` is always passed, so a hung ffmpeg process waiting on stdin cannot happen; a caller that needs a hard ceiling on a specific job should apply its own external timeout/kill around that one invocation.
383
392
 
393
+ ## Stability
394
+
395
+ 1.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 2.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-1x). 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.
396
+
384
397
  ## Development
385
398
 
386
399
  ```bash
@@ -391,11 +404,11 @@ python3 evals/run.py --list # agent eval prompts (see evals/)
391
404
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
392
405
  ```
393
406
 
394
- CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), and uploads each runner's FFmpeg listings as an artifact.
407
+ CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1, Python 3.9 and 3.13), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus two Linux jobs on FFmpeg 5.1.1 (static build) and 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
395
408
 
396
409
  `tests/test_contract.py` runs on all three OSes, but a handful of its tests build a fake `ffmpeg` as a `#!/bin/sh` script on a PATH shim to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser — that technique isn't portable to Windows, so `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class (fixture-driven layout parsing) are individually `skipIf`'d there and show as `skipped`, not silently absent, in the Windows job's log. Everything else — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and every tool exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
397
410
 
398
- **Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it asks [release-drafter](.github/release-drafter.yml) to resolve the next version from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; PRs labelled `chore`, `ci`, `docs` or `dependencies` are excluded, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json` and `docs/contract.md`, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
411
+ **Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json` and `docs/contract.md`, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
399
412
 
400
413
  Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
401
414
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 40 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",
@@ -109,3 +109,59 @@ When a test needs to special-case a platform, prefer gating with
109
109
  mechanism, and write the skip/relaxation reason as a full sentence
110
110
  explaining the underlying platform behaviour — not just "flaky on
111
111
  Windows" — so a future reader doesn't have to re-derive it from the CI log.
112
+
113
+ ## FFmpeg 5.x (the 5.1.1 static-build CI job)
114
+
115
+ Found the day the job was added (#146); 6.1+ behaves the same on all three OSes, so none of
116
+ these had ever shown up before.
117
+
118
+ - **`scdet=...:sc_pass=1` passes only frames whose score exceeds the threshold.** On 5.x every
119
+ truly static frame (score exactly 0: a title card, colour bars) is dropped before the next
120
+ filter and the frame numbers are re-counted without them. `scenes.py` used it with
121
+ `threshold=0` expecting every frame through, so a 4 s smptebars scene made the cuts on both
122
+ sides of it disappear from its neighbourhood test. 6.1+ passes every frame regardless.
123
+ Dropped the option; scores are also indexed by frame number now, missing frames counting as 0.
124
+ - **`drawtext` `boxborderw=v|h` (and the four-value form) is 6.1+.** 5.x and 6.0 fail the whole
125
+ filter with "Error setting option boxborderw to value 9|16". `_common.drawtext_boxborderw()`
126
+ emits the larger single value on older builds (`_common.ffmpeg_version()` parses
127
+ `ffmpeg -version` once; it is the only place the tools branch on a version string).
128
+ - **`showwaves` keeps emitting frames after the audio ends, `-shortest` notwithstanding.** A
129
+ 12 s source came out 14.08 s on 5.1.1. `waveform.py` now also passes `-t <source duration>`.
130
+ - **`-display_rotation` is 6.0+.** Only the test fixture builder used it (to make a rotated
131
+ phone-style clip); on 5.x it writes the stream's `rotate` tag instead, which every probe here
132
+ reads identically.
133
+ - **John Van Sickle's 7.0.2 static build has no `drawtext`** (built with freetype, yet the
134
+ filter is absent), and BtbN no longer publishes 7.x; the 7.1 job therefore runs in a Debian
135
+ trixie container (apt ffmpeg 7.1.5 with libass, freetype and zimg).
136
+
137
+ ## FFmpeg 7.1+ (the debian-trixie container CI job)
138
+
139
+ - **Output `-colorspace bt709` is no longer just a tag: on an untagged source it converts.**
140
+ 7.1 added colourspace negotiation to libavfilter, and the CLI feeds the encoder's
141
+ `-colorspace/-color_primaries/-color_trc` into the graph's output constraints. A source
142
+ whose bitstream carries no colour tags (`color_space=unknown` -- test sources, screen
143
+ recordings, many cameras) then *differs* from the requested BT.709, so ffmpeg auto-inserts
144
+ a real matrix conversion (swscale guesses bt601 for "unknown"): every SDR re-encode through
145
+ `x264_args()` shifted the picture, and a `--lut-strength 0` no-op grade came back ~24 dB
146
+ PSNR from its source. 5.x/6.x wrote the same options as tags only (47 dB, no conversion).
147
+ The 7.0.2 static build does *not* show it; 7.1.1 (conda-forge) reproduces it locally, so
148
+ that is the build to use when the trixie job goes red on a colour test.
149
+ Fix: `_common.bt709_tag_args()` writes the tags through the encoder's own VUI parameters
150
+ (`-x264-params colorprim=...:transfer=...:colormatrix=...`, x265 likewise) from 7.1 on,
151
+ which libavfilter never sees; older builds keep the output options, since those were the
152
+ only way to get an mp4 `colr` atom there. A decoder-side `-colorspace bt709 ... -i` override
153
+ was tried first and rejected: it also tags a `-c copy` output of an untagged source (export
154
+ copy must stay a real copy), and it exposed a separate, pre-existing `--correct` bug (below).
155
+ Verified on 5.1.1, 6.1.1, 7.1.1 and 8.1.2. 8.x has the same encode-time conversion; it
156
+ went unnoticed there because 8.x's `psnr` filter *also* negotiates colourspace and undid it
157
+ before measuring (40 dB for re-matrixed pixels), and it then flagged the fixed, byte-clean
158
+ output as 26 dB instead. The tests' `_psnr` helper now pins identical colour tags on both
159
+ inputs so every build reports the same number for the same two files.
160
+ - **The conda-forge 7.1.1 build deadlocks on `tpad` + `adelay`/`apad` (pad.py) and ignores
161
+ SIGTERM.** Debian's 7.1.5 in CI does not. Run pad tests against CI, not that build, and note
162
+ that the tools have no subprocess timeout to get an agent out of such a hang.
163
+ - **Not a 7.1 issue, found while chasing it: `color --correct` desaturates a bt709-*tagged*
164
+ source by ~8 % at identity settings on 6.1 and 7.1 alike** (113.6 → 103.9 saturation_avg):
165
+ the RGB stages (exposure/colortemperature/colorbalance) make swscale go yuv→rgb with the
166
+ frame's bt709 matrix and back with its bt601 default. The identity test only ever used an
167
+ untagged source, where both legs pick bt601 and cancel out. Tracked separately.
@@ -131,3 +131,30 @@ applied, or on files changed, and make the irreversible step refuse anything sur
131
131
  than assume the surprise was intended. And after wiring any such automation, watch the first
132
132
  few real runs' *results* (npm, tags) rather than their exit codes: the three runs here were
133
133
  "success" by every check the job had.
134
+
135
+ ## An action input that does not exist is a warning, not an error -- and "excluded from the notes" is not "no release"
136
+
137
+ The fix for the accidental majors above (#145) still released **1.0.4** for its own,
138
+ workflow-only merge. Two assumptions in `release.yml` were wrong and nothing checked either:
139
+
140
+ - `release-drafter/release-drafter@v6` was called with `dry-run: true` to "compute the next
141
+ version read-only". That action has no `dry-run` input. GitHub Actions logs
142
+ `Unexpected input(s) 'dry-run'` as a *warning* and runs the step anyway -- so every release
143
+ run had been rewriting the draft release live, and the "read-only" in the comment was fiction.
144
+ - `exclude-labels` in `release-drafter.yml` was expected to make a chore-only merge resolve to
145
+ the same version as the last tag. It only removes those PRs from the draft *notes*; the
146
+ version resolver still applies `default: patch` and reports last+patch. The workflow's "same
147
+ version → no-op" guard therefore never fired.
148
+
149
+ Both were visible in the first run's log and in the action's documented inputs, and both were
150
+ missed because the PR's test plan verified the YAML *parsed* and the config *contained* the
151
+ intended keys -- not that the action *did* what the comment claimed. Fixed by taking the
152
+ decision away from the action: `.github/scripts/resolve_version.py` reads the merged PRs'
153
+ labels through `gh api`, returns nothing when nothing is releasable, refuses `major`, and has
154
+ a unit test in `tests/test_contract.py` with fake label data for each rule.
155
+
156
+ The general rule, twice over now: when wiring a third-party action, read its `action.yml`
157
+ inputs (or `Unexpected input(s)` in the first log) before trusting a parameter, and treat
158
+ any step whose output decides an irreversible action as something to unit-test with fixed
159
+ inputs, not something to confirm by reading its YAML. And watch the first real run's
160
+ *effect* (tags, npm), which is how both incidents were actually noticed.
@@ -15,7 +15,7 @@ import subprocess
15
15
  import sys
16
16
  from fractions import Fraction
17
17
  from pathlib import Path
18
- from typing import Any, Dict, List, Optional, Sequence
18
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
19
19
 
20
20
  # Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
21
21
  # arrows). On Windows the console streams default to a legacy code page and raise
@@ -74,6 +74,38 @@ ERROR_CODE = {
74
74
  ERROR_RETRYABLE = False
75
75
 
76
76
 
77
+ _FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
78
+
79
+
80
+ def ffmpeg_version() -> "Tuple[int, int]":
81
+ """(major, minor) of the FFmpeg build on PATH, parsed once from `ffprobe -version`; (0, 0)
82
+ when it cannot be read. ffprobe rather than ffmpeg because --dry-run promises never to run
83
+ ffmpeg (docs/contract.md: ffmpeg_execution "none") while ffprobe always may, and the two
84
+ ship from the same build. Used only to pick between two spellings of an option where FFmpeg
85
+ changed behaviour between releases (the tools otherwise never branch on the version: doctor's
86
+ capability listing is the source of truth for what a build can do)."""
87
+ global _FFMPEG_VERSION
88
+ if _FFMPEG_VERSION is None:
89
+ _FFMPEG_VERSION = (0, 0)
90
+ try:
91
+ out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True).stdout
92
+ m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
93
+ if m:
94
+ _FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
95
+ except OSError:
96
+ pass
97
+ return _FFMPEG_VERSION
98
+
99
+
100
+ def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
101
+ """drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
102
+ arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
103
+ (found by the FFmpeg 5.1.1 CI job, #146). Older builds get the larger single value."""
104
+ if ffmpeg_version() >= (6, 1):
105
+ return f"{vertical}|{horizontal}"
106
+ return str(max(vertical, horizontal))
107
+
108
+
77
109
  def die(msg: str, code: int = 1, kind: str = "input") -> "None":
78
110
  """Exit with a message. Under --json also print a machine-readable failure document
79
111
  (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
@@ -698,10 +730,30 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
698
730
  return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
699
731
 
700
732
 
733
+ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
734
+ """Tag an SDR output as BT.709 without touching its pixels.
735
+
736
+ Up to FFmpeg 7.0 the output options -colorspace/-color_primaries/-color_trc were tags only.
737
+ 7.1 added colourspace negotiation to libavfilter and feeds those options into the graph's
738
+ output constraints, so on a source whose bitstream carries no colour tags (test sources,
739
+ screen recordings, many cameras) the CLI now auto-inserts a *real* matrix conversion (its
740
+ guess for "unknown" is bt601) into every SDR re-encode: a --lut-strength 0 no-op grade
741
+ came back ~24 dB PSNR from its source on 7.1. From 7.1 on, the tags therefore go through
742
+ the encoder's own VUI parameters instead, which libavfilter never sees; a source that is
743
+ genuinely tagged bt601/bt2020 is left alone either way (it keeps its own tags on the old
744
+ path, and the encoder VUI is a label, not a conversion, on the new one).
745
+ """
746
+ if ffmpeg_version() < (7, 1):
747
+ return ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
748
+ if encoder == "libx265":
749
+ return ["-x265-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
750
+ return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
751
+
752
+
701
753
  def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
702
754
  args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
703
755
  if keep_bt709:
704
- args += ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
756
+ args += bt709_tag_args("libx264")
705
757
  return args
706
758
 
707
759
 
package/scripts/export.py CHANGED
@@ -26,7 +26,7 @@ import sys
26
26
  from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
- from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
29
+ from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
30
30
 
31
31
  PRESETS: Dict[str, Dict] = {
32
32
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
@@ -39,7 +39,6 @@ PRESETS: Dict[str, Dict] = {
39
39
  "copy": {"w": None, "h": None, "ext": None, "video": ["-c:v", "copy"], "audio": ["-c:a", "copy"], "max": None, "desc": "stream copy, no re-encode (source codecs/container/colour tags unchanged)"},
40
40
  }
41
41
 
42
- BT709 = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
43
42
 
44
43
 
45
44
  def main() -> int:
@@ -110,7 +109,7 @@ def main() -> int:
110
109
  if "-r" not in video:
111
110
  cmd += cfr_args(meta)
112
111
  if args.preset not in ("prores",):
113
- cmd += BT709
112
+ cmd += bt709_tag_args(video[video.index("-c:v") + 1])
114
113
  if out_ext == "mp4":
115
114
  cmd += ["-movflags", "+faststart"]
116
115
  cmd += (p["audio"] if has_audio else ["-an"])
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -139,7 +139,7 @@ def main() -> int:
139
139
  ye = f"{margin}" if "top" in pos else f"h-text_h-{margin}"
140
140
  box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
141
141
  txt_color = ff_color(bg if args.template == "chapter" else text_c)
142
- filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={pady}|{padx}:alpha='{fade_a}':{en}")
142
+ filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}")
143
143
 
144
144
  elif args.template == "progress":
145
145
  h = max(3, int(base * 0.008))
package/scripts/scenes.py CHANGED
@@ -39,25 +39,35 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
39
39
  equal recall compared with the raw scdet threshold."""
40
40
  ffmpeg = require_tool("ffmpeg")
41
41
  proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
42
- "scale=320:-2,scdet=threshold=0:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
42
+ "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"],
43
43
  stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
44
- times: List[float] = []
45
- scores: List[float] = []
46
- cur_t = None
44
+ # No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
45
+ # score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
46
+ # card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
47
+ # without them. The +-12-frame neighbourhood around a real cut then fills with the moving
48
+ # segment's scores instead of the still one's zeros, the cut fails the ratio test, and a
49
+ # 4 s smptebars scene made the cuts on both sides of it disappear (found by the 5.1.1 CI
50
+ # job, #146). 6.1+ passes every frame either way. Scores are still indexed by frame number
51
+ # and any frame the filter did not report counts as 0, so a build that drops frames again
52
+ # cannot shift the neighbourhood.
53
+ by_frame: Dict[int, Tuple[float, float]] = {}
54
+ cur = None
47
55
  for line in proc.stdout.splitlines():
48
56
  m = SCORE_RE.match(line)
49
57
  if m:
50
- cur_t = float(m.group(2))
58
+ cur = (int(m.group(1)), float(m.group(2)))
51
59
  continue
52
- if line.startswith("lavfi.scd.score=") and cur_t is not None:
60
+ if line.startswith("lavfi.scd.score=") and cur is not None:
53
61
  try:
54
- times.append(cur_t)
55
- scores.append(float(line.split("=", 1)[1]))
62
+ by_frame[cur[0]] = (cur[1], float(line.split("=", 1)[1]))
56
63
  except ValueError:
57
64
  pass
58
65
  cuts = [0.0]
59
- if not scores:
66
+ if not by_frame:
60
67
  return cuts
68
+ n_frames = max(by_frame) + 1
69
+ times: List[float] = [by_frame[i][0] if i in by_frame else -1.0 for i in range(n_frames)]
70
+ scores: List[float] = [by_frame[i][1] if i in by_frame else 0.0 for i in range(n_frames)]
61
71
  w = 12
62
72
  for i, sc in enumerate(scores):
63
73
  if sc < threshold:
@@ -78,6 +78,11 @@ def main() -> int:
78
78
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", f"0:a:{args.audio_stream}"]
79
79
  cmd += ["-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
80
80
  cmd += aac_args()
81
+ # -shortest alone is not enough on FFmpeg 5.x: showwaves keeps emitting frames after the
82
+ # audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
83
+ # at the source's own duration when probe knows it.
84
+ if meta.get("duration"):
85
+ cmd += ["-t", f"{float(meta['duration']):.3f}"]
81
86
  cmd += ["-shortest", output]
82
87
  run(cmd)
83
88