ffmpeg-skill 2.8.0 → 2.8.2

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 CHANGED
@@ -401,7 +401,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
401
401
  **What is tested where.** The contract and the test suite (`tests/test_contract.py`,
402
402
  `tests/test_all.py`, which aggregates one module per tool group — `test_analysis.py`,
403
403
  `test_editing.py`, `test_audio.py`, `test_picture.py`, `test_delivery.py`,
404
- `test_orchestration.py` — over the shared footage in `tests/_fixtures.py`) run on Linux, macOS
404
+ `test_orchestration.py`, `test_accel.py`, `test_asr.py`, `test_cut_copy.py` — over the shared footage in `tests/_fixtures.py`) run on Linux, macOS
405
405
  and Windows on every pull request, minus the handful of POSIX-shim tests listed under
406
406
  [Development](#development). The real-device media corpus
407
407
  (`tests/corpus.py`) has been run on Linux and macOS; the full corpus has **not** been run on
@@ -480,10 +480,15 @@ FFmpeg itself:
480
480
 
481
481
  | OS | Command |
482
482
  |----|---------|
483
- | macOS | `brew install ffmpeg-full` (the plain `ffmpeg` formula lacks the subtitles, drawtext and zscale filters) |
483
+ | macOS | `brew install ffmpeg-full`, then put it on `PATH` (below). The plain `ffmpeg` formula lacks the subtitles, drawtext and zscale filters |
484
484
  | Ubuntu / Debian | `sudo apt install ffmpeg` |
485
485
  | Windows | `winget install Gyan.FFmpeg` |
486
486
 
487
+ **On macOS, `ffmpeg-full` is keg-only:** Homebrew installs it without linking it, so after `brew install ffmpeg-full` the `ffmpeg` on `PATH` is still the lean `ffmpeg` formula (often present as a dependency of another formula), or no `ffmpeg` at all, so `doctor` keeps reporting `ffmpeg`, or the `subtitles` and `drawtext` filters, as missing. Make it the one the tools find, in one of two ways:
488
+
489
+ - **Put it first on `PATH`.** Add `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` to your shell profile. `brew --prefix ffmpeg-full` is the stable `opt/` path, so this survives upgrades, but it only reaches processes started from a shell that read the profile.
490
+ - **Link it over the lean formula:** `brew unlink ffmpeg 2>/dev/null; brew link --overwrite --force ffmpeg-full`. Every process finds this one, but `brew doctor` warns about a linked keg-only formula, and an upgrade of the lean `ffmpeg` has been seen to link the lean build back on top without a message. After upgrading, check that `command -v ffmpeg` still resolves into `ffmpeg-full`, and re-run the link if it doesn't.
491
+
487
492
  ## Requirements
488
493
 
489
494
  - FFmpeg 5.0+. Always required: `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `scdet`, `silencedetect` and `tile` filters. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
package/bin/install.js CHANGED
@@ -94,7 +94,9 @@ function checkFfmpeg() {
94
94
  const r = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8' });
95
95
  if (r.error || r.status !== 0) {
96
96
  console.warn('\n warning: ffmpeg was not found on PATH. The skill needs FFmpeg to run:');
97
- console.warn(' macOS: brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)');
97
+ console.warn(' macOS: brew install ffmpeg-full; export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"');
98
+ console.warn(' # keg-only, so installing it does not put it on PATH: keep the export in your shell profile;');
99
+ console.warn(' # the plain ffmpeg formula lacks subtitles/drawtext/zscale');
98
100
  console.warn(' Ubuntu: sudo apt install ffmpeg');
99
101
  console.warn(' Windows: winget install Gyan.FFmpeg\n');
100
102
  return false;
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 (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.8.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.8.2`) | 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`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.8.0", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.8.2", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -247,7 +247,7 @@ runs the tool with the planned `argv`, then the verify steps (a `check` step may
247
247
  ### Repeatability
248
248
 
249
249
  No tool keeps state or uses randomness. `deterministic_inputs` is `false` only for
250
- `verify`, whose output includes timings. `idempotency_hint` says what "same inputs"
250
+ `verify` and `cropdetect`, whose output includes timings or machine state. `idempotency_hint` says what "same inputs"
251
251
  gives you:
252
252
 
253
253
  | Hint | Tools |
@@ -255,7 +255,7 @@ gives you:
255
255
  | `bit_exact` | probe, check, scenes, look, metadata, background |
256
256
  | `content_equivalent` (same media, bytes may differ between encoder builds) | every encoding tool, cut, sync, report |
257
257
  | `cached` | batch (content-hash cache, re-runs skip unchanged inputs); render **when `--cache DIR` is given** — the hint stays `content_equivalent` because that is what render is without the flag, and the cache is opt-in |
258
- | `environment_dependent` | verify |
258
+ | `environment_dependent` | verify, cropdetect |
259
259
 
260
260
  The hints describe the CPU encoders. Under `--hw`, or `FFMPEG_SKILL_HW=1` on the machine, a tool
261
261
  that encodes video runs VideoToolbox, whose bytes are not promised identical from run to run:
@@ -568,12 +568,13 @@ Per-tool keys added after 2.2.2, all additive:
568
568
 
569
569
  | key | tool | what it holds |
570
570
  |---|---|---|
571
+ | `boomerang`, `notes` | `loop.py` | `boomerang` is `true` for `--boomerang` (frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice); `notes` says the audio was dropped when the source had any (a boomerang is silent) |
571
572
  | `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
