ffmpeg-skill 3.1.7 → 3.2.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
@@ -214,7 +214,7 @@ These are the rules the skill file gives the agent and the code enforces.
214
214
  |---|---|
215
215
  | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision (`hdr` for a real PQ/HLG/DV transfer only, `bt2020_or_hdr` also for BT.2020 SDR), colour space, rotation, every audio stream; `--analyze` flags Log footage |
216
216
  | `scenes.py` | Scene changes, audio peaks, highlight proposals (`--rank-by audio` loudest, or `--rank-by duration` longest — both proxies, not "best") and a per-scene sheet; cut list for `cut.py --segments`; `--beats` measures the music's beat grid (tempo, beat times, confidence); `--shots` classifies each shot static/pan/motion by measured optical flow; `--audio-peaks` lists per-second dBFS; `--speech` reports a speech-vs-music ratio, not a classification |
217
- | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG so the agent can see what it made; `--safe NAME` shades the zones a platform's own UI covers |
217
+ | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG so the agent can see what it made; `--safe NAME` shades the zones a platform's own UI covers; `--guides title,action,4:3` outlines the broadcast safe areas (on the PNG only) |
218
218
 
219
219
  **Editing**
220
220
 
@@ -240,7 +240,7 @@ These are the rules the skill file gives the agent and the code enforces.
240
240
  | `freeze.py` | Hold a frame for N seconds (`--at`, `--hold`, `--mode insert/extend`) — an end-card hold or a comedic beat |
241
241
  | `pad.py` | Add black/silent padding at the start and/or end of the timeline (`--start`, `--end`) — distinct from `fit.py --fit pad`'s per-frame letterbox bars |
242
242
  | `speedramp.py` | Step through different constant speeds across a clip via `--segment START-END:FACTOR` (repeatable) — distinct from `fit.py`'s single whole-clip speed factor |
243
- | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length; `--boomerang` plays it forward then backward |
243
+ | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length; `--crossfade` smooths the seam, `--boomerang` plays it forward then backward |
244
244
  | `broll.py` | Cut away to a B-roll clip over the A-roll for a window (`--insert B --at T --duration D`, repeatable) and come back; A's length and audio untouched by default |
245
245
  | `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit; `--auto-chapters` proposes the markers from measured pauses and scene cuts and names them `Chapter N` for you to rename |
246
246
  | `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
@@ -251,7 +251,7 @@ These are the rules the skill file gives the agent and the code enforces.
251
251
  |---|---|
252
252
  | `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
253
253
  | `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection); a third or later camera is an additional `more_sources` positional (`sync.py REF A B ...`), the `second` positional itself keeps its name |
254
- | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only` |
254
+ | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only`; `--versions streaming,ebu-r128,broadcast-jp` (or `LUFS/TP` pairs) writes one file per target from one master, each measured after and listed in `versions` (`output` is null), `verified` only when every version met its own target |
255
255
 
256
256
  **Picture**
257
257
 
@@ -266,7 +266,7 @@ These are the rules the skill file gives the agent and the code enforces.
266
266
 
267
267
  | Tool | What it does |
268
268
  |---|---|
