ffmpeg-skill 3.1.7 → 3.2.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.
@@ -131,7 +131,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
131
131
  {"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
132
132
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
133
133
  "loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
134
- required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}],
134
+ required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
135
135
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
136
136
  "insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
137
137
  required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
@@ -197,8 +197,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
197
197
  required=FF + [X264, AAC], optional=[HDR_X265],
198
198
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
199
199
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
200
- required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
201
- {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
200
+ required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores and dnxhr-* use pcm_s16le, copy stream-copies, gif has no audio)"},
201
+ {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
202
202
  {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
203
203
  {"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
204
204
  {"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
@@ -219,7 +219,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
219
219
  "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)", "editor timeline (.fcpxml / .edl / .otio) instead of a render (--export-timeline)"],
220
220
  required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
221
221
  video_required=False, audio_only=True, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
222
- "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
222
+ "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir (none for a steps recipe, not a render project, in which no step names {out}: its rows carry written: false)", "content-hash cache"],
223
223
  required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
224
224
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
225
225
  "verify": dict(role="verification", inputs=["media files and/or folders"], outputs=["PASS/FAIL JSON per step", "Markdown report (--report)", "step outputs (--out / --keep)"],
@@ -276,7 +276,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
276
276
  "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
277
277
  "broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
278
278
  "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
279
- "loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
279
+ "loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --crossfade first writes one repeat losslessly (FFV1 + FLAC, a temporary file) and repeats that, so the output still has one lossy encode. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
280
280
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
281
281
  "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
282
282
  "reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
@@ -622,7 +622,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
622
622
  "sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given; with --fix-drift each also carries drift_skipped_reason"},
623
623
  "drift_skipped_reason": {"type": ["string", "null"], "enum": ["too_short", "low_confidence", None], "description": "--fix-drift, one SOURCE: why the drift correction was not applied (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was; also a diagnostics.skipped entry, drift_correction_skipped"}}
624
624
  elif name == "look":
625
- extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
625
+ extra = {"outputs": {"type": "array", "items": {"type": "string"}},
626
+ "guides": {"type": "array", "description": "--guides only: each outline drawn on the PNG, {guide: action|title, fraction (0.93 / 0.90 of the frame, SMPTE ST 2046-1), of: frame} or {guide: 4:3, window, action: 0.90, title: 0.80}; drawn on the preview only"}}
626
627
  elif name == "render":
627
628
  extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
628
629
  "cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
@@ -652,14 +653,19 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
652
653
  elif name == "export":
653
654
  extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
654
655
  "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
656
+ "alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
657
+ "key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
658
+ "fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
655
659
  "trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
656
- "non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
660
+ "non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|key_fill|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
657
661
  "dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
658
662
  "notes": {"type": "array", "items": {"type": "string"}}}
659
663
  elif name == "loudness":
660
664
  extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
661
665
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
662
- "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
666
+ "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"},
667
+ "versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
668
+ "outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
663
669
  elif name == "color":
