ffmpeg-skill 3.8.20 → 3.8.21
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 +2 -2
- package/docs/contract.md +27 -2
- package/mcp/server.py +1 -1
- package/package.json +1 -1
- package/references/scripts.md +31 -4
- package/scripts/_common/decision.py +24 -0
- package/scripts/_common/runner.py +105 -7
- package/scripts/_contract.py +12 -5
- package/scripts/batch.py +259 -24
- package/scripts/cut.py +44 -4
- package/scripts/render.py +107 -6
- package/scripts/waveform.py +5 -0
package/README.md
CHANGED
|
@@ -277,7 +277,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
277
277
|
| Tool | What it does |
|
|
278
278
|
|---|---|
|
|
279
279
|
| `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics and a picture-in-picture or chroma-keyed `video` layer sized for the final frame, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table); `--export-timeline edit.fcpxml\|.edl\|.otio` (2.1) writes the cut as an editor timeline for Final Cut, DaVinci Resolve or Premiere instead of rendering it — clips, speed, dissolves, the music bed and chapter markers carry over, and `not_exported` names what stays behind |
|
|
280
|
-
| `batch.py` | Apply a step recipe or a project to a folder with a content-hash cache; `--watch`; `--jobs N` processes several files at once under one shared `--timeout` |
|
|
280
|
+
| `batch.py` | Apply a step recipe or a project to a folder with a content-hash cache; `--watch`; `--jobs N` processes several files at once under one shared `--timeout`. A step that writes must name `{out}` or its own `-o`/`--output`: one that writes its default output instead is refused (`step_writes_without_out`) |
|
|
281
281
|
| `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list; `--switch energy` auto-cuts to whichever camera is loudest at that moment instead of a hand-built list (audio level as a stand-in for who is talking: there is no speaker identification, so it needs each speaker on their own camera's mic), with `--min-shot` (minimum shot length) and `--edl` (write a plain START-END cut list, not a CMX EDL; for Premiere/Resolve use `--write-project` then `render.py --export-timeline`) |
|
|
282
282
|
| `verify.py` | Run the toolchain on real device files and report PASS / FAIL per step |
|
|
283
283
|
|
|
@@ -471,7 +471,7 @@ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools
|
|
|
471
471
|
|
|
472
472
|
### Does it validate the filter graph before running?
|
|
473
473
|
|
|
474
|
-
No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--transcribe` (with `--filler` or a sentence flag) also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `metadata` with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement, and `render` and `batch` run each stage or step as its own dry run, so a measuring stage measures an input that exists. `verify` accepts `--dry-run` and ignores it: its steps run, and its document says `dry_run: false`. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
|
|
474
|
+
No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. A plan is not a promise of the outcome: `cut.py`'s plan of a stream copy names in `possible_reencode_reason` what a real run may still re-encode it for (`tolerance`: where the copy lands is measured from the written file). A real `render.py` run starts with its own dry run, so when that dry run is refused because a stage needs a filter or an encoder the build lacks, the real run is refused the same way before the first stage encodes. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--transcribe` (with `--filler` or a sentence flag) also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `metadata` with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement, and `render` and `batch` run each stage or step as its own dry run, so a measuring stage measures an input that exists. `verify` accepts `--dry-run` and ignores it: its steps run, and its document says `dry_run: false`. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
|
|
475
475
|
|
|
476
476
|
### Can it remove burned-in subtitles or logos?
|
|
477
477
|
|
package/docs/contract.md
CHANGED
|
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
26
26
|
| Field | Meaning | Changes when |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
29
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
29
|
+
| `skill.version` | the npm / package.json version (`3.8.21`) | any release |
|
|
30
30
|
|
|
31
31
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
32
32
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
154
154
|
"deprecated": [],
|
|
155
155
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
156
156
|
{"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"}],
|
|
157
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
157
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.21", "execution_mode": "local", "kind": "execution",
|
|
158
158
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
159
159
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
160
160
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -217,6 +217,11 @@ Every tool is in exactly one row; `tests/test_contract.py`'s
|
|
|
217
217
|
render project): a recipe in which no step does (a QC pass of `check.py`/`probe.py`) writes none,
|
|
218
218
|
and each `results` row carries `written: false` beside its usual `output` path, which stays a
|
|
219
219
|
string (#415).
|
|
220
|
+
A step that names no `{out}` and no `-o` of its own but whose `--json` document reports an
|
|
221
|
+
`output` (its default output, written beside its input) is refused (`kind: input`,
|
|
222
|
+
`error.reason: step_writes_without_out`) and the batch stops at that item: later rows are
|
|
223
|
+
`skipped: "recipe_refused"`. Under `--dry-run` nothing is written; a real run has written that one
|
|
224
|
+
file, and `error.details.file` names it (#510).
|
|
220
225
|
|
|
221
226
|
### Verification policy
|
|
222
227
|
|
|
@@ -278,6 +283,22 @@ behind an ffmpeg that logs each decode and asserts that every tool that decoded
|
|
|
278
283
|
`DRY_RUN_ANALYSIS`. `SKILL.md`, `README.md`, `references/scripts.md` and `references/flags.md`
|
|
279
284
|
repeat the same list (a test checks each names every tool in it); the contract is the authority.
|
|
280
285
|
|
|
286
|
+
A dry run and a real run report the same missing filter or encoder. `render.py`, and `waveform.py`
|
|
287
|
+
with `--title`, `--srt` or `--text`, run their work as stages, and a stage finds a component the
|
|
288
|
+
build lacks only when it runs; so a real run of either first runs its own dry run (the same
|
|
289
|
+
command line with `--dry-run`) and, when that is refused with `filter_missing` or
|
|
290
|
+
`encoder_missing`, is refused with the same document (kind, message, `reason`, `details`, hint,
|
|
291
|
+
`stage`) before the first stage encodes. Any other outcome of that dry run is left to the real
|
|
292
|
+
run (#510).
|
|
293
|
+
|
|
294
|
+
`cut.py --dry-run` does not write the cut, so it does not know where a single stream copy lands:
|
|
295
|
+
a plan of `mode: copy` (or `smart`) names in `possible_reencode_reason` the `reencode_reason`
|
|
296
|
+
values a real run may still add, and a `notes` line says the same: `tolerance` when the copy is
|
|
297
|
+
judged by its written length (a copy that lands `--tolerance` or more from the requested cut is
|
|
298
|
+
re-encoded, `mode: hybrid`), `copy_failed` and `stream_lost` on every copy, `concat_fallback` on a
|
|
299
|
+
`--segments` copy join, `smart_fallback` for `--smart`. It is `[]` in a real run and for a plan
|
|
300
|
+
that already re-encodes. `batch.py --dry-run`'s `cut_stream_copy` counts those plans.
|
|
301
|
+
|
|
281
302
|
`--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool that re-encodes,
|
|
282
303
|
marked `common`. They are resolved in one place
|
|
283
304
|
(`_common.encoder_args()`): hevc keeps an HDR source Main10 with its tags and writes 8-bit
|
|
@@ -770,8 +791,12 @@ Per-tool keys added after 2.2.2, all additive:
|
|
|
770
791
|
| `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and, when nothing was named, the language the engine decoded where it reports one (whisper.cpp's word-timing JSON, faster-whisper, openai-whisper's word-timing JSON), else `null`; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
|
|
771
792
|
| `notes` | `silence.py --transcribe` with `--filler` or a sentence flag (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
|
|
772
793
|
| `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost` (the copy wrote no video or no audio: an MPEG-TS copy whose window holds no video keyframe, #367), `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. Since 3.0.0 `precision` is the least exact segment's, equal to `least_exact_precision`, and `keyframe_snapped` equals `start_snapped` (2.x: `precision` was the whole run's, `packet` only when nothing re-encoded, and `keyframe_snapped` was `precision == "packet"`, a stream copy end to end; see "What 3.0 changed"). `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
|
|
794
|
+
| `caption_srt` | `render.py` | the SRT the captions stage generated from a `captions.text` cue file, delivered as `<output>.srt` beside the output and guarded with it before the first stage (an existing one is refused without `--overwrite`, an input is never replaced); the planned path under `--dry-run`; `null` with no such stage, captions from `srt`/`ass`, `--stop-after` before the export, or when it was not delivered: a `caption_srt_not_delivered` skip says why (`cached`: the stage came from `--cache`, which keeps its video only; `timeline_changed`: the export trimmed the delivery, so the cue times are not its own) (#510) |
|
|
795
|
+
| `kept` | `batch.py` `results` rows | an item stopped by Ctrl-C/SIGTERM while its last step ran, when that step had already written a file at the final path that was not there when it started (`export.py --normalize` keeps its encode during the loudness pass): the path, on disk and unverified. `render.py` reports the same in its failure document's `output` and message: a stage that writes the delivery is watched (size, mtime, inode before it starts), and a file it left there is kept, while one it did not change is not claimed (#510) |
|
|
796
|
+
| `possible_reencode_reason` | `cut.py` (every run) | under `--dry-run`, the `reencode_reason` values a real run may still add to a plan of `mode: copy` or `smart` (then `hybrid` or `accurate`), since the dry run does not write or measure the output cut (a `--segments` dry run does read the source's packets, and judges each part against `--tolerance` from them): `copy_failed` and `stream_lost` on every planned copy, `tolerance` when a copied part is judged by its written length (a single segment, video or audio-only; a `--segments` part of a source with no B-frames, a Matroska/MPEG-TS part or an audio-only part; not under `--tolerance -1`), `concat_fallback` on a `--segments` stream-copy join, `smart_fallback` for `--smart`; a `notes` line says the same. `[]` in a real run and for a plan that already re-encodes (#510) |
|
|
773
797
|
| `edit_list`, `stored_preroll_seconds`, `av_start_skew_seconds`, `notes` | `cut.py` (every run) | `edit_list`: `true` only for a single-segment `.mp4`/`.m4v`/`.mov` stream copy, which by default since 3.0.0 keeps the MP4 edit list the copy writes (`--no-edit-list` restores 2.x's `-avoid_negative_ts make_zero`): the keyframe's pre-roll is stored but hidden, so the picture starts at `--start`. `stored_preroll_seconds`: with `edit_list`, the seconds decoded from the keyframe the copy's seek lands on (measured with the seek ffmpeg makes) up to the first presented frame; `null` otherwise. With an edit list a copy is judged by its video's length (a `notes` line says so when the container is longer), and one that starts where asked but ends past `--tolerance` re-encodes (`tolerance`) without a `lossless_alternative`. `av_start_skew_seconds`: the output's audio start minus its video start (`null` when either is missing or under `--dry-run`); on a stream copy, when it is more than max(2 frames, 0.1 s) away from the source's own audio/video offset where the copy's picture starts (the keyframe it began from; `--start` with an edit list), a `notes` line names `--accurate`, and the edit list for a single `.mp4`/`.mov` copy whose start snapped under `--no-edit-list`. A re-encode carries no such note. The skew is reported, never repaired. `notes`: what the run did that a caller should know, in plain sentences (`[]` when nothing) |
|
|
774
798
|
| `video.start_time`, `audio.start_time` | `probe.py` (and every `probe` block a tool returns) | where each stream starts, in seconds on the file's own clock (ffprobe's stream `start_time`; `null` when unknown). A video stream that starts after its audio, or the reverse, is how a lossless cut's A/V skew shows; `cut.py` reads them for `av_start_skew_seconds` |
|
|
799
|
+
| `cut_possible_reencode_reason` | `batch.py` `results` rows, under `--dry-run` | per `cut.py` call of the row, that call's `possible_reencode_reason` (`[]` for a call whose plan already re-encodes): what a real run may still re-encode a planned copy for. The dry run's summary line points at it (#510 review) |
|
|
775
800
|
| `cut_reencode_reasons` | `batch.py` (and its `results` rows) | a row whose recipe ran `cut.py` lists each `reencode_reason` its `cut.py` calls named, once per call, beside `cut_reencoded`; the top level counts them across the folder (`{}` when no call re-encoded, `null` when no `cut.py` step ran) on a completed batch, as `cut_stream_copy` does: a failure document carries `results`, whose rows hold each row's reasons; a re-encoded call in a row served from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as `unknown`. `cut_stream_copy` is unchanged |
|
|
776
801
|
| `vfr_check` | `cut.py` (a video input that could still stream-copy) | the sampled frame timing, `{heuristic: true, measured, method: "sampled", guard, windows, deltas}`; `measured` is `sampled_cfr`, `vfr` or `inconclusive`. `--vfr-guard average` (2.x's default) keeps the 2.x guard: a source whose nominal and average frame rates differ (`variable_frame_rate_suspected`) re-encodes as `--accurate` (reason `vfr`, stderr says "variable-frame-rate"), and only then is the timing sampled and reported, with a `notes` line naming `--vfr-guard sampled` when it measured `sampled_cfr`; the key is absent otherwise. `--vfr-guard sampled` (the default since 3.0.0) samples before every copy and re-encodes on `vfr` (reason `vfr`) or `inconclusive` (fewer than 20 intervals; `vfr_inconclusive`); `--vfr-guard off` samples and keeps the copy, with a `notes` line. Absent for audio-only input or output, `--accurate`, `--codec` and a pending `--dry-run` input |
|
|
777
802
|
| `join_check`, `segment_end_snap_seconds` | `cut.py --segments` | `join_check`: a video stream-copy join measured by demuxing, `{packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}`. `expected_packets` counts the source's video packets from each part's start keyframe to its end (or the end of the video); `max_step_seconds` is the largest presentation step, `null` when `vfr_check` measured anything but `sampled_cfr` (a `--vfr-guard off` join of variable timing: count only), and each step at a join must be the step its parts predict within 1 ms. `audio_offset_ms`: per part, its sound's shift minus its picture's in ms (+ = sound late), from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than `a:0`; an MPEG-TS source's AAC is hashed without its ADTS headers); `null` for a part with no unique match (digital silence, a periodic tone), and as a whole when the source has no audio or the video check failed. `audio_parts_checked` counts the measured parts. `ok` is the conjunction (count, steps, every measured part within 5 ms); `ok: false` means the join was re-cut (`concat_fallback`) and this is the failed measurement. `null` for a single segment, audio, `--dry-run`, any re-encode forced for every segment, a segment that re-encoded, or a join re-cut before a copy was tried (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error). `segment_end_snap_seconds`: per segment, the signed seconds its end moved to the keyframe the copied part stops at, the first at or after the requested end (`null` for a part that reaches the end of the video, a part of a source with no B-frames, cut to its requested end as in 2.5.1, or a Matroska/MPEG-TS part, which is not snapped); `null` as a whole unless the output is those copied parts |
|
package/mcp/server.py
CHANGED
|
@@ -70,7 +70,7 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
|
70
70
|
if isinstance(args.get("argv"), list):
|
|
71
71
|
argv = [str(a) for a in args["argv"]]
|
|
72
72
|
if name not in _contract.MCP_JSON_EXEMPT and "--json" not in argv and "--help" not in argv:
|
|
73
|
-
argv.
|
|
73
|
+
argv = _common.runner.with_flags(argv, ["--json"]) # before a `--`, where it is still an option
|
|
74
74
|
return argv
|
|
75
75
|
spec = specs().get(name)
|
|
76
76
|
exceptions = spec["mcp"]["argument_exceptions"] if spec else {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.21",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -211,7 +211,9 @@ When the source cannot be matched, the result is `mode: accurate`, `reencode_rea
|
|
|
211
211
|
time.
|
|
212
212
|
`reencode_reason` lists every cause of a re-encode (`requested`, `codec`, `vfr`,
|
|
213
213
|
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost`, `tolerance`, `concat_fallback`,
|
|
214
|
-
`smart`, `smart_fallback`);
|
|
214
|
+
`smart`, `smart_fallback`); under `--dry-run`, which does not write or measure the output cut (a `--segments` dry run reads the source's packets), `possible_reencode_reason`
|
|
215
|
+
lists the ones a real run may still add to a plan of `mode: copy` or `smart` (`tolerance`: where a
|
|
216
|
+
copy lands is measured only from the written file), and a `notes` line says so;
|
|
215
217
|
`--segments` adds `segment_precision`; `precision` and `least_exact_precision` are the least exact one
|
|
216
218
|
(since 3.0.0; 2.x's `precision` was the whole run's, `packet` only when nothing re-encoded).
|
|
217
219
|
A `--segments` video copy join adds `join_check` (`packets`, `expected_packets`,
|
|
@@ -948,6 +950,18 @@ render.py plan.json [--audit-log FILE] # execute a plan written by <tool>
|
|
|
948
950
|
render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otio # the cut for an editor, nothing rendered
|
|
949
951
|
render.py project.json --intermediate-preset medium # the stages' own encoder preset instead of superfast
|
|
950
952
|
```
|
|
953
|
+
A real render first runs its own dry run; when that is refused because a stage needs a filter or an
|
|
954
|
+
encoder this build lacks (`filter_missing`, `encoder_missing`), the real run is refused with the dry
|
|
955
|
+
run's document before the first stage encodes, not after the stages before it (`waveform.py` with a
|
|
956
|
+
title or captions does the same).
|
|
957
|
+
A `captions` block with `text` (a cue file) delivers the SRT the captions stage generates as
|
|
958
|
+
`<output>.srt` beside the output (`caption_srt`), guarded with the output before the first stage;
|
|
959
|
+
it is not delivered (a `caption_srt_not_delivered` skip) when the stage came from `--cache` or the
|
|
960
|
+
export trimmed the delivery. A Ctrl-C while a stage that writes the delivery runs (the export)
|
|
961
|
+
names a file it had already written there as kept, unverified; a file that was there before and
|
|
962
|
+
that the stage left alone is not claimed, and the work directory's files are not called kept when
|
|
963
|
+
the directory goes at exit. A stage's `--cache` key also covers the logo and font file its
|
|
964
|
+
`--brand` file names.
|
|
951
965
|
`--intermediate-preset` (2.15; default `superfast` since 2.16, `veryfast` in 2.15): when the project has an export stage that
|
|
952
966
|
re-encodes (any preset but `copy`) and the run goes that far (no `--stop-after` before it), the
|
|
953
967
|
stages before it that encode with x264 or x265 (clips, fit, join, silence, captions, graphics,
|
|
@@ -1410,10 +1424,21 @@ platform (`"*.mp4"` finds `GOPR0001.MP4`; the letters inside a `[...]` class kee
|
|
|
1410
1424
|
theirs), and a glob that matches nothing is a note. A cache skips files already
|
|
1411
1425
|
done with the same recipe: an item's key is its source's `file_key()` (name,
|
|
1412
1426
|
size, mtime in nanoseconds, first and last MiB), the recipe, the GPU setting,
|
|
1413
|
-
the files the steps name
|
|
1427
|
+
the files the steps name (a path built from the first step's `{in}`, such as
|
|
1428
|
+
`--image {in}.png`, keyed per item by that file's content; one built from a later step's
|
|
1429
|
+
`{in}`, an intermediate, makes the item run every time, `cache_miss.reason:
|
|
1430
|
+
side_input_unresolved`) and, for a project recipe, the project file and every
|
|
1414
1431
|
file it names (music, cues, brand and its logo and font, overlays, chapters,
|
|
1415
1432
|
the clips other than the one each item replaces), read again on every
|
|
1416
|
-
`--watch` pass.
|
|
1433
|
+
`--watch` pass. A step that names no `{out}` and no `-o` of its own but reports an output
|
|
1434
|
+
file (its default output: `metadata.py {in}` writes `<stem>_meta.mp4` beside the source) is
|
|
1435
|
+
refused, `error.reason: step_writes_without_out`, and the batch stops at that item (the
|
|
1436
|
+
later rows are `skipped: "recipe_refused"`): its file is not what the next step reads nor
|
|
1437
|
+
what is delivered. A dry run says so with nothing written; a real run has written that one
|
|
1438
|
+
file and names it. A step before the last that also writes side files beside its
|
|
1439
|
+
intermediate (`caption.py --text`'s `.srt`) loses them with the item's work directory: a
|
|
1440
|
+
`batch_side_file_not_delivered` skip names them. A Ctrl-C while the last step runs reports
|
|
1441
|
+
a file it had already written at the final path as the row's `kept`. Use `--dry-run` to preview the plan. The recipe's
|
|
1417
1442
|
keys are `glob`, `steps`, `project`, `clip_key`, `output_dir`, `suffix` and `ext`; any
|
|
1418
1443
|
other key is refused before an item runs, with the nearest valid name when one is close, else
|
|
1419
1444
|
with the list of valid keys.
|
|
@@ -1423,7 +1448,9 @@ A recipe that runs `cut.py` rolls its calls up: each of those `results` rows car
|
|
|
1423
1448
|
and the top level `cut_stream_copy` (`{calls, stream_copy, reencoded,
|
|
1424
1449
|
stream_copy_rate}`) and `cut_reencode_reasons` (counts per reason; `unknown` for a
|
|
1425
1450
|
re-encoded call cached by an earlier 2.x batch); both are `null`
|
|
1426
|
-
when no `cut.py` step ran.
|
|
1451
|
+
when no `cut.py` step ran. Under `--dry-run` they count the calls' plans, and a planned stream
|
|
1452
|
+
copy may still re-encode in a real run: each row then carries `cut_possible_reencode_reason`, every
|
|
1453
|
+
`cut.py` call's `possible_reencode_reason`.
|
|
1427
1454
|
|
|
1428
1455
|
**`--jobs N` (1.17)** processes N files at once (threads: the work is
|
|
1429
1456
|
subprocess waiting). Capped at `min(N, cpu_count, 8)` — every item is itself an
|
|
@@ -1348,6 +1348,30 @@ def check_brand_keys(data: Dict[str, Any], path: str) -> None:
|
|
|
1348
1348
|
'export.py\'s preset, or a render project\'s "loudness")')
|
|
1349
1349
|
|
|
1350
1350
|
|
|
1351
|
+
BRAND_FILE_KEYS = ("logo", "font_file") # the brand keys that name a file a tool reads
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def brand_asset_files(path: str) -> List[Tuple[str, str]]:
|
|
1355
|
+
"""(key, path) of each file a brand file names (logo, font_file), resolved as load_brand()
|
|
1356
|
+
resolves them: against the brand file's own directory. What a cache key must hash beside the
|
|
1357
|
+
brand file's own bytes, since a logo or a font replaced under its name changes the output
|
|
1358
|
+
(render.py --cache; batch.py's side inputs, #510 review). A brand file that cannot be read
|
|
1359
|
+
names nothing here; the tool refuses it when it runs."""
|
|
1360
|
+
try:
|
|
1361
|
+
data = read_json(path)
|
|
1362
|
+
except (OSError, ValueError):
|
|
1363
|
+
return []
|
|
1364
|
+
if not isinstance(data, dict):
|
|
1365
|
+
return []
|
|
1366
|
+
base = Path(path).resolve().parent
|
|
1367
|
+
out: List[Tuple[str, str]] = []
|
|
1368
|
+
for key in BRAND_FILE_KEYS:
|
|
1369
|
+
value = data.get(key)
|
|
1370
|
+
if isinstance(value, str) and value:
|
|
1371
|
+
out.append((key, value if os.path.isabs(value) else str(base / value)))
|
|
1372
|
+
return out
|
|
1373
|
+
|
|
1374
|
+
|
|
1351
1375
|
def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
1352
1376
|
"""Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
|
|
1353
1377
|
import copy
|
|
@@ -363,7 +363,7 @@ class Context:
|
|
|
363
363
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "checked", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
|
|
364
364
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
365
365
|
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
|
|
366
|
-
"source_timecode", "smpte_hint", "delivered", "expected_timecode")
|
|
366
|
+
"source_timecode", "smpte_hint", "delivered", "expected_timecode", "watched", "temp_dirs", "side_deliveries")
|
|
367
367
|
|
|
368
368
|
def __init__(self) -> None:
|
|
369
369
|
self.reset()
|
|
@@ -377,6 +377,9 @@ class Context:
|
|
|
377
377
|
self.expected_timecode = None # the start timecode a tool that moved the picture's start wrote (timeline.output_timecode_args; "" for none): source_carry compares it
|
|
378
378
|
self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
|
|
379
379
|
self.delivered: List[str] = [] # deliverables written before a later step (export --normalize, render's chapters/check): a stop then keeps them (#415)
|
|
380
|
+
self.watched: Dict[str, Any] = {} # a path a running child writes (render's export stage): its owned_identity() before the child started (watch_destination)
|
|
381
|
+
self.temp_dirs: List[str] = [] # directories this run removes when it exits (render's work directory): nothing in them is kept
|
|
382
|
+
self.side_deliveries: set = set() # delivered files that are not the run's output (render's <output>.srt): never `output` after a stop
|
|
380
383
|
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)
|
|
381
384
|
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
382
385
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
@@ -627,12 +630,14 @@ def stop_children() -> None:
|
|
|
627
630
|
_CHILDREN.clear()
|
|
628
631
|
|
|
629
632
|
|
|
630
|
-
def mark_delivered(path: str) -> None:
|
|
633
|
+
def mark_delivered(path: str, side: bool = False) -> None:
|
|
631
634
|
"""Record that this run has written its deliverable and still has a step to run on it (export's
|
|
632
635
|
--normalize, render's chapters and check stages): a timeout or an interrupt from then on says the
|
|
633
636
|
file is there and kept, instead of "nothing was written" (#415)."""
|
|
634
637
|
if path and path not in STATE.delivered:
|
|
635
638
|
STATE.delivered.append(path)
|
|
639
|
+
if path and side:
|
|
640
|
+
STATE.side_deliveries.add(path) # `side`: a file beside the output (an SRT), kept but not the output (#510 review)
|
|
636
641
|
|
|
637
642
|
|
|
638
643
|
def child_exit_code(doc: Any, returncode: Optional[int]) -> int:
|
|
@@ -647,9 +652,34 @@ def child_exit_code(doc: Any, returncode: Optional[int]) -> int:
|
|
|
647
652
|
return 128 - code if code < 0 else code
|
|
648
653
|
|
|
649
654
|
|
|
655
|
+
def watch_destination(path: Optional[str]) -> None:
|
|
656
|
+
"""Record what is at `path` before a child tool writes it (render.py's export stage writes the
|
|
657
|
+
delivery itself): if this run is stopped while the child runs, kept_deliverables() compares
|
|
658
|
+
the file then with this record. A file the child changed is this run's and is reported kept;
|
|
659
|
+
one it did not change (the caller's own, restored by the child's cleanup, or never touched)
|
|
660
|
+
is not claimed (#510)."""
|
|
661
|
+
if path and not STATE.dry_run:
|
|
662
|
+
STATE.watched[path] = owned_identity(path)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def unwatch_destination(path: Optional[str]) -> None:
|
|
666
|
+
STATE.watched.pop(path or "", None)
|
|
667
|
+
|
|
668
|
+
|
|
650
669
|
def kept_deliverables() -> List[str]:
|
|
651
|
-
"""The deliverables mark_delivered() named that are on disk
|
|
652
|
-
|
|
670
|
+
"""The deliverables mark_delivered() named that are on disk, and any watched destination that
|
|
671
|
+
is on disk and is not the file that was there when its child started (size, mtime, inode)."""
|
|
672
|
+
kept = [p for p in STATE.delivered if os.path.isfile(p)]
|
|
673
|
+
for path, before in STATE.watched.items():
|
|
674
|
+
now = owned_identity(path)
|
|
675
|
+
if now is not None and now != before and path not in kept:
|
|
676
|
+
kept.append(path)
|
|
677
|
+
return kept
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _in_temp_dir(path: str) -> bool:
|
|
681
|
+
real = os.path.realpath(path)
|
|
682
|
+
return any(real.startswith(os.path.realpath(d) + os.sep) for d in STATE.temp_dirs)
|
|
653
683
|
|
|
654
684
|
|
|
655
685
|
def written_on_disk() -> List[str]:
|
|
@@ -657,7 +687,8 @@ def written_on_disk() -> List[str]:
|
|
|
657
687
|
deliverables mark_delivered() named excepted. A stopped command's partial output is removed
|
|
658
688
|
before a stop is reported, so it is not among them."""
|
|
659
689
|
delivered = {os.path.realpath(p) for p in STATE.delivered}
|
|
660
|
-
|
|
690
|
+
# a file in a directory removed at exit (render's work directory) is not on disk after the stop
|
|
691
|
+
return sorted(p for p in STATE.written if p not in delivered and os.path.isfile(p) and not _in_temp_dir(p))
|
|
661
692
|
|
|
662
693
|
|
|
663
694
|
def kept_clause() -> str:
|
|
@@ -676,13 +707,32 @@ def kept_clause() -> str:
|
|
|
676
707
|
"without the step that was stopped (pass --overwrite to make it again)")
|
|
677
708
|
|
|
678
709
|
|
|
679
|
-
def
|
|
710
|
+
def kept_output() -> Optional[str]:
|
|
711
|
+
"""The kept file that is this run's output: the last kept one that is not a side delivery (the
|
|
712
|
+
video, not the <output>.srt delivered after it), else the last kept one."""
|
|
680
713
|
kept = kept_deliverables()
|
|
681
|
-
|
|
714
|
+
main = [p for p in kept if p not in STATE.side_deliveries]
|
|
715
|
+
return (main or kept or [None])[-1]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _kept_fields() -> Dict[str, Any]:
|
|
719
|
+
out = kept_output()
|
|
720
|
+
return {"output": out} if out else {}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
_STOPPING = {"signal": None}
|
|
682
724
|
|
|
683
725
|
|
|
684
726
|
def _on_signal(signum: int, frame: Any) -> None:
|
|
685
727
|
import signal as _signal
|
|
728
|
+
if _STOPPING["signal"] is not None:
|
|
729
|
+
# a second signal while the first one's stop runs: a Ctrl-C reaches every process of the
|
|
730
|
+
# group, and the parent then sends its SIGTERM too. Raising again here exited from the
|
|
731
|
+
# middle of the cleanup the first one started (an output lock's __exit__), and left the
|
|
732
|
+
# lock and its takeover file beside the output (#510). The parent's SIGKILL after
|
|
733
|
+
# STOP_GRACE still ends a cleanup that hangs.
|
|
734
|
+
return
|
|
735
|
+
_STOPPING["signal"] = signum
|
|
686
736
|
name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
|
|
687
737
|
stop_children()
|
|
688
738
|
die(f"interrupted by {name}: the running command was stopped and its partial output removed; {kept_clause()}",
|
|
@@ -3188,6 +3238,54 @@ def require_filter(name: str, why: str) -> None:
|
|
|
3188
3238
|
hint=filter_missing_hint(name))
|
|
3189
3239
|
|
|
3190
3240
|
|
|
3241
|
+
def with_flags(argv: Sequence[str], flags: Sequence[str]) -> List[str]:
|
|
3242
|
+
"""`argv` with `flags` added where argparse still reads them as options: before a bare `--`
|
|
3243
|
+
when there is one (everything after it is positional, so `--dry-run --json` appended after
|
|
3244
|
+
`render.py -- project.json` was refused as unrecognised arguments, #510 review), else at the end."""
|
|
3245
|
+
argv = [str(a) for a in argv]
|
|
3246
|
+
if "--" in argv:
|
|
3247
|
+
i = argv.index("--")
|
|
3248
|
+
return argv[:i] + list(flags) + argv[i:]
|
|
3249
|
+
return argv + list(flags)
|
|
3250
|
+
|
|
3251
|
+
|
|
3252
|
+
BUILD_COMPONENT_REASONS = ("filter_missing", "encoder_missing")
|
|
3253
|
+
|
|
3254
|
+
|
|
3255
|
+
def refuse_missing_components_first(script: str, why: str) -> None:
|
|
3256
|
+
"""A tool that runs its work as several stages (render.py, waveform.py with a title or
|
|
3257
|
+
captions) finds a filter or an encoder the build lacks at the stage that needs it, after the
|
|
3258
|
+
stages before it have encoded (#510). Its --dry-run runs every stage's own dry run, which
|
|
3259
|
+
refuses such a stage before anything is written, so a real run runs that dry run first (this
|
|
3260
|
+
same command line with --dry-run) and, when it is refused for a component this build lacks,
|
|
3261
|
+
is refused with the same document: kind, message, reason, details, hint and stage. Any other
|
|
3262
|
+
outcome of the dry run, a refusal for another reason or a timeout included, is left to the
|
|
3263
|
+
real run, which decides on the files as they then are. Nothing to do under --dry-run itself."""
|
|
3264
|
+
if STATE.dry_run:
|
|
3265
|
+
return
|
|
3266
|
+
info(f"checking every {why} against this ffmpeg build first (a dry run of this command)")
|
|
3267
|
+
proc = run_tool([script] + with_flags(sys.argv[1:], ["--dry-run", "--json"]))
|
|
3268
|
+
try:
|
|
3269
|
+
doc = json.loads(proc.stdout.strip() or "{}")
|
|
3270
|
+
except ValueError:
|
|
3271
|
+
doc = None
|
|
3272
|
+
err = doc.get("error") if isinstance(doc, dict) else None
|
|
3273
|
+
if proc.returncode == 0:
|
|
3274
|
+
return
|
|
3275
|
+
if not isinstance(err, dict) or err.get("reason") not in BUILD_COMPONENT_REASONS:
|
|
3276
|
+
# not a component this build lacks: the real run meets the same thing, or not, itself
|
|
3277
|
+
info("the dry run did not complete (" + (str(err.get("message"))[:200] if isinstance(err, dict) else
|
|
3278
|
+
f"exit {proc.returncode}, no document")
|
|
3279
|
+
+ "); its stages are checked as they run")
|
|
3280
|
+
return
|
|
3281
|
+
extra: Dict[str, Any] = {"hint": err["hint"]} if err.get("hint") else {}
|
|
3282
|
+
for key in ("stage", "encoder"):
|
|
3283
|
+
if doc.get(key):
|
|
3284
|
+
extra[key] = doc[key]
|
|
3285
|
+
die(str(err.get("message") or err["reason"]), code=int(doc.get("exit_code") or 1), kind=str(err.get("kind") or "ffmpeg"),
|
|
3286
|
+
error_extra={k: err[k] for k in ("reason", "details") if err.get(k) is not None}, **extra)
|
|
3287
|
+
|
|
3288
|
+
|
|
3191
3289
|
def filter_missing_hint(name: str) -> str:
|
|
3192
3290
|
"""The filter_missing hint, with the platform's fuller-build line doctor prints for it."""
|
|
3193
3291
|
base = f"this ffmpeg build has no {name} filter: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"
|
package/scripts/_contract.py
CHANGED
|
@@ -426,8 +426,9 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
426
426
|
# error.reason: a stable code for why a refusal happened, beside `kind` (which says what class of
|
|
427
427
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
428
428
|
ERROR_REASONS = {
|
|
429
|
+
"step_writes_without_out": "kind input: batch.py: a recipe step names no {out} (and no -o of its own) but its --json document reports an output file, its own default output, which the next step does not read and the batch does not deliver; the batch stops at that item (later items: skipped recipe_refused). Under --dry-run nothing is written; a real run has written that one file and leaves it. details: {step, tool, file, written, files, written_files, not_started}",
|
|
429
430
|
"disk_full": "kind ffmpeg: ffmpeg reported \"No space left on device\" while writing; the hint says to free space or write elsewhere; details: {}",
|
|
430
|
-
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command names: for a command run() is given (not a run_analysis() measurement pass), refused before ffmpeg runs, dry runs included, when the build's filter list was read and lacks it, else ffmpeg's own \"No such filter\"; details: {name}; the hint points at doctor",
|
|
431
|
+
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command names: for a command run() is given (not a run_analysis() measurement pass), refused before ffmpeg runs, dry runs included, when the build's filter list was read and lacks it, else ffmpeg's own \"No such filter\"; render.py and waveform.py with a title or captions run their own dry run first and are refused with its document (the stage's message, details and stage) before the first stage encodes; details: {name}; the hint points at doctor",
|
|
431
432
|
"input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
|
|
432
433
|
"input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
|
|
433
434
|
"ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
|
|
@@ -459,7 +460,7 @@ ERROR_REASONS = {
|
|
|
459
460
|
"plan_field_invalid": "render.py PLAN: inputs, unbound_inputs, verify (objects), commands (strings) or cwd has the wrong JSON type; "
|
|
460
461
|
"details: {plan, plan_sha256, checked_at, field, field_type, audit_log}",
|
|
461
462
|
"decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
|
|
462
|
-
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or a command run() is given names an encoder the build lacks for a stream the output keeps (an HDR source kept as HEVC, --codec hevc, a preset's encoder; not -c:a for a source with no sound, or with -an): refused before ffmpeg runs, dry runs included, when the build's encoder list was read and lacks it, else ffmpeg's own \"Unknown encoder\" (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
463
|
+
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or a command run() is given names an encoder the build lacks for a stream the output keeps (an HDR source kept as HEVC, --codec hevc, a preset's encoder; not -c:a for a source with no sound, or with -an): refused before ffmpeg runs, dry runs included, when the build's encoder list was read and lacks it, else ffmpeg's own \"Unknown encoder\" (kind ffmpeg; details: {name}; the hint points at doctor); render.py and waveform.py with a title or captions run their own dry run first and are refused with its document before the first stage encodes",
|
|
463
464
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
464
465
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
465
466
|
"audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
|
|
@@ -551,6 +552,9 @@ DIAGNOSTIC_CODES = {
|
|
|
551
552
|
"report_section_missing": ("warnings", "report.py: a loudness, compliance or contact-sheet section could not be built (also a failing verification step naming the section)"),
|
|
552
553
|
"report_section_dry_run": ("skipped", "report.py --dry-run draws no contact sheet"),
|
|
553
554
|
"check_dry_run": ("skipped", "render.py --dry-run does not run a check stage or a plan's check step"),
|
|
555
|
+
"caption_srt_not_delivered": ("skipped", "render.py: the captions stage's generated SRT is not delivered beside the output: the stage was served from --cache (reason cached), or a later stage changed the timeline (reason timeline_changed: the export trimmed the delivery); {reason, path, trimmed}"),
|
|
556
|
+
"batch_cache_inputs_changed": ("skipped", "batch.py: an item's source or a side input changed while it was processed (its cache key, made again when the item ends, is not the one it was looked up with), so its output is not stored in the cache; {file}"),
|
|
557
|
+
"batch_side_file_not_delivered": ("skipped", "batch.py: a step before the last wrote side files beside its intermediate (caption.py's .srt, an .ass), which go with the item's work directory; only the last step's side files land beside the output; {file, step, tool, files}"),
|
|
554
558
|
"check_no_output": ("skipped", "render.py PLAN: the tool reported no output for the plan's check step"),
|
|
555
559
|
"check_nothing_to_check": ("skipped", "render.py: a \"check\" stage with neither \"platform\" nor \"content\": true"),
|
|
556
560
|
"check_output_unparsed": ("warnings", "render.py: check.py printed no JSON document; `check` is a failure document (error.reason check_output_unparsed)"),
|
|
@@ -760,6 +764,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
760
764
|
elif name == "render":
|
|
761
765
|
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
|
|
762
766
|
"cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path|destination_not_owned, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
|
|
767
|
+
"caption_srt": {"type": ["string", "null"], "description": "the captions stage's SRT generated from a captions \"text\" cue file, delivered as <output>.srt beside the output (guarded with the output, before the first stage: an existing file is refused without --overwrite, an input is never replaced); under --dry-run the planned path; null when there is none (captions from \"srt\" or \"ass\", --stop-after before export) or it was not delivered (a caption_srt_not_delivered skip: the stage came from --cache, or the export trimmed the delivery so the cue times are not its own)"},
|
|
763
768
|
"caption": {"type": ["object", "null"], "description": "the caption stage's own block, forwarded verbatim from caption.py (cue-layout counts and fit-size keys); null without a caption stage"},
|
|
764
769
|
"snap": {"type": ["object", "null"], "description": "a project's \"snap\": cut.py's snap block for the first snapped clip, with every clip's in clips; null when nothing was snapped"},
|
|
765
770
|
"intermediate_preset": {"type": ["string", "null"], "description": "2.15: the x264/x265 preset the stages before an encoding export ran at (--intermediate-preset, default superfast since 2.16); null when no stage received it (no encoding export reached, --fast, --stop-after before the export, a copy export, or nothing but the export encoded)"},
|
|
@@ -781,9 +786,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
781
786
|
"failed": {"type": "integer"}, "total": {"type": "integer"},
|
|
782
787
|
"out_dir": {"type": ["string", "null"], "description": "the folder the steps' outputs are in when they are kept: --out, or with --keep alone a new folder that outlives the run; null when they were written to a temporary folder and deleted"}}
|
|
783
788
|
elif name == "batch":
|
|
784
|
-
extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
|
|
789
|
+
extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable|entry_predates_output_check, ...} when a cache entry was not served (entry_predates_output_check: the recipe has a step that names no {out} and the entry was stored before batch checked such a step's own output, so it runs once more and is stored again), or {reason: side_input_unresolved, step, argument} for an item never served from the cache because a later step names a side input built from its {in} (an intermediate that does not exist before the item runs; a first step's {in}-derived side input is keyed by its content per item); skipped \"timeout\" or \"interrupted\" for an item that never started, \"recipe_refused\" for one not started because an earlier item's step wrote a file without naming {out} (error.reason step_writes_without_out) and interrupted true for one stopped while running (with kept: the final path, when the stopped last step had written a file there that was not there before it started, as export.py --normalize keeps its encode: on disk, unverified); under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call), and under --dry-run cut_possible_reencode_reason (per cut.py call, that call's possible_reencode_reason: what a real run may still re-encode a planned copy for)"},
|
|
785
790
|
"processed": {"type": "integer"}, "total": {"type": "integer"},
|
|
786
|
-
"cut_stream_copy": {"type": ["object", "null"], "description": "cut.py calls across the folder: {calls, stream_copy, reencoded, stream_copy_rate}; null when no cut.py step ran"},
|
|
791
|
+
"cut_stream_copy": {"type": ["object", "null"], "description": "cut.py calls across the folder: {calls, stream_copy, reencoded, stream_copy_rate}; under --dry-run the calls' plans, where a planned stream copy may still re-encode in a real run (cut.py's possible_reencode_reason); null when no cut.py step ran"},
|
|
787
792
|
"cut_reencode_reasons": {"type": ["object", "null"], "description": "how many cut.py calls named each reencode_reason ({} when none re-encoded); a re-encoded call whose row came from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as unknown; null when no cut.py step ran"}}
|
|
788
793
|
elif name == "report":
|
|
789
794
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
|
|
@@ -819,6 +824,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
819
824
|
elif name == "cut":
|
|
820
825
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
821
826
|
"mode": {"enum": ["copy", "hybrid", "accurate", "smart"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced (or --smart fell back, reason in smart.reason); hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why); smart: --smart re-encoded only the partial GOPs at the cut points and copied the rest, verified"},
|
|
827
|
+
"possible_reencode_reason": {"type": "array", "items": {"enum": ["copy_failed", "stream_lost", "tolerance", "concat_fallback", "smart_fallback"]},
|
|
828
|
+
"description": "--dry-run only (else []): the reencode_reason values a real run may still add to a plan of mode copy or smart, which it would then report as hybrid or accurate, since the dry run does not write or measure the output cut (a --segments dry run does read the source's packets; see below): copy_failed and stream_lost on every planned copy; tolerance when a copied part is judged by its written length (a single segment, video or audio-only; a --segments part of a source with no B-frames, a Matroska/MPEG-TS part, or an audio-only part; not under --tolerance -1), so a copy that lands --tolerance or more from the requested cut is re-encoded; concat_fallback on a --segments stream-copy join; smart_fallback for mode smart, whose checks run on the written cut. A notes line says the same. [] for a plan that already re-encodes. A --segments dry run reads the source's packets and judges each part against --tolerance from them, as a real run does; tolerance is listed when a part is also judged by its written length"},
|
|
822
829
|
"smart": {"type": "object", "description": "--smart only: {applied: true, parts [{segment, kind: reencode|copy, start, end, frames}], reencoded_frames, copied_frames, matched (the source parameters the re-encoded parts reproduce: codec_name, profile, level, pix_fmt, width, height, sample_aspect_ratio, has_b_frames, colour tags), encoder (the libx264 arguments), checks {frames, grid_worst_seconds, copied_packets_equal, frames_decoded_equal, time_base}, time_base}; or {applied: false, reason, reason_code} when the cut could not be made or did not verify and the whole cut was re-encoded instead (reason_code: no_video, pending_input, vfr, codec, hold, params_unreadable, container, pix_fmt, profile, interlaced, reorder_depth, frame_rate, hdr, packets, no_frames, segments, av_offset, open_gop, seek, encode, copy_count, encode_count, params_mismatch, frames, decode, time_base)"},
|
|
823
830
|
"requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
|
|
824
831
|
"requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
|
|
@@ -844,7 +851,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
844
851
|
"edit_list": {"type": "boolean", "description": "a single MP4/MOV stream copy kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (the default since 3.0.0; false under --no-edit-list and for every other path)"},
|
|
845
852
|
"stored_preroll_seconds": {"type": ["number", "null"], "description": "with edit_list: seconds decoded from the keyframe the copy's seek lands on up to the requested start and hidden by the edit list (a player that ignores edit lists shows them); null otherwise"},
|
|
846
853
|
"av_start_skew_seconds": {"type": ["number", "null"], "description": "the output's audio start minus its video start (null when either is missing or under --dry-run); reported, never repaired: on a stream copy only, a warning in notes when it is more than max(2 frames, 0.1 s) away from the source's own skew where the copy's picture starts (the keyframe it began from; the requested start with an edit list), naming --accurate (and the edit list for a single MP4/MOV copy whose start snapped under --no-edit-list)"},
|
|
847
|
-
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an edit list, or an edit-list copy judged by its video; an A/V start skew with its remedies; a source --vfr-guard average took for VFR whose sampled timing is constant (naming --vfr-guard sampled), or a sampled measurement that forced or (--vfr-guard off) skipped a re-encode; a --segments segment ended with the video, or held on its last frame for its sound; a --segments join re-cut because the source uses open GOPs, because a later B-frame .mp4/.mov segment starts between keyframes, or because its join check failed"},
|
|
854
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an edit list, or an edit-list copy judged by its video; an A/V start skew with its remedies; a source --vfr-guard average took for VFR whose sampled timing is constant (naming --vfr-guard sampled), or a sampled measurement that forced or (--vfr-guard off) skipped a re-encode; a --segments segment ended with the video, or held on its last frame for its sound; a --segments join re-cut because the source uses open GOPs, because a later B-frame .mp4/.mov segment starts between keyframes, or because its join check failed; under --dry-run, a plan of mode copy or smart that a real run may still re-encode (possible_reencode_reason)"},
|
|
848
855
|
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (2.x's default) only when its nominal and average frame rates differ, which re-encodes it (reason vfr) whatever the sample says; under sampled (the default since 3.0.0) or off on every such input (absent for audio-only input or output, --accurate, --codec, or a --dry-run input a plan has not written yet): {heuristic: true, measured, method: \"sampled\", guard, windows, deltas}. measured is sampled_cfr (every packet interval in up to five 6 s windows within a tick, at least 1 ms and at most half a frame, of its window's median, and the windows agree; the file-start window's first interval is ignored, and a seeked window is read from its first keyframe on, because a seek lands mid-GOP and the frames before it are not a full run), vfr (under sampled, forces a re-encode, reason vfr) or inconclusive (fewer than 20 intervals or ffprobe failed; under sampled, reason vfr_inconclusive). --vfr-guard off keeps the copy either way, with a note. Variable timing between the windows is not seen"},
|
|
849
856
|
"lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"},
|
|
850
857
|
"join_check": {"type": ["object", "null"], "description": "--segments video stream-copy join: the written file measured by demuxing, {packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}. expected_packets counts the source's video packets from each part's start keyframe to its end (or the end of the video); max_step_seconds is the largest presentation step (null when vfr_check measured anything but sampled_cfr, e.g. a --vfr-guard off join of variable timing, which checks the count only), and each step at a join must be the step its parts predict within 1 ms; audio_offset_ms is, per part, its sound's shift minus its picture's (+ = sound late) from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than a:0; an MPEG-TS source's AAC is hashed without its ADTS headers) (null for a part with no unique match, e.g. digital silence; null as a whole when the source has no audio or the video check failed), and audio_parts_checked counts the measured parts; ok is the conjunction (counts, steps, every measured part within 5 ms), and false means the join was re-cut from the source (concat_fallback) and this is the failed measurement. null for a single segment, audio, --dry-run, --accurate/--codec/a forced re-encode, a segment that re-encoded, or a join that fell back before a copy join was attempted (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error)"},
|