572
573
  | `silent` | `join.py` (every run) | `[{index, path, peak_db, at, end}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `at`/`end` are where the input sits in the output, in seconds (a transition overlaps its neighbours). An input named by `--allow-silent N[,N]` (1-based) is a planned pause: always joined, listed with `intended: true`, no warning or note, whatever `--on-silent` says. `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
573
574
  | `short_segments`, `duplicates` | `join.py` (every run) | warnings, never refusals, and the join command is unchanged: `short_segments: [{index, path, duration}]` names each measured input shorter than 2 frames at the join's fps (an audio-only join: shorter than 0.05 s); `duplicates: [{path, indices}]` names a path (compared resolved) listed more than once — repeating a clip can be intended. `[]` when none; each non-empty one adds a `notes` line |
574
575
  | `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
575
576
  | `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it) and on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it: `{requested, source: flag\|env, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
576
- | `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and `null` when the engine identified it itself; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
577
+ | `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and, when nothing was named, the language the engine decoded where it reports one (whisper.cpp's word-timing JSON, faster-whisper, openai-whisper's word-timing JSON), else `null`; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
577
578
  | `notes` | `silence.py --filler --transcribe` (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
578
579
  | `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
579
580
  | `edit_list`, `stored_preroll_seconds`, `av_start_skew_seconds`, `notes` | `cut.py` (every run) | `edit_list`: `true` only for a single-segment `.mp4`/`.m4v`/`.mov` stream copy run with `--edit-list`, which keeps the MP4 edit list the copy writes instead of `-avoid_negative_ts make_zero` (the default, unchanged): the keyframe's pre-roll is stored but hidden, so the picture starts at `--start`. `stored_preroll_seconds`: with `edit_list`, the seconds decoded from the keyframe the copy's seek lands on (measured with the seek ffmpeg makes) up to the first presented frame; `null` otherwise. Under `--edit-list` a copy is judged by its video's length (a `notes` line says so when the container is longer), and one that starts where asked but ends past `--tolerance` re-encodes (`tolerance`) without a `lossless_alternative`. `av_start_skew_seconds`: the output's audio start minus its video start (`null` when either is missing or under `--dry-run`); on a stream copy, when it is more than max(2 frames, 0.1 s) away from the source's own audio/video offset where the copy's picture starts (the keyframe it began from; `--start` under `--edit-list`), a `notes` line names `--accurate`, and `--edit-list` for a single `.mp4`/`.mov` copy whose start snapped. A re-encode carries no such note. The skew is reported, never repaired. `notes`: what the run did that a caller should know, in plain sentences (`[]` when nothing) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
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",
@@ -617,7 +617,7 @@ Three refusals, all `kind: input`, all before any encode:
617
617
 
618
618
  Results: `filler.removed_count`, `filler.removed_seconds`, `filler.removed`
619
619
  (one entry per span), `filler.lang`, `filler.list`, `filler.word_timings` and
620
- `filler.warnings`. With `--transcribe`, `filler.source` is `whisper:ENGINE` or
620
+ `filler.warnings`; `--filler-lang auto` refuses (`kind: input`) a transcript in a language with no built-in list, so pass `--filler-words FILE` for it. With `--transcribe`, `filler.source` is `whisper:ENGINE` or
621
621
  `parakeet:ENGINE` and `filler.transcription` says which engine, model, language
622
622
  and routing made the words; a top-level `notes` line says when `--engine auto`