269
- | `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) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
269
+ | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `dnxhr-lb`/`sq`/`hq`/`hqx`/`444`, the alpha masters `prores4444`/`prores4444xq`/`qtrle`/`vp9-alpha`/`hap-alpha` (alpha read back), `key-fill` (a switcher's fill + key pair), `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR or are an editing master (`prores`, `dnxhr-*`: the source's tags kept, codec, profile and pixel format read back) (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) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
270
270
  | `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 |
271
271
  | `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 ; `--compare REF` measures SSIM/PSNR against a reference; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
272
272
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
package/SKILL.md CHANGED
@@ -107,7 +107,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
107
107
  | "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
108
108
  | "smooth slow motion" | `fit.py in.mp4 --duration 2x --smooth interpolate` |
109
109
  | "reverse this clip" | `reverse.py in.mp4` |
110
- | "loop it to fill 30 seconds" | `loop.py clip.mp4 --duration 30` |
110
+ | "loop it to fill 30 seconds", "seamless loop" | `loop.py clip.mp4 --duration 30` (`--crossfade 0.5` hides the seam) |
111
111
  | "make it vertical / 9:16 / square" | `fit.py in.mp4 --aspect 9:16 --fit pad` (or `crop`, `blur`) |
112
112
  | "resize to a height/width" | `fit.py in.mp4 --height 1080` |
113
113
  | "crop to this exact box" | `crop.py in.mp4 --x 100 --y 0 --width 1080 --height 1920` |
@@ -137,7 +137,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
137
137
  | "use our brand fonts/colours/logo" | `--brand brand.json` on caption/overlay/graphics |
138
138
  | "sync the lav mic", "line up two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` |
139
139
  | "the audio drifts out of sync" | `sync.py camera.mp4 rec.wav --fix-drift --replace-audio` |
140
- | "normalise to -14 LUFS" | `loudness.py in.mp4` |
140
+ | "normalise to -14 LUFS", "streaming and broadcast versions" | `loudness.py in.mp4` (`--versions streaming,ebu-r128`) |
141
141
  | "clean up the audio", "remove the hiss" | `audio.py in.mp4 --voice` (or `--denoise`) |
142
142
  | "music under the talking", "duck harder" | `audio.py in.mp4 --music bed.mp3 --duck --fade-out 3` |
143
143
  | "convert the 5.1 to stereo" | `audio.py in.mov --downmix` |
@@ -148,7 +148,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
148
148
  | "audiogram", "turn this podcast into a video" | `render.py --template audiogram ep.m4a --image cover.png` |
149
149
  | "make this a Reel / TikTok / Short / YouTube" | `render.py --template reels in.mp4 --cues cues.txt` |
150
150
  | "post it everywhere" | `render.py --template all in.mp4 --cues cues.txt` |
151
- | "export for YouTube / Reels / X", "ProRes master" | `export.py in.mp4 --preset youtube` |
151
+ | "export for YouTube / Reels / X", "ProRes / DNxHR / alpha master", "key and fill" | `export.py in.mp4 --preset youtube` (`prores`, `dnxhr-hq`, `prores4444`, `key-fill`) |
152
152
  | "under 25 MB" | `export.py in.mp4 --preset youtube --max-mb 25` |
153
153
  | "make a GIF preview" | `export.py in.mp4 --preset gif` |
154
154
  | "the recording is corrupted" | `export.py in.mp4 --preset copy --repair` |
@@ -172,7 +172,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
172
172
  | "every file in the folder", "use all the cores" | `batch.py FOLDER --recipe batch.json --jobs auto` |
173
173
  | "three cameras, cut between them" | `multicam.py a.mp4 b.mp4 c.mp4 --switch energy` |
174
174
  | "what's in this file", "is it Log?" | `probe.py in.mp4` (`--analyze`) |
175
- | "show me what it looks like" | `look.py out.mp4 --tiles 3x2`, then view the PNG |
175
+ | "show me what it looks like", "title safe" | `look.py out.mp4 --tiles 3x2`, then view the PNG (`--at T --guides title,action`) |
176
176
  | "static vs moving shots", "speech or music" | `scenes.py in.mp4 --shots` (`--audio-peaks`, `--speech`) |
177
177
  | "where does the subject move" | `cropdetect.py in.mp4 --motion-centre` |
178
178
  | "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
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.1.7`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.2.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.1.7", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.2.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"},
@@ -204,6 +204,11 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
204
204
  | `execution` (writes a new artifact) | cut, fit, caption, overlay, graphics, multicam, audio, join, color, export, render, batch |
205
205
  | `verification` (checks or shows an artifact) | check, look, verify, report |
206
206
 
207
+ `batch` writes one artifact per input only when a recipe step names `{out}` (or the recipe is a
208
+ render project): a recipe in which no step does (a QC pass of `check.py`/`probe.py`) writes none,
209
+ and each `results` row carries `written: false` beside its usual `output` path, which stays a
210
+ string (#415).
211
+
207
212
  ### Verification policy
208
213
 
209
214
  The workflow in `SKILL.md` is "probe first, verify last". The contract states it per tool:
@@ -775,6 +780,16 @@ the track gone and nothing in the result saying so):
775
780
  | `non_av_streams` | `export.py` | the source's subtitle and data streams: `{kept: [{type, index, codec, language, output_codec}], dropped: [{type, index, codec, language, reason}]}` (`index` is the n-th stream of its type; `language` on subtitles). Read back from the written file; under `--dry-run`, as planned. A subtitle track is copied when the container holds it, else converted to the container's text track (`mov_text` in .mp4/.mov, SubRip in .mkv); a platform trim cuts it with the picture. `reason`: `write_failed` (the attempt that mapped it failed and the encode ran again without it; the error is in its `non_av_streams_dropped` fallback, whose `dropped` says data alone first, then subtitles too), `gif`, `not_in_output` |
776
781
  | `dropped_non_av_streams` | `export.py` | `true` when `non_av_streams.dropped` is not empty (a `notes` line too), as on the tools that use `run_keeping_subtitles()` |
777
782
 
783
+ Keys added after 3.1.8, additive (delivery masters and versions, #417-#423):
784
+
785
+ | key | tool | what it holds |
786
+ |---|---|---|
787
+ | `alpha_mode`; `verification[]` steps `codec`, `alpha` | `export.py` alpha presets (`prores4444`, `prores4444xq`, `qtrle`, `vp9-alpha`, `hap-alpha`); `codec` also on `prores` and `dnxhr-*` | `alpha_mode`: `straight` or `premultiplied` (`--alpha-mode`). `codec`: the written codec, profile and pixel format against the preset's. `alpha`: the written file's alpha against the source's frame by frame (the mean alpha of a 64x36 copy of each frame), each within 2 of 255 (`comparison: {ok, paired, worst_delta, worst_frame}`; with a different frame count, a conformed variable-rate source, the mean and the most transparent frame, `paired: false`; `frames: {expected, measured}`, each `{mean, min, frames}`). A source without alpha is refused; a dry run decides on the pixel format and a VP9 stream's `alpha_mode` tag without decoding. A VP9 source is decoded with libvpx-vp9, and these presets stay on the CPU under `--hw` (a note says so) |
788
+ | `key`, `fill_sha256`; steps `key_fill_pair`, `key_level` | `export.py --preset key-fill` | the key file `{output (<out stem>_key.mov), identity, sha256, timecode}` and the fill's SHA-256. `key_fill_pair`: width, height, frame count, rate and the video streams' lengths equal on both; `key_level`: the key's level against the source's alpha over every frame. Both paths are guarded as outputs (not the input, not an existing file without `--overwrite`); the source's subtitle/data streams are reported in `non_av_streams.dropped` with `reason: key_fill`. `--max-mb` is refused |
789
+ | `versions`, `outputs` | `loudness.py --versions` | one row per target `{target, output, targets, result}` (top-level `output` is null) and the files written; each version's `loudness` and `duration` steps carry `version`, and `verified` holds only when every version met its own target. An empty list is refused; a `--plan` path that is one of the version files is refused (`output_path_taken`) |
790
+ | `crossfade`, `expected_duration`, `seam` | `loop.py` | `crossfade`: the fade in seconds, whole frames; `expected_duration`: the length asked for, checked by the `duration` step within 1.5 frames; `seam`: the first seam measured (`video_step` against `video_typical_step`, `audio_level_step_db`) |
791
+ | `guides` | `look.py --guides` | each outline drawn on the PNG (`action` 0.93, `title` 0.90 of the frame, or the `4:3` centre cut with its own); a frame (either clip under `--compare`) no wider than 4:3 refuses `4:3` |
792
+
778
793
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
779
794
 
780
795
  | key | tool | what it holds |
@@ -882,6 +897,14 @@ caller never parses the prose:
882
897
  A tool that runs another (`render.py` stages and plans) forwards the child's `reason` and
883
898
  `details` in its own `error`. Both are absent on a failure that is not a refusal, like `hint`.
884
899
 
900
+ A `timeout` or `interrupted` failure exits 124, or 130/143, whether the tool or a child tool it ran
901
+ was stopped (`export.py --normalize`'s loudness pass, `render.py`'s stages, check, executed plan and
902
+ pack destinations, `waveform.py`'s title and captions pass the child's code on). Its message ends "nothing was written" only when
903
+ that is so: once a tool has written its deliverable and runs a further step on it
904
+ (`export.py --normalize`, `render.py`'s chapters and check stages), the message names the file,
905
+ says it is kept without the step that was stopped, and the document carries it as `output`
906
+ (3.1.6, #415).
907
+
885
908
  **Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
886
909
  skipped -- is recorded as it happens (a stderr line too) and lands in the document as
887
910
  `diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.1.7",
3
+ "version": "3.2.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",
@@ -550,15 +550,24 @@ output; the result says so with `dropped_non_av_streams: true`.
550
550
 
551
551
  ### loop.py — repeat a clip
552
552
  ```
553
- loop.py INPUT --times N | --duration T [--boomerang] [-o OUT]
553
+ loop.py INPUT --times N | --duration T [--crossfade S | --boomerang] [-o OUT]
554
554
  ```
555
555
  `--times` repeats the whole clip that many times back to back (2 =
556
556
  original + 1 repeat). `--duration` instead loops (and trims the last
557
557
  repeat) to hit an exact target length. For a background loop, an ambient
558
- bed, or filling a fixed slot length with a short clip. Does not smooth the
559
- loop point (no crossfade at the seam) -- a clip that doesn't already loop
560
- cleanly will show a visible cut/pop at each repeat, which is a property of
561
- the source material this tool cannot fix.
558
+ bed, or filling a fixed slot length with a short clip. Without `--crossfade`
559
+ the loop point is not smoothed: a clip that doesn't already loop cleanly
560
+ shows a visible cut/pop at each repeat.
561
+ `--crossfade S` makes the clip loop first: its last S seconds (whole frames)
562
+ fade into its first S, picture and sound, so each seam continues the motion.
563
+ One repeat is then the clip minus S (a 4 s clip with `--crossfade 1` repeats
564
+ every 3 s), S may be at most half the clip, and `--times 1` writes one
565
+ seamless loop file. The repeat is written losslessly to a temporary file
566
+ first, so the output still has a single lossy encode.
567
+ Every run except `--boomerang` measures the first seam of the written file
568
+ (`seam`: the picture step across it against the typical step between
569
+ neighbouring frames, and the sound level step in dB) and checks the length
570
+ against the request (`expected_duration`, within 1.5 frames).
562
571
  `--boomerang` plays the clip forward, then backward (frames 0..N-1, then
563
572
  N-2..1, repeated), so any clip loops without a jump and neither turnaround
564
573
  frame is shown twice. `--times` then counts round trips (1 is allowed), and
@@ -1240,10 +1249,18 @@ above the cap is clamped with a note, not refused, and both `jobs` and
1240
1249
  earlier has no such record and is processed again once, `entry_unverifiable`).
