ffmpeg-skill 0.10.0 → 0.12.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 +33 -9
- package/SKILL.md +77 -12
- package/bin/install.js +15 -1
- package/mcp/server.py +2 -0
- package/package.json +2 -2
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +122 -11
- package/scripts/_common.py +200 -12
- package/scripts/_contract.py +204 -12
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -24
- package/scripts/color.py +36 -9
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +60 -10
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +59 -5
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -0
- package/scripts/sync.py +1 -1
package/references/scripts.md
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
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`, `-o OUT
|
|
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`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`report` still run ffmpeg/ffprobe to measure or analyse (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
|
## Contents
|
|
6
6
|
- probe.py — inspect
|
|
7
7
|
- cut.py — cut / join segments
|
|
8
|
-
- fit.py — target duration and/or aspect
|
|
8
|
+
- fit.py — target duration and/or aspect, rotate/flip
|
|
9
|
+
- crop.py — crop to an exact pixel rectangle
|
|
10
|
+
- insert.py — still image to a timed silent clip, with Ken Burns zoom/pan
|
|
11
|
+
- background.py — generate a solid-colour or gradient clip
|
|
12
|
+
- reverse.py — reverse playback
|
|
13
|
+
- stabilize.py — motion stabilisation (vidstab)
|
|
14
|
+
- sequence.py — numbered/globbed image sequence to video
|
|
9
15
|
- silence.py — remove dead air / jump cuts
|
|
10
16
|
- join.py — concatenate with transitions
|
|
11
17
|
- render.py — the whole edit in one project.json
|
|
@@ -21,12 +27,13 @@ Every script prints the same information with `--help`; this file exists so the
|
|
|
21
27
|
- verify.py — real-footage verification kit
|
|
22
28
|
- look.py — see the result
|
|
23
29
|
- caption.py — subtitles (static, animated, karaoke)
|
|
24
|
-
- overlay.py — logo, image, title
|
|
30
|
+
- overlay.py — logo, image, title, video picture-in-picture, chroma key
|
|
25
31
|
- sync.py — offset detection, alignment, drift correction
|
|
26
32
|
- color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
|
|
27
33
|
- audio.py — clean-up, music, ducking, layout
|
|
28
34
|
- loudness.py — EBU R128 normalisation
|
|
29
35
|
- export.py — delivery presets
|
|
36
|
+
- proxy.py — low-bitrate proxy for analysis/preview
|
|
30
37
|
|
|
31
38
|
## Scripts
|
|
32
39
|
|
|
@@ -48,18 +55,90 @@ keyframes, instant, lossless); if the snapped result deviates more than
|
|
|
48
55
|
Multiple segments are concatenated in the order given. stderr reports whether
|
|
49
56
|
the result was "lossless stream copy" or "re-encoded".
|
|
50
57
|
|
|
51
|
-
### fit.py — target duration and/or aspect
|
|
58
|
+
### fit.py — target duration and/or aspect, rotate/flip
|
|
52
59
|
```
|
|
53
60
|
fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
|
|
54
|
-
[--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--pad-color black]]
|
|
55
|
-
[--fps N] [-o OUT]
|
|
61
|
+
[--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black]]
|
|
62
|
+
[--rotate 90|180|270] [--flip h|v] [--fps N] [-o OUT]
|
|
56
63
|
```
|
|
57
64
|
`speed` retimes video and audio together (pitch-preserving `atempo`); it
|
|
58
65
|
refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
|
|
59
66
|
(frame blending, fast) or `--smooth interpolate` (motion-compensated
|
|
60
67
|
`minterpolate`, fluid but roughly 10-20x slower than realtime). `trim` keeps
|
|
61
|
-
the head (or the middle with `--from-center`). `--
|
|
62
|
-
|
|
68
|
+
the head (or the middle with `--from-center`). `--width`/`--height` set the
|
|
69
|
+
output size: give one and the other follows the aspect (source aspect if
|
|
70
|
+
`--aspect` isn't also given); give both for an exact frame. `--rotate` applies
|
|
71
|
+
a new clockwise rotation (90/180/270 swap width/height for 90 and 270 — this
|
|
72
|
+
is separate from the rotation *metadata* fit.py already reads to size a
|
|
73
|
+
source correctly); `--flip h|v` mirrors the picture; both can combine, rotate
|
|
74
|
+
first. `--fps` forces a constant frame rate; VFR sources are conformed
|
|
75
|
+
automatically even without it.
|
|
76
|
+
|
|
77
|
+
### crop.py — crop to an exact pixel rectangle
|
|
78
|
+
```
|
|
79
|
+
crop.py INPUT --x X --y Y --width W --height H [-o OUT]
|
|
80
|
+
```
|
|
81
|
+
Crops to a literal `{x, y, width, height}` rectangle in source pixels —
|
|
82
|
+
distinct from `fit.py --fit crop`, which crops to an *aspect ratio* and picks
|
|
83
|
+
the rectangle for you. Use this when the rectangle is already known (a
|
|
84
|
+
face-detection box, a saved crop, a hand-picked region). The rectangle must
|
|
85
|
+
lie entirely inside the source frame (after accounting for display rotation);
|
|
86
|
+
`--width`/`--height` must be even (4:2:0 chroma) and are refused, never
|
|
87
|
+
rounded, if they aren't.
|
|
88
|
+
|
|
89
|
+
### insert.py — still image to a timed silent clip
|
|
90
|
+
```
|
|
91
|
+
insert.py IMAGE --duration T [--width W] [--height H] [--fps N]
|
|
92
|
+
[--zoom in|out [--zoom-amount 1.3]] [--pan left|right|up|down] [-o OUT]
|
|
93
|
+
```
|
|
94
|
+
Produces a silent, exact-duration clip from one image. `--width`/`--height`
|
|
95
|
+
resolve like `fit.py`'s (one given -> the other follows the image's aspect;
|
|
96
|
+
both given -> exact frame, scaled to fill and centre-cropped, never
|
|
97
|
+
distorted). `--zoom in|out` is a Ken Burns effect: a slow linear zoom across
|
|
98
|
+
the whole clip, ending (zoom in) or starting (zoom out) at `--zoom-amount`
|
|
99
|
+
(default 1.3). `--pan` drifts the visible window across the image while
|
|
100
|
+
zoomed — it needs `--zoom` (panning uses the extra image area a zoom exposes).
|
|
101
|
+
|
|
102
|
+
### background.py — generate a solid-colour or gradient clip
|
|
103
|
+
```
|
|
104
|
+
background.py -o OUT --duration T --width W --height H
|
|
105
|
+
[--color C | --gradient C1:C2 [--angle DEG]]
|
|
106
|
+
```
|
|
107
|
+
No input file: ffmpeg's own `color`/`gradients` source filters generate the
|
|
108
|
+
clip directly. For a title-card background, a placeholder layer, or a base
|
|
109
|
+
for `overlay.py` to composite onto. `--width`/`--height` must be even.
|
|
110
|
+
|
|
111
|
+
### reverse.py — reverse playback
|
|
112
|
+
```
|
|
113
|
+
reverse.py INPUT [--no-audio] [-o OUT]
|
|
114
|
+
```
|
|
115
|
+
Reverses video (and audio, unless `--no-audio`) with ffmpeg's `reverse`/
|
|
116
|
+
`areverse` filters, which buffer the whole clip in memory — keep this to
|
|
117
|
+
clips it makes sense to reverse (seconds to a couple of minutes), not
|
|
118
|
+
something this tool limits for you.
|
|
119
|
+
|
|
120
|
+
### stabilize.py — motion stabilisation
|
|
121
|
+
```
|
|
122
|
+
stabilize.py INPUT [--shakiness 1-10] [--smoothing N] [--zoom 0-100] [-o OUT]
|
|
123
|
+
```
|
|
124
|
+
Two-pass `vidstabdetect`/`vidstabtransform`: pass 1 analyses camera motion to
|
|
125
|
+
a temporary transforms file (deleted after the run), pass 2 smooths and
|
|
126
|
+
re-renders. `--shakiness` (default 5) trades analysis time for how much
|
|
127
|
+
motion it looks for; `--smoothing` (default 15) is how many neighbouring
|
|
128
|
+
frames the camera path is averaged over; `--zoom` crops in slightly to hide
|
|
129
|
+
the black edges stabilizing can introduce. Needs an ffmpeg built with
|
|
130
|
+
`--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
|
|
131
|
+
missing (a plain Homebrew ffmpeg build, for example) rather than failing at
|
|
132
|
+
run time.
|
|
133
|
+
|
|
134
|
+
### sequence.py — numbered/globbed image sequence to video
|
|
135
|
+
```
|
|
136
|
+
sequence.py --dir DIR --pattern "frame_%04d.png"|"*.png" --fps N
|
|
137
|
+
[--start-number N] [--width W] [--height H] [-o OUT]
|
|
138
|
+
```
|
|
139
|
+
Turns a numbered or glob-matched set of still images into a video. The match
|
|
140
|
+
is checked on disk before ffmpeg runs (an empty match or a missing first
|
|
141
|
+
frame is refused here, not discovered from an opaque ffmpeg error).
|
|
63
142
|
|
|
64
143
|
### silence.py — remove dead air / jump cuts
|
|
65
144
|
```
|
|
@@ -204,25 +283,47 @@ frame like an editor would. Use `--compare` to show before/after to the user.
|
|
|
204
283
|
### caption.py — subtitles (static, animated, karaoke)
|
|
205
284
|
```
|
|
206
285
|
caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
|
|
286
|
+
[--mode burn|mux] [--audio-stream N] [--fps N]
|
|
207
287
|
[--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
|
|
208
288
|
[--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
|
|
209
289
|
[--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
|
|
210
290
|
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
211
291
|
```
|
|
212
|
-
Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines
|
|
292
|
+
Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`,
|
|
293
|
+
or `00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode` (`hh:mm:ss:ff`, frame count
|
|
294
|
+
converted with `--fps`, or the input video's own fps when `--input` is given and `--fps` is
|
|
295
|
+
not — a timecode-shaped cue with no fps available is refused rather than misread as plain text).
|
|
213
296
|
Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
|
|
214
297
|
`--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
|
|
215
298
|
SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
|
|
216
299
|
word from `--color` to `--highlight-color` evenly across the cue (word timing
|
|
217
300
|
is distributed, not transcribed). The ASS is kept next to the output so the
|
|
218
301
|
user can hand-tune timings and re-run with `--ass`.
|
|
302
|
+
`--mode burn` (default) renders subtitles into the picture and always
|
|
303
|
+
re-encodes both streams. `--mode mux` copies video and audio untouched and
|
|
304
|
+
adds the SRT as a separate, player-toggleable subtitle stream instead —
|
|
305
|
+
takes only a plain SRT (`--srt`/`--text`/`--transcribe`, not `--ass`, since
|
|
306
|
+
styling has no soft-subtitle equivalent) and no `--animate`/`--karaoke`. The
|
|
307
|
+
subtitle codec follows the output container: `mov_text` for `.mp4`/`.m4v`/`.mov`,
|
|
308
|
+
`srt` for `.mkv`, `webvtt` for `.webm`.
|
|
309
|
+
`--audio-stream N` (default 0, the first track) picks which audio stream of a
|
|
310
|
+
multi-track input (dubbed languages, M&E stems) is kept — applies to burn's
|
|
311
|
+
re-encoded audio, mux's stream-copied audio, `--transcribe`'s speech-to-text
|
|
312
|
+
source, and karaoke's energy-timing analysis alike, so all four agree on the
|
|
313
|
+
same track instead of each silently defaulting to whichever one ffmpeg's own
|
|
314
|
+
stream selection would have picked.
|
|
219
315
|
|
|
220
|
-
### overlay.py — logo, image, title
|
|
316
|
+
### overlay.py — logo, image, title, video picture-in-picture, chroma key
|
|
221
317
|
```
|
|
222
318
|
overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
|
|
319
|
+
| --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
|
|
223
320
|
[--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
|
|
224
321
|
```
|
|
225
|
-
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
|
|
322
|
+
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
|
|
323
|
+
playing. `--video` composites a second video as a picture-in-picture layer
|
|
324
|
+
(same position/scale/opacity/time-range knobs as `--image`); only the main
|
|
325
|
+
input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
|
|
326
|
+
(with `--video`) keys out that colour first for green-screen compositing.
|
|
226
327
|
|
|
227
328
|
### sync.py — offset detection, alignment, drift correction
|
|
228
329
|
```
|
|
@@ -292,3 +393,13 @@ export.py --list
|
|
|
292
393
|
Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
|
|
293
394
|
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
294
395
|
|
|
396
|
+
### proxy.py — low-bitrate proxy for analysis/preview
|
|
397
|
+
```
|
|
398
|
+
proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
|
|
399
|
+
```
|
|
400
|
+
Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
|
|
401
|
+
factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
|
|
402
|
+
x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
|
|
403
|
+
run `color.py --to-sdr` first if SDR is wanted). Only executes the spec
|
|
404
|
+
given — does not decide which asset to proxy or what for.
|
|
405
|
+
|
package/scripts/_common.py
CHANGED
|
@@ -41,12 +41,52 @@ INSTALL_HINTS = {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
# `kind` (below) has been the only machine-readable failure axis since 0.1: a flat, 4-value
|
|
45
|
+
# vocabulary (input / missing_tool / ffmpeg / output) set at the ~7 call sites that ever pass one
|
|
46
|
+
# explicitly, defaulting to "input" everywhere else. `ERROR_CODE` is an additive, purely
|
|
47
|
+
# informational refinement layered on top for agents that want a stable enum to switch on instead
|
|
48
|
+
# of pattern-matching `kind` strings -- it is a static 1:1 relabelling of the exact same 4 buckets,
|
|
49
|
+
# not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
|
|
50
|
+
# distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
|
|
51
|
+
# split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
|
|
52
|
+
# bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
|
|
53
|
+
# and every "kind": "input" failure covers both a missing file and a bad flag value alike. Adding
|
|
54
|
+
# codes for distinctions the code can't actually make would be guessing, not reporting -- if a
|
|
55
|
+
# future call site can genuinely tell capability-missing apart from bad-argument (see doctor()'s
|
|
56
|
+
# available/missing/unknown states, which already model this for detection but aren't wired into
|
|
57
|
+
# any die() call), split ERROR_CODE then, with evidence, not speculatively now.
|
|
58
|
+
ERROR_CODE = {
|
|
59
|
+
"input": "INPUT_INVALID",
|
|
60
|
+
"missing_tool": "DEPENDENCY_MISSING",
|
|
61
|
+
"ffmpeg": "FFMPEG_EXECUTION_FAILED",
|
|
62
|
+
"output": "OUTPUT_INVALID",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
|
|
66
|
+
# always deterministic (the same bad path or absent binary fails identically every time), and a
|
|
67
|
+
# "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
|
|
68
|
+
# condition (full disk, OOM) rather than a bad command -- is never distinguishable from a
|
|
69
|
+
# deterministic content-cause failure without exit-code/stderr sniffing this codebase does not do.
|
|
70
|
+
# Reporting retryable=True for a code we can't actually back up would invite an agent into a blind
|
|
71
|
+
# retry loop against a command that will fail the same way every time; false-for-everything is the
|
|
72
|
+
# honest answer until real sniffing exists to justify anything else.
|
|
73
|
+
ERROR_RETRYABLE = False
|
|
74
|
+
|
|
75
|
+
|
|
44
76
|
def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
45
77
|
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
46
78
|
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
|
|
47
79
|
sys.stderr.write(f"error: {msg}\n")
|
|
48
80
|
if STATE.json:
|
|
49
|
-
print_json({
|
|
81
|
+
print_json({
|
|
82
|
+
"status": "failed", "exit_code": code,
|
|
83
|
+
"error": {
|
|
84
|
+
"kind": kind, "message": msg,
|
|
85
|
+
"code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
|
|
86
|
+
"retryable": ERROR_RETRYABLE,
|
|
87
|
+
},
|
|
88
|
+
"commands": list(STATE.commands),
|
|
89
|
+
})
|
|
50
90
|
sys.exit(code)
|
|
51
91
|
|
|
52
92
|
|
|
@@ -135,10 +175,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
135
175
|
|
|
136
176
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
137
177
|
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
178
|
+
meta: Dict[str, Any] = {}
|
|
179
|
+
if output and not STATE.dry_run:
|
|
180
|
+
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
138
181
|
if STATE.json:
|
|
139
182
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
140
|
-
if
|
|
141
|
-
doc["probe"] =
|
|
183
|
+
if meta:
|
|
184
|
+
doc["probe"] = meta
|
|
142
185
|
doc.update(extra)
|
|
143
186
|
print_json(doc)
|
|
144
187
|
elif output:
|
|
@@ -153,11 +196,59 @@ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
|
|
|
153
196
|
return os.path.basename(cmd[0]).startswith("ffmpeg")
|
|
154
197
|
|
|
155
198
|
|
|
199
|
+
def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
200
|
+
"""A failed ffmpeg command can still have opened its output container (muxer header
|
|
201
|
+
written) before erroring out mid-stream -- unlike a failure that happens before ffmpeg ever
|
|
202
|
+
touches the output path (a bad filter argument, a missing input), which never creates the
|
|
203
|
+
file at all. Both are reported the same way (status: failed), but only the first case used
|
|
204
|
+
to leave a stray, usually-0-byte file behind: verify_output()'s cleanup only runs on the
|
|
205
|
+
success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
|
|
206
|
+
to write so a caller scanning the output directory after a failure never mistakes a partial
|
|
207
|
+
artifact for a real (if unverified) one."""
|
|
208
|
+
output = cmd[-1]
|
|
209
|
+
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
if os.path.exists(output):
|
|
213
|
+
os.remove(output)
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
156
218
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
219
|
+
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
220
|
+
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
157
221
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
158
222
|
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
|
|
159
223
|
|
|
160
224
|
|
|
225
|
+
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
226
|
+
"""Refuse an ffmpeg command whose output path resolves to the same file as one of its
|
|
227
|
+
inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
|
|
228
|
+
strings; a relative/absolute pair, a leading "./", a redundant ".." segment, or a symlink
|
|
229
|
+
all resolve to the same file but pass that check, so "-o ./same.mp4" on an input opened as
|
|
230
|
+
"same.mp4" would otherwise silently let ffmpeg's -y clobber the source mid-encode. Every
|
|
231
|
+
write-side script routes through this one run() choke point rather than each computing its
|
|
232
|
+
own output path defensively, so the guard lives here once instead of at 25+ call sites."""
|
|
233
|
+
output = cmd[-1]
|
|
234
|
+
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
235
|
+
return
|
|
236
|
+
try:
|
|
237
|
+
out_real = os.path.realpath(output)
|
|
238
|
+
except OSError:
|
|
239
|
+
return
|
|
240
|
+
for i, a in enumerate(cmd):
|
|
241
|
+
if a == "-i" and i + 1 < len(cmd):
|
|
242
|
+
inp = cmd[i + 1]
|
|
243
|
+
try:
|
|
244
|
+
if os.path.realpath(inp) == out_real:
|
|
245
|
+
die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
|
|
246
|
+
f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
|
|
247
|
+
kind="input")
|
|
248
|
+
except OSError:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
|
|
161
252
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
162
253
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
163
254
|
|
|
@@ -167,6 +258,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
167
258
|
"""
|
|
168
259
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
169
260
|
if is_ffmpeg:
|
|
261
|
+
_check_no_overwrite_input(cmd)
|
|
170
262
|
STATE.commands.append(_cmdline(cmd))
|
|
171
263
|
if not quiet:
|
|
172
264
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
@@ -180,8 +272,17 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
180
272
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
181
273
|
"""Plain run with stdout/stderr captured."""
|
|
182
274
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
183
|
-
if
|
|
184
|
-
|
|
275
|
+
if proc.returncode != 0:
|
|
276
|
+
# Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
|
|
277
|
+
# path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
|
|
278
|
+
# check=False so they can compose their own die() message from proc.stderr, but the
|
|
279
|
+
# partial-output risk is identical either way -- and for a script that retries into the
|
|
280
|
+
# same output path after a check=False failure (e.g. color.py's --retag copy-then-
|
|
281
|
+
# reencode fallback), removing the stale partial first is strictly safer than leaving it
|
|
282
|
+
# for -y to overwrite.
|
|
283
|
+
_cleanup_partial_output(cmd)
|
|
284
|
+
if check:
|
|
285
|
+
_fail(cmd, proc.returncode, proc.stderr)
|
|
185
286
|
return proc
|
|
186
287
|
|
|
187
288
|
|
|
@@ -216,8 +317,10 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
216
317
|
_, err = proc.communicate()
|
|
217
318
|
if last:
|
|
218
319
|
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
219
|
-
if
|
|
220
|
-
|
|
320
|
+
if proc.returncode != 0:
|
|
321
|
+
_cleanup_partial_output(cmd)
|
|
322
|
+
if check:
|
|
323
|
+
_fail(cmd, proc.returncode, err)
|
|
221
324
|
return subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
222
325
|
|
|
223
326
|
|
|
@@ -233,12 +336,56 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
|
|
|
233
336
|
return cmd
|
|
234
337
|
|
|
235
338
|
|
|
236
|
-
|
|
237
|
-
|
|
339
|
+
MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".gif", ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".png", ".jpg", ".jpeg"}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _output_failed(path: str, why: str) -> "None":
|
|
343
|
+
"""An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
|
|
344
|
+
0-byte file behind that a later step could mistake for a result."""
|
|
345
|
+
try:
|
|
346
|
+
if os.path.exists(path) and os.path.getsize(path) == 0:
|
|
347
|
+
os.remove(path)
|
|
348
|
+
why += " (empty file removed)"
|
|
349
|
+
except OSError:
|
|
350
|
+
pass
|
|
351
|
+
die(f"output verification failed: {path}: {why}", kind="output")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def verify_output(path: str) -> Dict[str, Any]:
|
|
355
|
+
"""The success criterion for every writing tool: the file exists, is not empty and ffprobe
|
|
356
|
+
can read at least one stream from it. Non-media artifacts (srt, edl, html, md) only need to
|
|
357
|
+
exist and be non-empty. Returns the probe (empty dict for non-media)."""
|
|
358
|
+
if not os.path.exists(path):
|
|
359
|
+
_output_failed(path, "not written")
|
|
360
|
+
if os.path.getsize(path) == 0:
|
|
361
|
+
_output_failed(path, "0 bytes")
|
|
362
|
+
if os.path.splitext(path)[1].lower() not in MEDIA_EXT:
|
|
363
|
+
return {}
|
|
364
|
+
meta = probe(path, role="output")
|
|
365
|
+
if not meta.get("video") and not meta.get("audio"):
|
|
366
|
+
_output_failed(path, "no video or audio stream")
|
|
367
|
+
return meta
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
371
|
+
"""Return a compact, script-friendly description of a media file.
|
|
372
|
+
|
|
373
|
+
role="output" marks a file this tool just wrote: a read failure is then reported as an
|
|
374
|
+
output-verification failure (kind "output") instead of an input problem."""
|
|
238
375
|
if not os.path.exists(path):
|
|
376
|
+
if role == "output" and not STATE["dry_run"]:
|
|
377
|
+
_output_failed(path, "not written")
|
|
239
378
|
if STATE["dry_run"]:
|
|
379
|
+
# width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
|
|
380
|
+
# below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
|
|
381
|
+
# probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
|
|
382
|
+
# which some tools' dry-run summary line echoed verbatim as if it were a real computed
|
|
383
|
+
# preview (#77). That was reverted once, because a couple of call sites divided by these
|
|
384
|
+
# values for aspect-ratio math and crashed on a real 0 (join.py, fit.py); those call
|
|
385
|
+
# sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
|
|
386
|
+
# by it, so the stub can finally report the honest, unknown value.
|
|
240
387
|
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
241
|
-
"video": {"codec": None, "width":
|
|
388
|
+
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
|
|
242
389
|
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
243
390
|
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
|
|
244
391
|
die(f"input not found: {path}")
|
|
@@ -249,6 +396,8 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
249
396
|
check=False,
|
|
250
397
|
)
|
|
251
398
|
if proc.returncode != 0:
|
|
399
|
+
if role == "output":
|
|
400
|
+
_output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
|
|
252
401
|
die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
|
|
253
402
|
raw = json.loads(proc.stdout or "{}")
|
|
254
403
|
fmt = raw.get("format", {})
|
|
@@ -274,6 +423,13 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
274
423
|
"video": None,
|
|
275
424
|
"audio": None,
|
|
276
425
|
"subtitle_streams": len(subs),
|
|
426
|
+
# every subtitle stream in file order: index n here is `-map 0:s:n`
|
|
427
|
+
"subtitle_stream_details": [{
|
|
428
|
+
"index": n,
|
|
429
|
+
"codec": s.get("codec_name"),
|
|
430
|
+
"language": (s.get("tags") or {}).get("language"),
|
|
431
|
+
"title": (s.get("tags") or {}).get("title"),
|
|
432
|
+
} for n, s in enumerate(subs)],
|
|
277
433
|
}
|
|
278
434
|
if video:
|
|
279
435
|
r_rate = _fraction(video.get("r_frame_rate"))
|
|
@@ -351,12 +507,30 @@ def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> s
|
|
|
351
507
|
return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
|
|
352
508
|
|
|
353
509
|
|
|
354
|
-
|
|
355
|
-
"""
|
|
510
|
+
class MissingFpsError(ValueError):
|
|
511
|
+
"""parse_time() saw an hh:mm:ss:ff SMPTE timecode but no fps was given to convert it -- distinct
|
|
512
|
+
from a plain ValueError so a caller that falls back to treating unparseable text as a literal
|
|
513
|
+
line (e.g. caption.py's free-text cue format) can still fail loudly on this one, instead of
|
|
514
|
+
silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
518
|
+
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
519
|
+
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
356
520
|
v = value.strip().replace(",", ".")
|
|
357
521
|
if not v:
|
|
358
522
|
raise ValueError("empty time")
|
|
359
523
|
parts = v.split(":")
|
|
524
|
+
if len(parts) == 4:
|
|
525
|
+
if fps is None or fps <= 0:
|
|
526
|
+
raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds")
|
|
527
|
+
h, m, s, f = parts
|
|
528
|
+
if "." in f:
|
|
529
|
+
raise ValueError(f"bad SMPTE timecode: {value}")
|
|
530
|
+
frame, whole_fps = int(f), int(round(fps))
|
|
531
|
+
if not (0 <= frame < whole_fps):
|
|
532
|
+
raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
|
|
533
|
+
return int(h) * 3600 + int(m) * 60 + int(s) + frame / fps
|
|
360
534
|
if len(parts) > 3:
|
|
361
535
|
raise ValueError(f"bad time: {value}")
|
|
362
536
|
total = 0.0
|
|
@@ -375,6 +549,20 @@ def fmt_srt_time(seconds: float) -> str:
|
|
|
375
549
|
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
376
550
|
|
|
377
551
|
|
|
552
|
+
def fmt_smpte_time(seconds: float, fps: float) -> str:
|
|
553
|
+
"""SMPTE non-drop-frame timecode 'hh:mm:ss:ff' for a real fps (not the fractional NTSC rates
|
|
554
|
+
-- 29.97/59.94 need drop-frame counting to stay wall-clock accurate, which this does not do)."""
|
|
555
|
+
if seconds < 0:
|
|
556
|
+
seconds = 0.0
|
|
557
|
+
whole_fps = int(round(fps))
|
|
558
|
+
total_frames = int(round(seconds * fps))
|
|
559
|
+
frame = total_frames % whole_fps
|
|
560
|
+
secs_total = total_frames // whole_fps
|
|
561
|
+
h, rem = divmod(secs_total, 3600)
|
|
562
|
+
m, s = divmod(rem, 60)
|
|
563
|
+
return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
|
|
564
|
+
|
|
565
|
+
|
|
378
566
|
def escape_filter_path(path: str) -> str:
|
|
379
567
|
"""Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
|
|
380
568
|
|