ffmpeg-skill 2.24.0 → 3.0.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.
@@ -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 = "1.0"
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"] + "; with --keep-hevc on an HEVC source"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
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,7 @@ 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
+ "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
416
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
416
417
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
417
418
  "output_is_directory": "the output path is a directory; details: {output}",
@@ -641,21 +642,21 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
641
642
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
642
643
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
643
644
  "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
644
- "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"},
645
+ "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
646
  "least_exact_precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
646
647
  "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": "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"},
648
+ "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
649
  "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
650
  "reencoded": {"type": "boolean"},
650
651
  "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
651
652
  "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
653
  "segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
653
654
  "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": "--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)"},
655
+ "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
656
  "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 under --edit-list), naming --accurate (and --edit-list for a single MP4/MOV copy whose start snapped)"},
657
- "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"},
658
- "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"},
657
+ "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)"},
658
+ "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"},
659
+ "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
660
  "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
661
  "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
662
  "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 +1008,29 @@ def ffmpeg_security(versions: Dict[str, Optional[str]], sources: Optional[Dict[s
1007
1008
  return out
1008
1009
 
1009
1010
 
1010
- # 2.10.9 deprecates Python 3.9 (docs/contract.md "Deprecation policy"; DEPRECATED below). The same
1011
- # line is printed by bin/install.js (PY39_DEPRECATION there; a test keeps the two equal).
1012
- PY39_DEPRECATION = ("warning: Python 3.9 is deprecated since ffmpeg-skill 2.10.9 (CPython 3.9 reached end of life "
1013
- "in October 2025 and gets no security fixes); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.")
1011
+ # 3.0.0 removed Python 3.9 (deprecated in 2.10.9) and FFmpeg 5.x (deprecated in 2.14.0): REMOVED
1012
+ # below. doctor reports a floor it is below in `unsupported` and as `ok: false`; bin/install.js
1013
+ # refuses Python below 3.10 and prints the same FFmpeg line (a test keeps the lines equal); every
1014
+ # tool refuses an FFmpeg it can read as older than 6.1 (runner.require_tool).
1015
+ MIN_PYTHON = (3, 10)
1016
+ MIN_FFMPEG = (6, 1)
1017
+ PYTHON_UNSUPPORTED = ("error: Python {v} is not supported since ffmpeg-skill 3.0.0 (3.9 was deprecated in 2.10.9; "
1018
+ "CPython 3.9 is end of life): use Python 3.10+.")
1019
+ FFMPEG_UNSUPPORTED = ("error: FFmpeg {v} is not supported since ffmpeg-skill 3.0.0 (5.x was deprecated in 2.14.0): "
1020
+ "use FFmpeg 6.1+.")
1014
1021
 
1015
1022
 
1016
- def python_deprecation(version_info: Optional[tuple] = None) -> Optional[str]:
1017
- """The deprecation line for this interpreter, or None when it is not a deprecated one."""
1023
+ def python_unsupported(version_info: Optional[tuple] = None) -> Optional[str]:
1024
+ """The unsupported-interpreter line for this Python, or None when it is 3.10 or later."""
1018
1025
  vi = tuple(version_info if version_info is not None else sys.version_info)[:2]
1019
- return PY39_DEPRECATION if vi == (3, 9) else None
1026
+ return PYTHON_UNSUPPORTED.format(v=f"{vi[0]}.{vi[1]}") if vi < MIN_PYTHON else None
1020
1027
 
1021
1028
 