1241
1250
  A `.ffskill_cache.json` that does not parse is started empty with a
1242
1251
  `batch_cache_reset` warning and a line in `output_dir/.ffskill-audit.jsonl`.
1243
- - With `--jobs > 1` each item gets its own work subdirectory
1244
- `<workdir>/<index>-<stem>/`, because step file names are stem-derived and two
1245
- sources sharing a stem would otherwise write over each other. `--jobs 1` (the
1246
- default) keeps the flat layout and is 1.16's path exactly.
1252
+ - Each item's intermediates go in a new directory of its own under the work
1253
+ directory (`<workdir>/<index>-<random>/`), removed with them when the item
1254
+ ends, whatever the outcome (3.1.6, #415): step file names are stem-derived, so
1255
+ `clip.mp4` and `clip.mov` sharing a directory, or a rerun meeting an earlier
1256
+ run's files, were refused as someone else's. A step that names no `{out}`
1257
+ (`check.py`) passes its input on; when the last step writes nothing, the last
1258
+ file written is delivered as the item's output, under its own extension (`.mp4`
1259
+ for a video source) unless the recipe sets `ext`. A recipe in which no step names
1260
+ `{out}` (a QC pass: `check.py`/`probe.py` on every file) writes nothing: each
1261
+ row has `written: false` (`output` keeps its path string), a note says so, and `ok` follows the steps' exit codes.
1262
+ Final names that differ only in case are a
1263
+ collision (they are one file on macOS and Windows).
1247
1264
  - `--watch` composes: each pass is parallel, passes are sequential.
