ffmpeg-skill 2.8.0 → 2.8.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/README.md +1 -1
- package/docs/contract.md +6 -5
- package/package.json +1 -1
- package/references/scripts.md +1 -1
- package/scripts/_common/asr.py +53 -9
- package/scripts/_common/drawtext.py +4 -1
- package/scripts/_common/emit.py +12 -3
- package/scripts/_common/runner.py +19 -3
- package/scripts/_contract.py +15 -9
- package/scripts/graphics.py +30 -7
- package/scripts/silence.py +32 -3
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
|
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.8.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`
|
|
@@ -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.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.8.1", "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
|
|
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.
|
|
3
|
+
"version": "2.8.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",
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
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
|
package/scripts/_common/asr.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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)
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
-
|
|
77
|
-
"VideoToolbox's larger files cost most)
|
|
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(
|
|
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
|
|
|
@@ -885,9 +885,10 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
885
885
|
why = videotoolbox_failure(proc.stderr or "")
|
|
886
886
|
if why is not None:
|
|
887
887
|
reason = f"VideoToolbox refused the encode: {why}"
|
|
888
|
+
# said before the retry, without a claim about its outcome: a retry that never
|
|
889
|
+
# returns (a timeout exits from inside _execute) leaves this line, which is true
|
|
890
|
+
_set_refusal_note(ctx, reason, "; retrying on the CPU encoder")
|
|
888
891
|
info(reason + "; retrying on the CPU encoder")
|
|
889
|
-
ctx.hw_notes.append(reason + "; re-encoded on the CPU")
|
|
890
|
-
ctx.hw_fallback = True
|
|
891
892
|
ctx.commands[-1] = _cmdline(cpu_cmd[:-1] + [cmd[-1]])
|
|
892
893
|
if not quiet:
|
|
893
894
|
# echoed like the first attempt, so a caller that reads the "$ " lines
|
|
@@ -895,6 +896,11 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
895
896
|
info("$ " + ctx.commands[-1], ctx=ctx)
|
|
896
897
|
proc, tried = even_retry(_execute(cpu_cmd), cpu_cmd)
|
|
897
898
|
retried = True
|
|
899
|
+
# the outcome replaces the line above: a failure document must not claim a
|
|
900
|
+
# re-encode that did not happen, and hw.fallback is the same claim as a flag
|
|
901
|
+
ok = proc.returncode == 0
|
|
902
|
+
_set_refusal_note(ctx, reason, "; re-encoded on the CPU" if ok else "; the CPU retry failed too")
|
|
903
|
+
ctx.hw_fallback = ctx.hw_fallback or ok
|
|
898
904
|
if proc.returncode != 0 and check:
|
|
899
905
|
_fail(tried, proc.returncode, proc.stderr or "", ctx=ctx, retried=retried)
|
|
900
906
|
if final and tmp:
|
|
@@ -910,6 +916,14 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
910
916
|
return proc
|
|
911
917
|
|
|
912
918
|
|
|
919
|
+
def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
|
|
920
|
+
"""One note per refusal: the latest outcome for `reason` replaces any earlier line for it, so a
|
|
921
|
+
job of many encodes names it once, and a process that ran an earlier job with the same refusal
|
|
922
|
+
does not keep that job's outcome beside this one."""
|
|
923
|
+
ctx.hw_notes[:] = [n for n in ctx.hw_notes if not n.startswith(reason)]
|
|
924
|
+
ctx.hw_notes.append(reason + outcome)
|
|
925
|
+
|
|
926
|
+
|
|
913
927
|
def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
|
|
914
928
|
"""One attempt, never exiting on failure (run() decides after its retries)."""
|
|
915
929
|
if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
|
|
@@ -1257,7 +1271,9 @@ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
|
|
|
1257
1271
|
# symlink guard is belt and braces there and unavailable on Windows
|
|
1258
1272
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
1259
1273
|
fd = os.open(path, flags, 0o600)
|
|
1260
|
-
|
|
1274
|
+
# newline="\n": text mode would write each kept line break as CRLF on Windows, and
|
|
1275
|
+
# drawtext from FFmpeg 6.1 draws that as a blank line plus a line break
|
|
1276
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
1261
1277
|
fh.write(body)
|
|
1262
1278
|
written.append(path)
|
|
1263
1279
|
return written
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
522
|
-
#
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
844
|
-
|
|
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
|
|
|
@@ -1439,7 +1445,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1439
1445
|
# first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
|
|
1440
1446
|
# process. A caller that sandboxes the network must know that before a first --transcribe.
|
|
1441
1447
|
"model_downloads": {
|
|
1442
|
-
"when": "first use of a speech engine (--transcribe) whose model is not cached yet; media is never uploaded",
|
|
1448
|
+
"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
1449
|
"in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
|
|
1444
1450
|
"child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
|
|
1445
1451
|
"openai-whisper": "OpenAI (its model checkpoint)"},
|
|
@@ -1513,7 +1519,7 @@ def main() -> int:
|
|
|
1513
1519
|
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
1520
|
gpu = d["gpu_encoders"]
|
|
1515
1521
|
if gpu["status"] == "parsed":
|
|
1516
|
-
print(f"GPU-backed encoders in this build: {len(gpu['present'])} (
|
|
1522
|
+
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
1523
|
print(_fonts_summary_line(d["fonts"]))
|
|
1518
1524
|
print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
|
|
1519
1525
|
for err in d["errors"]:
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
-
|
|
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 +
|
|
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+{
|
|
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+{
|
|
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 +
|
|
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
|
-
|
|
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}",
|
package/scripts/silence.py
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
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":
|
|
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),
|