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.
@@ -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`). `--fps` forces a constant frame
62
- rate; VFR sources are conformed automatically even without it.
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 playing.
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
+
@@ -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({"status": "failed", "error": {"kind": kind, "message": msg}})
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 output and not STATE.dry_run and os.path.exists(output):
141
- doc["probe"] = probe(output)
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 check and proc.returncode != 0:
184
- _fail(cmd, proc.returncode, proc.stderr)
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 check and proc.returncode != 0:
220
- _fail(cmd, proc.returncode, err)
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
- def probe(path: str) -> Dict[str, Any]:
237
- """Return a compact, script-friendly description of a media file."""
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": 1920, "height": 1080, "fps": 30.0, "pix_fmt": None, "hdr": False,
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
- def parse_time(value: str) -> float:
355
- """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250') or SRT '00:01:30,250'."""
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