623
623
  ran the English-only Parakeet model on English assumed (no `--filler-lang`, and
@@ -301,6 +301,7 @@ def _skipped_parakeet(route: "Route", language: Optional[str], language_flag: st
301
301
  def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
302
302
  ffmpeg: str, shutil, subprocess, wanted: str = "auto") -> Transcription:
303
303
  from _common import run_analysis, STATE
304
+ language = engine_language(language)
304
305
  wav = os.path.join(tmpdir, "audio.wav")
305
306
  # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
306
307
  # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
@@ -360,12 +361,14 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
360
361
  import threading
361
362
  result: list = []
362
363
  crashed: list = []
364
+ heard: list = [] # the language faster-whisper decoded, when it names one
363
365
 
364
366
  def work() -> None:
365
367
  try:
366
368
  m = WhisperModel(model, device="cpu", compute_type="int8")
367
- segments, _ = m.transcribe(wav, language=lang, word_timestamps=False)
369
+ segments, decoded = m.transcribe(wav, language=lang, word_timestamps=False)
368
370
  result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
371
+ heard.append(getattr(decoded, "language", None))
369
372
  except Exception as exc: # noqa: BLE001 -- a model that would not load or run: a failed engine
370
373
  crashed.append(exc)
371
374
 
@@ -388,7 +391,8 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
388
391
  info("transcribed with faster-whisper")
389
392
  write_srt(cues, out_srt)
390
393
  return Transcription(cues=cues, engine="faster-whisper",
391
- facts=dict(facts, engine="faster-whisper", model=model, language=lang))
394
+ facts=dict(facts, engine="faster-whisper", model=model,
395
+ language=lang or _heard_language(heard)))
392
396
  except ImportError:
393
397
  pass
394
398
  # 3. openai-whisper CLI
@@ -508,6 +512,23 @@ def _words_from_whisper_cpp_json(path: str) -> "List[Dict[str, Any]]":
508
512
  return [w for w in out if w["word"].strip()]
509
513
 
510
514
 
515
+ def _whisper_json_language(path: str) -> Optional[str]:
516
+ """The language a Whisper engine's JSON says it decoded: whisper.cpp's `result.language`, or
517
+ openai-whisper's top-level `language`. None when the file has neither."""
518
+ try:
519
+ doc = json.loads(Path(path).read_text(encoding="utf-8")) or {}
520
+ lang = str(((doc.get("result") or {}).get("language")) or doc.get("language") or "").strip().lower()
521
+ except (OSError, ValueError, AttributeError):
522
+ return None
523
+ return lang or None
524
+
525
+
526
+ def _heard_language(heard: list) -> Optional[str]:
527
+ """The language an in-process engine decoded (faster-whisper's info.language), lower-cased, or None."""
528
+ lang = next((str(h).strip().lower() for h in heard if h), "")
529
+ return lang or None
530
+
531
+
511
532
  def _words_from_openai_whisper_json(path: str) -> "List[Dict[str, Any]]":
512
533
  """openai-whisper --word_timestamps True --output_format json: segments[].words[]."""
513
534
  try:
@@ -644,6 +665,22 @@ def _is_english(language: Optional[str]) -> bool:
644
665
  return (language or "").lower().split("-")[0].split("_")[0] in ("en", "eng", "english")
645
666
 
646
667
 
668
+ def engine_language(language: Optional[str]) -> Optional[str]:
669
+ """`--language` as the engines take it. "auto" (caption.py's documented default, spelled out)
670
+ is no language: None, the undetected route. Every spelling of English (en-US, eng, English)
671
+ is "en", which Parakeet accepts as English and Whisper accepts as a language code; any other
672
+ value is its two-letter primary language (ja-JP -> ja)."""
673
+ named = (language or "").strip()
674
+ if not named or named.lower() == "auto":
675
+ return None
676
+ if _is_english(named):
677
+ return "en"
678
+ # Whisper takes the two-letter code: a regional or script subtag (ja-JP, pt-BR, zh-Hant) names the
679
+ # same language to it, and an ISO 639-2 code (jpn) is passed as given
680
+ primary = named.replace("_", "-").split("-")[0].lower()
681
+ return primary if len(primary) == 2 else named
682
+
683
+
647
684
  def _whisper_cpp_cli(shutil) -> Optional[str]:
648
685
  cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
649
686
  if not cli:
@@ -675,7 +712,7 @@ def detect_language(wav: str, shutil, subprocess) -> Optional[str]:
675
712
  if not cli:
676
713
  return None
677
714
  model = next((p for p in (_whisper_cpp_model(m) for m in ("tiny", "base", "small", "tiny.en"))
678
- if os.path.exists(p) and not p.endswith(".en.bin")), None)
715
+ if os.path.exists(p) and not _english_only_whisper_model(p)), None)
679
716
  if not model:
680
717
  return None
681
718
  proc = _asr_run([cli, "-m", model, "-f", wav, "-dl", "-l", "auto"], subprocess, "whisper.cpp language detection")
@@ -756,9 +793,10 @@ def _names_parakeet_model(name: str) -> bool:
756
793
 
757
794
 
758
795
  def _english_only_whisper_model(name: str) -> bool:
759
- """An English-only Whisper model: tiny.en, base.en, ... or a ggml-*.en.bin file."""
796
+ """An English-only Whisper model: tiny.en, base.en, ... or a ggml-*.en.bin file, in any
797
+ quantisation (ggml-base.en-q5_1.bin, tiny.en-q8_0) or variant (ggml-small.en-tdrz.bin)."""
760
798
  base = os.path.basename(name).lower()
