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.
- package/README.md +4 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +25 -2
- package/package.json +1 -1
- package/references/scripts.md +63 -11
- package/scripts/_common/decision.py +13 -1
- package/scripts/_common/emit.py +22 -0
- package/scripts/_common/runner.py +102 -22
- package/scripts/_contract.py +17 -8
- package/scripts/batch.py +175 -28
- package/scripts/check.py +7 -0
- package/scripts/color.py +3 -0
- package/scripts/cut.py +3 -0
- package/scripts/export.py +317 -12
- package/scripts/look.py +60 -5
- package/scripts/loop.py +182 -18
- package/scripts/loudness.py +136 -62
- package/scripts/metadata.py +9 -2
- package/scripts/render.py +39 -4
- package/scripts/sync.py +3 -0
- package/scripts/waveform.py +8 -0
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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 = {
|
|
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(
|
|
444
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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)}:{
|
|
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),
|
|
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),
|
|
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),
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|