1248
1265
 
1249
1266
  ### caption.py --transcribe — optional local speech-to-text
@@ -1433,7 +1450,13 @@ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact shee
1433
1450
  look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
1434
1451
  look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
1435
1452
  look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
1453
+ look.py INPUT --guides title,action,4:3 [--at 3] # broadcast safe-area outlines
1436
1454
  ```
1455
+ `--guides` draws safe-area outlines on the PNG only (look.py writes no video, so a guide never
1456
+ reaches a delivery): `action` 93 % and `title` 90 % of the frame (SMPTE ST 2046-1), and `4:3` the
1457
+ centre 4:3 window of a wider frame with SD's 90 % action and 80 % title safe inside it (refused on
1458
+ a frame that is not wider than 4:3). It combines with `--safe PLATFORM` (the app's own UI zones)
1459
+ and works on a contact sheet's tiles as on a single frame; the result's `guides` lists them.
1437
1460
  Outputs PNG. View it with the Read tool (or any image viewer) and judge the
1438
1461
  frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
1439
1462
  destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
@@ -1874,7 +1897,16 @@ Run `loudness.py` after this for final levels.
1874
1897
  ### loudness.py — EBU R128 normalisation
1875
1898
  ```
1876
1899
  loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
1877
- ```
1900
+ loudness.py INPUT --versions=streaming,ebu-r128,broadcast-jp[,-16/-1.5] [--lra 11]
1901
+ ```
1902
+ `--versions` writes one file per target from the one master, each a full
1903
+ two-pass normalisation measured after: `streaming` (-14 LUFS / -1 dBTP),
1904
+ `ebu-r128` (-23 / -1), `broadcast-jp` (-24 LKFS / -1, ARIB TR-B32), or any
1905
+ `LUFS/TP` pair (write `--versions=...` when the list starts with a `-`). Files are `<name>_loudnorm_<target>.<ext>` (`-o` is refused);
1906
+ the result's `versions` lists each target, file and measurement, its
1907
+ verification steps carry a `version` key, and `verified` holds only when every
1908
+ version met its own target. An empty list (`--versions=`) is refused, and so is a `--plan` path
1909
+ that is one of the version files.
1878
1910
  `--lra N` is the loudness-range target in LU (default 11): lower it to squeeze a
