ffmpeg-skill 1.4.4 → 1.4.6

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.
@@ -16,7 +16,7 @@ import re
16
16
  import sys
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args
19
+ from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs
20
20
 
21
21
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
22
 
@@ -65,7 +65,7 @@ def main() -> int:
65
65
  ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
66
66
  ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
67
67
  ap.add_argument("--crf", type=int, default=18)
68
- ap.add_argument("--preset", default="medium")
68
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
69
69
  add_common(ap)
70
70
  args = ap.parse_args()
71
71
  apply_common(args)
@@ -86,6 +86,19 @@ def main() -> int:
86
86
  "removed_seconds": round(removed, 3),
87
87
  }
88
88
  info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
89
+ if not silences and not STATE.dry_run:
90
+ # Nothing under the threshold is a valid result, not a failure -- but an agent that only
91
+ # sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
92
+ # what threshold would bite, so the retry is a flag change, not a workaround.
93
+ level = measured_level_dbfs(args.input)
94
+ if level:
95
+ suggested = min(-5.0, round(level["mean_dbfs"] + 6.0))
96
+ summary["hint"] = (f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; the track's mean level is "
97
+ f"{level['mean_dbfs']:.1f} dBFS (peak {level['peak_dbfs']:.1f}). For a quiet-room recording try "
98
+ f"--threshold {suggested:g}, or a shorter --min-silence")
99
+ else:
100
+ summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
101
+ info("hint: " + summary["hint"])
89
102
 
90
103
  if args.edl:
91
104
  with open(args.edl, "w", encoding="utf-8") as fh:
@@ -19,7 +19,7 @@ import argparse
19
19
  import sys
20
20
  from typing import List, Tuple
21
21
 
22
- from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
22
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
23
23
 
24
24
  MAX_SPEED = 20.0
25
25
  MIN_SPEED = 0.05
