ffmpeg-skill 2.7.0 → 2.8.1

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.
@@ -73,8 +73,17 @@ def _set_current_ctx(ctx: "Context") -> None:
73
73
 
74
74
  ENV_HW_NOTE = ("VideoToolbox chosen by FFMPEG_SKILL_HW=1 (machine default; ~1.1-3.5x the bytes of x264/x265 "
75
75
  "at matched quality); rerun with --no-hw for a final deliverable")
76
- ENV_NOT_FOR_DELIVERY_NOTE = ("FFMPEG_SKILL_HW=1 is not applied to export.py's delivery presets (a delivered file is where "
77
- "VideoToolbox's larger files cost most); pass --hw to put this export on the GPU")
76
+ ENV_NOT_FOR_DELIVERY_BASE = ("FFMPEG_SKILL_HW=1 is not applied to export.py's delivery presets (a delivered file is where "
77
+ "VideoToolbox's larger files cost most)")
78
+ ENV_NOT_FOR_DELIVERY_NOTE = ENV_NOT_FOR_DELIVERY_BASE + "; pass --hw to put this export on the GPU"
79
+
80
+
81
+ def env_not_for_delivery_note() -> str:
82
+ """The note for an export the variable left on the CPU: the advice to pass --hw only on a
83
+ machine that can take it (the same advice on a Linux box would send the caller to a flag that
84
+ then reports the CPU encode)."""
85
+ from _common import decision # the same name the --hw decisions (and their tests) stand the platform in on
86
+ return ENV_NOT_FOR_DELIVERY_NOTE if decision.hw_platform_reason() is None else ENV_NOT_FOR_DELIVERY_BASE
78
87
 
79
88
 
80
89
  def _video_encoders(commands: Sequence[str]) -> "Tuple[Optional[str], Optional[str]]":
@@ -120,7 +129,7 @@ def hw_report(ctx: "Context", enc: Optional[str]) -> Dict[str, Any]:
120
129
  # (once: a stage that ran on it already said so)
121
130
  notes.append(ENV_HW_NOTE)
122
131
  if ctx.hw_env_ignored and enc not in (None, "copy"):
123
- notes.append(ENV_NOT_FOR_DELIVERY_NOTE)
132
+ notes.append(env_not_for_delivery_note())
124
133
  return {"requested": requested, "source": source, "used": used,
125
134
  "fallback": bool(ctx.hw_fallback) or any(st.get("fallback") for st in stages), "notes": notes}
126
135
 
@@ -9,6 +9,7 @@ import json
9
9
  import math
10
10
  import os
11
11
  import re
12
+ import statistics
12
13
  from fractions import Fraction
13
14
  from typing import Any, Dict, List, Optional, Sequence, Tuple
14
15
  from _common.emit import die
@@ -285,6 +286,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
285
286
  "nb_frames": _to_int(video.get("nb_frames")),
286
287
  # the stream's own length (a container's also counts audio priming and a longer track)
287
288
  "duration": _to_float(video.get("duration")),
289
+ # where the stream starts; a video stream that starts after its audio (or the reverse)
290
+ # is how a lossless cut's A/V skew shows up
291
+ "start_time": _to_float(video.get("start_time")),
288
292
  "bitrate": _to_int(video.get("bit_rate")),
289
293
  }
290
294
  if audio:
@@ -295,6 +299,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
295
299
  "sample_rate": _to_int(audio.get("sample_rate")),
296
300
  "bitrate": _to_int(audio.get("bit_rate")),
297
301
  "duration": _to_float(audio.get("duration")),
302
+ "start_time": _to_float(audio.get("start_time")),
298
303
  }
299
304
  # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