1879
1911
  wide-dynamic mix into a phone speaker, raise it to leave a film mix alone.
1880
1912
  `--json` reports the measured range on both sides — `measured.input_lra` for the
@@ -1895,11 +1927,31 @@ integrated loudness ended more than 1 LU from the target because of it.
1895
1927
 
1896
1928
  ### export.py — delivery presets
1897
1929
  ```
1898
- export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|h265|gif|copy
1930
+ export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|dnxhr-lb|dnxhr-sq|dnxhr-hq|dnxhr-hqx|dnxhr-444|prores4444|prores4444xq|qtrle|vp9-alpha|hap-alpha|key-fill|h265|gif|copy
1899
1931
  [--fit pad|crop] [--no-scale] [--allow-long] [--crf N | --max-mb MB] [--normalize] [-o OUT]
1900
1932
  export.py INPUT --preset copy --repair [-o OUT]
1901
1933
  export.py --list
1902
1934
  ```
1935
+ Editing masters (`prores`, `dnxhr-lb/sq/hq/hqx/444`, and the alpha presets) keep the source's size,
1936
+ rate and colour tags and read the written codec and profile back (`codec` verification step).
1937
+ The alpha presets `prores4444`, `prores4444xq` (16-bit alpha), `qtrle`, `vp9-alpha` (WebM, Opus)
1938
+ and `hap-alpha` (an ffmpeg built with snappy; width and height multiples of 4) refuse a source
1939
+ without alpha, take `--alpha-mode straight|premultiplied` (default straight, reported as
1940
+ `alpha_mode`), and compare the mean alpha of each written frame with the source's, frame by frame
1941
+ on 64x36 copies, each within 2 of 255 (a region smaller than the copy's pixels can lose its alpha
1942
+ unseen; `alpha` step; a different frame count falls back to the mean and the most transparent frame,
1943
+ `comparison.paired: false`). A VP9 source is decoded with libvpx-vp9 (the
1944
+ native decoder drops the alpha); a dry run decides from the pixel format and a VP9 stream's
1945
+ `alpha_mode` tag without decoding. Under `--hw` these presets stay on the CPU (VideoToolbox's ProRes
1946
+ is 422 HQ, with no alpha), and a note says so. PNG/EXR image sequences are not written by `export.py`.
1947
+ `--preset key-fill` writes a switcher's key/fill pair from an alpha source in one run: the fill
1948
+ (`-o`, the colour; `--alpha-mode premultiplied` premultiplies it) and the key (`<out stem>_key.mov`,
1949
+ the alpha as a luma matte: alpha 255 is video white, 0 black), both ProRes 422 HQ with the source's
1950
+ start timecode. It checks both have the same size, frame count, rate and video length (`key_fill_pair`)
1951
+ and the key's level against the source's alpha over every frame (`key_level`), and reports both
1952
+ SHA-256 hashes (`fill_sha256`, `key.sha256`). Both paths are guarded like `-o` (not the input, not
1953
+ an existing file without `--overwrite`, written through a temp file); the source's subtitle/data
1954
+ streams are not written (`non_av_streams.dropped`, `reason: key_fill`); `--max-mb` is refused.
1903
1955
  `--repair` (2.24, `--preset copy` only) remuxes a damaged file: timestamps are regenerated
1904
1956
  (`-fflags +genpts`), packets flagged corrupt are dropped (`+discardcorrupt`), demuxing goes on past
1905
1957
  errors (`-err_detect ignore_err`) and negative timestamps are shifted to zero. The input is read,
