ffmpeg-skill 1.10.0 → 1.11.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 +13 -6
- package/SKILL.md +111 -275
- package/docs/contract.md +12 -10
- package/package.json +2 -1
- package/references/gotchas.md +172 -0
- package/references/scripts.md +4 -2
- package/scripts/_common.py +119 -29
- package/scripts/_contract.py +20 -6
- package/scripts/batch.py +3 -0
- package/scripts/render.py +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"references/scripts.md",
|
|
34
34
|
"references/devices.md",
|
|
35
35
|
"references/ci-platform-pitfalls.md",
|
|
36
|
+
"references/gotchas.md",
|
|
36
37
|
"docs/contract.md",
|
|
37
38
|
"SKILL.md",
|
|
38
39
|
"README.md",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Gotchas and things that look right but are wrong
|
|
2
|
+
|
|
3
|
+
The long form of SKILL.md's "Things that look right but are wrong" and "Gotchas"
|
|
4
|
+
bullets. SKILL.md keeps one line per item and points here with the anchors below;
|
|
5
|
+
nothing here is optional reading when the job touches that area — it is the
|
|
6
|
+
detail, not an aside.
|
|
7
|
+
|
|
8
|
+
## Colour, HDR and Log
|
|
9
|
+
|
|
10
|
+
### HDR and colour
|
|
11
|
+
SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py` reports
|
|
12
|
+
`hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing script keeps
|
|
13
|
+
the output HDR (HEVC Main10, source colour tags) so nothing is silently
|
|
14
|
+
flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
|
|
15
|
+
`color.py --to-sdr` first for SDR-only destinations, LUT work or H.264
|
|
16
|
+
deliverables. `hdr: true` counts BT.2020 primaries too, so it is also true for a
|
|
17
|
+
wide-gamut SDR file; `hdr_signal: true` is the narrower fact — a real PQ / HLG /
|
|
18
|
+
Dolby Vision transfer — and `hdr_format` names the in-between case
|
|
19
|
+
(`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR input.
|
|
20
|
+
iPhone `.mov` files also carry timecode/metadata tracks; scripts map only the
|
|
21
|
+
first audio track, so extra tracks are dropped on re-encode. Keep ProRes masters
|
|
22
|
+
at source colour: `export.py --preset prores` does not retag.
|
|
23
|
+
|
|
24
|
+
Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
|
|
25
|
+
colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
|
|
26
|
+
do not tag BT.709 on BT.2020 pixels.
|
|
27
|
+
|
|
28
|
+
### Log footage
|
|
29
|
+
S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
|
|
30
|
+
`probe.py --analyze`; `looks_like_log: true` means apply the manufacturer's
|
|
31
|
+
`.cube` with `color.py --lut` before anything else.
|
|
32
|
+
|
|
33
|
+
## Cutting
|
|
34
|
+
|
|
35
|
+
### Keyframe cuts
|
|
36
|
+
A lossless `cut.py` result may start up to one GOP (often 1–10 s) earlier than
|
|
37
|
+
requested; the script re-encodes automatically when the deviation exceeds 0.5 s.
|
|
38
|
+
If the user insists on lossless output, pass `--tolerance -1` and tell them the
|
|
39
|
+
cut lands on the nearest earlier keyframe. A `-c copy` cut on VFR or a
|
|
40
|
+
non-keyframe boundary produces a file that "works" but starts on a frozen or
|
|
41
|
+
wrong frame — respect the automatic re-encode rather than forcing the copy.
|
|
42
|
+
|
|
43
|
+
### Variable frame rate
|
|
44
|
+
`probe.py` sets `variable_frame_rate_suspected` when `r_frame_rate` and
|
|
45
|
+
`avg_frame_rate` disagree (phone and screen recordings). Every re-encoding
|
|
46
|
+
script then adds `-fps_mode cfr` at the source's average rate, and `cut.py`
|
|
47
|
+
switches itself to `--accurate` (copy-cuts on VFR are unreliable). Pick the rate
|
|
48
|
+
explicitly with `fit.py --fps 30|60` when the average is odd (e.g. 23.4 fps from
|
|
49
|
+
dropped frames).
|
|
50
|
+
|
|
51
|
+
## Audio
|
|
52
|
+
|
|
53
|
+
### Sync, multicam and drift
|
|
54
|
+
A sync or multicam alignment with `confidence` under 0.3, or an offset larger
|
|
55
|
+
than 60 % of the analysis window, is probably wrong: enlarge `--analyze-seconds`
|
|
56
|
+
or find a clap. `multicam.py` reports one `confidence` per camera — check all of
|
|
57
|
+
them, not just that the command succeeded, before trusting the cut.
|
|
58
|
+
|
|
59
|
+
`sync.py`/`multicam.py` align audio tracks to each other, never lip sync (mouth
|
|
60
|
+
movement vs. audio) — there is no face or mouth detection anywhere in this skill.
|
|
61
|
+
High confidence means the audio matched well, not that the picture looks right;
|
|
62
|
+
whether lip sync is correct needs a look at the video, not the reported offset.
|
|
63
|
+
|
|
64
|
+
Don't mix files with different frame rates or sample rates in one
|
|
65
|
+
`cut.py --segments` join without re-encoding (`--accurate`). After `sync.py`,
|
|
66
|
+
verify by running it again on the output: offset (and drift ppm with
|
|
67
|
+
`--fix-drift`) should be ~0. Recordings longer than ~10 minutes from separate
|
|
68
|
+
devices: always use `--fix-drift`.
|
|
69
|
+
|
|
70
|
+
### Loudness and ambience
|
|
71
|
+
"Normalised" audio can still clip: check true peak, not just LUFS (`check.py`
|
|
72
|
+
does both). And do not normalise ambience or near-silence to a speech target — a
|
|
73
|
+
clip measured at -40 LUFS or below is room tone, wind or nothing; raising it
|
|
74
|
+
25 dB raises the noise, not the content. Leave the level, say so, and offer music
|
|
75
|
+
or narration.
|
|
76
|
+
|
|
77
|
+
## Text and framing
|
|
78
|
+
|
|
79
|
+
### Captions, fonts and text order
|
|
80
|
+
Captions burned before a crop/resize land off-frame: frame changes first, then
|
|
81
|
+
text. Captions burned at an intermediate size and then upscaled by `export.py`
|
|
82
|
+
come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to
|
|
83
|
+
1080x1920) — fit to the delivery size first
|
|
84
|
+
(`fit.py --width 1080 --height 1920`), then caption, then export.
|
|
85
|
+
|
|
86
|
+
CJK and other non-Latin text: libass and drawtext need a font that has the
|
|
87
|
+
glyphs. Check with `fc-list | grep -i cjk`. Then either name it
|
|
88
|
+
(`caption.py --font "Noto Sans CJK JP"`) or point at the file
|
|
89
|
+
(`overlay.py --font-file /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,
|
|
90
|
+
`caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a matching
|
|
91
|
+
font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
|
|
92
|
+
`brew install --cask font-noto-sans-cjk`.
|
|
93
|
+
|
|
94
|
+
Windows drawtext crashes on some real builds (#100): the drawtext tools resolve a
|
|
95
|
+
concrete `--font-file` by default, which avoids it; if one still crashes, pass
|
|
96
|
+
`--font-file` explicitly. Details: `references/ci-platform-pitfalls.md`.
|
|
97
|
+
|
|
98
|
+
### Reframing, fps and duration
|
|
99
|
+
`--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot
|
|
100
|
+
loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y`
|
|
101
|
+
toward the subject, or a reframe is often the honest answer — a silent centre
|
|
102
|
+
crop is a guess, not a decision.
|
|
103
|
+
|
|
104
|
+
Conforming 60 fps to 30 halves the motion samples: fine for a talking head,
|
|
105
|
+
visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows.
|
|
106
|
+
|
|
107
|
+
"Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (3×); by
|
|
108
|
+
trim it drops two thirds of the words. Ask which, or propose a highlight cut with
|
|
109
|
+
`scenes.py`.
|
|
110
|
+
|
|
111
|
+
### Dimensions and rotation
|
|
112
|
+
`yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
|
|
113
|
+
automatically. Phone footage often carries a `rotation` tag; `probe.py` reports
|
|
114
|
+
it and `fit.py` accounts for it when computing the output frame.
|
|
115
|
+
|
|
116
|
+
## Planning
|
|
117
|
+
|
|
118
|
+
### Highlights
|
|
119
|
+
`scenes.py --highlights` defaults to the loudest scenes (`--rank-by audio`): a
|
|
120
|
+
quiet but important moment (a confession, a punchline landing in silence) is
|
|
121
|
+
skipped, and pure crowd noise or a mic bump can outrank it. `--rank-by duration`
|
|
122
|
+
picks the longest unbroken scenes instead. Neither is "the best parts" — check
|
|
123
|
+
the contact sheet (`--sheet`) before treating the picks as final.
|
|
124
|
+
|
|
125
|
+
### Chaining and speed
|
|
126
|
+
Anything chained by hand through three re-encodes should be one `render.py`
|
|
127
|
+
project instead, so the plan is one file and the user can change one number.
|
|
128
|
+
Re-encodes use x264 `medium`; for long files add `--preset veryfast` to
|
|
129
|
+
intermediates and keep the default for the final export.
|
|
130
|
+
|
|
131
|
+
## Audio-only files
|
|
132
|
+
|
|
133
|
+
Audio files are a first-class input, not a special case. `probe.py`, `cut.py`,
|
|
134
|
+
`silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform
|
|
135
|
+
podcast` all accept WAV, FLAC, MP3, M4A/AAC, OGG and Opus (any container ffmpeg
|
|
136
|
+
can read) and write the codec that fits the output extension, so the same
|
|
137
|
+
commands work with `talk.wav` in place of `talk.mp4`. What changes:
|
|
138
|
+
|
|
139
|
+
- The output extension picks the format: `-o out.mp3` converts, `-o out.wav`
|
|
140
|
+
keeps PCM, `-o out.m4a` writes AAC. `audio.py in.wav -o out.mp3` with no
|
|
141
|
+
other flag is a plain conversion.
|
|
142
|
+
- `cut.py` stream-copies audio too, so trims land on a packet boundary
|
|
143
|
+
(`precision: packet`, a few ms; the JSON reports `duration_error_ms`). Pass
|
|
144
|
+
`--accurate` for a sample-exact trim: `precision: sample` when the output is
|
|
145
|
+
PCM or FLAC, `codec_frame` when a lossy codec (AAC, MP3, Opus) frames it
|
|
146
|
+
again. A `.wav` output is always PCM, never AAC packets inside a WAV.
|
|
147
|
+
- `join.py` joins audio-only clips as audio (`acrossfade` or a butt join) at
|
|
148
|
+
one sample rate and channel layout; the output must have an audio extension.
|
|
149
|
+
Video and audio clips cannot be mixed in one join.
|
|
150
|
+
- An audio extension on a video input (`audio.py talk.mp4 -o talk.wav`,
|
|
151
|
+
`cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav`) extracts the audio; the
|
|
152
|
+
output has no video stream. `audio.py --audio-stream N` picks a track when
|
|
153
|
+
`probe` lists several under `audio_streams`.
|
|
154
|
+
- `Look: not needed` in the report; `Check:` still applies for loudness
|
|
155
|
+
(`check.py file.wav --platform podcast` measures LUFS and true peak).
|
|
156
|
+
- Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
|
|
157
|
+
`color`, `export`, `scenes`, `look`) refuse an audio file with
|
|
158
|
+
"input has no video stream". Say so instead of forcing a video wrapper.
|
|
159
|
+
|
|
160
|
+
### Audio-only recipes
|
|
161
|
+
|
|
162
|
+
| User says (audio file) | Do |
|
|
163
|
+
|-----------|----|
|
|
164
|
+
| "normalise this WAV to -14 LUFS", "podcast levels" | `loudness.py talk.wav -I -14 --tp -1 -o talk_norm.wav` (`-I -16 --tp -1.5` podcast) |
|
|
165
|
+
| "remove the silence from this recording" | `silence.py talk.wav -o talk_tight.wav` |
|
|
166
|
+
| "clean up the noise in this M4A" | `audio.py talk.m4a --voice -o talk_clean.m4a` (speech) or `--denoise` |
|
|
167
|
+
| "convert this WAV to MP3" | `audio.py talk.wav -o talk.mp3` |
|
|
168
|
+
| "trim this audio from 00:30 to 02:00" | `cut.py talk.wav --start 0:30 --end 2:00 -o talk_cut.wav` (`--accurate` for sample-exact) |
|
|
169
|
+
| "join these recordings", "intro + episode + outro" | `join.py intro.wav episode.m4a outro.wav -o full.flac` (`--transition none` for a butt join) |
|
|
170
|
+
| "extract the audio from the video" | `audio.py talk.mp4 -o talk.wav` (`--voice -o talk.m4a` to clean it on the way) |
|
|
171
|
+
| "compress / limit / gate the voice" | `audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav` |
|
|
172
|
+
| "is this loud enough for Apple Podcasts?" | `check.py talk.m4a --platform podcast` |
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.10.2: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--crf` is deprecated since 1.10.0 (it warns on stderr and is removed in 2.0): use `--quality`, except on `export.py`, whose `--crf` is not an alias and stays. With `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment, any tool refuses (`kind: input`) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Time grammar (every time-taking flag, 1.9)
|
|
6
6
|
|
|
@@ -406,7 +406,9 @@ and the project has no `loudness` stage; `"normalize": false` opts out.
|
|
|
406
406
|
|
|
407
407
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
408
408
|
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
|
409
|
-
CLI flags of each script (see the docstring).
|
|
409
|
+
CLI flags of each script (see the docstring); a key `render.py` does not read -- at
|
|
410
|
+
the top level or in any stage/clip object -- is refused (`kind: input`) naming the
|
|
411
|
+
key and the nearest valid one, never silently ignored. Use it whenever an edit has
|
|
410
412
|
more than two steps or the user is likely to ask for changes: edit the JSON,
|
|
411
413
|
re-render, and the result is reproducible. `--dry-run --json` prints the
|
|
412
414
|
complete command plan for review.
|
package/scripts/_common.py
CHANGED
|
@@ -7,6 +7,7 @@ error reporting, and provides a compact media probe used by every script.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
import math
|
|
10
11
|
import os
|
|
11
12
|
import platform
|
|
12
13
|
import argparse
|
|
@@ -177,7 +178,9 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
|
|
|
177
178
|
read a failed delivery as a success."""
|
|
178
179
|
hint = extra.pop("hint", None)
|
|
179
180
|
ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
|
|
181
|
+
_set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
|
|
180
182
|
ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
|
|
183
|
+
STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
|
|
181
184
|
sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
|
|
182
185
|
if ctx.json:
|
|
183
186
|
doc: Dict[str, Any] = {
|
|
@@ -234,7 +237,7 @@ class Context:
|
|
|
234
237
|
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
235
238
|
"""
|
|
236
239
|
|
|
237
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
|
|
240
|
+
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
|
|
238
241
|
|
|
239
242
|
def __init__(self) -> None:
|
|
240
243
|
self.reset()
|
|
@@ -242,6 +245,7 @@ class Context:
|
|
|
242
245
|
def reset(self) -> None:
|
|
243
246
|
self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
|
|
244
247
|
self.json = False # emit() prints a JSON document instead of the output path
|
|
248
|
+
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
245
249
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
246
250
|
self.fast = False # x264 preset forced to veryfast
|
|
247
251
|
self.duration_hint: Optional[float] = None # expected output length, for the progress percent
|
|
@@ -259,6 +263,15 @@ class Context:
|
|
|
259
263
|
|
|
260
264
|
STATE = Context()
|
|
261
265
|
|
|
266
|
+
# The atexit plan hook takes no arguments, so emit()/die() record the Context they were given
|
|
267
|
+
# here; nothing passed a ctx = it stays None and the hook falls back to STATE, as before (1.10).
|
|
268
|
+
_CURRENT_CTX: "Optional[Context]" = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _set_current_ctx(ctx: "Context") -> None:
|
|
272
|
+
global _CURRENT_CTX
|
|
273
|
+
_CURRENT_CTX = ctx
|
|
274
|
+
|
|
262
275
|
|
|
263
276
|
def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
264
277
|
"""Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
|
|
@@ -266,6 +279,8 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
266
279
|
g = ap.add_argument_group("agent options")
|
|
267
280
|
g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
|
|
268
281
|
g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
|
|
282
|
+
g.add_argument("--json-brief", action="store_true",
|
|
283
|
+
help="like --json but trimmed: status, output, dry_run, verified, a compact summary of the output probe, this tool's own keys, and the command count instead of the command lines (failures print the full failure document, unchanged)")
|
|
269
284
|
g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
|
|
270
285
|
g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
|
|
271
286
|
if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
|
|
@@ -280,8 +295,17 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
280
295
|
# `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
|
|
281
296
|
# here, once, rather than in each re-encoding tool's own parser.
|
|
282
297
|
crf = ap._option_string_actions["--crf"]
|
|
283
|
-
|
|
284
|
-
|
|
298
|
+
# The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
|
|
299
|
+
# spelling argparse accepts, including the --cr / --c abbreviations) from the default;
|
|
300
|
+
# apply_common() puts _CRF_DEFAULT back when the flag was absent.
|
|
301
|
+
global _CRF_DEFAULT
|
|
302
|
+
_CRF_DEFAULT = crf.default
|
|
303
|
+
crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
|
|
304
|
+
crf.default = None
|
|
305
|
+
if "deprecated" not in (crf.help or ""):
|
|
306
|
+
# the nine tools that declare --crf with no help string used to fall through this and
|
|
307
|
+
# never show the mark at all (review 9)
|
|
308
|
+
crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
|
|
285
309
|
# only the tools that re-encode (they declare --crf before add_common): one encoder choice
|
|
286
310
|
# resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
|
|
287
311
|
g.add_argument("--codec", choices=CODECS, default=None,
|
|
@@ -290,13 +314,18 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
290
314
|
help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
|
|
291
315
|
|
|
292
316
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
as opposed to argparse filling in its default. Used for the deprecation warnings."""
|
|
296
|
-
return any(a == flag or a.startswith(flag + "=") for a in sys.argv[1:])
|
|
317
|
+
# The declared default of a deprecated --crf, parked by add_common() (one parser per process).
|
|
318
|
+
_CRF_DEFAULT: Optional[int] = None
|
|
297
319
|
|
|
298
320
|
|
|
299
321
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
322
|
+
# Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
|
|
323
|
+
# whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
|
|
324
|
+
# an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
|
|
325
|
+
# prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
|
|
326
|
+
crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
|
|
327
|
+
if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
|
|
328
|
+
args.crf = _CRF_DEFAULT
|
|
300
329
|
STATE.plan = getattr(args, "plan", None) or None
|
|
301
330
|
STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
|
|
302
331
|
if STATE.plan:
|
|
@@ -304,7 +333,10 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
304
333
|
# tools without --json) still get their plan written, at exit, unless die() ran (review 6)
|
|
305
334
|
import atexit
|
|
306
335
|
atexit.register(_plan_at_exit)
|
|
307
|
-
STATE.
|
|
336
|
+
STATE.json_brief = bool(getattr(args, "json_brief", False))
|
|
337
|
+
# --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
|
|
338
|
+
# passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
|
|
339
|
+
STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
|
|
308
340
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
309
341
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
310
342
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
@@ -330,12 +362,10 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
330
362
|
die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
|
|
331
363
|
hint="give -o NAME.mov")
|
|
332
364
|
crf = getattr(args, "crf", None)
|
|
333
|
-
# The warning the deprecation policy asks for, only when the
|
|
334
|
-
#
|
|
335
|
-
# an
|
|
336
|
-
|
|
337
|
-
# is not deprecated: warn only where --quality exists.
|
|
338
|
-
if crf is not None and hasattr(args, "quality") and _passed_explicitly("--crf"):
|
|
365
|
+
# The warning the deprecation policy asks for, only when the caller typed the flag (see
|
|
366
|
+
# crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
|
|
367
|
+
# --crf is not an alias and is not deprecated: warn only where --quality exists.
|
|
368
|
+
if crf is not None and crf_explicit:
|
|
339
369
|
info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
|
|
340
370
|
top = 63 if STATE.codec == "av1" else 51
|
|
341
371
|
if crf is not None and not 0 <= int(crf) <= top:
|
|
@@ -408,6 +438,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
408
438
|
`ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
|
|
409
439
|
omitted, every read falls back to the process-global STATE as before."""
|
|
410
440
|
ctx = ctx or STATE
|
|
441
|
+
_set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
|
|
411
442
|
meta: Dict[str, Any] = {}
|
|
412
443
|
if output and not ctx.dry_run:
|
|
413
444
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
@@ -430,22 +461,74 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
430
461
|
if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
|
|
431
462
|
doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
|
|
432
463
|
if ctx.plan:
|
|
433
|
-
doc["plan"] = write_plan(ctx.plan, output, extra)
|
|
434
|
-
print_json(doc)
|
|
464
|
+
doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
|
|
465
|
+
print_json(_brief(doc, meta) if ctx.json_brief else doc)
|
|
435
466
|
elif ctx.plan:
|
|
436
|
-
print(write_plan(ctx.plan, output, extra))
|
|
467
|
+
print(write_plan(ctx.plan, output, extra, ctx=ctx))
|
|
437
468
|
elif output:
|
|
438
469
|
print(output)
|
|
439
470
|
|
|
440
471
|
|
|
472
|
+
# Keys the brief document replaces or drops: the full probe (summarised), the command lines
|
|
473
|
+
# (counted), the per-step verification list (its verdict stays as `verified`) and the 2.0 preview.
|
|
474
|
+
_BRIEF_DROP = ("probe", "commands", "verification", "result_v2")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _brief_summary(meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
|
|
478
|
+
"""The handful of output facts a caller reports or branches on, from the probe this tool
|
|
479
|
+
already ran -- plus the measured loudness when the tool measured one. Keys whose value is
|
|
480
|
+
unknown are left out rather than emitted as null."""
|
|
481
|
+
video = (meta or {}).get("video") or {}
|
|
482
|
+
audio = (meta or {}).get("audio") or {}
|
|
483
|
+
summary: Dict[str, Any] = {}
|
|
484
|
+
duration = (meta or {}).get("duration")
|
|
485
|
+
if duration is not None:
|
|
486
|
+
summary["duration_s"] = round(float(duration), 3)
|
|
487
|
+
for key, value in (("width", video.get("width")), ("height", video.get("height")), ("fps", video.get("fps")),
|
|
488
|
+
("vcodec", video.get("codec")), ("acodec", audio.get("codec")), ("channels", audio.get("channels"))):
|
|
489
|
+
if value is not None:
|
|
490
|
+
summary[key] = value
|
|
491
|
+
lufs = None
|
|
492
|
+
for source, key in ((extra.get("result"), "input_i"), (extra.get("measured"), "input_i")):
|
|
493
|
+
if lufs is None and isinstance(source, dict):
|
|
494
|
+
lufs = _to_float(source.get(key))
|
|
495
|
+
for step in extra.get("verification") or []:
|
|
496
|
+
if lufs is None and isinstance(step, dict):
|
|
497
|
+
lufs = _to_float(step.get("lufs"))
|
|
498
|
+
# a silent file measures -inf, which json.dumps writes as the non-standard -Infinity: the
|
|
499
|
+
# brief document stays valid JSON by leaving the key out instead (the full document's own
|
|
500
|
+
# `measured`/`result` still carries whatever the tool reported).
|
|
501
|
+
if lufs is not None and math.isfinite(lufs):
|
|
502
|
+
summary["lufs"] = round(lufs, 2)
|
|
503
|
+
return summary
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
507
|
+
"""--json-brief: the same success document with the bulky parts replaced by what a caller
|
|
508
|
+
acts on. Same keys, same meanings -- `commands` becomes the count of the command lines,
|
|
509
|
+
`probe` becomes `summary` -- plus every tool-specific key the tool itself passed to emit().
|
|
510
|
+
Failures are untouched: die() prints the full failure document either way."""
|
|
511
|
+
brief: Dict[str, Any] = {"status": doc["status"], "output": doc["output"], "dry_run": doc["dry_run"],
|
|
512
|
+
"verified": doc.get("verified", False)}
|
|
513
|
+
summary = _brief_summary(meta, doc)
|
|
514
|
+
if summary:
|
|
515
|
+
brief["summary"] = summary
|
|
516
|
+
brief["commands"] = len(doc.get("commands") or [])
|
|
517
|
+
for key, value in doc.items():
|
|
518
|
+
if key not in brief and key not in _BRIEF_DROP:
|
|
519
|
+
brief[key] = value
|
|
520
|
+
return brief
|
|
521
|
+
|
|
522
|
+
|
|
441
523
|
PLAN_VERSION = 1
|
|
442
524
|
_PLAN_STRIP = ("--plan", "--dry-run", "--json")
|
|
443
525
|
|
|
444
526
|
|
|
445
527
|
def _plan_at_exit() -> None:
|
|
446
|
-
|
|
528
|
+
ctx = _CURRENT_CTX or STATE
|
|
529
|
+
if ctx.plan and not ctx.plan_written:
|
|
447
530
|
try:
|
|
448
|
-
write_plan(
|
|
531
|
+
write_plan(ctx.plan, None, {}, ctx=ctx)
|
|
449
532
|
except SystemExit:
|
|
450
533
|
pass
|
|
451
534
|
|
|
@@ -468,13 +551,13 @@ def fingerprint(path: str) -> Dict[str, Any]:
|
|
|
468
551
|
return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
|
|
469
552
|
|
|
470
553
|
|
|
471
|
-
def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]:
|
|
554
|
+
def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Optional[Context]" = None) -> List[str]:
|
|
472
555
|
"""Every existing file the plan depends on: the `-i` inputs of the planned commands, any
|
|
473
556
|
existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
|
|
474
557
|
inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
|
|
475
558
|
import shlex
|
|
476
559
|
seen: List[str] = []
|
|
477
|
-
for a in list(argv) + list(STATE.plan_inputs):
|
|
560
|
+
for a in list(argv) + list((ctx or STATE).plan_inputs):
|
|
478
561
|
if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
|
|
479
562
|
seen.append(a)
|
|
480
563
|
for line in commands:
|
|
@@ -488,10 +571,14 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]
|
|
|
488
571
|
return seen
|
|
489
572
|
|
|
490
573
|
|
|
491
|
-
def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
574
|
+
def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
|
|
492
575
|
"""The dry run as an artifact: what will run, on which exact inputs, producing what, checked
|
|
493
|
-
how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
|
|
576
|
+
how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
|
|
577
|
+
|
|
578
|
+
`ctx` is the Context whose commands and inputs the plan describes (emit()/die() pass the one
|
|
579
|
+
they were given); omitted, it is the process-global STATE as before."""
|
|
494
580
|
import datetime
|
|
581
|
+
ctx = ctx or STATE
|
|
495
582
|
argv = [a for a in sys.argv[1:]]
|
|
496
583
|
cleaned: List[str] = []
|
|
497
584
|
skip = False
|
|
@@ -520,8 +607,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
|
520
607
|
"tool": tool,
|
|
521
608
|
"argv": cleaned,
|
|
522
609
|
"cwd": os.getcwd(),
|
|
523
|
-
"inputs": [fingerprint(p) for p in _plan_inputs(
|
|
524
|
-
"commands": list(
|
|
610
|
+
"inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
|
|
611
|
+
"commands": list(ctx.commands),
|
|
525
612
|
"output": os.path.abspath(output) if output else None,
|
|
526
613
|
"verify": verify,
|
|
527
614
|
"notes": list(extra.get("notes") or []),
|
|
@@ -534,8 +621,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
|
534
621
|
os.replace(tmp, path)
|
|
535
622
|
except OSError as exc:
|
|
536
623
|
die(f"cannot write plan {path}: {exc}", kind="output")
|
|
537
|
-
|
|
538
|
-
info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
|
|
624
|
+
ctx.plan_written = True
|
|
625
|
+
info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}", ctx)
|
|
539
626
|
return path
|
|
540
627
|
|
|
541
628
|
|
|
@@ -1018,7 +1105,6 @@ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool
|
|
|
1018
1105
|
"""RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
|
|
1019
1106
|
block must be the same length for the correlation); remove_mean subtracts the envelope's mean
|
|
1020
1107
|
(sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
|
|
1021
|
-
import math
|
|
1022
1108
|
step = max(1, int(step))
|
|
1023
1109
|
n = len(samples)
|
|
1024
1110
|
stop = n - step + 1 if full_blocks_only else n
|
|
@@ -1497,7 +1583,11 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
1497
1583
|
raise ValueError(f"bad time: {value}")
|
|
1498
1584
|
total = 0.0
|
|
1499
1585
|
for part in parts:
|
|
1500
|
-
|
|
1586
|
+
try:
|
|
1587
|
+
total = total * 60 + float(part)
|
|
1588
|
+
except ValueError:
|
|
1589
|
+
# not the interpreter's "could not convert string to float: 'zz'" (review 9)
|
|
1590
|
+
raise ValueError(f"'{value}': not a time")
|
|
1501
1591
|
return total
|
|
1502
1592
|
|
|
1503
1593
|
|
package/scripts/_contract.py
CHANGED
|
@@ -342,12 +342,15 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
342
342
|
if isinstance(action, argparse._HelpAction):
|
|
343
343
|
continue
|
|
344
344
|
prop: Dict[str, Any] = _json_type(action)
|
|
345
|
+
# add_common() parks a deprecated --crf's default aside (so an explicit flag is
|
|
346
|
+
# distinguishable from the default); the schema still advertises the real one
|
|
347
|
+
default = getattr(action, "deprecated_default", action.default)
|
|
345
348
|
if action.help and action.help != argparse.SUPPRESS:
|
|
346
|
-
prop["description"] = action.help % {"default":
|
|
349
|
+
prop["description"] = action.help % {"default": default} if "%(default)" in action.help else action.help
|
|
347
350
|
if action.choices:
|
|
348
351
|
prop["enum"] = list(action.choices)
|
|
349
|
-
if
|
|
350
|
-
prop["default"] =
|
|
352
|
+
if default not in (None, False, argparse.SUPPRESS):
|
|
353
|
+
prop["default"] = default
|
|
351
354
|
if action.option_strings:
|
|
352
355
|
prop["cli"] = list(action.option_strings)
|
|
353
356
|
if action.required:
|
|
@@ -927,6 +930,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
927
930
|
"semantics": "prints the ffmpeg command lines that would run; no output file is written",
|
|
928
931
|
**({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
|
|
929
932
|
"supports_json": "json" in schema["properties"],
|
|
933
|
+
# additive mirror of supports_json (1.10.2): --json-brief is the same document trimmed
|
|
934
|
+
"supports_json_brief": "json_brief" in schema["properties"],
|
|
930
935
|
"mutates_input": False,
|
|
931
936
|
"produces_artifact": meta["produces_artifact"],
|
|
932
937
|
"verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
|
|
@@ -1129,7 +1134,14 @@ def main() -> int:
|
|
|
1129
1134
|
else:
|
|
1130
1135
|
print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
|
|
1131
1136
|
print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
|
|
1132
|
-
|
|
1137
|
+
# counts, not the full capability list: the names of the ~60 available capabilities
|
|
1138
|
+
# answer no question a caller has (they are in `doctor --json .available` when one
|
|
1139
|
+
# does), while what is MISSING is the whole reason to run doctor (1.10.2 token diet).
|
|
1140
|
+
head = (f"{'ok' if d['ok'] else 'NOT ok'}: {len(d['available'])} capabilities available, "
|
|
1141
|
+
f"{len(d['missing'])} required missing, {len(d['missing_optional'])} optional missing")
|
|
1142
|
+
if d["unknown"]:
|
|
1143
|
+
head += f", {len(d['unknown'])} unknown"
|
|
1144
|
+
print(head)
|
|
1133
1145
|
print(f"missing required: {', '.join(d['missing']) or 'none'}")
|
|
1134
1146
|
print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
|
|
1135
1147
|
if d["unknown"]:
|
|
@@ -1139,9 +1151,11 @@ def main() -> int:
|
|
|
1139
1151
|
print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
|
|
1140
1152
|
gpu = d["gpu_encoders"]
|
|
1141
1153
|
if gpu["status"] == "parsed":
|
|
1142
|
-
print(f"GPU-backed encoders in this build: {
|
|
1154
|
+
print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
|
|
1143
1155
|
fonts = d["fonts"]
|
|
1144
|
-
print(f"default drawtext font '{fonts['default_font']}': {fonts['status']}
|
|
1156
|
+
print(f"default drawtext font '{fonts['default_font']}': {fonts['status']}"
|
|
1157
|
+
+ (f" ({fonts['detail']})" if fonts["status"] != "available" else ""))
|
|
1158
|
+
print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
|
|
1145
1159
|
for err in d["errors"]:
|
|
1146
1160
|
print(f"detection error: {err}", file=sys.stderr)
|
|
1147
1161
|
if d["ok"]:
|
package/scripts/batch.py
CHANGED
|
@@ -83,6 +83,9 @@ def run_step(argv: List[str]) -> bool:
|
|
|
83
83
|
if proc.returncode != 0:
|
|
84
84
|
info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
85
85
|
return False
|
|
86
|
+
for line in proc.stderr.splitlines():
|
|
87
|
+
if line.startswith("warning:"): # a step's deprecation notice is not swallowed by a success (review 9)
|
|
88
|
+
info(" " + line)
|
|
86
89
|
return True
|
|
87
90
|
|
|
88
91
|
|