ffmpeg-skill 3.0.0 → 3.1.1

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/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.0.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.1`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.0.0", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.1", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -788,7 +788,19 @@ auto` found engines and none transcribed, the refusal is `kind: input` with `rea
788
788
  engines auto passed over for a language that is not English), `engine` (the first one it names)
789
789
  and `engines: [{engine, reason, detail}]`, where `detail` carries an engine's own error line. A
790
790
  named `--engine` that is not installed is `kind: missing_tool`; one that is installed and failed
791
- is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`.
791
+ is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`. Since 3.1.0 the "no local
792
+ speech-to-text engine found" refusal itself is `kind: missing_tool` with `reason:
793
+ "asr_engine_missing"` (it was `kind: input`): a missing dependency, like a named engine that is
794
+ not installed.
795
+
796
+ An ffmpeg run that fails keeps `kind: ffmpeg` and exit code 1, and since 3.1.0 carries
797
+ `error.reason` and a `hint` when ffmpeg's own message names a cause a caller can act on
798
+ (`_common.runner.FFMPEG_FAILURE_CLASSES`): `disk_full` ("No space left on device"),
799
+ `encoder_missing` and `filter_missing` (`details.name`; the build lacks it, `doctor` says how to
800
+ get one that has it), `input_incomplete` ("moov atom not found": a cut-off recording or an
801
+ unfinished copy, which `--repair` cannot rebuild) and `input_unreadable`. A tool that copies the
802
+ source's subtitle and data streams retried the whole encode without them after any failure; it
803
+ now retries only when the source has such a stream and the failure is none of these.
792
804
 
793
805
  `check.py` also gains an informational `subtitles` row on **every** platform:
794
806
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -447,11 +447,13 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
447
447
  def die_no_engine(alternative: str, flag: str = "--transcribe") -> None:
448
448
  """The one "no local speech-to-text engine" refusal, in the one set of words.
449
449
 
450
- kind: input, exit 1, and the three install lines -- caption.py and silence.py both land here
451
- rather than each spelling out its own version of the same missing dependency.
450
+ kind missing_tool (3.1.0; it was kind input, which read as a bad argument), `reason:
451
+ "asr_engine_missing"`, exit 1, and the three install lines -- caption.py and silence.py both
452
+ land here rather than each spelling out its own version of the same missing dependency.
452
453
  """
453
454
  die(f"no local speech-to-text engine found for {flag}.\n" + ASR_INSTALL_HINT + "\n" + alternative,
454
- kind="input")
455
+ kind="missing_tool", error_extra={"reason": "asr_engine_missing", "details": {}},
456
+ hint="install one of the engines above; `python3 scripts/_contract.py doctor` shows which are found")
455
457
 
456
458
 
457
459
  def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
@@ -989,7 +991,7 @@ def transcribe_words(video: str, language: "Optional[str]" = None, model: "Optio
989
991
 
990
992
  def transcribe_words_result(video: str, language: "Optional[str]" = None, model: "Optional[str]" = None,
991
993
  audio_stream: int = 0, engine: "Optional[str]" = None,
992
- language_flag: str = "--filler-lang") -> Transcription:
994
+ language_flag: str = "--filler-lang", prompt: "Optional[str]" = None) -> Transcription:
993
995
  """[{word, start, end}, ...] from a local speech engine, as a Transcription naming the engine.
994
996
 
995
997
  `model` (else $FFMPEG_SKILL_ASR_MODEL, else base: requested_model()) is the model and
@@ -1001,7 +1003,10 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1001
1003
  when the engine ran but its build produced no word-level timings, so the caller can refuse
1002
1004
  naming that engine instead of pretending the audio had no words in it. No engine at all
1003
1005
  raises through die_no_engine(), the same refusal caption.py gives. `language_flag` is the
1004
- caller's flag for the language, named in assumed_english_note().
1006
+ caller's flag for the language, named in assumed_english_note(). `prompt` is passed to a
1007
+ Whisper engine as its initial prompt (whisper.cpp --prompt, faster-whisper initial_prompt,
1008
+ openai-whisper --initial_prompt): Whisper writes clean text and leaves out "uh"/"um" unless
1009
+ the context shows it that they are wanted, so silence.py --filler primes it with its own list.
1005
1010
  """
1006
1011
  import shutil as _shutil
1007
1012
  from _common.runner import SafeShutil
@@ -1056,7 +1061,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1056
1061
  base = os.path.join(tmpdir, "out")
1057
1062
  # whisper.cpp's own default is -l en: without "auto" it decodes any speech as English
1058
1063
  cmd = [cli, "-m", model_path, "-f", wav, "--output-json-full", "-of", base, "-l", lang or "auto",
1059
- *WHISPER_CPP_NO_CONTEXT]
1064
+ *WHISPER_CPP_NO_CONTEXT] + (["--prompt", prompt] if prompt else [])
1060
1065
  proc = _asr_run(cmd, _subprocess, "whisper.cpp")
1061
1066
  if proc.returncode == 0 and os.path.exists(base + ".json"):
1062
1067
  words = _words_from_whisper_cpp_json(base + ".json")
@@ -1082,7 +1087,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1082
1087
  def work() -> None:
1083
1088
  try:
1084
1089
  m = WhisperModel(model, device="cpu", compute_type="int8")
1085
- segments, decoded = m.transcribe(wav, language=lang, word_timestamps=True)
1090
+ segments, decoded = m.transcribe(wav, language=lang, word_timestamps=True,
1091
+ **({"initial_prompt": prompt} if prompt else {}))
1086
1092
  heard.append(getattr(decoded, "language", None))
1087
1093
  for seg in segments:
1088
1094
  for w in (getattr(seg, "words", None) or []):
@@ -1117,6 +1123,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1117
1123
  "--output_format", "json", "--output_dir", tmpdir]
1118
1124
  if lang:
1119
1125
  cmd += ["--language", lang]
1126
+ if prompt:
1127
+ cmd += ["--initial_prompt", prompt]
1120
1128
  proc = _asr_run(cmd, _subprocess, "openai-whisper")
1121
1129
  doc = os.path.join(tmpdir, "audio.json")
1122
1130
  if proc.returncode == 0 and os.path.exists(doc):
@@ -598,6 +598,35 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
598
598
  pass
599
599
 
600
600
 
601
+ # ffmpeg's own words for the failures a caller can act on. Each pattern gives `error.reason` and a
602
+ # hint; the kind stays "ffmpeg" and the exit code 1 (docs/design-decisions.md), so a caller that
603
+ # keyed on those sees no change. Matched against the stderr tail, the first hit wins.
604
+ FFMPEG_FAILURE_CLASSES = (
605
+ (re.compile(r"No space left on device"), "disk_full",
606
+ "the disk the output is written to is full: free space there (or write elsewhere with -o) and run again"),
607
+ (re.compile(r"Unknown encoder '([^']+)'|Encoder '?([\w-]+)'? not found|Requested encoder '?([\w-]+)'? not found"),
608
+ "encoder_missing", "this ffmpeg build has no {0} encoder: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"),
609
+ (re.compile(r"No such filter: '([^']+)'|Filter '?([\w-]+)'? not found"),
610
+ "filter_missing", "this ffmpeg build has no {0} filter: `python3 scripts/_contract.py doctor` lists what it has and how to get a fuller build"),
611
+ (re.compile(r"moov atom not found"), "input_incomplete",
612
+ "the input has no index (moov atom): a recording that was cut off or a copy that has not finished; "
613
+ "copy it again from its source. `export --preset copy --repair` cannot rebuild a missing index"),
614
+ (re.compile(r"Invalid data found when processing input"), "input_unreadable",
615
+ "ffmpeg cannot read the input as media: check that it plays and finished copying; `check.py --content` names damaged spans in a file that does"),
616
+ )
617
+
618
+
619
+ def classify_ffmpeg_failure(stderr: str) -> "Optional[Tuple[str, str, Dict[str, str]]]":
620
+ """(reason, hint, details) for an ffmpeg stderr that names an actionable cause, else None."""
621
+ for pattern, reason, hint in FFMPEG_FAILURE_CLASSES:
622
+ m = pattern.search(stderr or "")
623
+ if m:
624
+ name = next((g for g in m.groups() if g), "")
625
+ details = {"name": name} if name else {}
626
+ return reason, hint.format(name or "that"), details
627
+ return None
628
+
629
+
601
630
  def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Context]" = None, retried: bool = False) -> None:
602
631
  # Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
603
632
  # every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
@@ -630,7 +659,11 @@ def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Conte
630
659
  ("ffmpeg reported no encoder-open or compression-session error, so the cause is not the GPU (see the message)"
631
660
  if why is None else f"VideoToolbox refused it ({why}) and no CPU encoder line was recorded to retry with"))
632
661
  extra = {"encoder": enc, "hw": report}
633
- die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode, **extra)
662
+ cls = classify_ffmpeg_failure(stderr)
663
+ if cls:
664
+ extra["hint"] = cls[1]
665
+ die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode,
666
+ error_extra={"reason": cls[0], "details": cls[2]} if cls else None, **extra)
634
667
 
635
668
 
636
669
  def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
@@ -1419,6 +1452,22 @@ def child_args() -> List[str]:
1419
1452
  return args
1420
1453
 
1421
1454
 
1455
+ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
1456
+ """Whether the command's first input has a subtitle or data stream (True when it cannot be
1457
+ told: the retry then runs as before)."""
1458
+ src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
1459
+ if not src or not os.path.isfile(src):
1460
+ return True
1461
+ try:
1462
+ out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", src],
1463
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
1464
+ timeout=PROBE_TIMEOUT).stdout
1465
+ except (OSError, subprocess.TimeoutExpired):
1466
+ return True
1467
+ kinds = {ln.strip() for ln in out.splitlines() if ln.strip()}
1468
+ return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
1469
+
1470
+
1422
1471
  def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1423
1472
  """Run an ffmpeg command that already maps its video/audio, trying first to also
1424
1473
  stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
@@ -1435,6 +1484,11 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1435
1484
  first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
1436
1485
  if first.returncode == 0:
1437
1486
  return False
1487
+ if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
1488
+ # a full disk, a missing encoder or an unreadable input fails the second encode the same
1489
+ # way: report the first failure rather than run the whole encode again and blame streams
1490
+ # the source may not even have
1491
+ _fail(first.args, first.returncode, first.stderr or "")
1438
1492
  error = " | ".join([ln.strip() for ln in (first.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
1439
1493
  record_fallback(f"the subtitle/data streams could not be copied into {os.path.basename(output)} "
1440
1494
  f"({error or 'ffmpeg exit ' + str(first.returncode)}); encoding again without them",
@@ -1451,10 +1505,29 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
1451
1505
  return STATE.timeout or None
1452
1506
 
1453
1507
 
1508
+ def _die_with_parent() -> None:
1509
+ """Linux: have the kernel SIGKILL this child when the tool that started it dies, even by
1510
+ SIGKILL (which no handler can catch). Without it a tool killed by an agent's time limit left
1511
+ ffmpeg running orphaned, writing the output under its final name after the caller had been
1512
+ told the run failed. macOS and Windows have no equivalent; SIGTERM/SIGINT are handled there
1513
+ as everywhere (`kind: interrupted`, the child stopped)."""
1514
+ try:
1515
+ import ctypes
1516
+ ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, 9) # PR_SET_PDEATHSIG, SIGKILL
1517
+ except Exception: # noqa: BLE001 - best effort; a child without it is what every other OS gets
1518
+ pass
1519
+
1520
+
1521
+ def child_kwargs() -> Dict[str, Any]:
1522
+ """Extra Popen arguments for an ffmpeg/ffprobe child (see _die_with_parent)."""
1523
+ return {"preexec_fn": _die_with_parent} if sys.platform.startswith("linux") else {}
1524
+
1525
+
1454
1526
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
1455
1527
  """Plain run with stdout/stderr captured."""
1456
1528
  limit = _limit_for(cmd)
1457
- child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
1529
+ child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
1530
+ **child_kwargs())
1458
1531
  _watch(child, cmd)
1459
1532
  try:
1460
1533
  out, err = child.communicate(timeout=limit)
@@ -1504,7 +1577,8 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
1504
1577
  full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
1505
1578
  t0 = time.time()
1506
1579
  limit = _limit_for(cmd)
1507
- proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
1580
+ proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
1581
+ **child_kwargs())
1508
1582
  _watch(proc, cmd)
1509
1583
  assert proc.stdout is not None and proc.stderr is not None
1510
1584
  lines: "queue.Queue[Optional[str]]" = queue.Queue()
@@ -412,6 +412,10 @@ 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: {}",
415
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}",
416
420
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
417
421
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
@@ -433,7 +437,8 @@ ERROR_REASONS = {
433
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}",
434
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}",
435
439
  "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
436
- "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}",
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: {}",
437
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}",
438
443
  }
439
444
 
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]
@@ -246,8 +246,12 @@ def resolve_filler(args, meta):
246
246
  # faster-whisper word_timestamps=True, openai-whisper --word_timestamps True). An SRT
247
247
  # cannot answer this question: a cue has a start and an end, a word does not.
248
248
  # The call's own result, not module state: engine, routing and notes belong to this run.
249
+ # Whisper leaves "uh"/"um" out of its text unless the context shows them: prime it with the
250
+ # filler list of the language asked for (English under auto, where most filler cuts are)
251
+ primer = FILLER_WORDS.get(args.filler_lang if args.filler_lang != "auto" else "en") or []
252
+ prompt = (", ".join(sorted(primer)[:8]).capitalize() + ". So, I mean...") if primer else None
249
253
  asr_run = _asr.transcribe_words_result(args.input, args.filler_lang if args.filler_lang != "auto" else None,
250
- model=args.model, engine=args.engine)
254
+ model=args.model, engine=args.engine, prompt=prompt)
251
255
  words, engine = asr_run.words, asr_run.engine
252
256
  transcription = dict(asr_run.facts)
253
257
  args._asr_notes = list(asr_run.notes)