761
- return (base[:-4] if base.endswith(".bin") else base).endswith(".en")
799
+ return re.search(r"\.en(-[a-z0-9_]+)*$", base[:-4] if base.endswith(".bin") else base) is not None
762
800
 
763
801
 
764
802
  def _whisper_ready(shutil, model: Optional[str]) -> bool:
@@ -913,6 +951,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
913
951
  from _common import require_tool, run_analysis, STATE
914
952
  ffmpeg = require_tool("ffmpeg")
915
953
  wanted = requested_engine(engine)
954
+ language = engine_language(language)
916
955
  parakeet_model = None if model == "base" else model
917
956
  tmpdir = _tempfile.mkdtemp(prefix="ffskill_asrw_")
918
957
  try:
@@ -962,7 +1001,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
962
1001
  words = _words_from_whisper_cpp_json(base + ".json")
963
1002
  info(f"word timings from whisper.cpp ({len(words)} words)")
964
1003
  return Transcription(words=words, engine="whisper.cpp",
965
- facts=dict(facts, engine="whisper.cpp", model=model_path, language=lang))
1004
+ facts=dict(facts, engine="whisper.cpp", model=model_path,
1005
+ language=lang or _whisper_json_language(base + ".json")))
966
1006
  line = _failure_line(proc)
967
1007
  info("whisper.cpp found but produced no word-timing JSON: " + line)
968
1008
  tried.append(_failed("whisper.cpp", "found but produced no word-timing JSON: " + line))
@@ -976,11 +1016,13 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
976
1016
  import threading
977
1017
  collected: list = []
978
1018
  crashed: list = []
1019
+ heard: list = []
979
1020
 
980
1021
  def work() -> None:
981
1022
  try:
982
1023
  m = WhisperModel(model, device="cpu", compute_type="int8")
983
- segments, _ = m.transcribe(wav, language=lang, word_timestamps=True)
1024
+ segments, decoded = m.transcribe(wav, language=lang, word_timestamps=True)
1025
+ heard.append(getattr(decoded, "language", None))
984
1026
  for seg in segments:
985
1027
  for w in (getattr(seg, "words", None) or []):
986
1028
  collected.append({"word": str(w.word).strip(),
@@ -1002,7 +1044,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1002
1044
  else:
1003
1045
  info(f"word timings from faster-whisper ({len(collected)} words)")
1004
1046
  return Transcription(words=[w for w in collected if w["word"]], engine="faster-whisper",
1005
- facts=dict(facts, engine="faster-whisper", model=model, language=lang))
1047
+ facts=dict(facts, engine="faster-whisper", model=model,
1048
+ language=lang or _heard_language(heard)))
1006
1049
  except ImportError:
1007
1050
  pass
1008
1051
 
@@ -1018,7 +1061,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1018
1061
  words = _words_from_openai_whisper_json(doc)
1019
1062
  info(f"word timings from openai-whisper ({len(words)} words)")
1020
1063
  return Transcription(words=words, engine="openai-whisper",
1021
- facts=dict(facts, engine="openai-whisper", model=model, language=lang))
1064
+ facts=dict(facts, engine="openai-whisper", model=model,
1065
+ language=lang or _whisper_json_language(doc)))
1022
1066
  line = _failure_line(proc)
1023
1067
  info("openai-whisper found but produced no word-timing JSON: " + line)
1024
1068
  tried.append(_failed("openai-whisper", "found but produced no word-timing JSON: " + line))
@@ -115,7 +115,7 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
115
115
  `]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
116
116
  done"` losing both characters. A newline is kept (`\r\n` and `\r` become `\n`): drawtext
117
117
  draws it as a line break, and graphics.py's wrap puts them there on purpose (stripping them
118
- ran a wrapped title off both edges of the frame). A tab, vertical tab or form
118
+ ran a wrapped title off both edges of the frame); trailing newlines are dropped. A tab, vertical tab or form
119
119
  feed becomes a space, as the wrap measures it; other control characters are stripped (that
120
120
  dates from the inline-escape route, where they broke the graph parser).
121
121
 