@@ -114,9 +114,21 @@ def frame_size(ratio: Optional[Fraction], width: Optional[int], height: Optional
114
114
  SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
115
115
 
116
116
 
117
+ # Source containers the tools' own encoders (x264/x265/SVT-AV1 and AAC) cannot be written into: WebM
118
+ # takes only VP8/VP9/AV1 and Vorbis/Opus. A default output name keeps the source's extension
119
+ # except for these, whose default is .mp4 (#415): the dry run planned `clip_loudnorm.webm` and the
120
+ # real run then failed in the muxer.
121
+ NO_DEFAULT_ENCODE_EXT = {".webm"}
122
+
123
+
117
124
  def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> str:
118
125
  p = Path(input_path)
119
- new_ext = ext if ext else p.suffix.lstrip(".") or "mp4"
126
+ if ext:
127
+ new_ext = ext
128
+ elif p.suffix.lower() in NO_DEFAULT_ENCODE_EXT:
129
+ new_ext = "mp4"
130
+ else:
131
+ new_ext = p.suffix.lstrip(".") or "mp4"
120
132
  return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
121
133
 
122
134
 
@@ -852,6 +852,21 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Option
852
852
  return seen
853
853
 
854
854
 
855
+ def _planned_outputs(commands: Sequence[str]) -> List[str]:
856
+ """The file each planned command writes: its last argument, unless that is stdout, a pipe or
857
+ an option (a measurement's `-f null -`)."""
858
+ import shlex
859
+ out: List[str] = []
860
+ for line in commands:
861
+ try:
862
+ toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
863
+ except ValueError:
864
+ continue
865
+ if len(toks) > 1 and toks[-1] != "-" and not toks[-1].startswith(("pipe:", "-")):
866
+ out.append(toks[-1])
867
+ return out
868
+
869
+
855
870
  def _plan_unbound_inputs(commands: Sequence[str], ctx: "Optional[Context]" = None) -> List[str]:
856
871
  """The inputs _plan_inputs() could not fingerprint because they did not exist when the plan
857
872
  was made: a planned command's `-i` file (or a side input registered through
@@ -926,6 +941,13 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
926
941
  refuse("input", "output_is_input" if named != output else "output_path_taken",
927
942
  f"--plan {path!r} is also {'the output' if named == output else 'an input'} of this run "
928
943
  f"({named!r}); give the plan its own path", output=path, other=named)
944
+ # and every file a planned command writes (its last argument): a tool with several outputs
945
+ # (loudness.py --versions, whose `output` is null; export.py's key file) names them only there,
946
+ # and a plan written over one of them is refused as an existing output when it is executed
947
+ for written in _planned_outputs(ctx.commands):
948
+ if os.path.realpath(written) == real:
949
+ refuse("input", "output_path_taken", f"--plan {path!r} is also a file this run writes ({written!r}); "
950
+ "give the plan its own path", output=path, other=written)
929
951
  doc = {
930
952
  "plan_version": PLAN_VERSION,
931
953
  "created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -14,7 +14,7 @@ import re
14
14
  import shutil
15
15
  import subprocess
16
16
  import sys
17
- from typing import Any, Dict, List, Optional, Sequence, Tuple
17
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
18
18
 
19
19
 
20
20
  INSTALL_HINTS = {
@@ -108,6 +108,13 @@ _FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
108
108
  _FFMPEG_VERSION_IS_RELEASE = False # ffmpeg_version() read a release number, not the libavutil major
109
109
 
110
110
 
111
+ def _probe_limit() -> float:
112
+ """The ceiling for a short read-only ffprobe/ffmpeg query run outside run(): PROBE_TIMEOUT, or
113
+ the caller's --timeout when that is lower (#415). Never unlimited: a version or stream-list read
114
+ that hangs is a stall, whatever --timeout 0 asks of the encodes."""
115
+ return min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
116
+
117
+
111
118
  def ffmpeg_version() -> "Tuple[int, int]":
112
119
  """(major, minor) of the FFmpeg build on PATH, parsed once from `ffprobe -version`; (0, 0)
113
120
  when it cannot be read. ffprobe rather than ffmpeg because --dry-run promises never to run
@@ -120,7 +127,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
120
127
  _FFMPEG_VERSION = (0, 0)
121
128
  try:
122
129
  out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
123
- timeout=PROBE_TIMEOUT).stdout
130
+ timeout=_probe_limit()).stdout
124
131
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
125
132
  if m:
126
133
  _FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
@@ -158,7 +165,7 @@ def ffmpeg_release() -> "Optional[Tuple[int, int, int]]":
158
165
  _FFMPEG_RELEASE_READ = True
159
166
  try:
160
167
  out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
161
- timeout=PROBE_TIMEOUT).stdout
168
+ timeout=_probe_limit()).stdout
162
169
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)(?:\.(\d+))?", out)
163
170
  if m:
164
171
  _FFMPEG_RELEASE = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
@@ -325,7 +332,7 @@ class Context:
325
332
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
333
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
334
  "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
328
- "source_timecode", "smpte_hint")
335
+ "source_timecode", "smpte_hint", "delivered")
329
336
 
330
337
  def __init__(self) -> None:
331
338
  self.reset()
@@ -337,6 +344,7 @@ class Context:
337
344
  self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
338
345
  self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
339
346
  self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
347
+ self.delivered: List[str] = [] # deliverables written before a later step (export --normalize, render's chapters/check): a stop then keeps them (#415)
340
348
  self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
341
349
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
342
350
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
@@ -533,12 +541,52 @@ def stop_children() -> None:
533
541
  _CHILDREN.clear()
534
542
 
535
543
 