664
670
  extra = {"dropped_non_av_streams": {"type": "boolean"},
665
671
  "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
@@ -668,6 +674,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
668
674
  "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
669
675
  elif name == "loop":
670
676
  extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
677
+ "crossfade": {"type": "number", "description": "--crossfade only: the fade used, in seconds, rounded to whole frames; one repeat is the clip minus this"},
678
+ "expected_duration": {"type": "number", "description": "seconds requested: --duration, or --times x one repeat (the clip, the clip minus the crossfade, or a boomerang round trip); the duration verification step compares the file against it within 1.5 frames"},
679
+ "seam": {"type": "object", "description": "not with --boomerang: the first seam of the written file measured (at: between_repeats, or end_to_start for one repeat; time; video_step and video_typical_step, the mean luma change across the seam and the median one between neighbouring frames on a 64x36 grey copy, 0-255; audio_level_step_db, the RMS level 20 ms after the seam minus 20 ms before). A measurement, not a pass/fail: a step far above the typical one is a visible jump"},
671
680
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
672
681
  elif name == "cut":
673
682
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
package/scripts/batch.py CHANGED
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
  from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
- from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
36
+ from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, env_hw, stop_children
37
37
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
38
38
 
39
39
  HERE = Path(__file__).resolve().parent
@@ -59,6 +59,30 @@ def file_key(path: Path) -> str:
59
59
  return h.hexdigest()
60
60
 
61
61
 
62
+ def side_inputs_key(recipe: Dict[str, Any]) -> str:
63
+ """The files a recipe's steps name besides {in}/{out} (check.py --compare REF, a LUT, a logo),
64
+ keyed by their own content: editing or removing one is a change the cache must see, or a QC
65
+ pass is served its old verdict against a reference that changed or is gone (#428 review).
66
+ Read on every pass, so a --watch loop sees the edit too."""
67
+ h = hashlib.sha1(usedforsecurity=False)
68
+ for step in recipe.get("steps") or []:
69
+ for arg in step[1:] if isinstance(step, list) else []:
70
+ if not isinstance(arg, str) or "{" in arg:
71
+ continue
72
+ value = arg.split("=", 1)[1] if arg.startswith("-") and "=" in arg else arg
73
+ if value.startswith("-") or not value:
74
+ continue
75
+ path = Path(value)
76
+ try:
77
+ if path.is_file():
78
+ h.update(f"{value}\0{file_key(path)}\0".encode())
79
+ continue
80
+ except OSError:
81
+ pass
82
+ h.update(f"{value}\0-\0".encode()) # absent now: different from when it existed
83
+ return h.hexdigest()[:8]
84
+
85
+
62
86
  def recipe_key(recipe: Dict[str, Any]) -> str:
63
87
  # A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
64
88
  # (export preset, captions, everything) live in the file at that path, not in this dict.
@@ -182,9 +206,80 @@ def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
182
206
  # detection of its own; see one_pass()'s pre-flight check, which uses this same computation
183
207
  # to catch that before any file is actually processed (and the earlier one silently clobbered).
184
208
  final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
209
+ steps = recipe.get("steps") or []
210
+ if not recipe.get("ext") and not recipe.get("project") and steps and not step_writes(steps[-1]) and not writes_nothing(recipe):
211
+ # the last step writes nothing, so what is delivered is an intermediate: its name says its
212
+ # container (a .webm source's fit.py step writes MP4, which must not be named .webm)
213
+ final_ext = intermediate_ext(src)
185
214
  return outdir / f"{src.stem}{suffix}.{final_ext}"
186
215
 
187
216
 
217
+ def intermediate_ext(src: Path) -> str:
218
+ """The extension of an item's intermediate step files: the source's own for an audio file,
219
+ else mp4 (the tools' encoders write x264/AAC, which .webm and friends do not take)."""
220
+ return src.suffix.lstrip(".") if src.suffix.lower() in (".wav", ".mp3", ".m4a", ".flac") else "mp4"
221
+
222
+
223
+ def step_writes(step: Any) -> bool:
224
+ """Whether a recipe step writes a file: it names {out} (check.py and probe.py do not)."""
225
+ return isinstance(step, list) and any("{out}" in str(a) for a in step[1:])
226
+
227
+
228
+ def writes_nothing(recipe: Dict[str, Any]) -> bool:
229
+ """A steps recipe in which no step names {out}: it measures each file and writes none."""
230
+ return not recipe.get("project") and not any(step_writes(st) for st in recipe.get("steps") or [])
231
+
232
+
233
+ def row_output(src: Path, recipe: Dict[str, Any], outdir: Path) -> Dict[str, Any]:
234
+ """An item's output fields for its row: `output` is always the final path, a string as it has
235
+ been since 1.x (a --json key never changes type within a major), and a recipe that writes
236
+ nothing adds `written: false` (#415, #428 review)."""
237
+ fields: Dict[str, Any] = {"output": str(final_path(src, recipe, outdir))}
238
+ if writes_nothing(recipe):
239
+ fields["written"] = False
240
+ return fields
241
+
242
+
243
+ def deliver(src: str, final: Path) -> None:
244
+ """Move an item's last written intermediate to its final path, under the output guard every
245
+ tool applies: an existing file is replaced only with --overwrite. A dry run writes nothing."""
246
+ if STATE.dry_run:
247
+ # the refusal the real run would meet, as a step writing {out} plans it (#428 review)
248
+ if final.exists() and not STATE.overwrite:
249
+ raise ItemError({"step": None, "tool": None, "kind": "input", "reason": "output_exists",
250
+ "message": f"refusing to overwrite existing output {str(final)!r}: pass --overwrite to replace it"})
251
+ log(f" [dry-run] would deliver the last written file to {final}")
252
+ return
253
+
254
+ def locked(msg: str, hint: str, details: Dict[str, Any]) -> None:
255
+ raise ItemError({"step": None, "tool": None, "kind": "input", "reason": "output_locked",
256
+ "message": msg, "hint": hint, "details": details})
257
+ # under the output lock every tool's write holds, so the existence check and the move are one
258
+ # step for another run delivering or writing the same path
259
+ with _OutputLock(str(final), on_locked=locked):
260
+ if final.exists() and not STATE.overwrite:
261
+ raise ItemError({"step": None, "tool": None, "kind": "input", "reason": "output_exists",
262
+ "message": f"refusing to overwrite existing output {str(final)!r}: pass --overwrite to replace it"})
263
+ try:
264
+ os.replace(src, final)
265
+ except OSError:
266
+ # another filesystem: copy beside the final path and rename, so a copy that fails part
267
+ # way never leaves a truncated file where the caller's was
268
+ import shutil
269
+ tmp = final.with_name(f".{final.stem}.ffskill-{os.getpid()}-{threading.get_ident()}{final.suffix}")
270
+ try:
271
+ shutil.copyfile(src, tmp)
272
+ os.replace(tmp, final)
273
+ except OSError as exc:
274
+ try:
275
+ os.remove(tmp)
276
+ except OSError:
277
+ pass
278
+ raise ItemError({"step": None, "tool": None, "kind": "output",
279
+ "message": f"could not deliver {src} to {final}: {exc}"})
280
+ log(f" delivered the last written file to {final}")
281
+
282
+
188
283
  def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
189
284
  deadline: "Optional[float]" = None) -> Dict[str, Any]:
190
285
  final = final_path(src, recipe, outdir)
@@ -211,7 +306,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
211
306
  proj["clips"].append({})
212
307
  proj["clips"][idx]["src"] = str(src.resolve())
213
308
  proj["output"] = str(final.resolve())
214
- pj = work / f"{src.stem}_project.json"
309
+ pj = work / "project.json"
215
310
  pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
216
311
  ok, _doc = run_step(["render.py", str(pj)], budget())
217
312
  step_docs = [("render", _doc)]
@@ -231,7 +326,11 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
231
326
  cut_reasons = []
232
327
  for i, step in enumerate(steps):
233
328
  last = i == len(steps) - 1
234
- 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('.')}")
329
+ # the item's directory is its own, so the name needs no stem (a long one overflowed it)
330
+ out = str(final) if last else str(work / f"step{i}.{intermediate_ext(src)}")
331
+ # a step that names no {out} (check.py, probe.py) writes nothing: the next step reads
332
+ # what this one read, and when it is the last, the last file written is delivered (#415)
333
+ writes = step_writes(step)
235
334
  argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
236
335
  step_ok, doc = run_step(argv, budget())
237
336
  step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
@@ -246,8 +345,16 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
246
345
  ok = False
247
346
  failure = step_error(i, str(argv[0]) if argv else None, doc)
248
347
  break
249
- cur = out
250
- result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
348
+ if writes:
349
+ cur = out
350
+ if ok and cur != str(final) and not writes_nothing(recipe):
351
+ # the last step wrote nothing: the item's deliverable is the last file a step wrote,
352
+ # which is in this item's work directory and would be removed with it (#415)
353
+ try:
354
+ deliver(cur, final)
355
+ except ItemError as exc:
356
+ ok, failure = False, exc.error
357
+ result: Dict[str, Any] = {"file": str(src), **row_output(src, recipe, outdir), "ok": ok, "seconds": round(time.time() - t0, 1)}
251
358
  if failure:
252
359
  result["error"] = failure
253
360
  # `ok` is "every step completed" (design-decisions: a spec miss a tool cannot fix is completed +
@@ -291,6 +398,8 @@ def cached_output_changed(hit: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
291
398
  output_missing, output_changed (size or mtime differ: something replaced or edited it) or
292
399
  entry_unverifiable (an entry written by 2.10.11 or earlier recorded no identity, so nothing can say
293
400
  the file is the one it describes)."""
401
+ if hit.get("written") is False or ("output" in hit and hit["output"] is None):
402
+ return None # a recipe that writes nothing (check.py over a folder): no file to have changed
294
403
  out = str(hit.get("output") or "")
295
404
  now = output_identity(out) if out else None
296
405
  if now is None:
@@ -344,9 +453,22 @@ def main() -> int:
344
453
  if not isinstance(name, str) or name not in ALLOWED_STEP_SCRIPTS:
345
454
  die(f"recipe step names a script that isn't one of this skill's own tools: {name!r} "
346
455
  f"(must be a bare filename like 'silence.py', found in scripts/)")
456
+ if writes_nothing(recipe):
457
+ # a QC batch (check.py / probe.py on every file): its rows carry written: false, and its
458
+ # cache entries claim no file, so they are served rather than missed as output_missing (#415)
459
+ note("no recipe step names {out}: the steps only read each file, so no output is written "
460
+ "(each row says written: false; its output path is not created)")
347
461
  outdir = Path(recipe.get("output_dir") or (folder / "out"))
348
462
  if not outdir.is_absolute():
349
463
  outdir = folder / outdir
464
+ # resolved, so an alias of the input folder ("sub/..", a symlink) is recognised as one: the
465
+ # input scan below excludes outdir's files by parentage, and the input check by resolved path
466
+ outdir = outdir.resolve()
467
+ if outdir == folder:
468
+ # the scan skips files under outdir, so it found nothing here, and with suffix "" a
469
+ # final path was the source itself, which --overwrite replaced (#428 review)
470
+ die(f"output_dir {recipe.get('output_dir')!r} is the input folder {folder}: its outputs would mix with "
471
+ "or replace the batch inputs -- set an \"output_dir\" outside it (the default is out/)", kind="input")
350
472
  work = Path(args.work) if args.work else outdir / ".work"
351
473
  # the children refuse an output whose directory does not exist, under --dry-run too, so the
352
474
  # directories are created for the plan as well -- and removed again afterwards when a dry
@@ -435,23 +557,47 @@ def main() -> int:
435
557
  # collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
436
558
  # processing anything, rather than letting the later one silently overwrite the earlier
437
559
  # one's finished output with the cache still recording both as "ok".
438
- by_final: Dict[Path, List[Path]] = {}
560
+ # Compared case-folded: Clip_out.mp4 and clip_out.mp4 are one file on macOS's and
561
+ # Windows' default filesystems, so a batch that only works on Linux is refused everywhere (#415).
562
+ by_final: Dict[str, List[Path]] = {}
439
563
  for src in files:
440
- by_final.setdefault(final_path(src, recipe, outdir), []).append(src)
441
- collisions = {dst: srcs for dst, srcs in by_final.items() if len(srcs) > 1}
564
+ by_final.setdefault(str(final_path(src, recipe, outdir)).casefold(), []).append(src)
565
+ collisions = {key: srcs for key, srcs in by_final.items() if len(srcs) > 1 and not writes_nothing(recipe)}
442
566
  if collisions:
443
- detail = "; ".join(f"{dst.name} <- {', '.join(s.name for s in srcs)}" for dst, srcs in collisions.items())
444
- die(f"{len(collisions)} output filename collision(s) in this batch -- rename the sources, "
567
+ detail = "; ".join(" / ".join(sorted({final_path(s, recipe, outdir).name for s in srcs}))
568
+ + " <- " + ", ".join(s.name for s in srcs) for srcs in collisions.values())
569
+ die(f"{len(collisions)} output filename collision(s) in this batch (names that differ only in case are one "
570
+ f"file on macOS and Windows) -- rename the sources, "
445
571
  f"or add a distinguishing \"suffix\"/\"ext\" per run, or split into separate globs: {detail}")
572
+ # A final path that is one of the inputs would replace a source -- an output_dir that is
573
+ # the input folder under another name ("sub/..") with suffix "" -- and --overwrite does
574
+ # not license that (#428 review). A recipe that writes nothing delivers no file.
575
+ if not writes_nothing(recipe):
576
+ inputs = {str(p.resolve()).casefold() for p in files}
577
+ hits = [s for s in files if str(final_path(s, recipe, outdir).resolve()).casefold() in inputs]
578
+ if hits:
579
+ die(f"{len(hits)} final output path(s) would replace a batch input ({', '.join(s.name for s in hits)}); "
580
+ "the output_dir is the input folder -- set a different \"output_dir\" or a non-empty \"suffix\"",
581
+ kind="input")
446
582
  def item_work(i: int, src: Path) -> Path:
447
- """Where this item's intermediates go. Parallel items must not share one work dir:
448
- the step file names are stem-derived, so two globs holding the same stem would write
449
- over each other. Serial runs keep the flat layout 1.16 used, byte for byte."""
450
- if jobs == 1:
451
- return work
452
- sub = work / f"{i}-{src.stem}"
453
- sub.mkdir(parents=True, exist_ok=True)
454
- return sub
583
+ """Where this item's intermediates go: a new directory of its own under the work
584
+ directory, removed with everything in it when the item ends (run_item()). The step
585
+ file names are stem-derived, so two items with one stem (clip.mp4, clip.mov) sharing a
586
+ directory refused each other's files, and a later run refused the files an earlier one
587
+ left there as someone else's (#415); parallel items had their own directories since
588
+ 1.17, serial ones now do too."""
589
+ import tempfile
590
+ # no stem in the name: a stem near the 255-byte limit made the directory name too long
591
+ return Path(tempfile.mkdtemp(prefix=f"{i}-", dir=str(work)))
592
+
593
+ def run_item(i: int, src: Path) -> Dict[str, Any]:
594
+ """process() in the item's own work directory, which goes when it ends, however it ends."""
595
+ import shutil
596
+ iw = item_work(i, src)
597
+ try:
598
+ return process(src, recipe, outdir, iw, deadline)
599
+ finally:
600
+ shutil.rmtree(iw, ignore_errors=True)
455
601
 
456
602
  def store(key: str, r: Dict[str, Any]) -> None:
457
603
  if not (r["ok"] and not STATE.dry_run):
@@ -462,10 +608,10 @@ def main() -> int:
462
608
  with cache_lock:
463
609
  # this run just rewrote r["output"]: an entry under another key (another recipe,
464
610
  # the other GPU setting) that names the same file now describes bytes that are gone
465
- for stale in [k for k, v in cache.items() if k != key and isinstance(v, dict) and v.get("output") == r["output"]]:
611
+ for stale in [k for k, v in cache.items() if r["output"] and r.get("written", True) and k != key and isinstance(v, dict) and v.get("output") == r["output"]]:
466
612
  del cache[stale]
467
613
  # the output as this run left it: a later hit is served only while it is unchanged
468
- cache[key] = dict(r, output_identity=output_identity(r["output"]))
614
+ cache[key] = dict(r, output_identity=output_identity(r["output"]) if r["output"] and r.get("written", True) else None)
469
615
  # write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
470
616
  # a concurrent manual run) could leave a truncated file that json.loads() above
471
617
  # then silently treats as "no cache" (a ValueError -> {}), discarding every prior
@@ -483,8 +629,9 @@ def main() -> int:
483
629
  slots: "List[Optional[Dict[str, Any]]]" = [None] * len(files)
484
630
  pending: List[tuple] = []
485
631
  cache_miss: Dict[int, Dict[str, Any]] = {}
632
+ pass_key = rkey + side_inputs_key(recipe)
486
633
  for i, src in enumerate(files):
487
- key = f"{file_key(src)}:{rkey}"
634
+ key = f"{file_key(src)}:{pass_key}"
488
635
  hit = cache.get(key)
489
636
  if isinstance(hit, dict) and not args.force:
490
637
  why = cached_output_changed(hit)
@@ -503,7 +650,7 @@ def main() -> int:
503
650
 
504
651
  def timed_out_row(src: Path) -> "Dict[str, Any]":
505
652
  timed_out["hit"] = True
506
- return {"file": str(src), "output": str(final_path(src, recipe, outdir)),
653
+ return {"file": str(src), **row_output(src, recipe, outdir),
507
654
  "ok": False, "seconds": 0.0, "skipped": "timeout"}
508
655
 
509
656
  def failed_row(src: Path, exc: BaseException) -> "Dict[str, Any]":
@@ -522,13 +669,13 @@ def main() -> int:
522
669
  error = {"step": None, "tool": None, "exception": exc.__class__.__name__,
523
670
  "message": (str(exc) or exc.__class__.__name__)[:300]}
524
671
  log(f" failed: {error['message']}")
525
- return {"file": str(src), "output": str(final_path(src, recipe, outdir)),
672
+ return {"file": str(src), **row_output(src, recipe, outdir),
526
673
  "ok": False, "verified": False, "seconds": 0.0, "error": error}
527
674
 
528
675
  def interrupted_row(src: Path, started: bool, r: "Optional[Dict[str, Any]]" = None) -> "Dict[str, Any]":
529
676
  """An item the interrupt reached: `interrupted: true` when it was running (its steps
530
677
  were stopped), `skipped: "interrupted"` when it had not started."""
531
- row = dict(r) if r else {"file": str(src), "output": str(final_path(src, recipe, outdir)), "seconds": 0.0}
678
+ row = dict(r) if r else {"file": str(src), **row_output(src, recipe, outdir), "seconds": 0.0}
532
679
  row.update(ok=False, verified=False)
533
680
  if started:
534
681
  row["interrupted"] = True
@@ -550,7 +697,7 @@ def main() -> int:
550
697
  continue
551
698
  info(f"=== {src.name}")
552
699
  try:
553
- r = process(src, recipe, outdir, item_work(i, src), deadline)
700
+ r = run_item(i, src)
554
701
  except KeyboardInterrupt:
555
702
  interrupted["hit"] = True
556
703
  # the running item, then every one not started: each keeps its row
@@ -572,7 +719,7 @@ def main() -> int:
572
719
  def work_one(i: int, src: Path, key: str) -> Dict[str, Any]:
573
720
  _LOG.buffer = [f"=== {src.name}"]
574
721
  try:
575
- r = process(src, recipe, outdir, item_work(i, src), deadline)
722
+ r = run_item(i, src)
576
723
  if not interrupted["hit"]: # a step the interrupt stopped is no result to keep
577
724
  store(key, r)
578
725
  return with_miss(i, r)
@@ -670,7 +817,7 @@ def main() -> int:
670
817
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
671
818
  if not args.json:
672
819
  for r in results:
673
- print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
820
+ print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output'] or '(no output: the steps only read it)'}" + (" (cached)" if r.get("cached") else ""))
674
821
  if interrupted["hit"]:
675
822
  # Ctrl-C on a batch that already produced files: the user needs the partial table, not a
676
823
  # traceback and not "nothing was written". Exit 130 (143 for SIGTERM) with every item:
@@ -684,7 +831,7 @@ def main() -> int:
684
831
  timed_out=timed_out["hit"])
685
832
  if timed_out["hit"]:
686
833
  skipped = [r["file"] for r in results if r.get("skipped") == "timeout"]
687
- die(f"the batch's {STATE.timeout:.0f} s budget ran out with {len(skipped)} item(s) not "
834
+ die(f"the batch's {STATE.timeout:g} s budget ran out with {len(skipped)} item(s) not "
688
835
  f"started: {', '.join(os.path.basename(f) for f in skipped[:5])}"
689
836
  + (" ..." if len(skipped) > 5 else "")
690
837
  + ". --timeout is the whole run's limit, not each item's; raise it or split the folder.",
package/scripts/check.py CHANGED
@@ -32,6 +32,7 @@ the row set is unchanged.
32
32
  """
33
33
  import argparse
34
34
  import json
35
+ import os
35
36
  import re
36
37
  import sys
37
38
  from fractions import Fraction
@@ -164,6 +165,12 @@ def main() -> int:
164
165
 
165
166
  # read before anything is measured and again after: a file replaced mid-check is not the one graded
166
167
  identity_before = None if dry_run_input_pending(args.input) else file_identity(args.input)
168
+ if identity_before is None and STATE.dry_run and not os.path.exists(args.input):
169
+ # a render/batch plan's earlier stage would write this file: there is nothing to grade yet,
170
+ # and grading the probe's empty answer divided by a 0 height
171
+ emit(None, platform=args.platform, checks=[], failed=0, warnings=0, ok=False,
172
+ notes=[f"[dry-run] {args.input} does not exist yet, so nothing was checked"])
173
+ return 0
167
174
  meta = probe(args.input)
168
175
  v, a = meta.get("video") or {}, meta.get("audio") or {}
169
176
  rows: List[Dict[str, Any]] = []
package/scripts/color.py CHANGED
@@ -23,6 +23,7 @@ import os
23
23
  import sys
24
24
  from typing import List, Optional
25
25
 
26
+ from _common.runner import fail_if_classified
26
27
  from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
27
28
  from _common.emit import warn
28
29
 
@@ -320,6 +321,7 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool) -> int:
320
321
  cmd += ["-movflags", "+faststart"]
321
322
  cmd.append(output)
322
323
  proc = run(cmd, check=False)
324
+ fail_if_classified(proc) # a full disk fails the re-encode too: its error, not two more encodes (#415)
323
325
  notes: List[str] = []
324
326
  reencoded = proc.returncode != 0
325
327
  if not reencoded and not STATE.dry_run:
@@ -345,6 +347,7 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool) -> int:
345
347
  cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
346
348
  "-map", "0:s?", "-map", "0:d?"] + label + enc + ["-c:s", "copy", "-c:d", "copy", output]
347
349
  proc2 = run(cmd, check=False)
350
+ fail_if_classified(proc2)
348
351
  if proc2.returncode != 0:
349
352
  info("re-encode with subtitles/data streams kept also failed; dropping them")
350
353
  dropped_streams = True
package/scripts/cut.py CHANGED
@@ -58,6 +58,7 @@ import tempfile
58
58
  import zlib
59
59
  from typing import List, Tuple
60
60
 
61
+ from _common.runner import fail_if_classified
61
62
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
62
63
  from _common import require_tool
63
64
  from _common.probe import TS_EXTS, signatures_match, stream_signature
@@ -893,6 +894,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
893
894
  proc = run(cmd, check=False)
894
895
  if proc.returncode != 0:
895
896
  if not reencode:
897
+ fail_if_classified(proc) # a full disk fails the re-encode too: its error, not a retry (#415)
896
898
  info("stream copy failed, falling back to re-encode")
897
899
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
898
900
  die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
@@ -1604,6 +1606,7 @@ def main() -> int:
1604
1606
  cmd += ["-movflags", "+faststart"]
1605
1607
  cmd += [joined]
1606
1608
  proc = run(cmd, check=False)
1609
+ fail_if_classified(proc) # #415: a full disk is not a reason to re-cut every segment
1607
1610
  if proc.returncode != 0:
1608
1611
  info("concat with stream copy failed; re-cutting every segment from the source into one re-encode")
1609
1612
  compatible = False