1022
- # 2.14.0 deprecates FFmpeg 5.x the same way (DEPRECATED below): 2.x keeps running on it and CI keeps
1023
- # its 5.1.1 job, and the line names 3.0.0 as the release that raises the floor to 6.1. bin/install.js
1024
- # prints it too (FFMPEG5_DEPRECATION there; a test keeps the two equal).
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)."""
1029
+ def ffmpeg_unsupported(version: Optional[str]) -> Optional[str]:
1030
+ """The unsupported-FFmpeg line for this version token, or None (6.1 or later, or not a release
1031
+ number: a git snapshot is not refused, as with the security advisory)."""
1032
1032
  v = _parse_ffmpeg_version(version)
1033
- return FFMPEG5_DEPRECATION if v and v[0] == 5 else None
1033
+ return FFMPEG_UNSUPPORTED.format(v=f"{v[0]}.{v[1]}") if v and tuple(v[:2]) < MIN_FFMPEG else None
1034
1034
 
1035
1035
 
1036
1036
  def _exe_report() -> Dict[str, Any]:
@@ -1289,6 +1289,13 @@ def doctor() -> Dict[str, Any]:
1289
1289
  "not CRF-equivalent (the result's `encoder`/`hw` say so)" for cap, alt in sorted(covered.items())]
1290
1290
  versions = {"ffmpeg": _version_line("ffmpeg"), "ffprobe": _version_line("ffprobe")}
1291
1291
  exes = _exe_report()
1292
+ # 3.0.0: below the requirements floor is a broken install, not an advisory
1293
+ unsupported = [line for line in (python_unsupported(), ffmpeg_unsupported(versions["ffmpeg"])) if line]
1294
+ probe_line = ffmpeg_unsupported(versions["ffprobe"])
1295
+ if probe_line:
1296
+ probe_line = probe_line.replace("error: FFmpeg ", "error: ffprobe ", 1)
1297
+ if probe_line not in unsupported:
1298
+ unsupported.append(probe_line)
1292
1299
  return {
1293
1300
  "version": skill_version(),
1294
1301
  "python": ".".join(str(x) for x in sys.version_info[:3]),
@@ -1304,8 +1311,9 @@ def doctor() -> Dict[str, Any]:
1304
1311
  "detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
1305
1312
  "errors": errors,
1306
1313
  **({"notes": notes} if notes else {}),
1307
- "ok": not missing_required and not unknown_required,
1308
- "tools": _tool_usability(state, drawtext_crashed, covered),
1314
+ "unsupported": unsupported,
1315
+ "ok": not missing_required and not unknown_required and not unsupported,
1316
+ "tools": _tool_usability(state, drawtext_crashed, covered, unsupported),
1309
1317
  "covered_by": covered,
1310
1318
  "gpu_encoders": _gpu_encoders(listings["encoders"]),
1311
1319
  "hw": _hw_default(),
@@ -1428,22 +1436,31 @@ def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
1428
1436
  return f"'{cap}' is not available; see docs/contract.md"
1429
1437
 
1430
1438
 
1431
- def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None) -> Dict[str, Dict[str, Any]]:
1439
+ def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None,
1440
+ unsupported: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]:
1432
1441
  """Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
1433
1442
  computed. Answers "can I run this tool on this machine today", not just "what capabilities
1434
1443
  exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