@@ -127,6 +127,9 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
127
127
  """
128
128
  cleaned = (text or "").replace("\r\n", "\n").replace("\r", "\n")
129
129
  cleaned = re.sub(r"[\x00-\x08\x0e-\x1f\x7f]", "", re.sub(r"[\t\x0b\x0c]", " ", cleaned))
130
+ # drawtext ignores one trailing newline and draws a second as a blank line, which would
131
+ # centre the block half a line too high; a label that is only newlines stays as it is
132
+ cleaned = cleaned.rstrip("\n") or cleaned
130
133
  import hashlib
131
134
  name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
132
135
  if tmpdir is None:
@@ -73,8 +73,17 @@ def _set_current_ctx(ctx: "Context") -> None:
73
73
 
74
74
  ENV_HW_NOTE = ("VideoToolbox chosen by FFMPEG_SKILL_HW=1 (machine default; ~1.1-3.5x the bytes of x264/x265 "
75
75
  "at matched quality); rerun with --no-hw for a final deliverable")
76
- ENV_NOT_FOR_DELIVERY_NOTE = ("FFMPEG_SKILL_HW=1 is not applied to export.py's delivery presets (a delivered file is where "
77
- "VideoToolbox's larger files cost most); pass --hw to put this export on the GPU")
76
+ ENV_NOT_FOR_DELIVERY_BASE = ("FFMPEG_SKILL_HW=1 is not applied to export.py's delivery presets (a delivered file is where "
77
+ "VideoToolbox's larger files cost most)")
78
+ ENV_NOT_FOR_DELIVERY_NOTE = ENV_NOT_FOR_DELIVERY_BASE + "; pass --hw to put this export on the GPU"
79
+
80
+
81
+ def env_not_for_delivery_note() -> str:
82
+ """The note for an export the variable left on the CPU: the advice to pass --hw only on a
83
+ machine that can take it (the same advice on a Linux box would send the caller to a flag that
84
+ then reports the CPU encode)."""
85
+ from _common import decision # the same name the --hw decisions (and their tests) stand the platform in on
86
+ return ENV_NOT_FOR_DELIVERY_NOTE if decision.hw_platform_reason() is None else ENV_NOT_FOR_DELIVERY_BASE
78
87
 
79
88
 
80
89
  def _video_encoders(commands: Sequence[str]) -> "Tuple[Optional[str], Optional[str]]":
@@ -120,7 +129,7 @@ def hw_report(ctx: "Context", enc: Optional[str]) -> Dict[str, Any]:
120
129
  # (once: a stage that ran on it already said so)
121
130
  notes.append(ENV_HW_NOTE)
122
131
  if ctx.hw_env_ignored and enc not in (None, "copy"):
123
- notes.append(ENV_NOT_FOR_DELIVERY_NOTE)
132
+ notes.append(env_not_for_delivery_note())
124
133
  return {"requested": requested, "source": source, "used": used,
125
134
  "fallback": bool(ctx.hw_fallback) or any(st.get("fallback") for st in stages), "notes": notes}
126
135
 
@@ -18,7 +18,9 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
18
18
 
19
19
 
20
20
  INSTALL_HINTS = {
21
- "Darwin": " brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)",
21
+ "Darwin": (" brew install ffmpeg-full; export PATH=\"$(brew --prefix ffmpeg-full)/bin:$PATH\""
22
+ " # keg-only, so installing it does not put it on PATH: keep the export in your shell profile;"
23
+ " the plain ffmpeg formula lacks subtitles/drawtext/zscale"),
22
24
  "Linux": (
23
25
  " Debian/Ubuntu: sudo apt install ffmpeg\n"
24
26
  " Fedora: sudo dnf install ffmpeg\n"
@@ -885,9 +887,10 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
885
887
  why = videotoolbox_failure(proc.stderr or "")
886
888
  if why is not None:
887
889
  reason = f"VideoToolbox refused the encode: {why}"
890
+ # said before the retry, without a claim about its outcome: a retry that never
891
+ # returns (a timeout exits from inside _execute) leaves this line, which is true
892
+ _set_refusal_note(ctx, reason, "; retrying on the CPU encoder")
888
893
  info(reason + "; retrying on the CPU encoder")
889
- ctx.hw_notes.append(reason + "; re-encoded on the CPU")
890
- ctx.hw_fallback = True
891
894
  ctx.commands[-1] = _cmdline(cpu_cmd[:-1] + [cmd[-1]])
892
895
  if not quiet:
893
896
  # echoed like the first attempt, so a caller that reads the "$ " lines
@@ -895,6 +898,11 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
895
898
  info("$ " + ctx.commands[-1], ctx=ctx)
896
899
  proc, tried = even_retry(_execute(cpu_cmd), cpu_cmd)
897
900
  retried = True
901
+ # the outcome replaces the line above: a failure document must not claim a
902
+ # re-encode that did not happen, and hw.fallback is the same claim as a flag
903
+ ok = proc.returncode == 0
904
+ _set_refusal_note(ctx, reason, "; re-encoded on the CPU" if ok else "; the CPU retry failed too")
905
+ ctx.hw_fallback = ctx.hw_fallback or ok
898
906
  if proc.returncode != 0 and check:
899
907
  _fail(tried, proc.returncode, proc.stderr or "", ctx=ctx, retried=retried)
900
908
  if final and tmp:
@@ -910,6 +918,14 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
910
918
  return proc
911
919
 
912
920
 
921
+ def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
922
+ """One note per refusal: the latest outcome for `reason` replaces any earlier line for it, so a
923
+ job of many encodes names it once, and a process that ran an earlier job with the same refusal
924
+ does not keep that job's outcome beside this one."""
925
+ ctx.hw_notes[:] = [n for n in ctx.hw_notes if not n.startswith(reason)]
926
+ ctx.hw_notes.append(reason + outcome)
927
+
928
+
913
929
  def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