544
+ def mark_delivered(path: str) -> None:
545
+ """Record that this run has written its deliverable and still has a step to run on it (export's
546
+ --normalize, render's chapters and check stages): a timeout or an interrupt from then on says the
547
+ file is there and kept, instead of "nothing was written" (#415)."""
548
+ if path and path not in STATE.delivered:
549
+ STATE.delivered.append(path)
550
+
551
+
552
+ def child_exit_code(doc: Any, returncode: Optional[int]) -> int:
553
+ """The exit code a tool passes on from a child tool that failed: the code its document states,
554
+ else its process's own, else 1. A child killed by a signal has a negative returncode (-9), which
555
+ as an exit code wraps (247); it is passed on as the shell reports it, 128 + the signal (137)."""
556
+ code = (doc.get("exit_code") if isinstance(doc, dict) else None) or returncode or 1
557
+ try:
558
+ code = int(code)
559
+ except (TypeError, ValueError):
560
+ return 1
561
+ return 128 - code if code < 0 else code
562
+
563
+
564
+ def kept_deliverables() -> List[str]:
565
+ """The deliverables mark_delivered() named that are on disk."""
566
+ return [p for p in STATE.delivered if os.path.isfile(p)]
567
+
568
+
569
+ def kept_clause() -> str:
570
+ """How a stopped run's message ends: "nothing was written", or which deliverable it had already
571
+ written and keeps (not the finished result: the step that was stopped did not run on it)."""
572
+ kept = kept_deliverables()
573
+ if not kept:
574
+ return "nothing was written"
575
+ return (f"{', '.join(kept)} had already been written by this run and {'is' if len(kept) == 1 else 'are'} kept, "
576
+ "without the step that was stopped (pass --overwrite to make it again)")
577
+
578
+
579
+ def _kept_fields() -> Dict[str, Any]:
580
+ kept = kept_deliverables()
581
+ return {"output": kept[-1]} if kept else {}
582
+
583
+
536
584
  def _on_signal(signum: int, frame: Any) -> None:
537
585
  import signal as _signal
538
586
  name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
539
587
  stop_children()
540
- die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
541
- code=128 + signum, kind="interrupted")
588
+ die(f"interrupted by {name}: the running command was stopped and its partial output removed; {kept_clause()}",
589
+ code=128 + signum, kind="interrupted", **_kept_fields())
542
590
 
543
591
 
544
592
  def install_signal_handlers() -> None:
@@ -895,10 +943,13 @@ class _OutputLock:
895
943
  the hour ran from the lock's creation, so a legitimate run longer than an hour lost its lock
896
944
  to a second writer. A takeover is recorded (`diagnostics.fallbacks`, stderr, and the audit
897
945
  log); so is running without a lock where none can be created (`diagnostics.warnings`)."""
898
- def __init__(self, output: str) -> None:
946
+ def __init__(self, output: str, on_locked: "Optional[Callable[[str, str, Dict[str, Any]], None]]" = None) -> None:
947
+ # on_locked(message, hint, details): a caller that must not exit (batch.py's item worker)
948
+ # raises its own error for a held lock instead of the refusal below
899
949
  self.path: Optional[str] = None
900
950
  self.fd: Optional[int] = None
901
951
  self.output = output
952
+ self.on_locked = on_locked
902
953
  if output == "-" or output.startswith("pipe:") or output.startswith("-"):
903
954
  return
904
955
  d, base = os.path.split(os.path.abspath(output))
@@ -931,12 +982,14 @@ class _OutputLock:
931
982
  taken = holder
932
983
  continue
933
984
  name = os.path.basename(self.path)[1:-len(".ffskill-lock")]
934
- refuse("input", "output_locked",
935
- f"another run is writing {name!r} right now (lock {self.path}"
936
- + (f", pid {holder['pid']}" if holder.get("pid") else "") + "); wait for it or choose a different --output/-o path",
937
- hint=("wait for that run, or choose another -o path; if pid "
938
- f"{holder.get('pid')} is not an ffmpeg-skill run, delete {self.path}"),
939
- lock=self.path, output=os.path.abspath(self.output), **{k: v for k, v in holder.items() if k != "stale"})
985
+ msg = (f"another run is writing {name!r} right now (lock {self.path}"
986
+ + (f", pid {holder['pid']}" if holder.get("pid") else "") + "); wait for it or choose a different --output/-o path")
987
+ hint = ("wait for that run, or choose another -o path; if pid "
988
+ f"{holder.get('pid')} is not an ffmpeg-skill run, delete {self.path}")
989
+ details = dict(lock=self.path, output=os.path.abspath(self.output), **{k: v for k, v in holder.items() if k != "stale"})
990
+ if self.on_locked:
991
+ self.on_locked(msg, hint, details)
992
+ refuse("input", "output_locked", msg, hint=hint, **details)
940
993
  except OSError as exc:
941
994
  # an unlockable location (a read-only directory surfaces elsewhere): the run goes
942
995
  # on without a lock, and says so -- two runs could now both write this output
@@ -1110,11 +1163,28 @@ def _remember_output(cmd: Sequence[str]) -> None:
1110
1163
  pass
1111
1164
 
1112
1165
 
1166
+ def remember_written(*paths: Optional[str]) -> None:
1167
+ """Count files a child tool of this run wrote (render.py's stages, batch.py's steps) as this
1168
+ run's own, as run() counts its ffmpeg outputs: a later stage that replaces one is not refused
1169
+ as replacing someone else's file (#415)."""
1170
+ for path in paths:
1171
+ if path and os.path.isfile(path):
1172
+ try:
1173
+ STATE.written.add(os.path.realpath(path))
1174
+ except OSError:
1175
+ pass
1176
+
1177
+
1113
1178
  def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
