ffmpeg-skill 2.7.0 → 2.8.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 +3 -3
- package/SKILL.md +5 -5
- package/docs/contract.md +11 -3
- package/package.json +1 -1
- package/references/gotchas.md +27 -5
- package/references/scripts.md +78 -4
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/decision.py +13 -0
- package/scripts/_common/probe.py +92 -0
- package/scripts/_common/runner.py +35 -12
- package/scripts/_contract.py +24 -5
- package/scripts/batch.py +28 -11
- package/scripts/cut.py +1311 -53
package/scripts/_contract.py
CHANGED
|
@@ -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"},
|
|
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"},
|
|
@@ -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,
|
|
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"
|
|
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": "
|
|
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
|
-
"
|
|
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)"},
|
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
|
|
191
|
-
#
|
|
192
|
-
#
|
|
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
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
#
|
|
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}
|
|
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
|
|