914
930
  """One attempt, never exiting on failure (run() decides after its retries)."""
915
931
  if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
@@ -1257,7 +1273,9 @@ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
1257
1273
  # symlink guard is belt and braces there and unavailable on Windows
1258
1274
  flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
1259
1275
  fd = os.open(path, flags, 0o600)
1260
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
1276
+ # newline="\n": text mode would write each kept line break as CRLF on Windows, and
1277
+ # drawtext from FFmpeg 6.1 draws that as a blank line plus a line break
1278
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
1261
1279
  fh.write(body)
1262
1280
  written.append(path)
1263
1281
  return written
@@ -235,7 +235,7 @@ DRY_RUN_ANALYSIS = {
235
235
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
236
236
  "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
237
237
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
238
- "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
238
+ "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use",
239
239
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
240
240
  "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
241
241
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
@@ -518,10 +518,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
518
518
  props = dict(base)
519
519
  props.update(extra)
520
520
  notes = props.get("notes")
521
- if hw and isinstance(notes, dict) and notes.get("description"):
522
- # a tool-specific condition is not the only one: emit() adds the GPU line to any tool
523
- props["notes"] = dict(notes, description=notes["description"] + "; also, on a VideoToolbox encode (--hw / "
524
- "$FFMPEG_SKILL_HW), a line that its quality is not CRF-equivalent (see hw)")
521
+ if hw:
522
+ # emit() adds the VideoToolbox quality line to `notes` on every tool that takes --hw, so every
523
+ # such tool publishes `notes`, and a tool-specific condition (loop's "--boomerang ... the audio
524
+ # was dropped", export's own lines) is not the only one it can carry
525
+ gpu = "a line that its quality is not CRF-equivalent (on a VideoToolbox encode: --hw / $FFMPEG_SKILL_HW; see hw)"
526
+ if isinstance(notes, dict) and notes.get("description"):
527
+ props["notes"] = dict(notes, description=notes["description"] + "; also " + gpu)
528
+ else:
529
+ props["notes"] = dict(notes or {"type": "array", "items": {"type": "string"}}, description=gpu[0].upper() + gpu[1:])
525
530
  required = ["status", "output", "dry_run", "commands"]
526
531
  return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
527
532
 
@@ -840,8 +845,9 @@ def doctor() -> Dict[str, Any]:
840
845
 
841
846
  `gpu_encoders` is a separate, honest answer to a question none of the required/optional
842
847
  capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
843
- ffmpeg BUILD carries, from `-encoders` alone. No tool here requires or uses one -- every tool
844
- still assumes CPU x264/x265 -- so `gpu_encoders` never affects `ok` or any tool's `usable`. It
848
+ ffmpeg BUILD carries, from `-encoders` alone. No tool requires one -- every tool runs on CPU
849
+ x264/x265 unless `--hw` / FFMPEG_SKILL_HW=1 puts it on VideoToolbox (Apple Silicon only) -- so
850
+ `gpu_encoders` never affects `ok` or any tool's `usable`. It
845
851
  only proves the build shipped the capability, never that the GPU/driver on this machine will
846
852
  actually accept a job (that needs a real encode, which this introspection never runs).
847
853
 
@@ -1005,10 +1011,15 @@ def _capability_fix_hint(cap: str) -> str:
1005
1011
  if cap in ("ffmpeg", "ffprobe"):
1006
1012
  from _common import INSTALL_HINTS
1007
1013
  hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
1008
- return f"install ffmpeg: {hint}"
1014
+ # a hint's "# why" is a shell comment so the printed line pastes; joined into a fix string
1015
+ # with "; " it would swallow what follows, so show it in parentheses here instead
1016
+ cmd, _, why = hint.partition("#")
1017
+ return f"install ffmpeg: {cmd.strip()} ({why.strip()})" if why else f"install ffmpeg: {hint}"
1009
1018
  system = platform.system()
1010
1019
  if system == "Darwin":
1011
- full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)"
1020
+ full_hint = ("on macOS, brew install ffmpeg-full; export PATH=\"$(brew --prefix ffmpeg-full)/bin:$PATH\" "
1021
+ "(ffmpeg-full is keg-only, so installing it does not replace the ffmpeg already found: keep the "
1022
+ "export in your shell profile; the plain formula lacks subtitles/drawtext/zscale)")
1012
1023
  elif system == "Windows":