1114
1179
  _cleanup_partial_output(cmd)
1115
- die(f"{os.path.basename(cmd[0])} exceeded the {seconds:.0f} s time limit and was killed; nothing was written. "
1116
- f"Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall",
1117
- code=124, kind="timeout")
1180
+ if _is_ffmpeg(cmd) or seconds < PROBE_TIMEOUT:
1181
+ advice = "Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall"
1182
+ else:
1183
+ # a probe's own ceiling, which a higher --timeout does not raise: a read this long is a stall (#415)
1184
+ advice = (f"A probe is limited to {PROBE_TIMEOUT:g} s whatever --timeout says, so this is a stall, not a long "
1185
+ "job: check that the input is readable (a network share, a file still being written)")
1186
+ die(f"{os.path.basename(cmd[0])} exceeded the {seconds:g} s time limit and was killed; {kept_clause()}. {advice}",
1187
+ code=124, kind="timeout", **_kept_fields())
1118
1188
 
1119
1189
 
1120
1190
  def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
@@ -1446,7 +1516,7 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
1446
1516
  child.communicate()
1447
1517
  _unwatch(child)
1448
1518
  name = os.path.basename(str(argv[0]))
1449
- msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
1519
+ msg = f"{name} did not finish within {limit:g} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
1450
1520
  doc = {"status": "failed", "exit_code": 124,
1451
1521
  "error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
1452
1522
  "commands": []}
@@ -1479,13 +1549,22 @@ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
1479
1549
  try:
1480
1550
  out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", src],
1481
1551
  stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
1482
- timeout=PROBE_TIMEOUT).stdout
1552
+ timeout=_probe_limit()).stdout
1483
1553
  except (OSError, subprocess.TimeoutExpired):
1484
1554
  return True
1485
1555
  kinds = {ln.strip() for ln in out.splitlines() if ln.strip()}
1486
1556
  return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
1487
1557
 
1488
1558
 
1559
+ def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
1560
+ """Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
1561
+ the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
1562
+ run_keeping_subtitles() follows since #389, for every copy-then-re-encode or keep-then-drop
1563
+ fallback (#415). Returns when the failure is unclassified, so the caller's retry runs."""
1564
+ if proc.returncode != 0 and classify_ffmpeg_failure(proc.stderr or ""):
1565
+ _fail(proc.args, proc.returncode, proc.stderr or "")
1566
+
1567
+
1489
1568
  # eia_608 (a QuickTime c608 caption track) decodes to text, so it converts like one (ffmpeg 6.1)
1490
1569
  TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text", "eia_608"})
1491
1570
  MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
@@ -1550,10 +1629,11 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1550
1629
 
1551
1630
 
1552
1631
  def _limit_for(cmd: Sequence[str]) -> Optional[float]:
1553
- """The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a fixed
1554
- short one, ffmpeg the configured one; None means unlimited."""
1632
+ """The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a short
1633
+ one, PROBE_TIMEOUT or --timeout when that is lower (as _read_ff_list() does: a caller's
1634
+ --timeout 2 bounds every child, #415), ffmpeg the configured one; None means unlimited."""
1555
1635
  if not _is_ffmpeg(cmd):
1556
- return PROBE_TIMEOUT if STATE.timeout else None
1636
+ return min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else None
1557
1637
  return STATE.timeout or None
1558
1638
 
1559
1639
 
@@ -1927,7 +2007,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
1927
2007
  tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1928
2008
  # --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
1929
2009
  # (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
1930
- limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
2010
+ limit = _probe_limit()
1931
2011
  _LIST_ERRORS.pop(flag, None)
1932
2012
  names: set = set()
1933
2013
  why = None