1435
1444
  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`."""
1445
+ (libx264 replaced by VideoToolbox on this machine) blocks nothing; the tool lists it in `covered_by`.
1446
+ A Python or FFmpeg below the 3.0.0 floor (`unsupported`) makes every tool unusable: each one
1447
+ refuses to start on it."""
1437
1448
  covered = covered or {}
1438
1449
  tools: Dict[str, Dict[str, Any]] = {}
1439
1450
  for name, meta in TOOL_META.items():
1440
1451
  required = list(meta["required"])
1441
1452
  missing = [c for c in required if state.get(c) == "missing" and c not in covered]
1442
1453
  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")}
1454
+ entry: Dict[str, Any] = {"usable": "no" if missing or unsupported else ("unknown" if unknown else "yes")}
1455
+ fixes = []
1444
1456
  if missing:
1445
1457
  entry["missing"] = missing
1446
- entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c, drawtext_crashed) for c in missing))
1458
+ fixes += [_capability_fix_hint(c, drawtext_crashed) for c in missing]
1459
+ if unsupported:
1460
+ entry["unsupported"] = list(unsupported)
1461
+ fixes.append("install Python 3.10+ and FFmpeg 6.1+ (ffmpeg and ffprobe from one build)")
1462
+ if fixes:
1463
+ entry["fix"] = "; ".join(dict.fromkeys(fixes))
1447
1464
  if unknown:
1448
1465
  entry["unknown"] = unknown
1449
1466
  via = {c: covered[c] for c in required if c in covered}
@@ -1592,25 +1609,13 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1592
1609
  # ----------------------------------------------------------------------------- deprecations
1593
1610
  # What the next major removes, announced here per docs/contract.md's three-step deprecation policy:
1594
1611
  # 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: all
1596
- # five 1.10.0 deprecations were settled by it (REMOVED below).
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
- ]
1612
+ # removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0 and at
1613
+ # 3.0.0: every deprecation announced in 1.x and 2.x was settled by its major (REMOVED below).
1614
+ DEPRECATED: List[Dict[str, str]] = []
1610
1615
 
1611
- # What 2.0.0 removed or changed, with the version that deprecated each: the "Removed" half of the
1612
- # policy's step 3, kept in the contract so a caller migrating from 1.x can diff it instead of reading
1613
- # a CHANGELOG. docs/contract.md's "What 2.0 changed" table is written from this list.
1616
+ # What each major removed or changed, with the version that deprecated each: the "Removed" half of
1617
+ # the policy's step 3, kept in the contract so a caller migrating can diff it instead of reading a
1618
+ # CHANGELOG. docs/contract.md's "What 2.0 changed" and "What 3.0 changed" tables are written from it.
1614
1619
  REMOVED: List[Dict[str, str]] = [
1615
1620
  {"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
1621
  "since": "1.10.0", "removed_in": "2.0.0", "where": "cli",
@@ -1627,6 +1632,12 @@ REMOVED: List[Dict[str, str]] = [
1627
1632
  {"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
1633
  "since": "1.10.0", "removed_in": "2.0.0", "where": "json",
1629
1634
  "replacement": "the top-level keys, unchanged from 1.x; --json-brief for a short document"},
1635
+ {"what": "running on Python 3.9 (the requirements.python floor)",
1636
+ "since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
1637
+ "replacement": "Python 3.10 or later; the npx installer skips an older interpreter and doctor reports it as unsupported (ok: false)"},
1638
+ {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)",
1639
+ "since": "2.14.0", "removed_in": "3.0.0", "where": "behaviour",
1640
+ "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
1641
  ]
1631
1642
 
1632
1643
 
@@ -1844,7 +1855,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1844
1855
  },
1845
1856
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
1846
1857
  },
1847
- "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
1858
+ "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1", "node": ">=16 (npx installer only)"},
1848
1859
  "execution": {
1849
1860
  "shell": False,
1850
1861
  "arbitrary_executables": False,
@@ -1908,7 +1919,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1908
1919
  "hidden .<output>.ffskill-audit.jsonl beside the output, and a cache event's .ffskill-audit.jsonl in the "
1909
1920
  "cache directory (render.py --cache DIR) or the batch's output_dir. Never written under --dry-run; a write that "
1910
1921
  "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"},
1922
+ "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
1923
  "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
1924
  },
1914
1925
  "capabilities": caps,
@@ -1926,12 +1937,10 @@ def main() -> int:
1926
1937
  args = ap.parse_args()
1927
1938
  if args.command == "doctor":
1928
1939
  d = doctor()
1929
- # Advisories, never failures: on stderr in both modes, so `doctor --json`'s stdout stays one
1930
- # JSON document. The FFmpeg ones are also in the document (`ffmpeg_security.warnings`); the
1931
- # Python 3.9 deprecation is stderr-only, as every deprecation warning is (docs/contract.md
1932
- # "Deprecation policy" step 1), and machine-readable in `contract --json`'s `deprecated`;
1933
- # so is the FFmpeg 5.x one.
1934
- for line in [python_deprecation(), ffmpeg_deprecation(d.get("ffmpeg"))] + d["ffmpeg_security"]["warnings"]:
1940
+ # On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
1941
+ # unsupported Python or FFmpeg (also in the document as `unsupported`, and part of `ok`)
1942
+ # and the FFmpeg security advisories (`ffmpeg_security.warnings`, never part of `ok`).
1943
+ for line in d["unsupported"] + d["ffmpeg_security"]["warnings"]:
1935
1944
  if line:
1936
1945
  print(line, file=sys.stderr)
1937
1946
  if args.json:
@@ -1969,7 +1978,8 @@ def main() -> int:
1969
1978
  if d["ok"]:
1970
1979
  return 0
1971
1980
  # 1: something required is missing; 2: nothing proven missing but a required capability is unknown
1972
- return 1 if d["missing"] else 2
1981
+ # an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
1982
+ return 1 if d["missing"] or d.get("unsupported") else 2
1973
1983
  print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
1974
1984
  return 0
1975
1985
 
package/scripts/check.py CHANGED
@@ -35,7 +35,7 @@ import json
35
35
  import re
36
36
  import sys
37
37
  from fractions import Fraction
38
- from typing import Any, Dict, List, Optional
38
+ from typing import Any, Dict, List, Optional, Tuple
39
39
 
40
40
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
41
41
  from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
@@ -95,6 +95,12 @@ def frame_time(n: int, v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[L
95
95
  return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
96
96
 
97
97
 
98
+ def display_size(v: Dict[str, Any]) -> "Tuple[int, int]":
99
+ """The frame size as decoded and auto-rotated, which is what --compare's ffmpeg pass sees."""
100
+ w, h = int(v.get("width") or 0), int(v.get("height") or 0)
101
+ return (h, w) if int(v.get("rotation") or 0) % 180 == 90 else (w, h)
102
+
103
+
98
104
  def _span_total(spans: Any) -> float:
99
105
  return sum(max(0.0, e - s) for s, e in spans)
100
106
 
@@ -147,6 +153,15 @@ def main() -> int:
147
153
  if args.max_mb is not None:
148
154
  spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
149
155
 
156
+ expected_identity = None
157
+ if args.expect_identity is not None:
158
+ # parsed before any measurement; "" (an unset shell variable) is malformed, not absent
159
+ try:
160
+ size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
161
+ except ValueError:
162
+ die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
163
+ expected_identity = {"size": size, "mtime_ns": mtime_ns}
164
+
150
165
  # read before anything is measured and again after: a file replaced mid-check is not the one graded
151
166
  identity_before = None if dry_run_input_pending(args.input) else file_identity(args.input)
152
167
  meta = probe(args.input)
@@ -368,8 +383,8 @@ def main() -> int:
368
383
  if not v or not rv:
369
384
  row("similarity", "WARN", "not compared", "two pictures", "--compare needs a video stream in both files",
370
385
  reason="one of the files has no picture")
371
- elif (v.get("width"), v.get("height")) != (rv.get("width"), rv.get("height")):
372
- row("similarity", "WARN", f"not compared: {v.get('width')}x{v.get('height')} vs {rv.get('width')}x{rv.get('height')}",
386
+ elif display_size(v) != display_size(rv):
387
+ row("similarity", "WARN", "not compared: %dx%d vs %dx%d (as displayed)" % (display_size(v) + display_size(rv)),
373
388
  "the same frame size", "compare against a reference at the delivery size (fit.py or export.py it first)",
374
389
  reason="SSIM and PSNR compare pixels, so the two frames must be the same size")
375
390
  else:
@@ -393,17 +408,15 @@ def main() -> int:
393
408
  nref = rv.get("nb_frames") or len(presentation_times(args.compare, float(rv.get("start_time") or 0.0))) or None
394
409
  if nref and nout and int(nref) != int(nout):
395
410
  STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
396
- f"order, so an added or dropped frame shifts every later pair")
411
+ f"order, so an added or dropped frame shifts every later pair, and only the "
412
+ f"first {min(int(nref), int(nout))} were compared")
397
413
  # read after every measurement, --compare's included, so a replacement at any point shows
398
414
  identity = None if dry_run_input_pending(args.input) else file_identity(args.input)
399
- if identity is not None and identity != identity_before and not args.expect_identity:
415
+ if identity is not None and identity != identity_before and expected_identity is None:
400
416
  STATE_NOTES.append("the file changed while it was being checked: input_identity is the file as it was "
401
417
  "at the end, and the rows may describe an earlier one; check it again")
402
- if args.expect_identity:
403
- try:
404
- size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
405
- except ValueError:
406
- die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
418
+ if expected_identity is not None:
419
+ size, mtime_ns = expected_identity["size"], expected_identity["mtime_ns"]
407
420
  if identity is not None:
408
421
  changed = identity != identity_before
409
422
  same = identity == {"size": size, "mtime_ns": mtime_ns} and not changed
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
- (a stream copy end to end), `start_snapped` (measured: a copied start moved past a frame),
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 shifts its timestamps to zero (-avoid_negative_ts
23
- make_zero), so the picture starts at the keyframe; --edit-list keeps the MP4 edit
24
- list instead, which hides the keyframe's pre-roll so the picture starts at --start.
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 uses x264 for an SDR source and x265 Main10 for an HDR or BT.2020 one, as every
33
- editing tool does; --keep-hevc re-encodes an SDR HEVC source as HEVC (x265 8-bit BT.709, or
34
- VideoToolbox under --hw), so a trimmed phone clip keeps its codec. --codec overrides both.
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
- A source whose nominal and average frame rates differ is taken for variable-frame-rate and
37
- re-encoded, as --accurate (--vfr-guard average, the default); its sampled packet timing is
38
- reported in `vfr_check`. --vfr-guard sampled re-encodes only when that sampled timing is
39
- variable or too short to judge, and --vfr-guard off keeps the copy with a note.
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
@@ -80,7 +83,11 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
80
83
  piece anyone asked to keep. Refuses a removal that would leave nothing."""
81
84
  least = 1.0 / fps if fps else 0.01
82
85
  keep, cursor = [], 0.0
83
- for s, e in sorted((max(0.0, s), min(total, e)) for s, e in removed):
86
+ outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
87
+ if outside:
88
+ die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
89
+ "so it removes nothing; check the units (seconds, mm:ss)")
90
+ for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
84
91
  if e <= cursor:
85
92
  continue
86
93
  if s - cursor >= least:
@@ -169,11 +176,10 @@ def least_exact(precisions: List[str]) -> str:
169
176
  def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]) -> dict:
170
177
  """The run's precision keys from its segments' outcomes.
171
178
 
172
- precision main's formula, unchanged: precision_of() for the whole run, so `packet` only
173
- when nothing re-encoded (the join included)
174
- keyframe_snapped precision == "packet": the run is a stream copy end to end, which lands on
175
- keyframes and packets. Kept with its 2.x meaning; whether a copied START
176
- 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")
177
183
  start_snapped measured: true when a copied segment's picture starts more than a frame from
178
184
  its requested start; false when every start is where asked (an edit list, a
179
185
  start on a keyframe, an audio-only copy's packet seek) or everything
@@ -182,7 +188,6 @@ def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]
182
188
  an audio-only --segments join of copied and re-encoded parts (a video join
183
189
  re-cuts every segment when any part re-encodes)
184
190
  segment_precision each segment's, for --segments (null for one segment)"""
185
- precision = precision_of(meta, dst, reencoded)
186
191
  snaps = [o.get("start_snapped") for o in outcomes]
187
192
  if any(s is True for s in snaps):
188
193
  start_snapped = True
@@ -190,8 +195,11 @@ def report_precision(meta: dict, dst: str, reencoded: bool, outcomes: List[dict]
190
195
  start_snapped = None
191
196
  else:
192
197
  start_snapped = False
193
- return {"precision": precision, "keyframe_snapped": precision == "packet", "start_snapped": start_snapped,
194
- "least_exact_precision": least_exact([o["precision"] for o in outcomes]),
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,
195
203
  "segment_precision": [o["precision"] for o in outcomes] if len(outcomes) > 1 else None}
196
204
 
197
205
 
@@ -1304,13 +1312,24 @@ def main() -> int:
1304
1312
  help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
1305
1313
  "(e.g. '1:20-2:05,10:00-10:30'); overlapping ranges merge")
1306
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)")
1307
- ap.add_argument("--edit-list", action="store_true", help="a single-segment .mp4/.mov stream copy keeps the MP4 edit list that hides the keyframe's pre-roll, so the picture starts at --start (default: -avoid_negative_ts make_zero, the picture starts at the keyframe); a player that ignores edit lists shows the pre-roll")
1308
- ap.add_argument("--keep-hevc", action="store_true", help="a re-encode of an SDR HEVC source stays HEVC (x265 8-bit BT.709; VideoToolbox under --hw) instead of x264 (default: x264 for SDR, x265 Main10 for HDR); --codec overrides it")
1309
- ap.add_argument("--vfr-guard", choices=["average", "sampled", "off"], default="average",
1310
- help="what keeps a variable-frame-rate source from a stream copy (default average: a nominal rate "
1311
- "that differs from the average re-encodes, as --accurate, and the sampled frame timing is "
1312
- "reported; sampled: re-encode only when sampled packet timestamps are variable or too few "
1313
- "to judge; off: measure and keep the copy, with a note)")
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)")
1314
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 "
1315
1334
  "keyframe snap; a --segments join is still re-cut for open GOPs, a later B-frame .mp4/.mov "
1316
1335
  "segment starting between keyframes, or a failed join check)")
@@ -1336,7 +1355,9 @@ def main() -> int:
1336
1355
  args = ap.parse_args()
1337
1356
  apply_common(args)
1338
1357
  global KEEP_HEVC
1339
- KEEP_HEVC = args.keep_hevc
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
1340
1361
 
1341
1362
  meta = probe(args.input)
1342
1363
  total = meta.get("duration") or 0.0
@@ -1488,7 +1509,7 @@ def main() -> int:
1488
1509
  join_check = None
1489
1510
  if len(segments) == 1:
1490
1511
  outcomes.append(cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta,
1491
- edit_list_ok=args.edit_list))
1512
+ edit_list_ok=not args.no_edit_list))
1492
1513
  elif hold_needed or args.accurate:
1493
1514
  # Straight to one encode through the concat filter. A held frame needs the re-cut (no part
1494
1515
  # can carry it into a copy join); and when every part re-encodes anyway, encoding each on
package/scripts/export.py CHANGED
@@ -249,16 +249,20 @@ _TIMESTAMP_WARNING = re.compile(r"(?i)\b(dts|pts)\b|timestamp")
249
249
  def media_damage(path: str) -> Dict[str, int]:
250
250
  """One full decode of `path` at -v warning: lines about timestamps (non-monotonic DTS, invalid
251
251
  PTS) and every other error line, counted. What --repair is measured by, before and after."""
252
- proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "warning", "-i", path, "-f", "null", "-"],
252
+ # repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
253
+ # N timestamp warnings and was itself counted as a decode error
254
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path, "-f", "null", "-"],
253
255
  check=False, record=True)
254
256
  lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
255
- stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln))
257
+ # "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
258
+ stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln) and "corrupt" not in ln.lower())
256
259
  return {"timestamp_warnings": stamps, "decode_errors": len(lines) - stamps}
257
260
 
258
261
 
259
262
  MIB = 1024 * 1024
260
263
  SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
261
264
  SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
265
+ SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
262
266
 
263
267
 
264
268
  def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
@@ -281,6 +285,9 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
281
285
  if has_audio and "-b:a" in p["audio"]:
282
286
  audio_kbps = float(p["audio"][p["audio"].index("-b:a") + 1].rstrip("k"))
283
287
  video_kbps = args.max_mb * MIB * 8 * SIZE_HEADROOM / dur / 1000 - audio_kbps
288
+ if video_kbps > SIZE_MAX_VIDEO_KBPS:
289
+ die(f"--max-mb {args.max_mb:g} allows {video_kbps / 1000:.0f} Mb/s over {dur:.1f}s, beyond any delivery bitrate; "
290
+ "a limit that large constrains nothing", hint="drop --max-mb (the preset's quality setting applies)")
284
291
  if video_kbps < SIZE_MIN_VIDEO_KBPS:
285
292
  die(f"--max-mb {args.max_mb:g} leaves {max(video_kbps, 0):.0f} kb/s for the picture over {dur:.1f}s "
286
293
  f"(after {audio_kbps:.0f} kb/s of audio); under {SIZE_MIN_VIDEO_KBPS} kb/s it is not watchable",
@@ -365,6 +372,9 @@ def main() -> int:
365
372
  vf.append(f"scale={p['w']}:-2")
366
373
  if args.repair and args.preset != "copy":
367
374
  die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
375
+ if args.max_mb is not None and args.preset == "gif":
376
+ # the GIF branch below returns before the size is ever applied or measured
377
+ die("--max-mb runs a two-pass x264 encode; --preset gif does not encode with x264 (shorten or shrink the GIF instead)")
368
378
  cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
369
379
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
370
380
  # kept where the container can hold them: ffmpeg's default stream selection takes one video