ffmpeg-skill 2.24.1 → 3.1.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 +12 -13
- package/SKILL.md +4 -4
- package/bin/install.js +24 -29
- package/docs/contract.md +74 -34
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +7 -4
- package/references/devices.md +2 -2
- package/references/gotchas.md +11 -9
- package/references/scripts.md +21 -18
- package/scripts/_common/asr.py +5 -3
- package/scripts/_common/drawtext.py +6 -10
- package/scripts/_common/probe.py +41 -3
- package/scripts/_common/runner.py +98 -4
- package/scripts/_contract.py +75 -60
- package/scripts/cut.py +46 -29
- package/scripts/render.py +21 -0
package/scripts/_contract.py
CHANGED
|
@@ -33,7 +33,7 @@ from typing import Any, Dict, List, Optional
|
|
|
33
33
|
HERE = Path(__file__).resolve().parent
|
|
34
34
|
ROOT = HERE.parent
|
|
35
35
|
SKILL_ID = "ffmpeg-skill"
|
|
36
|
-
CONTRACT_VERSION = "
|
|
36
|
+
CONTRACT_VERSION = "2.0"
|
|
37
37
|
# `doctor`'s own introspection calls (-filters/-encoders/-bsfs/-version) are meant to be fast,
|
|
38
38
|
# bounded, non-media operations; a hang here would silently freeze the one tool meant to report
|
|
39
39
|
# whether the machine is broken. Media-processing scripts (cut, fit, ...) are NOT bounded this
|
|
@@ -79,7 +79,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
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
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"] + ";
|
|
82
|
+
dict(HDR_X265, when=HDR_X265["when"] + "; on an SDR HEVC source (the default since 3.0.0; --no-keep-hevc uses x264)"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
|
|
83
83
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
84
84
|
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
85
85
|
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
|
|
@@ -412,6 +412,11 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
412
412
|
# error.reason: a stable code for why a refusal happened, beside `kind` (which says what class of
|
|
413
413
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
414
414
|
ERROR_REASONS = {
|
|
415
|
+
"disk_full": "kind ffmpeg: ffmpeg reported \"No space left on device\" while writing; the hint says to free space or write elsewhere; details: {}",
|
|
416
|
+
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command used (\"No such filter\"); details: {name}; the hint points at doctor",
|
|
417
|
+
"input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
|
|
418
|
+
"input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
|
|
419
|
+
"ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
|
|
415
420
|
"output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
|
|
416
421
|
"output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
|
|
417
422
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
@@ -432,7 +437,8 @@ ERROR_REASONS = {
|
|
|
432
437
|
"plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
|
|
433
438
|
"plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
|
|
434
439
|
"plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
|
|
435
|
-
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool
|
|
440
|
+
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
441
|
+
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
436
442
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
437
443
|
}
|
|
438
444
|
|
|
@@ -641,21 +647,21 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
641
647
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
642
648
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
643
649
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
644
|
-
"description": "the whole run's
|
|
650
|
+
"description": "since 3.0.0 the least exact segment's (as least_exact_precision; 2.x: the whole run's, packet only when nothing re-encoded): packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
|
|
645
651
|
"least_exact_precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
646
652
|
"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"},
|
|
647
|
-
"keyframe_snapped": {"type": "boolean", "description": "
|
|
653
|
+
"keyframe_snapped": {"type": ["boolean", "null"], "description": "since 3.0.0 equal to start_snapped: a copied start really moved (2.x: precision was packet, the run a stream copy end to end)"},
|
|
648
654
|
"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)"},
|
|
649
655
|
"reencoded": {"type": "boolean"},
|
|
650
656
|
"reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
|
|
651
657
|
"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), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
|
|
652
658
|
"segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
|
|
653
659
|
"description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
|
|
654
|
-
"edit_list": {"type": "boolean", "description": "
|
|
660
|
+
"edit_list": {"type": "boolean", "description": "a single MP4/MOV stream copy 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 (the default since 3.0.0; false under --no-edit-list and for every other path)"},
|
|
655
661
|
"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"},
|
|
656
|
-
"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
|
|
657
|
-
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an
|
|
658
|
-
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (
|
|
662
|
+
"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 with an edit list), naming --accurate (and the edit list for a single MP4/MOV copy whose start snapped under --no-edit-list)"},
|
|
663
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an edit list, or an edit-list copy judged by its video; an A/V start skew with its remedies; a source --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"},
|
|
664
|
+
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (2.x's default) only when its nominal and average frame rates differ, which re-encodes it (reason vfr) whatever the sample says; under sampled (the default since 3.0.0) 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, and a seeked window is read from its first keyframe on, because a seek lands mid-GOP and the frames before it are not a full run), 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"},
|
|
659
665
|
"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"},
|
|
660
666
|
"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)"},
|
|
661
667
|
"declick_ms": {"type": ["number", "null"], "description": "--segments joined by the re-encode (--accurate, a forced re-encode or concat_fallback): the fade in ms asked for on each side of every join, so butted cuts do not click (20 by default, --declick-ms; 0 under --no-declick; the file's own start and end are not faded). Each fade is capped at half its segment, so a segment shorter than twice this is faded for half its length. null when there is no join to fade: a single segment, a source with no audio, or a stream-copy join, whose sound is copied and not faded"},
|
|
@@ -1007,30 +1013,29 @@ def ffmpeg_security(versions: Dict[str, Optional[str]], sources: Optional[Dict[s
|
|
|
1007
1013
|
return out
|
|
1008
1014
|
|
|
1009
1015
|
|
|
1010
|
-
#
|
|
1011
|
-
#
|
|
1012
|
-
|
|
1013
|
-
|
|
1016
|
+
# 3.0.0 removed Python 3.9 (deprecated in 2.10.9) and FFmpeg 5.x (deprecated in 2.14.0): REMOVED
|
|
1017
|
+
# below. doctor reports a floor it is below in `unsupported` and as `ok: false`; bin/install.js
|
|
1018
|
+
# refuses Python below 3.10 and prints the same FFmpeg line (a test keeps the lines equal); every
|
|
1019
|
+
# tool refuses an FFmpeg it can read as older than 6.1 (runner.require_tool).
|
|
1020
|
+
MIN_PYTHON = (3, 10)
|
|
1021
|
+
MIN_FFMPEG = (6, 1)
|
|
1022
|
+
PYTHON_UNSUPPORTED = ("error: Python {v} is not supported since ffmpeg-skill 3.0.0 (3.9 was deprecated in 2.10.9; "
|
|
1023
|
+
"CPython 3.9 is end of life): use Python 3.10+.")
|
|
1024
|
+
FFMPEG_UNSUPPORTED = ("error: FFmpeg {v} is not supported since ffmpeg-skill 3.0.0 (5.x was deprecated in 2.14.0): "
|
|
1025
|
+
"use FFmpeg 6.1+.")
|
|
1014
1026
|
|
|
1015
1027
|
|
|
1016
|
-
def
|
|
1017
|
-
"""The
|
|
1028
|
+
def python_unsupported(version_info: Optional[tuple] = None) -> Optional[str]:
|
|
1029
|
+
"""The unsupported-interpreter line for this Python, or None when it is 3.10 or later."""
|
|
1018
1030
|
vi = tuple(version_info if version_info is not None else sys.version_info)[:2]
|
|
1019
|
-
return
|
|
1031
|
+
return PYTHON_UNSUPPORTED.format(v=f"{vi[0]}.{vi[1]}") if vi < MIN_PYTHON else None
|
|
1020
1032
|
|
|
1021
1033
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
FFMPEG5_DEPRECATION = ("warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (the 5.x branches date from 2022; "
|
|
1026
|
-
"Ubuntu 24.04 ships 6.1); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.")
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
def ffmpeg_deprecation(version: Optional[str]) -> Optional[str]:
|
|
1030
|
-
"""The deprecation line for this ffmpeg's version token, or None (not 5.x, or not a release
|
|
1031
|
-
number: a git snapshot gets no line, as with the security advisory)."""
|
|
1034
|
+
def ffmpeg_unsupported(version: Optional[str]) -> Optional[str]:
|
|
1035
|
+
"""The unsupported-FFmpeg line for this version token, or None (6.1 or later, or not a release
|
|
1036
|
+
number: a git snapshot is not refused, as with the security advisory)."""
|
|
1032
1037
|
v = _parse_ffmpeg_version(version)
|
|
1033
|
-
return
|
|
1038
|
+
return FFMPEG_UNSUPPORTED.format(v=f"{v[0]}.{v[1]}") if v and tuple(v[:2]) < MIN_FFMPEG else None
|
|
1034
1039
|
|
|
1035
1040
|
|
|
1036
1041
|
def _exe_report() -> Dict[str, Any]:
|
|
@@ -1289,6 +1294,13 @@ def doctor() -> Dict[str, Any]:
|
|
|
1289
1294
|
"not CRF-equivalent (the result's `encoder`/`hw` say so)" for cap, alt in sorted(covered.items())]
|
|
1290
1295
|
versions = {"ffmpeg": _version_line("ffmpeg"), "ffprobe": _version_line("ffprobe")}
|
|
1291
1296
|
exes = _exe_report()
|
|
1297
|
+
# 3.0.0: below the requirements floor is a broken install, not an advisory
|
|
1298
|
+
unsupported = [line for line in (python_unsupported(), ffmpeg_unsupported(versions["ffmpeg"])) if line]
|
|
1299
|
+
probe_line = ffmpeg_unsupported(versions["ffprobe"])
|
|
1300
|
+
if probe_line:
|
|
1301
|
+
probe_line = probe_line.replace("error: FFmpeg ", "error: ffprobe ", 1)
|
|
1302
|
+
if probe_line not in unsupported:
|
|
1303
|
+
unsupported.append(probe_line)
|
|
1292
1304
|
return {
|
|
1293
1305
|
"version": skill_version(),
|
|
1294
1306
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
@@ -1304,8 +1316,9 @@ def doctor() -> Dict[str, Any]:
|
|
|
1304
1316
|
"detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
|
|
1305
1317
|
"errors": errors,
|
|
1306
1318
|
**({"notes": notes} if notes else {}),
|
|
1307
|
-
"
|
|
1308
|
-
"
|
|
1319
|
+
"unsupported": unsupported,
|
|
1320
|
+
"ok": not missing_required and not unknown_required and not unsupported,
|
|
1321
|
+
"tools": _tool_usability(state, drawtext_crashed, covered, unsupported),
|
|
1309
1322
|
"covered_by": covered,
|
|
1310
1323
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
1311
1324
|
"hw": _hw_default(),
|
|
@@ -1428,22 +1441,31 @@ def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
|
|
|
1428
1441
|
return f"'{cap}' is not available; see docs/contract.md"
|
|
1429
1442
|
|
|
1430
1443
|
|
|
1431
|
-
def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None
|
|
1444
|
+
def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None,
|
|
1445
|
+
unsupported: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]:
|
|
1432
1446
|
"""Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
|
|
1433
1447
|
computed. Answers "can I run this tool on this machine today", not just "what capabilities
|
|
1434
1448
|
exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
|
|
1435
1449
|
own required-capability list by hand to answer that. A required capability in `covered`
|
|
1436
|
-
(libx264 replaced by VideoToolbox on this machine) blocks nothing; the tool lists it in `covered_by`.
|
|
1450
|
+
(libx264 replaced by VideoToolbox on this machine) blocks nothing; the tool lists it in `covered_by`.
|
|
1451
|
+
A Python or FFmpeg below the 3.0.0 floor (`unsupported`) makes every tool unusable: each one
|
|
1452
|
+
refuses to start on it."""
|
|
1437
1453
|
covered = covered or {}
|
|
1438
1454
|
tools: Dict[str, Dict[str, Any]] = {}
|
|
1439
1455
|
for name, meta in TOOL_META.items():
|
|
1440
1456
|
required = list(meta["required"])
|
|
1441
1457
|
missing = [c for c in required if state.get(c) == "missing" and c not in covered]
|
|
1442
1458
|
unknown = [c for c in required if state.get(c) == "unknown"]
|
|
1443
|
-
entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
|
|
1459
|
+
entry: Dict[str, Any] = {"usable": "no" if missing or unsupported else ("unknown" if unknown else "yes")}
|
|
1460
|
+
fixes = []
|
|
1444
1461
|
if missing:
|
|
1445
1462
|
entry["missing"] = missing
|
|
1446
|
-
|
|
1463
|
+
fixes += [_capability_fix_hint(c, drawtext_crashed) for c in missing]
|
|
1464
|
+
if unsupported:
|
|
1465
|
+
entry["unsupported"] = list(unsupported)
|
|
1466
|
+
fixes.append("install Python 3.10+ and FFmpeg 6.1+ (ffmpeg and ffprobe from one build)")
|
|
1467
|
+
if fixes:
|
|
1468
|
+
entry["fix"] = "; ".join(dict.fromkeys(fixes))
|
|
1447
1469
|
if unknown:
|
|
1448
1470
|
entry["unknown"] = unknown
|
|
1449
1471
|
via = {c: covered[c] for c in required if c in covered}
|
|
@@ -1592,25 +1614,13 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1592
1614
|
# ----------------------------------------------------------------------------- deprecations
|
|
1593
1615
|
# What the next major removes, announced here per docs/contract.md's three-step deprecation policy:
|
|
1594
1616
|
# step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
|
|
1595
|
-
# removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0
|
|
1596
|
-
#
|
|
1597
|
-
DEPRECATED: List[Dict[str, str]] = [
|
|
1598
|
-
# CPython 3.9 reached end of life in October 2025. Raising the floor is a break, so 2.x keeps
|
|
1599
|
-
# running on it (CI still tests 3.9) and doctor / bin/install.js print PY39_DEPRECATION.
|
|
1600
|
-
{"what": "running on Python 3.9 (the requirements.python floor)",
|
|
1601
|
-
"since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
|
|
1602
|
-
"replacement": "Python 3.10 or later; on 3.9, doctor and the npx installer print a one-line warning"},
|
|
1603
|
-
# FFmpeg 5.x: the 5.x branches date from 2022 and keep version-specific workarounds alive.
|
|
1604
|
-
# 2.x keeps running on 5.0+ (CI still tests 5.1.1) and doctor / bin/install.js print
|
|
1605
|
-
# FFMPEG5_DEPRECATION; 3.0.0 raises requirements.ffmpeg to 6.1.
|
|
1606
|
-
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)",
|
|
1607
|
-
"since": "2.14.0", "removed_in": "3.0.0", "where": "behaviour",
|
|
1608
|
-
"replacement": "FFmpeg 6.1 or later; on 5.x, doctor and the npx installer print a one-line warning"},
|
|
1609
|
-
]
|
|
1617
|
+
# removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0 and at
|
|
1618
|
+
# 3.0.0: every deprecation announced in 1.x and 2.x was settled by its major (REMOVED below).
|
|
1619
|
+
DEPRECATED: List[Dict[str, str]] = []
|
|
1610
1620
|
|
|
1611
|
-
# What
|
|
1612
|
-
# policy's step 3, kept in the contract so a caller migrating
|
|
1613
|
-
#
|
|
1621
|
+
# What each major removed or changed, with the version that deprecated each: the "Removed" half of
|
|
1622
|
+
# the policy's step 3, kept in the contract so a caller migrating can diff it instead of reading a
|
|
1623
|
+
# CHANGELOG. docs/contract.md's "What 2.0 changed" and "What 3.0 changed" tables are written from it.
|
|
1614
1624
|
REMOVED: List[Dict[str, str]] = [
|
|
1615
1625
|
{"what": "--crf as an alias of --quality on every re-encoding tool that takes --quality (export.py keeps --crf: its preset chooses the encoder)",
|
|
1616
1626
|
"since": "1.10.0", "removed_in": "2.0.0", "where": "cli",
|
|
@@ -1627,6 +1637,12 @@ REMOVED: List[Dict[str, str]] = [
|
|
|
1627
1637
|
{"what": "the result_v2 preview key and FFMPEG_SKILL_RESULT_V2 (the v1 top-level keys it was to replace were un-deprecated: they are the 2.0 shape)",
|
|
1628
1638
|
"since": "1.10.0", "removed_in": "2.0.0", "where": "json",
|
|
1629
1639
|
"replacement": "the top-level keys, unchanged from 1.x; --json-brief for a short document"},
|
|
1640
|
+
{"what": "running on Python 3.9 (the requirements.python floor)",
|
|
1641
|
+
"since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
|
|
1642
|
+
"replacement": "Python 3.10 or later; the npx installer skips an older interpreter and doctor reports it as unsupported (ok: false)"},
|
|
1643
|
+
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)",
|
|
1644
|
+
"since": "2.14.0", "removed_in": "3.0.0", "where": "behaviour",
|
|
1645
|
+
"replacement": "FFmpeg 6.1 or later; every tool refuses an older one it can read (kind: missing_tool), and doctor reports it as unsupported"},
|
|
1630
1646
|
]
|
|
1631
1647
|
|
|
1632
1648
|
|
|
@@ -1844,7 +1860,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1844
1860
|
},
|
|
1845
1861
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
|
|
1846
1862
|
},
|
|
1847
|
-
"requirements": {"python": ">=3.
|
|
1863
|
+
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1", "node": ">=16 (npx installer only)"},
|
|
1848
1864
|
"execution": {
|
|
1849
1865
|
"shell": False,
|
|
1850
1866
|
"arbitrary_executables": False,
|
|
@@ -1908,7 +1924,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1908
1924
|
"hidden .<output>.ffskill-audit.jsonl beside the output, and a cache event's .ffskill-audit.jsonl in the "
|
|
1909
1925
|
"cache directory (render.py --cache DIR) or the batch's output_dir. Never written under --dry-run; a write that "
|
|
1910
1926
|
"fails is a diagnostics.warnings entry (audit_log_unwritable), never a failure"},
|
|
1911
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1927
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported)", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1912
1928
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
1913
1929
|
},
|
|
1914
1930
|
"capabilities": caps,
|
|
@@ -1926,12 +1942,10 @@ def main() -> int:
|
|
|
1926
1942
|
args = ap.parse_args()
|
|
1927
1943
|
if args.command == "doctor":
|
|
1928
1944
|
d = doctor()
|
|
1929
|
-
#
|
|
1930
|
-
#
|
|
1931
|
-
#
|
|
1932
|
-
|
|
1933
|
-
# so is the FFmpeg 5.x one.
|
|
1934
|
-
for line in [python_deprecation(), ffmpeg_deprecation(d.get("ffmpeg"))] + d["ffmpeg_security"]["warnings"]:
|
|
1945
|
+
# On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
|
|
1946
|
+
# unsupported Python or FFmpeg (also in the document as `unsupported`, and part of `ok`)
|
|
1947
|
+
# and the FFmpeg security advisories (`ffmpeg_security.warnings`, never part of `ok`).
|
|
1948
|
+
for line in d["unsupported"] + d["ffmpeg_security"]["warnings"]:
|
|
1935
1949
|
if line:
|
|
1936
1950
|
print(line, file=sys.stderr)
|
|
1937
1951
|
if args.json:
|
|
@@ -1969,7 +1983,8 @@ def main() -> int:
|
|
|
1969
1983
|
if d["ok"]:
|
|
1970
1984
|
return 0
|
|
1971
1985
|
# 1: something required is missing; 2: nothing proven missing but a required capability is unknown
|
|
1972
|
-
|
|
1986
|
+
# an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
|
|
1987
|
+
return 1 if d["missing"] or d.get("unsupported") else 2
|
|
1973
1988
|
print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
|
|
1974
1989
|
return 0
|
|
1975
1990
|
|
package/scripts/cut.py
CHANGED
|
@@ -13,15 +13,16 @@ WAV), -o out.m4a writes AAC; an audio extension on a video input drops the
|
|
|
13
13
|
picture (mp4 -> wav extraction). The result reports `precision`
|
|
14
14
|
(packet / sample / codec_frame / frame) and the measured duration error, plus
|
|
15
15
|
`mode` (copy / accurate / hybrid -- "hybrid" means a lossless cut silently
|
|
16
|
-
re-encoded because the keyframe snap exceeded --tolerance), `keyframe_snapped`
|
|
17
|
-
|
|
16
|
+
re-encoded because the keyframe snap exceeded --tolerance), `keyframe_snapped` and
|
|
17
|
+
`start_snapped` (measured: a copied start moved past a frame; equal since 3.0.0),
|
|
18
18
|
`reencode_reason`, `requested_start`/`requested_end` (or `requested_segments` for --segments),
|
|
19
19
|
`requested_duration`, `output_duration` and `duration_delta_seconds` -- so a
|
|
20
20
|
caller never has to trust "it probably cut where I asked" on faith.
|
|
21
21
|
|
|
22
|
-
A single-segment .mp4/.mov copy
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
A single-segment .mp4/.mov copy keeps the MP4 edit list (the default since 3.0.0),
|
|
23
|
+
which hides the keyframe's pre-roll so the picture starts at --start; --no-edit-list
|
|
24
|
+
shifts its timestamps to zero instead (-avoid_negative_ts make_zero, every 2.x
|
|
25
|
+
release's default), so the picture starts at the keyframe.
|
|
25
26
|
An --accurate re-encode of a video does not: make_zero moved the picture by the
|
|
26
27
|
AAC priming (about 21 ms, and further when a B-frame's decode time is negative)
|
|
27
28
|
while the sound stayed at 0. A start that lands on a frame keeps that frame at 0.
|
|
@@ -29,14 +30,16 @@ A start between frames begins on the next frame and may keep up to one frame of
|
|
|
29
30
|
timestamp. The MP4 edit list hides the encoder priming, and a source whose audio
|
|
30
31
|
starts late keeps that offset.
|
|
31
32
|
|
|
32
|
-
A re-encode
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
A re-encode keeps an SDR HEVC source HEVC (x265 8-bit BT.709, or VideoToolbox under --hw;
|
|
34
|
+
the default since 3.0.0), so a trimmed phone clip keeps its codec; --no-keep-hevc uses x264
|
|
35
|
+
as every 2.x release did. Other SDR sources get x264 and an HDR or BT.2020 one x265 Main10,
|
|
36
|
+
as every editing tool does. --codec overrides all of these.
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
The VFR guard samples a video's packet timing (`vfr_check`) and re-encodes, as --accurate,
|
|
39
|
+
only when it is variable or too short to judge (--vfr-guard sampled, the default since
|
|
40
|
+
3.0.0). --vfr-guard average is every 2.x release's: a source whose nominal and average frame
|
|
41
|
+
rates differ is taken for variable-frame-rate and re-encoded. --vfr-guard off keeps the copy
|
|
42
|
+
with a note.
|
|
40
43
|
|
|
41
44
|
Examples:
|
|
42
45
|
python3 cut.py input.mp4 --start 00:00:10 --end 00:00:25
|
|
@@ -173,11 +176,10 @@ def least_exact(precisions: List[str]) -> str:
|
|
|
173
176
|
def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]) -> dict:
|
|
174
177
|
"""The run's precision keys from its segments' outcomes.
|
|
175
178
|
|
|
176
|
-
precision
|
|
177
|
-
|
|
178
|
-
keyframe_snapped
|
|
179
|
-
|
|
180
|
-
really moved is start_snapped
|
|
179
|
+
precision since 3.0.0, the least exact segment's precision (least_exact_precision);
|
|
180
|
+
2.x reported precision_of() for the whole run
|
|
181
|
+
keyframe_snapped since 3.0.0, start_snapped (a copied start really moved); 2.x reported
|
|
182
|
+
"the run is a stream copy end to end" (precision_of() == "packet")
|
|
181
183
|
start_snapped measured: true when a copied segment's picture starts more than a frame from
|
|
182
184
|
its requested start; false when every start is where asked (an edit list, a
|
|
183
185
|
start on a keyframe, an audio-only copy's packet seek) or everything
|
|
@@ -186,7 +188,6 @@ def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]
|
|
|
186
188
|
an audio-only --segments join of copied and re-encoded parts (a video join
|
|
187
189
|
re-cuts every segment when any part re-encodes)
|
|
188
190
|
segment_precision each segment's, for --segments (null for one segment)"""
|
|
189
|
-
precision = precision_of(meta, dst, reencoded)
|
|
190
191
|
snaps = [o.get("start_snapped") for o in outcomes]
|
|
191
192
|
if any(s is True for s in snaps):
|
|
192
193
|
start_snapped = True
|
|
@@ -194,8 +195,11 @@ def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]
|
|
|
194
195
|
start_snapped = None
|
|
195
196
|
else:
|
|
196
197
|
start_snapped = False
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
least = least_exact([o["precision"] for o in outcomes])
|
|
199
|
+
# 3.0.0: `precision` is the least exact segment's and `keyframe_snapped` says whether a copied
|
|
200
|
+
# start really moved (2.x: the run-level formula, and "the run is a stream copy end to end")
|
|
201
|
+
return {"precision": least, "keyframe_snapped": start_snapped, "start_snapped": start_snapped,
|
|
202
|
+
"least_exact_precision": least,
|
|
199
203
|
"segment_precision": [o["precision"] for o in outcomes] if len(outcomes) > 1 else None}
|
|
200
204
|
|
|
201
205
|
|
|
@@ -1308,13 +1312,24 @@ def main() -> int:
|
|
|
1308
1312
|
help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
|
|
1309
1313
|
"(e.g. '1:20-2:05,10:00-10:30'); overlapping ranges merge")
|
|
1310
1314
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
"
|
|
1315
|
+
el = ap.add_mutually_exclusive_group()
|
|
1316
|
+
el.add_argument("--edit-list", action="store_true",
|
|
1317
|
+
help="a single-segment .mp4/.mov stream copy keeps the MP4 edit list that hides the keyframe's pre-roll, "
|
|
1318
|
+
"so the picture starts at --start (the default since 3.0.0); a player that ignores edit lists shows the pre-roll")
|
|
1319
|
+
el.add_argument("--no-edit-list", action="store_true",
|
|
1320
|
+
help="shift the copy's timestamps instead (-avoid_negative_ts make_zero, every 2.x release's default): "
|
|
1321
|
+
"the picture starts at the keyframe before --start")
|
|
1322
|
+
kh = ap.add_mutually_exclusive_group()
|
|
1323
|
+
kh.add_argument("--keep-hevc", action="store_true",
|
|
1324
|
+
help="a re-encode of an SDR HEVC source stays HEVC (x265 8-bit BT.709; VideoToolbox under --hw); "
|
|
1325
|
+
"the default since 3.0.0; --codec overrides it")
|
|
1326
|
+
kh.add_argument("--no-keep-hevc", action="store_true",
|
|
1327
|
+
help="re-encode an SDR HEVC source with x264, as every 2.x release did (HDR still gets x265 Main10)")
|
|
1328
|
+
ap.add_argument("--vfr-guard", choices=["average", "sampled", "off"], default="sampled",
|
|
1329
|
+
help="what keeps a variable-frame-rate source from a stream copy (default sampled since 3.0.0: re-encode "
|
|
1330
|
+
"only when sampled packet timestamps are variable or too few to judge; average, every 2.x "
|
|
1331
|
+
"release's default: a nominal rate that differs from the average re-encodes, as --accurate, "
|
|
1332
|
+
"and the sampled timing is reported; off: measure and keep the copy, with a note)")
|
|
1318
1333
|
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never for a "
|
|
1319
1334
|
"keyframe snap; a --segments join is still re-cut for open GOPs, a later B-frame .mp4/.mov "
|
|
1320
1335
|
"segment starting between keyframes, or a failed join check)")
|
|
@@ -1340,7 +1355,9 @@ def main() -> int:
|
|
|
1340
1355
|
args = ap.parse_args()
|
|
1341
1356
|
apply_common(args)
|
|
1342
1357
|
global KEEP_HEVC
|
|
1343
|
-
|
|
1358
|
+
# 3.0.0 defaults: keep HEVC and the edit list unless the --no- form is given (--edit-list and
|
|
1359
|
+
# --keep-hevc stay accepted, and now say what happens anyway)
|
|
1360
|
+
KEEP_HEVC = not args.no_keep_hevc
|
|
1344
1361
|
|
|
1345
1362
|
meta = probe(args.input)
|
|
1346
1363
|
total = meta.get("duration") or 0.0
|
|
@@ -1492,7 +1509,7 @@ def main() -> int:
|
|
|
1492
1509
|
join_check = None
|
|
1493
1510
|
if len(segments) == 1:
|
|
1494
1511
|
outcomes.append(cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta,
|
|
1495
|
-
edit_list_ok=args.
|
|
1512
|
+
edit_list_ok=not args.no_edit_list))
|
|
1496
1513
|
elif hold_needed or args.accurate:
|
|
1497
1514
|
# Straight to one encode through the concat filter. A held frame needs the re-cut (no part
|
|
1498
1515
|
# can carry it into a copy join); and when every part re-encodes anyway, encoding each on
|
package/scripts/render.py
CHANGED
|
@@ -1048,6 +1048,22 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
1048
1048
|
return 0
|
|
1049
1049
|
|
|
1050
1050
|
|
|
1051
|
+
def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
1052
|
+
"""Whether the render carries sound at this point: measured on the intermediate when it
|
|
1053
|
+
exists, otherwise (a dry run) from the sources: a clip with an audio stream, music or a
|
|
1054
|
+
replacement track."""
|
|
1055
|
+
if os.path.exists(current) and not STATE.dry_run: # a dry run may find an earlier render's file there
|
|
1056
|
+
return bool((probe(current).get("audio") or {}).get("codec"))
|
|
1057
|
+
au = proj.get("audio") or {}
|
|
1058
|
+
if au.get("music") or au.get("replace"):
|
|
1059
|
+
return True
|
|
1060
|
+
for c in proj.get("clips") or []:
|
|
1061
|
+
src = rel(c["src"]) if isinstance(c, dict) and c.get("src") else None
|
|
1062
|
+
if not src or not os.path.exists(src) or (probe(src).get("audio") or {}).get("codec"):
|
|
1063
|
+
return True
|
|
1064
|
+
return False
|
|
1065
|
+
|
|
1066
|
+
|
|
1051
1067
|
def main() -> int:
|
|
1052
1068
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1053
1069
|
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
@@ -1543,6 +1559,11 @@ def main() -> int:
|
|
|
1543
1559
|
|
|
1544
1560
|
# ---- loudness
|
|
1545
1561
|
ld = proj.get("loudness")
|
|
1562
|
+
if ld and not _has_sound(current, proj, rel):
|
|
1563
|
+
# a template's loudness stage on a silent source (screen recording, timelapse, drone) used
|
|
1564
|
+
# to fail the render after the picture stages had run, while the dry run said it would work
|
|
1565
|
+
record_skip("the loudness stage is not run: the video has no audio", "loudness_no_audio", stage="loudness")
|
|
1566
|
+
ld = None
|
|
1546
1567
|
if ld:
|
|
1547
1568
|
nxt = str(work / f"loudnorm{mid}")
|
|
1548
1569
|
argv = [current, "-o", nxt]
|