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 +4 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +25 -2
- package/package.json +1 -1
- package/references/scripts.md +63 -11
- package/scripts/_common/decision.py +13 -1
- package/scripts/_common/emit.py +22 -0
- package/scripts/_common/runner.py +102 -22
- package/scripts/_contract.py +17 -8
- package/scripts/batch.py +175 -28
- package/scripts/check.py +7 -0
- package/scripts/color.py +3 -0
- package/scripts/cut.py +3 -0
- package/scripts/export.py +317 -12
- package/scripts/look.py +60 -5
- package/scripts/loop.py +182 -18
- package/scripts/loudness.py +136 -62
- package/scripts/metadata.py +9 -2
- package/scripts/render.py +39 -4
- package/scripts/sync.py +3 -0
- package/scripts/waveform.py +8 -0
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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.
|
|
559
|
-
loop point
|
|
560
|
-
|
|
561
|
-
the
|
|
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
|
-
-
|
|
1244
|
-
`<workdir>/<index>-<
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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=
|
|
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=
|
|
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;
|
|
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
|
-
|
|
935
|
-
f"
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
|
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=
|
|
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
|
|
1554
|
-
|
|
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 =
|
|
2010
|
+
limit = _probe_limit()
|
|
1931
2011
|
_LIST_ERRORS.pop(flag, None)
|
|
1932
2012
|
names: set = set()
|
|
1933
2013
|
why = None
|