300
305
  out["audio_streams"] = [{
@@ -343,6 +348,93 @@ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
343
348
  return sorted(set(out))
344
349
 
345
350
 
351
+ # frame timing: is a stream's frame rate really variable? (cut.py's VFR guard)
352
+ TIMING_WINDOWS = 5 # sampled windows per file, fewer on a short one
353
+ TIMING_WINDOW_SECONDS = 6
354
+ TIMING_MIN_DELTAS = 20 # fewer measured intervals than this is no evidence either way
355
+
356
+
357
+ def complete_pts(packets: Sequence[Dict[str, Any]]) -> List[Any]:
358
+ """The pts of `packets` (as read, in decode order) whose frames are all there: those at or
359
+ before the last dts read. A read that stops just after a frame stored ahead of the B-frames
360
+ shown before it has missed those B-frames, and their gap would read as a dropped frame; every
361
+ frame shown by the last dts was decoded by then, so it was read. (FFmpeg 9.0's interval reads
362
+ measured whole on MP4; this keeps a demuxer that stops elsewhere from faking VFR.)"""
363
+ dts = [p["dts"] for p in packets if isinstance(p.get("dts"), int)]
364
+ pts = [p.get("pts") for p in packets]
365
+ return pts if not dts else [t for t in pts if isinstance(t, int) and t <= max(dts)]
366
+
367
+
368
+ def classify_frame_timing(windows: Sequence[Sequence[Any]], time_base: Any) -> Dict[str, Any]:
369
+ """Whether the frame timing in sampled windows is constant. `windows` are lists of pts in
370
+ `time_base` ticks, the first starting at the file's first packet.
371
+
372
+ Each window's pts are sorted (decode order stores B-frames out of sequence) and differenced,
373
+ with repeats and missing values dropped. The file-start window also drops its first interval:
374
+ an edit list or encoder priming can stretch it without the frame rate being variable.
375
+ `sampled_cfr` needs every interval within one tick (at least 1 ms) of its window's median and
376
+ the window medians to agree, so a single dropped or irregular frame is `vfr`. The tolerance
377
+ never exceeds half a frame: a tick that coarse (AVI's 1/fps) cannot round an interval, and one
378
+ tick of slack there would pass a dropped frame. Fewer than
379
+ TIMING_MIN_DELTAS intervals is `inconclusive`. What happens between the windows is not seen."""
380
+ result: Dict[str, Any] = {"measured": "inconclusive", "windows": len(windows), "deltas": 0}
381
+ tick = _fraction(str(time_base)) if time_base else None
382
+ if not tick or tick <= 0:
383
+ return result
384
+ per_window: List[List[float]] = []
385
+ for i, pts in enumerate(windows):
386
+ vals = sorted(float(p) for p in pts if isinstance(p, (int, float)) and math.isfinite(p))
387
+ deltas = [(b - a) * float(tick) for a, b in zip(vals, vals[1:]) if b != a]
388
+ per_window.append(deltas[1:] if i == 0 else deltas)
389
+ per_window = [d for d in per_window if d]
390
+ result["deltas"] = sum(len(d) for d in per_window)
391
+ if result["deltas"] < TIMING_MIN_DELTAS:
392
+ return result
393
+ medians = [statistics.median(d) for d in per_window]
394
+ tol = min(max(0.001, float(tick) + 1e-6), min(medians) / 2)
395
+ steady = all(abs(x - m) <= tol for d, m in zip(per_window, medians) for x in d)
396
+ result["measured"] = "sampled_cfr" if steady and max(medians) - min(medians) <= tol else "vfr"
397
+ return result
398
+
399
+
400
+ def timing_window_starts(duration: float, start: float = 0.0) -> List[float]:
401
+ """Up to TIMING_WINDOWS non-overlapping window starts spread over the file, the first on its
402
+ first packet. -read_intervals takes timestamps, so a file that starts at 10 s is read from 10."""
403
+ n = max(1, min(TIMING_WINDOWS, int((duration or 0.0) // TIMING_WINDOW_SECONDS)))
404
+ span = (duration or 0.0) - TIMING_WINDOW_SECONDS
405
+ return [round(start + (span * i / (n - 1) if n > 1 else 0.0), 3) for i in range(n)]
406
+
407
+
408
+ def measure_frame_timing(path: str, duration: Optional[float], start: float = 0.0) -> Dict[str, Any]:
409
+ """classify_frame_timing over sampled windows of `path`'s first video stream that is not cover
410
+ art (probe()'s choice): one packet-only ffprobe per window, no decoding. An interval read seeks
411
+ back to the keyframe before its start and, given a relative end, ends that far past the
412
+ keyframe -- on a long GOP every window read the file's first seconds -- so each window names
413
+ its absolute end and keeps only the frames from its start. The first window does not seek at
414
+ all: a seek to the start of an edit-listed MP4 skips its keyframe, stored before the start
415
+ with a negative pts, and loses the frames that depend on it (seeks near the start behave
416
+ erratically, so no margin is safe). The cost: a file whose video begins long after its first
417
+ packet is demuxed, not decoded, up to the video start. An ffprobe failure is `inconclusive`."""
418
+ ffprobe = require_tool("ffprobe")
419
+ windows: List[List[Any]] = []
420
+ time_base = None
421
+ for i, t in enumerate(timing_window_starts(duration or 0.0, start)):
422
+ proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-show_packets",
423
+ "-show_entries", "packet=pts,dts:stream=time_base", "-of", "json",
424
+ "-read_intervals", f"{t if i else ''}%{t + TIMING_WINDOW_SECONDS:.3f}", path], quiet=True, check=False)
425
+ try:
426
+ doc = json.loads(proc.stdout) if proc.returncode == 0 else None
427
+ except ValueError:
428
+ doc = None
429
+ if not isinstance(doc, dict):
430
+ return {"measured": "inconclusive", "windows": len(windows), "deltas": 0}
431
+ time_base = time_base or ((doc.get("streams") or [{}])[0] or {}).get("time_base")
432
+ tick = float(_fraction(str(time_base)) or 0) if time_base else 0.0
433
+ windows.append([p for p in complete_pts(doc.get("packets") or [])
434
+ if isinstance(p, int) and p * tick >= t - 1e-6])
435
+ return classify_frame_timing(windows, time_base)
436
+
437
+
346
438
  def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
347
439
  audio_stream: Optional[int] = None) -> Optional[Dict[str, float]]:
348
440
  """Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
@@ -885,9 +885,10 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
885
885
  why = videotoolbox_failure(proc.stderr or "")
886
886
  if why is not None:
887
887
  reason = f"VideoToolbox refused the encode: {why}"
888
+ # said before the retry, without a claim about its outcome: a retry that never
889
+ # returns (a timeout exits from inside _execute) leaves this line, which is true
890
+ _set_refusal_note(ctx, reason, "; retrying on the CPU encoder")
888
891
  info(reason + "; retrying on the CPU encoder")
889
- ctx.hw_notes.append(reason + "; re-encoded on the CPU")
890
- ctx.hw_fallback = True
891
892
  ctx.commands[-1] = _cmdline(cpu_cmd[:-1] + [cmd[-1]])
892
893
  if not quiet:
893
894
  # echoed like the first attempt, so a caller that reads the "$ " lines
@@ -895,6 +896,11 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
895
896
  info("$ " + ctx.commands[-1], ctx=ctx)
896
897
  proc, tried = even_retry(_execute(cpu_cmd), cpu_cmd)
897
898
  retried = True
899
+ # the outcome replaces the line above: a failure document must not claim a
900
+ # re-encode that did not happen, and hw.fallback is the same claim as a flag
901
+ ok = proc.returncode == 0
902
+ _set_refusal_note(ctx, reason, "; re-encoded on the CPU" if ok else "; the CPU retry failed too")
903
+ ctx.hw_fallback = ctx.hw_fallback or ok
898
904
  if proc.returncode != 0 and check:
899
905
  _fail(tried, proc.returncode, proc.stderr or "", ctx=ctx, retried=retried)
900
906
  if final and tmp:
@@ -910,6 +916,14 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
910
916
  return proc
911
917
 
912
918
 
919
+ def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
920
+ """One note per refusal: the latest outcome for `reason` replaces any earlier line for it, so a
921
+ job of many encodes names it once, and a process that ran an earlier job with the same refusal
922
+ does not keep that job's outcome beside this one."""
923
+ ctx.hw_notes[:] = [n for n in ctx.hw_notes if not n.startswith(reason)]
924
+ ctx.hw_notes.append(reason + outcome)
925
+
926
+
913
927
  def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
914
928
  """One attempt, never exiting on failure (run() decides after its retries)."""
915
929
  if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
@@ -1158,11 +1172,17 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
1158
1172
  return cmd
1159
1173
 
1160
1174
 
1161
- def place_output(src: str, dst: str) -> None:
1175
+ def place_output(src: str, dst: str, move: bool = False) -> None:
1162
1176
  """Deliver an already-rendered file to `dst` under the same rules as an ffmpeg output:
1163
- the path is checked, an existing file is only replaced through a sibling temp so a
1164
- failed copy never costs the caller what was there, and the result is remembered as ours.
1165
- render.py's final `copyfile()` used to bypass all three."""
1177
+ the path is checked, the output's lock is held (as run() holds it for an ffmpeg write), an
1178
+ existing file is only replaced through a sibling temp so a failed copy never costs the
1179
+ caller what was there, and the result is remembered as ours. render.py's final
1180
+ `copyfile()` used to bypass all of them.
1181
+
1182
+ `move`: `src` is a temp file the caller wrote next to `dst` (sibling_temp), renamed into place
1183
+ with os.replace() under the lock instead of copied -- a checked `cut.py --segments` join was
1184
+ copied whole, about 2 s for 240 MB. A rename that fails (another filesystem) falls back to
1185
+ the copy."""
1166
1186
  import shutil
1167
1187
  cmd = ["ffmpeg", dst]
1168
1188
  _check_output_path(cmd)
@@ -1170,18 +1190,35 @@ def place_output(src: str, dst: str) -> None:
1170
1190
  d, base = os.path.split(dst)
1171
1191
  stem, ext = os.path.splitext(base)
1172
1192
  tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
1173
- try:
1174
- shutil.copyfile(src, tmp)
1175
- os.replace(tmp, dst)
1176
- except OSError as e:
1193
+ with _OutputLock(dst):
1194
+ if move:
1195
+ try:
1196
+ os.replace(src, dst)
1197
+ _remember_output(cmd)
1198
+ return
1199
+ except OSError:
1200
+ pass
1177
1201
  try:
1178
- os.remove(tmp)
1179
- except OSError:
1180
- pass
1181
- die(f"could not place {dst}: {e}", kind="output")
1202
+ shutil.copyfile(src, tmp)
1203
+ os.replace(tmp, dst)
1204
+ except OSError as e:
1205
+ try:
1206
+ os.remove(tmp)
1207
+ except OSError:
1208
+ pass
1209
+ die(f"could not place {dst}: {e}", kind="output")
1182
1210
  _remember_output(cmd)
1183
1211
 
1184
1212
 
1213
+ def sibling_temp(dst: str, tag: str) -> str:
1214
+ """A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
1215
+ place_output(..., move=True) renames into place: same directory, so the rename never
1216
+ crosses a filesystem."""
1217
+ d, base = os.path.split(dst)
1218
+ stem, ext = os.path.splitext(base)
1219
+ return os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
1220
+
1221
+
1185
1222
  _DRAWTEXT_TMPDIR: "Optional[str]" = None
1186
1223
 
1187
1224
 
@@ -1234,7 +1271,9 @@ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
1234
1271
  # symlink guard is belt and braces there and unavailable on Windows
1235
1272
  flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
1236
1273
  fd = os.open(path, flags, 0o600)
1237
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
1274
+ # newline="\n": text mode would write each kept line break as CRLF on Windows, and
1275
+ # drawtext from FFmpeg 6.1 draws that as a blank line plus a line break
1276
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
1238
1277
  fh.write(body)
1239
1278
  written.append(path)
1240
1279
  return written
@@ -78,7 +78,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
78
78
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
79
79
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
80
80
  "cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family, or audio extracted when -o has an audio extension)"],
81
- required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
81
+ required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"},
82
+ dict(HDR_X265, when=HDR_X265["when"] + "; with --keep-hevc on an HEVC source"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
82
83
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
83
84
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
84
85
  required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
@@ -234,7 +235,7 @@ DRY_RUN_ANALYSIS = {
234
235
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
235
236
  "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
236
237
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
237
- "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
238
+ "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use",
238
239
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
239
240
  "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
240
241
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
@@ -254,7 +255,7 @@ DRY_RUN_NOTES = {
254
255
  # of the original audio codec never happens on those tools.
255
256
  REENCODE_META: Dict[str, Dict[str, str]] = {
256
257
  "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
257
- "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source, or a keyframe snap past --tolerance (see cut.py --json: mode, keyframe_snapped)"),
258
+ "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, --codec, a VFR source (--vfr-guard), a keyframe snap past --tolerance, or a --segments join the parts cannot stream-copy (see cut.py --json: mode, reencode_reason, keyframe_snapped)"),
258
259
  "fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
259
260
  "crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
260
261
  "deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
@@ -453,7 +454,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
453
454
  elif name == "verify":
454
455
  extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
455
456
  elif name == "batch":
456
- extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
457
+ extra = {"results": {"type": "array", "description": "one row per input; a row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
458
+ "processed": {"type": "integer"}, "total": {"type": "integer"},
459
+ "cut_stream_copy": {"type": ["object", "null"], "description": "cut.py calls across the folder: {calls, stream_copy, reencoded, stream_copy_rate}; null when no cut.py step ran"},
460
+ "cut_reencode_reasons": {"type": ["object", "null"], "description": "how many cut.py calls named each reencode_reason ({} when none re-encoded); a re-encoded call whose row came from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as unknown; null when no cut.py step ran"}}
457
461
  elif name == "report":
458
462
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
459
463
  elif name == "export":
@@ -475,9 +479,24 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
475
479
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
476
480
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
477
481
  "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
478
- "description": "packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
482
+ "description": "the whole run's: packet only when nothing re-encoded, the --segments join included (stream copy on a packet/keyframe boundary); sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
483
+ "least_exact_precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
484
+ "description": "the least exact segment's precision (the order packet, codec_frame, frame, sample); differs from precision only for an audio-only --segments join of copied and re-encoded parts"},
485
+ "keyframe_snapped": {"type": "boolean", "description": "precision is packet: the run is a stream copy end to end, which lands on keyframes and packets. Whether a copied start really moved is start_snapped"},
486
+ "start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
479
487
  "reencoded": {"type": "boolean"},
480
- "lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"}}
488
+ "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "tolerance", "concat_fallback"]},
489
+ "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
490
+ "segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
491
+ "description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
492
+ "edit_list": {"type": "boolean", "description": "--edit-list on a single MP4/MOV stream copy: it kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (false for every other path, the default included)"},
493
+ "stored_preroll_seconds": {"type": ["number", "null"], "description": "with edit_list: seconds decoded from the keyframe the copy's seek lands on up to the requested start and hidden by the edit list (a player that ignores edit lists shows them); null otherwise"},
494
+ "av_start_skew_seconds": {"type": ["number", "null"], "description": "the output's audio start minus its video start (null when either is missing or under --dry-run); reported, never repaired: on a stream copy only, a warning in notes when it is more than max(2 frames, 0.1 s) away from the source's own skew where the copy's picture starts (the keyframe it began from; the requested start under --edit-list), naming --accurate (and --edit-list for a single MP4/MOV copy whose start snapped)"},
495
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an --edit-list edit list, or an --edit-list copy judged by its video; an A/V start skew with its remedies; a source the default --vfr-guard average took for VFR whose sampled timing is constant (naming --vfr-guard sampled), or a sampled measurement that forced or (--vfr-guard off) skipped a re-encode; a --segments segment ended with the video, or held on its last frame for its sound; a --segments join re-cut because the source uses open GOPs, because a later B-frame .mp4/.mov segment starts between keyframes, or because its join check failed"},
496
+ "vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (the default) only when its nominal and average frame rates differ, which re-encodes it (reason vfr) whatever the sample says; under sampled or off on every such input (absent for audio-only input or output, --accurate, --codec, or a --dry-run input a plan has not written yet): {heuristic: true, measured, method: \"sampled\", guard, windows, deltas}. measured is sampled_cfr (every packet interval in up to five 6 s windows within a tick, at least 1 ms and at most half a frame, of its window's median, and the windows agree; the file-start window's first interval is ignored), vfr (under sampled, forces a re-encode, reason vfr) or inconclusive (fewer than 20 intervals or ffprobe failed; under sampled, reason vfr_inconclusive). --vfr-guard off keeps the copy either way, with a note. Variable timing between the windows is not seen"},
497
+ "lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"},
498
+ "join_check": {"type": ["object", "null"], "description": "--segments video stream-copy join: the written file measured by demuxing, {packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}. expected_packets counts the source's video packets from each part's start keyframe to its end (or the end of the video); max_step_seconds is the largest presentation step (null when vfr_check measured anything but sampled_cfr, e.g. a --vfr-guard off join of variable timing, which checks the count only), and each step at a join must be the step its parts predict within 1 ms; audio_offset_ms is, per part, its sound's shift minus its picture's (+ = sound late) from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than a:0; an MPEG-TS source's AAC is hashed without its ADTS headers) (null for a part with no unique match, e.g. digital silence; null as a whole when the source has no audio or the video check failed), and audio_parts_checked counts the measured parts; ok is the conjunction (counts, steps, every measured part within 5 ms), and false means the join was re-cut from the source (concat_fallback) and this is the failed measurement. null for a single segment, audio, --dry-run, --accurate/--codec/a forced re-encode, a segment that re-encoded, or a join that fell back before a copy join was attempted (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error)"},
499
+ "segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
481
500
  elif name == "join":
482
501
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
483
502
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
@@ -499,10 +518,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
499
518
  props = dict(base)
500
519
  props.update(extra)
501
520
  notes = props.get("notes")
502
- if hw and isinstance(notes, dict) and notes.get("description"):
503
- # a tool-specific condition is not the only one: emit() adds the GPU line to any tool
504
- props["notes"] = dict(notes, description=notes["description"] + "; also, on a VideoToolbox encode (--hw / "
505
- "$FFMPEG_SKILL_HW), a line that its quality is not CRF-equivalent (see hw)")
521
+ if hw:
522
+ # emit() adds the VideoToolbox quality line to `notes` on every tool that takes --hw, so every
523
+ # such tool publishes `notes`, and a tool-specific condition (loop's "--boomerang ... the audio
524
+ # was dropped", export's own lines) is not the only one it can carry
525
+ gpu = "a line that its quality is not CRF-equivalent (on a VideoToolbox encode: --hw / $FFMPEG_SKILL_HW; see hw)"
526
+ if isinstance(notes, dict) and notes.get("description"):
527
+ props["notes"] = dict(notes, description=notes["description"] + "; also " + gpu)
528
+ else:
529
+ props["notes"] = dict(notes or {"type": "array", "items": {"type": "string"}}, description=gpu[0].upper() + gpu[1:])
506
530
  required = ["status", "output", "dry_run", "commands"]
507
531
  return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
508
532
 
@@ -821,8 +845,9 @@ def doctor() -> Dict[str, Any]:
821
845
 
822
846
  `gpu_encoders` is a separate, honest answer to a question none of the required/optional
823
847
  capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
824
- ffmpeg BUILD carries, from `-encoders` alone. No tool here requires or uses one -- every tool
825
- still assumes CPU x264/x265 -- so `gpu_encoders` never affects `ok` or any tool's `usable`. It
848
+ ffmpeg BUILD carries, from `-encoders` alone. No tool requires one -- every tool runs on CPU
849
+ x264/x265 unless `--hw` / FFMPEG_SKILL_HW=1 puts it on VideoToolbox (Apple Silicon only) -- so
850
+ `gpu_encoders` never affects `ok` or any tool's `usable`. It
826
851
  only proves the build shipped the capability, never that the GPU/driver on this machine will
827
852
  actually accept a job (that needs a real encode, which this introspection never runs).
828
853
 
@@ -1420,7 +1445,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1420
1445
  # first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
1421
1446
  # process. A caller that sandboxes the network must know that before a first --transcribe.
1422
1447
  "model_downloads": {
1423
- "when": "first use of a speech engine (--transcribe) whose model is not cached yet; media is never uploaded",
1448
+ "when": "first use of a speech engine (--transcribe; silence.py --filler --transcribe runs it under --dry-run too, caption.py --transcribe does not) whose model is not cached yet; media is never uploaded",
1424
1449
  "in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
1425
1450
  "child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
1426
1451
  "openai-whisper": "OpenAI (its model checkpoint)"},
@@ -1494,7 +1519,7 @@ def main() -> int:
1494
1519
  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)")
1495
1520
  gpu = d["gpu_encoders"]
1496
1521
  if gpu["status"] == "parsed":
1497
- print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
1522
+ print(f"GPU-backed encoders in this build: {len(gpu['present'])} (only --hw / FFMPEG_SKILL_HW=1 use one, VideoToolbox on Apple Silicon; names in doctor --json)")
1498
1523
  print(_fonts_summary_line(d["fonts"]))
1499
1524
  print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
1500
1525
  for err in d["errors"]:
package/scripts/batch.py CHANGED
@@ -173,6 +173,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
173
173
  ok, _doc = run_step(["render.py", str(pj)], budget())
174
174
  step_docs = [("render", _doc)]
175
175
  cut_reencoded: List[bool] = []
176
+ cut_reasons: List[str] = []
176
177
  else:
177
178
  steps = recipe.get("steps") or []
178
179
  if not steps:
@@ -181,18 +182,20 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
181
182
  ok = True
182
183
  cut_reencoded = []
183
184
  step_docs = []
185
+ cut_reasons = []
184
186
  for i, step in enumerate(steps):
185
187
  last = i == len(steps) - 1
186
188
  out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
187
189
  argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
188
190
  step_ok, doc = run_step(argv, budget())
189
191
  step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
190
- # only cut.py's own doc carries `reencoded` -- true if ANY range this call cut needed
191
- # the tolerance-triggered hybrid re-encode fallback (cut.py ORs its per-segment results
192
- # into one top-level field; it doesn't report which range, so this is per cut.py call,
193
- # not per --segments range).
192
+ # only cut.py's own doc carries `reencoded` -- true if ANY range this call cut (or its
193
+ # --segments join) re-encoded, for the reasons in its `reencode_reason` (distinct per
194
+ # call, so cut_reencode_reasons lists each reason once per cut.py call that named it).
195
+ # Per cut.py call, not per --segments range.
194
196
  if step_ok and argv and argv[0] == "cut.py" and isinstance(doc, dict) and "reencoded" in doc:
195
197
  cut_reencoded.append(bool(doc["reencoded"]))
198
+ cut_reasons.extend(r for r in (doc.get("reencode_reason") or []) if isinstance(r, str))
196
199
  if not step_ok:
197
200
  ok = False
198
201
  break
@@ -200,6 +203,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
200
203
  result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
201
204
  if cut_reencoded:
202
205
  result["cut_reencoded"] = cut_reencoded
206
+ result["cut_reencode_reasons"] = cut_reasons
203
207
  # the encoder each item really ran and its GPU facts (--hw / FFMPEG_SKILL_HW: on VideoToolbox,
204
208
  # fell back and why): this process encodes nothing itself, so its top-level hw.used is null
205
209
  result.update(steps_encoder_report(step_docs))
@@ -563,23 +567,36 @@ def main() -> int:
563
567
  die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
564
568
  kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
565
569
  # cut.py's own `reencoded`, rolled up across every cut.py step this batch ran: how much of the
566
- # folder landed on the fast lossless path (mode "copy") vs fell back to the tolerance-triggered
567
- # hybrid re-encode on at least one range -- purely a function of the sources' keyframe placement
568
- # relative to each cut point, not something a single run can predict, so it's worth reporting
569
- # after the fact rather than not at all. Per cut.py call, not per --segments range: cut.py ORs
570
- # its own per-segment results into one top-level field and doesn't say which range needed it.
570
+ # folder landed on the fast lossless path (mode "copy") vs re-encoded on at least one range.
571
+ # Keyframe placement, VFR sources, --accurate/--codec in the recipe and --segments joins all
572
+ # cause it, so the summary counts cut.py's own `reencode_reason` values rather than guessing
573
+ # one cause. Per cut.py call, not per --segments range.
571
574
  all_cuts = [r for res in results for r in (res.get("cut_reencoded") or [])]
572
575
  cut_summary = None
576
+ # how many cut.py calls named each reason, beside cut_stream_copy (whose shape is 2.x's,
577
+ # unchanged); null when no cut.py step ran
578
+ reasons: Optional[Dict[str, int]] = None
573
579
  if all_cuts:
574
580
  copied = sum(1 for r in all_cuts if not r)
575
581
  cut_summary = {"calls": len(all_cuts), "stream_copy": copied, "reencoded": len(all_cuts) - copied,
576
582
  "stream_copy_rate": round(copied / len(all_cuts), 3)}
583
+ reasons = {}
584
+ for res in results:
585
+ if "cut_reencode_reasons" not in res:
586
+ # a row from a cache an earlier 2.x batch wrote: it says which calls re-encoded,
587
+ # not why, so they count as "unknown" rather than leaving {} ("none re-encoded")
588
+ unknown = sum(1 for r in res.get("cut_reencoded") or [] if r)
589
+ if unknown:
590
+ reasons["unknown"] = reasons.get("unknown", 0) + unknown
591
+ for r in res.get("cut_reencode_reasons") or []:
592
+ reasons[r] = reasons.get(r, 0) + 1
593
+ why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
577
594
  info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
578
- f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} fell back to hybrid re-encode on at least one range")
595
+ f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
579
596
  emit(None, results=results, processed=done, total=len(results),
580
597
  jobs=jobs, jobs_requested=requested, wall_seconds=round(time.time() - started, 1),
581
598
  item_seconds_total=round(sum(float(r.get("seconds") or 0) for r in results), 1),
582
- timed_out=timed_out["hit"], cut_stream_copy=cut_summary)
599
+ timed_out=timed_out["hit"], cut_stream_copy=cut_summary, cut_reencode_reasons=reasons)
583
600
  return 0
584
601
 
585
602