1013
1024
  full_hint = "on Windows, winget install Gyan.FFmpeg (the gyan.dev full build carries subtitles/drawtext/zscale; a plain choco ffmpeg package can lack them)"
1014
1025
  else:
@@ -1439,7 +1450,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1439
1450
  # first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
1440
1451
  # process. A caller that sandboxes the network must know that before a first --transcribe.
1441
1452
  "model_downloads": {
1442
- "when": "first use of a speech engine (--transcribe) whose model is not cached yet; media is never uploaded",
1453
+ "when": "first use of a speech engine (--transcribe; silence.py --filler --transcribe runs it under --dry-run too, caption.py --transcribe does not) whose model is not cached yet; media is never uploaded",
1443
1454
  "in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
1444
1455
  "child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
1445
1456
  "openai-whisper": "OpenAI (its model checkpoint)"},
@@ -1513,7 +1524,7 @@ def main() -> int:
1513
1524
  print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
1514
1525
  gpu = d["gpu_encoders"]
1515
1526
  if gpu["status"] == "parsed":
1516
- print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
1527
+ print(f"GPU-backed encoders in this build: {len(gpu['present'])} (only --hw / FFMPEG_SKILL_HW=1 use one, VideoToolbox on Apple Silicon; names in doctor --json)")
1517
1528
  print(_fonts_summary_line(d["fonts"]))
1518
1529
  print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
1519
1530
  for err in d["errors"]:
@@ -125,6 +125,10 @@ def main() -> int:
125
125
  for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
126
126
  _v = getattr(args, _attr, None)
127
127
  if isinstance(_v, str):
128
+ # one line-break form for every route: libass draws a bare CR as nothing, drawtext
129
+ # ignores one trailing newline, and lines_of() counts the rest -- so the layout and the
130
+ # drawn lines agree only if the text is normalised here, once
131
+ _v = _v.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
128
132
  setattr(args, _attr, _v if _v.strip() else None)
129
133
 
130
134
  args.platform = resolve_platform(args.platform)
@@ -295,6 +299,15 @@ def main() -> int:
295
299
  slice_overlong=True, sliced=sliced_atoms) if para.strip() else [para])
296
300
  return "\n".join(out)
297
301
 
302
+ def lines_of(text):
303
+ """How many lines drawtext and libass draw for `text` (a trailing newline is not one)."""
304
+ return max(1, len(str(text or "").replace("\r\n", "\n").replace("\r", "\n").rstrip("\n").split("\n")))
305
+
306
+ def block_h(text, size_px):
307
+ """The height the layouts below give a label: `size_px` for the first line, as they always
308
+ have, and a line pitch of 1.2 x the size for each further one."""
309
+ return size_px + (lines_of(text) - 1) * int(size_px * 1.2)
310
+
298
311
  def add_text(text, drawtext, *, target=None, **el):
299
312
  """One line of template text: a drawtext filter on the old route, an ASS element on the
300
313
  new one. The geometry is computed identically either way."""
@@ -314,7 +327,8 @@ def main() -> int:
314
327
  h1 = int(base * 0.055)
315
328
  h2 = int(base * 0.038)
316
329
  pad = int(base * 0.02)
317
- bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
330
+ name_h = block_h(args.name, h1)
331
+ bar_h = name_h + (block_h(args.title, h2) + pad if args.title else 0) + pad * 2
318
332
  bar_w = int(base * 0.62)
319
333
  y0 = H - m_bottom - bar_h
320
334
  # slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
@@ -328,7 +342,7 @@ def main() -> int:
328
342
  x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
329
343
  draws = []