@@ -60,7 +60,7 @@ def main() -> int:
60
60
  ap.add_argument("--segment", action="append", required=True, dest="segments",
61
61
  help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
62
62
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
63
- ap.add_argument("--preset", default="medium", help="x264 preset")
63
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
64
64
  add_common(ap)
65
65
  args = ap.parse_args()
66
66
  apply_common(args)
package/scripts/sphere.py CHANGED
@@ -31,7 +31,7 @@ Examples:
31
31
  import argparse
32
32
  import sys
33
33
 
34
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
34
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
35
35
 
36
36
  # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
37
  # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
@@ -65,7 +65,7 @@ def main() -> int:
65
65
  out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
66
66
  out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
67
67
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
68
- ap.add_argument("--preset", default="medium", help="x264 preset")
68
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
69
69
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
70
70
  add_common(ap)
71
71
  args = ap.parse_args()
@@ -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
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
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -40,7 +40,7 @@ def main() -> int:
40
40
  ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
41
41
  ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
42
42
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
43
- ap.add_argument("--preset", default="medium", help="x264 preset")
43
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
44
44
  add_common(ap)
45
45
  args = ap.parse_args()
46
46
  apply_common(args)
@@ -62,7 +62,7 @@ def main() -> int:
62
62
  trf = str(Path(tmp) / "transforms.trf")
63
63
  trf_arg = escape_filter_path(trf)
64
64
 
65
- if not STATE["dry_run"]:
65
+ if not STATE.dry_run:
66
66
  ffmpeg = require_tool("ffmpeg")
67
67
  detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
68
  if args.tripod:
@@ -22,7 +22,7 @@ import argparse
22
22
  import math
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
26
26
 
27
27
 
28
28
  def main() -> int:
@@ -36,7 +36,7 @@ def main() -> int:
36
36
  ap.add_argument("--audio-stream", type=int, default=0,
37
37
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
38
38
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
39
- ap.add_argument("--preset", default="medium", help="x264 preset")
39
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
40
40
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
41
41
  add_common(ap)
42
42
  args = ap.parse_args()
package/scripts/sync.py CHANGED
@@ -29,11 +29,10 @@ import cmath
29
29
  import json
30
30
  import math
31
31
  import os
32
- import struct
33
32
  import sys
34
33
  from typing import List
35
34
 
36
- from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args
35
+ from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
36
 
38
37
  SR = 8000 # decode sample rate
39
38
 
@@ -55,26 +54,12 @@ OVERLAP_WEIGHT_EXP = 0.5
55
54
 
56
55
 
57
56
  def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
58
- ffmpeg = require_tool("ffmpeg")
59
- cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
60
- "-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
61
- proc = run_analysis(cmd, check=False, text=False)
62
- if proc.returncode != 0 or not proc.stdout:
63
- die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
64
- n = len(proc.stdout) // 2
65
- return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
57
+ return decode_pcm_mono(path, SR, seconds, start)
66
58
 
67
59
 
68
60
  def envelope(samples: List[float], step: int) -> List[float]:
69
- """RMS energy per block, mean-removed so silence does not correlate."""
70
- env = []
71
- for i in range(0, len(samples) - step + 1, step):
72
- block = samples[i : i + step]
73
- env.append(math.sqrt(sum(x * x for x in block) / step))
74
- if not env:
75
- return env
76
- mean = sum(env) / len(env)
77
- return [e - mean for e in env]
61
+ """RMS energy per full block, mean-removed so silence does not correlate."""
62
+ return rms_envelope(samples, step, full_blocks_only=True, remove_mean=True)
78
63
 
79
64
 
80
65
  def fft(a: List[complex]) -> List[complex]:
@@ -22,7 +22,7 @@ Examples:
22
22
  import argparse
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color
25
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS
26
26
 
27
27
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
28
28
 
@@ -42,7 +42,7 @@ def main() -> int:
42
42
  ap.add_argument("--audio-stream", type=int, default=0,
43
43
  help="which audio stream of the input to render, 0-based in file order (default 0)")
44
44
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
45
- ap.add_argument("--preset", default="medium", help="x264 preset")
45
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
46
46
  add_common(ap)
47
47
  args = ap.parse_args()
48
48
  apply_common(args)
@@ -1,229 +0,0 @@
1
- # Process pitfalls
2
-
3
- Mistakes made (or nearly made) while developing this repo that were not about FFmpeg
4
- or a platform's behaviour — about the *process* of making a change safely. Written down
5
- for the same reason `references/ci-platform-pitfalls.md` exists: a mistake that isn't
6
- recorded gets repeated the next time a session starts fresh with no memory of it.
7
-
8
- **This file is a living record.** Whenever a change here is made (or nearly made, then
9
- caught before landing) because an existing guardrail — a pinned test, an environment
10
- constraint, a platform's real behaviour under repeated attempts — wasn't checked first,
11
- add an entry below. Don't wait to be asked.
12
-
13
- ## Before narrowing a `required`/`optional` capability list, grep for the pinned test that checks it
14
-
15
- `scripts/_contract.py`'s `TOOL_META[...]["required"]` drives `doctor`'s per-tool `usable`
16
- answer (`_tool_usability()` in `_contract.py` only reads `required`, never `optional`).
17
- Moving a capability from `required` to `optional` — even when it's honestly true that a
18
- new flag makes it conditional — silently changes what `doctor` reports as `usable: no`
19
- on a machine missing that capability, for the tool's *default* invocation too.
20
-
21
- `tests/test_contract.py`'s `DoctorDetectionTests` pins specific `usable` outcomes against
22
- real captured `ffmpeg -filters`/`-encoders` fixtures (e.g. a plain Homebrew macOS build
23
- correctly reporting `caption.usable: "no"` because it lacks `filter:subtitles`). A change
24
- to `required` that isn't checked against these first can pass a quick unit test and still
25
- break this fixture-based guarantee.
26
-
27
- Caught twice while adding capability metadata for new flags (`caption.py --mode mux` in
28
- #51, `doctor`'s `gpu_encoders` in #52) — in both cases the fix was to grep
29
- `tests/test_contract.py` for `usable` and `_doctor(` *before* editing `TOOL_META`, not
30
- after a test failure revealed it. Do that grep first, every time `required`/`optional`
31
- changes.
32
-
33
- ## Git tag push and GitHub Release creation are not reachable from this environment — so they are not done from it
34
-
35
- The git credentials available here can push to `refs/heads/*` (branches) but not
36
- `refs/tags/*` — confirmed by a 403 straight from the git-receive-pack endpoint, not an
37
- auth failure, meaning it's a deliberate scope restriction, not a bug to route around.
38
- The GitHub MCP tool surface has no `create_release`/`create_tag` equivalent either, and a
39
- direct call to the GitHub REST API's `/releases` endpoint with a raw token is blocked by
40
- the outbound proxy itself.
41
-
42
- Confirmed once (retried the tag push a second time "just in case" before accepting it).
43
- Don't retry either path a second time. Since 0.16.11 this is moot for releases:
44
- `.github/workflows/release.yml` creates the tag, the GitHub Release and the npm publish
45
- from GitHub Actions on every push to `main` (README, "Development" → "Releasing"), so a
46
- session never needs to push a tag at all — merging the PR is the release. The one thing
47
- still worth knowing: that workflow's own `git push origin HEAD:main` (the automatic
48
- version-bump commit) and its tag run under the built-in `GITHUB_TOKEN`, which by design
49
- triggers no further workflow runs, so a red `tests` run for that bump commit is not
50
- "missing" — it is never scheduled; the PR run before the merge is the one that counted.
51
-
52
- ## A throwaway probe must be pointed at a copy in a directory you have just verified with `pwd`, never at "the repo, probably"
53
-
54
- While validating the auto-bump script for `release.yml`, a scratch run was meant to
55
- execute against a temporary copy of the repo. It ran with the real checkout as its
56
- working directory instead — a `cd` into the scratch path happened in a shell whose
57
- working directory was reset between commands — and its `git add -A && git commit`,
58
- `git tag v0.16.12` and two empty commits landed on the real branch and moved the real
59
- local `v0.16.12` tag. Nothing was pushed, and `git reflog` plus a `git fetch --force` of
60
- the tag from `origin` restored everything, but it was only noticed because `git status`
61
- showed files the session had not edited.
62
-
63
- Rule: a probe that runs `git commit`, `git tag`, `rm -rf`, or writes into the tree goes
64
- in a directory created *and* verified in the same command (`cd "$DIR" && pwd && ...`),
65
- not one assumed from an earlier `cd`; prefer a fixture built from a few `printf` lines
66
- over a copy of the whole checkout (the copy carries the real `.git`, so a mistake there
67
- is a mistake in the real history); and run `git status` on the real repo before
68
- committing anything afterwards. `.claude/skills/destructive-operations/SKILL.md` says the
69
- same thing for the tools themselves — it applies to the person testing them too.
70
-
71
- ## A quantitative test failing three different ways across fixture redesigns means the platform, not the fixture, is the problem
72
-
73
- `test_stabilize_reduces_frame_to_frame_motion` (macOS CI, `stabilize.py`) failed with
74
- three independently redesigned shake fixtures in a row — each time the instinct was "the
75
- fixture's frequencies must be wrong," each time the retuned fixture failed a *different*
76
- way on the next CI run. The actual cause (libvidstab behaving differently across the
77
- Linux and macOS ffmpeg builds) was diagnosable from the first failure: a synthetic
78
- fixture that reliably improves under one implementation and reliably gets worse under
79
- another is evidence the implementations disagree, not that the fixture is miscalibrated.
80
-
81
- If a quantitative assertion fails on one platform, survives a redesign, and fails again
82
- on the *same* platform in a different way: stop redesigning the fixture. Either restrict
83
- the strict assertion to the platform where it's provably correct (keeping a weaker,
84
- platform-general check — output exists, has the right duration — everywhere), or escalate
85
- before spending a third CI cycle on it.
86
-
87
- ## A fix merged after CHANGELOG.md's current-version section was drafted can silently miss it
88
-
89
- `CHANGELOG.md`'s `## 0.12.0` section was written once, covering everything merged up to
90
- that point. Two fixes that closed real issues after that point (#62's `--audio-stream`
91
- extension via PR #72, #77's dry-run-dims fix via PR #88) landed with no further nudge to
92
- go back and add a bullet — #62's fix actually got a bullet (its content is genuinely
93
- described) but the `Closes #62` link was left off, and #77 was missed outright until a
94
- direct question ("shouldn't this bump the version?") prompted a manual check. Neither was
95
- caught by CI, because nothing checked CHANGELOG.md against what had actually been closed.
96
-
97
- Caught by hand both times, then closed properly with `tests/test_contract.py`'s
98
- `test_changelog_mentions_every_closed_issue_since_last_tag`, which walks `git log` back to
99
- the latest release tag, extracts every `Closes #N.` from a commit body, and fails if that
100
- issue number doesn't appear anywhere in `CHANGELOG.md`. This needs real history (`ci.yml`'s
101
- `actions/checkout` step now passes `fetch-depth: 0` for exactly this reason — the default
102
- shallow clone leaves no tag reachable to diff against, which would make the test silently
103
- skip itself in CI, not fail). If this test ever needs to skip a genuinely changelog-less
104
- closed issue (a pure process note, a duplicate, a revert of an unreleased change), name the
105
- exemption in the test itself with a reason — don't just widen the regex or drop the check.
106
-
107
- ## Automation that can publish must not take its "major" cue from text it did not write
108
-
109
- On 2026-09-11, three routine Dependabot merges (`actions/upload-artifact` 4→7,
110
- `actions/setup-node` 4→7, `dependabot/fetch-metadata` 2→3) were published to npm as
111
- **1.0.0, 1.0.1 and 1.0.2**. The release pipeline (`release.yml`, since #129) resolves the next
112
- version from PR labels; an autolabeler rule added the same day applied `major` to any PR whose
113
- *body* contained the literal breaking-change marker. Dependabot PR bodies quote the upstream
114
- project's release notes verbatim, and upload-artifact's v5.0.0 notes contain exactly that
115
- phrase — about *their* Node runtime, nothing to do with this package. One label, three
116
- accidental majors, in nine minutes, with every job green.
117
-
118
- Two things made it worse than one bad rule: every chore merge released at all (so the first
119
- accident was followed by two more before anyone looked), and nothing in the pipeline treated
120
- "the major number changed" as different from any other bump.
121
-
122
- Fixes (this commit): no autolabeler rule produces `major` any more; `chore`/`ci`/`docs`/
123
- `dependencies` PRs are excluded from version resolution so they release nothing; `release.yml`
124
- refuses to auto-bump across a major boundary regardless of labels; and the release job is
125
- serialised (`concurrency`) so back-to-back merges cannot race on the bump push.
126
-
127
- The general rule: a pipeline that publishes must never derive an irreversible decision (a
128
- major bump, a publish, a tag) from text it did not author — PR bodies, commit messages and
129
- release notes are quotations as often as they are statements. Match on labels a person
130
- applied, or on files changed, and make the irreversible step refuse anything surprising rather
131
- than assume the surprise was intended. And after wiring any such automation, watch the first
132
- few real runs' *results* (npm, tags) rather than their exit codes: the three runs here were
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.
161
-
162
- ### The release bump step required the literal `(nothing yet)` line under `## Unreleased`
163
-
164
- Found on the first run after #163 (2026-09-11). `release.yml`'s auto-bump located the CHANGELOG
165
- insertion point with `assert "## Unreleased\n\n(nothing yet)\n\n" in changelog`. #163 did the
166
- natural thing and wrote its notes under Unreleased, so the bump step failed on the assert
167
- before the push, the tag or the publish -- a clean no-op, but a red run and no release. The
168
- script now takes whatever sits under Unreleased into the new version's section and puts the
169
- placeholder back, so hand-written notes are welcome there. Lesson: an anchor that is also
170
- prose will be edited; anchor on the heading, not on the placeholder text.
171
-
172
- ### The built-in GITHUB_TOKEN cannot push the release bump through a ruleset
173
-
174
- Found on the first release after the main ruleset went active (2026-09-11, run for #166):
175
- `git push origin HEAD:main` from release.yml was declined with GH013 ("Changes must be made
176
- through a pull request", "8 of 8 required status checks are expected"). GitHub Actions cannot
177
- be added as a ruleset bypass actor (the import rejects the actor, the UI does not list it), so
178
- the bump push now uses the `RELEASE_PUSH_TOKEN` secret -- a fine-grained PAT of a repository
179
- admin with Contents: read/write on this repo -- whose "Repository admin" bypass applies. A PAT
180
- push triggers workflows (GITHUB_TOKEN's do not), so the bump commit carries `[skip ci]`; the
181
- tag, Release and npm publish all happen in the originating run. Rotate the PAT before it
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.