330
344
  for text, fs, colour, ty in ((args.name, h1, text_c, y0 + pad),
331
- (args.title, h2, primary, y0 + pad + h1 + pad // 2)):
345
+ (args.title, h2, primary, y0 + pad + name_h + pad // 2)):
332
346
  if not text:
333
347
  continue
334
348
  draws.append(f"drawtext={drawtext_text_opts(text)}:{fo}:fontsize={fs}:"
@@ -355,17 +369,23 @@ def main() -> int:
355
369
  h1 = int(base * 0.11)
356
370
  h2 = int(base * 0.045)
357
371
  filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
372
+ # the title is centred as a block, so the bar and the subtitle sit below its last line:
373
+ # `title_h` is the block's height, as the one-line layout has always taken h1; a further
374
+ # subtitle line hangs below the first, whose position the one-line layout fixes
375
+ title_h = block_h(args.title, h1)
376
+ sub_y = title_h // 2 + int(base * 0.03)
377
+ sub_extra = block_h(args.subtitle, h2) - h2 if args.subtitle else 0
358
378
  add_text(args.title,
359
379
  f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
360
380
  size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
361
381
  y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
362
382
  outline_color="000000", start=s, end=e, fade=(300, 300))
363
- filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
383
+ filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{title_h // 2}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
364
384
  if args.subtitle:
365
385
  add_text(args.subtitle,
366
- f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
386
+ f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{sub_y + sub_extra // 2}:alpha='{fade_a}':{en}",
367
387
  size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
368
- y=H / 2 + h1 // 2 + int(base * 0.03), outline=max(1.0, h2 / 20.0),
388
+ y=H / 2 + sub_y + sub_extra // 2, outline=max(1.0, h2 / 20.0),
369
389
  outline_color="000000", start=s, end=e, fade=(300, 300))
370
390
 
371
391
  elif args.template in ("chapter", "bug"):
@@ -439,10 +459,13 @@ def main() -> int:
439
459
  hen = f"enable='between(t,{s:.3f},{he:.3f})'"
440
460
  h1 = int(base * 0.085)
441
461
  bar_h = max(3, int(base * 0.01))
442
- band_h = int(base * 0.30)
462
+ hook_title = wrapped(args.title, h1)
463
+ # the base band (30% of the short side) holds two lines; a longer wrapped title (a 9:16
464
+ # hook is four or five) grows the band to the text plus 0.7 of a line of margin, instead of
465
+ # drawing white text on the bare video. The band is clipped at the frame edges.
466
+ band_h = min(H, max(int(base * 0.30), block_h(hook_title, h1) + int(h1 * 0.7)))
443
467
  y0 = (H - band_h) // 2
444
468
  filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
445
- hook_title = wrapped(args.title, h1)
446
469
  add_text(hook_title,
447
470
  f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}"
448
471
  f"{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
@@ -141,9 +141,38 @@ def resolve_filler(args, meta):
141
141
  "pass the result with --words.", kind="input")
142
142
 
143
143
  lang = args.filler_lang
144
+ reported_lang = lang
144
145
  if lang == "auto":
145
- lang = str((raw or {}).get("language") or "").lower()[:2] if isinstance(raw, dict) else ""
146
- lang = lang if lang in FILLER_WORDS else "en"
146
+ # the language the transcript says it is in: a --words document's own field, else what the
147
+ # engine was told or detected for this run (`--transcribe`). A language that is known and has
148
+ # no built-in list is refused, as --filler-lang names it: the English list would cut real
149
+ # words of that language (Norwegian "er" is "is"), and a transcript with no language at all
150
+ # (or one that says "auto", which is no language either) keeps the English list it always had
151
+ # the ISO 639-2 spellings of the languages that have a built-in list (the mux tag uses them)
152
+ iso3 = {"eng": "en", "spa": "es", "deu": "de", "ger": "de", "fra": "fr", "fre": "fr",
153
+ "ita": "it", "jpn": "ja", "por": "pt"}
154
+
155
+ def _code(value) -> str:
156
+ value = str(value or "").strip().lower().replace("_", "-")
157
+ if value in ("", "auto"):
158
+ return ""
159
+ # a regional tag names its primary language (ja-JP is ja); a three-letter code that is not
160
+ # in the table stays as it is (est is Estonian, not the Spanish "es"); a word such as
161
+ # "english" is cut to its two-letter code
162
+ primary = value.split("-")[0]
163
+ if primary in iso3:
164
+ return iso3[primary]
165
+ return primary[:2] if len(primary) > 3 else primary
166
+ spoken = _code((raw or {}).get("language")) if isinstance(raw, dict) else ""
167
+ spoken = spoken or _code(transcription.get("language") or transcription.get("detected_language"))
168
+ if spoken and spoken not in FILLER_WORDS and not args.filler_words:
169
+ die(f"the transcript is in {spoken}, which has no built-in filler list. The languages with one are "
170
+ f"{', '.join(sorted(FILLER_WORDS))}; pass --filler-words FILE with your own list for {spoken}, or "
171
+ "--filler-lang for a language that has one.", kind="input")
172
+ lang = spoken if spoken in FILLER_WORDS else "en"
173
+ # with a custom list the run goes ahead, and the result names the language it heard, not the
174
+ # English list it did not use
175
+ reported_lang = spoken or lang
147
176
  listname = "builtin"
148
177
  if args.filler_words:
149
178
  wordlist = set(_word_list(read_text_or_die(args.filler_words, "--filler-words")))
@@ -174,7 +203,7 @@ def resolve_filler(args, meta):
174
203
  warnings.append(f"--filler-extra {', '.join(extra_markers)}: a discourse marker, not a "
175
204
  "disfluency -- this will cut real sentences.")
176
205
  block = {
177
- "lang": lang, "source": source, "engine": engine,
206
+ "lang": reported_lang, "source": source, "engine": engine,
178
207
  **({"transcription": transcription} if engine and transcription else {}),
179
208
  "words": sorted(wordlist), "removed": [dict(s) for s in spans],
180
209
  "removed_count": len(spans),