ffmpeg-skill 3.1.6 → 3.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +22 -6
- package/package.json +1 -1
- package/references/scripts.md +19 -6
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/decision.py +13 -1
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +20 -3
- package/scripts/_common/runner.py +136 -22
- package/scripts/_common/timeline.py +118 -23
- package/scripts/_contract.py +33 -2
- package/scripts/batch.py +175 -28
- package/scripts/caption.py +95 -11
- package/scripts/check.py +7 -0
- package/scripts/color.py +3 -0
- package/scripts/cut.py +3 -0
- package/scripts/export.py +22 -15
- package/scripts/metadata.py +9 -2
- package/scripts/render.py +39 -4
- package/scripts/silence.py +30 -5
- package/scripts/sync.py +3 -0
- package/scripts/waveform.py +8 -0
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.1.8`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.1.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.1.8", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -204,6 +204,11 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
204
204
|
| `execution` (writes a new artifact) | cut, fit, caption, overlay, graphics, multicam, audio, join, color, export, render, batch |
|
|
205
205
|
| `verification` (checks or shows an artifact) | check, look, verify, report |
|
|
206
206
|
|
|
207
|
+
`batch` writes one artifact per input only when a recipe step names `{out}` (or the recipe is a
|
|
208
|
+
render project): a recipe in which no step does (a QC pass of `check.py`/`probe.py`) writes none,
|
|
209
|
+
and each `results` row carries `written: false` beside its usual `output` path, which stays a
|
|
210
|
+
string (#415).
|
|
211
|
+
|
|
207
212
|
### Verification policy
|
|
208
213
|
|
|
209
214
|
The workflow in `SKILL.md` is "probe first, verify last". The contract states it per tool:
|
|
@@ -786,8 +791,10 @@ Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
|
786
791
|
| `matched_untimed`, `matched_too_long` | `silence.py --filler` (inside `filler`) | listed words that were not cut: no usable timing in the transcript (also a `filler_words_untimed` warning), or longer than a filler (`FILLER_MAX_WORD`, a held vowel) |
|
|
787
792
|
|
|
788
793
|
`caption.py --transcribe` and `silence.py --filler --transcribe`: when a local speech engine ran
|
|
789
|
-
and produced no cue
|
|
790
|
-
|
|
794
|
+
and produced no cue (for `--filler`: a Whisper transcript with no text at all; text without word
|
|
795
|
+
timings is a build without them, refused as such), the refusal is `"<engine> found no speech in
|
|
796
|
+
<input>"` with `kind: input`, `reason: "no_speech"` and `engine` in the failure document (the
|
|
797
|
+
three ASR reasons below are in `error.reason` too, with `error.details`) — distinct from the "no local
|
|
791
798
|
speech-to-text engine found" refusal, which now only means no engine was found. When `--engine
|
|
792
799
|
auto` found engines and none transcribed, the refusal is `kind: input` with `reason:
|
|
793
800
|
"engine_failed"` (one ran and failed) or `"english_only"` (the only engines found were Parakeet
|
|
@@ -865,8 +872,9 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
865
872
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
866
873
|
`media_path_is_url`, `media_path_is_playlist` (an HLS or ffconcat playlist given as media),
|
|
867
874
|
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
868
|
-
`render.py`'s `plan_*` reasons,
|
|
869
|
-
`encoder_list_unreadable
|
|
875
|
+
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
876
|
+
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
877
|
+
and `english_only`), and `error.details`, the facts the message is written from, so a
|
|
870
878
|
caller never parses the prose:
|
|
871
879
|
|
|
872
880
|
```
|
|
@@ -879,6 +887,14 @@ caller never parses the prose:
|
|
|
879
887
|
A tool that runs another (`render.py` stages and plans) forwards the child's `reason` and
|
|
880
888
|
`details` in its own `error`. Both are absent on a failure that is not a refusal, like `hint`.
|
|
881
889
|
|
|
890
|
+
A `timeout` or `interrupted` failure exits 124, or 130/143, whether the tool or a child tool it ran
|
|
891
|
+
was stopped (`export.py --normalize`'s loudness pass, `render.py`'s stages, check, executed plan and
|
|
892
|
+
pack destinations, `waveform.py`'s title and captions pass the child's code on). Its message ends "nothing was written" only when
|
|
893
|
+
that is so: once a tool has written its deliverable and runs a further step on it
|
|
894
|
+
(`export.py --normalize`, `render.py`'s chapters and check stages), the message names the file,
|
|
895
|
+
says it is kept without the step that was stopped, and the document carries it as `output`
|
|
896
|
+
(3.1.6, #415).
|
|
897
|
+
|
|
882
898
|
**Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
|
|
883
899
|
skipped -- is recorded as it happens (a stderr line too) and lands in the document as
|
|
884
900
|
`diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.8",
|
|
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
|
@@ -659,7 +659,8 @@ so the user can edit the list by hand. Quiet rooms need `--threshold -40`
|
|
|
659
659
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
660
660
|
|
|
661
661
|
**Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
|
|
662
|
-
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off).
|
|
662
|
+
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off). It is kept around
|
|
663
|
+
silences only: a filler word's cut is the word plus `--filler-pad` (since 3.1.6). `--speech-aware` keeps the short
|
|
663
664
|
breaths inside a sentence and cuts only at sentence-boundary pauses. A `--threshold` set too high
|
|
664
665
|
(-25, say) counts a weak word ending as silence: the result then warns.
|
|
665
666
|
|
|
@@ -852,7 +853,11 @@ OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
|
|
|
852
853
|
`LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
|
|
853
854
|
timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
|
|
854
855
|
track; inline `chapters` are markers. Times take the render's own grammar (`"0:05"`,
|
|
855
|
-
`hh:mm:ss:ff` at the source's fps
|
|
856
|
+
`hh:mm:ss:ff` at the source's fps; an `out` past a source's end ends at it, as the render
|
|
857
|
+
cuts it, with a note; a chapter metadata.py would refuse is refused). Source positions count
|
|
858
|
+
from each source's start timecode at its own frame rate (EDL source timecodes, drop-frame
|
|
859
|
+
where the source's is; the FCPXML asset's `start`; OTIO's `available_range`), so a conform
|
|
860
|
+
finds the frames (3.1.6). The sequence is the project's `frame` sized by
|
|
856
861
|
fit.py's rule (`{aspect 16:9, width 1920}` is 1920x1080; an aspect alone takes the export
|
|
857
862
|
preset's size when the preset has that aspect; an aspect fit.py refuses, such as `16/9`, is
|
|
858
863
|
refused unless the project's own `fit.aspect` replaces it in the render). A clip of another
|
|
@@ -1235,10 +1240,18 @@ above the cap is clamped with a note, not refused, and both `jobs` and
|
|
|
1235
1240
|
earlier has no such record and is processed again once, `entry_unverifiable`).
|
|
1236
1241
|
A `.ffskill_cache.json` that does not parse is started empty with a
|
|
1237
1242
|
`batch_cache_reset` warning and a line in `output_dir/.ffskill-audit.jsonl`.
|
|
1238
|
-
-
|
|
1239
|
-
`<workdir>/<index>-<
|
|
1240
|
-
|
|
1241
|
-
|
|
1243
|
+
- Each item's intermediates go in a new directory of its own under the work
|
|
1244
|
+
directory (`<workdir>/<index>-<random>/`), removed with them when the item
|
|
1245
|
+
ends, whatever the outcome (3.1.6, #415): step file names are stem-derived, so
|
|
1246
|
+
`clip.mp4` and `clip.mov` sharing a directory, or a rerun meeting an earlier
|
|
1247
|
+
run's files, were refused as someone else's. A step that names no `{out}`
|
|
1248
|
+
(`check.py`) passes its input on; when the last step writes nothing, the last
|
|
1249
|
+
file written is delivered as the item's output, under its own extension (`.mp4`
|
|
1250
|
+
for a video source) unless the recipe sets `ext`. A recipe in which no step names
|
|
1251
|
+
`{out}` (a QC pass: `check.py`/`probe.py` on every file) writes nothing: each
|
|
1252
|
+
row has `written: false` (`output` keeps its path string), a note says so, and `ok` follows the steps' exit codes.
|
|
1253
|
+
Final names that differ only in case are a
|
|
1254
|
+
collision (they are one file on macOS and Windows).
|
|
1242
1255
|
- `--watch` composes: each pass is parallel, passes are sequential.
|
|
1243
1256
|
|
|
1244
1257
|
### caption.py --transcribe — optional local speech-to-text
|
package/scripts/_common/asr.py
CHANGED
|
@@ -115,7 +115,9 @@ def die_no_speech(engine: str, video: str) -> None:
|
|
|
115
115
|
"""An engine ran and heard nothing: kind input, `reason: "no_speech"`, naming the engine and
|
|
116
116
|
the input -- not the "no engine found" refusal (one was found) and not "no cues found in"
|
|
117
117
|
the engine's own temporary SRT (a path the caller never gave and that is already deleted)."""
|
|
118
|
+
# the reason is error.reason as every refusal's is (#414), and stays top-level where 2.x put it
|
|
118
119
|
die(f"{engine} found no speech in {video}", kind="input", reason=NO_SPEECH_REASON, engine=engine,
|
|
120
|
+
error_extra={"reason": NO_SPEECH_REASON, "details": {"engine": engine}},
|
|
119
121
|
hint="check that the input (and --audio-stream) carries the speech, or write the cues by hand with --text")
|
|
120
122
|
|
|
121
123
|
|
|
@@ -135,6 +137,7 @@ def die_engine_failed(engine: str, video: str, tried: "List[Dict[str, str]]", in
|
|
|
135
137
|
detail = next((t["detail"] for t in tried if t["engine"] == engine), "failed")
|
|
136
138
|
die(f"--engine {engine} could not transcribe {video}: {engine} {detail}", kind="input",
|
|
137
139
|
reason=ENGINE_FAILED_REASON, engine=engine, detail=detail,
|
|
140
|
+
error_extra={"reason": ENGINE_FAILED_REASON, "details": {"engine": engine, "detail": detail}},
|
|
138
141
|
hint="fix the engine (its own message is in detail), or use --engine auto to fall back to another installed engine")
|
|
139
142
|
|
|
140
143
|
|
|
@@ -152,7 +155,9 @@ def die_engines_failed(tried: "List[Dict[str, str]]", video: str, flag: str, alt
|
|
|
152
155
|
die(f"no installed speech-to-text engine could transcribe {video} for {flag}:\n"
|
|
153
156
|
+ "\n".join(f" {t['engine']} {t['detail']}" for t in tried) + "\n" + alternative,
|
|
154
157
|
kind="input", reason=ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
|
|
155
|
-
engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint
|
|
158
|
+
engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint,
|
|
159
|
+
error_extra={"reason": ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
|
|
160
|
+
"details": {"engine": (failed or tried)[0]["engine"], "engines": [dict(t) for t in tried]}})
|
|
156
161
|
|
|
157
162
|
|
|
158
163
|
def _failed(engine: str, detail: str) -> "Dict[str, str]":
|
|
@@ -317,15 +322,11 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
317
322
|
parakeet_model: Optional[str] = None) -> Transcription:
|
|
318
323
|
"""`model` is what the Whisper engines run; the Parakeet engines are offered `parakeet_model`
|
|
319
324
|
(default `model`), which differs only for a Parakeet model from $FFMPEG_SKILL_ASR_MODEL."""
|
|
320
|
-
from _common import
|
|
325
|
+
from _common import STATE
|
|
321
326
|
pmodel = model if parakeet_model is None else parakeet_model
|
|
322
327
|
language = engine_language(language)
|
|
323
328
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
324
|
-
|
|
325
|
-
# is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
|
|
326
|
-
# reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
|
|
327
|
-
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
328
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
329
|
+
engine_wav(ffmpeg, video, audio_stream, wav)
|
|
329
330
|
# every engine found that did not transcribe, and why: the refusal names them rather than
|
|
330
331
|
# telling the caller to install what is installed
|
|
331
332
|
tried: "List[Dict[str, str]]" = []
|
|
@@ -980,6 +981,37 @@ def _heard_nothing(text: str, key: str) -> bool:
|
|
|
980
981
|
return isinstance(doc, dict) and doc.get(key) == []
|
|
981
982
|
|
|
982
983
|
|
|
984
|
+
def _whisper_heard_nothing(path: str, key: str) -> bool:
|
|
985
|
+
"""True when a Whisper engine's JSON is well formed and none of its segments (`key`:
|
|
986
|
+
whisper.cpp's "transcription", openai-whisper's "segments") holds any text: the engine heard
|
|
987
|
+
no speech (#414). A document with text and no word timings is a build without them, not
|
|
988
|
+
silence, and stays the "no word-level timings" refusal."""
|
|
989
|
+
try:
|
|
990
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
991
|
+
except (OSError, ValueError):
|
|
992
|
+
return False
|
|
993
|
+
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
|
994
|
+
return False
|
|
995
|
+
# every segment a dict with a text field, all of them blank: a segment with no `text` at all is
|
|
996
|
+
# output this skill cannot read, not silence (as _heard_nothing() reads Parakeet's)
|
|
997
|
+
return all(isinstance(seg, dict) and isinstance(seg.get("text"), str) and not seg["text"].strip()
|
|
998
|
+
for seg in doc[key])
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def engine_wav(ffmpeg: str, video: str, audio_stream: int, wav: str) -> None:
|
|
1002
|
+
"""The 16 kHz mono WAV an engine transcribes, on the file's clock: a sound that starts after
|
|
1003
|
+
the file's start is padded with silence to its place (aresample first_pts=0, as
|
|
1004
|
+
decode_pcm_mono() does since #409), so every cue and word time counts from the file's start,
|
|
1005
|
+
not from the sound's first sample (#414: a sound 0.276 s late gave cues 0.276 s early).
|
|
1006
|
+
A measurement input in our own temp dir, not a deliverable, so not a run() call (no
|
|
1007
|
+
--dry-run gate, not recorded); it keeps the time limit and reports an unreadable input as
|
|
1008
|
+
kind ffmpeg instead of a CalledProcessError traceback."""
|
|
1009
|
+
from _common import run_analysis
|
|
1010
|
+
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
1011
|
+
"-map", f"0:a:{audio_stream}", "-vn", "-af", "aresample=async=1:first_pts=0",
|
|
1012
|
+
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
1013
|
+
|
|
1014
|
+
|
|
983
1015
|
def transcribe_words(video: str, language: "Optional[str]" = None, model: "Optional[str]" = None,
|
|
984
1016
|
audio_stream: int = 0, engine: "Optional[str]" = None) -> "Tuple[List[Dict[str, Any]], Optional[str]]":
|
|
985
1017
|
"""transcribe_words_result()'s (words, engine) alone: the call shape this function has had
|
|
@@ -1021,9 +1053,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1021
1053
|
tmpdir = _tempfile.mkdtemp(prefix="ffskill_asrw_")
|
|
1022
1054
|
try:
|
|
1023
1055
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
1024
|
-
|
|
1025
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000",
|
|
1026
|
-
"-c:a", "pcm_s16le", wav])
|
|
1056
|
+
engine_wav(ffmpeg, video, audio_stream, wav)
|
|
1027
1057
|
|
|
1028
1058
|
tried: "List[Dict[str, str]]" = [] # engines found that gave no words, and why
|
|
1029
1059
|
|
|
@@ -1065,6 +1095,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1065
1095
|
proc = _asr_run(cmd, _subprocess, "whisper.cpp")
|
|
1066
1096
|
if proc.returncode == 0 and os.path.exists(base + ".json"):
|
|
1067
1097
|
words = _words_from_whisper_cpp_json(base + ".json")
|
|
1098
|
+
if not words and _whisper_heard_nothing(base + ".json", "transcription"):
|
|
1099
|
+
die_no_speech("whisper.cpp", video)
|
|
1068
1100
|
info(f"word timings from whisper.cpp ({len(words)} words)")
|
|
1069
1101
|
return Transcription(words=words, engine="whisper.cpp",
|
|
1070
1102
|
facts=dict(facts, engine="whisper.cpp", model=model_path,
|
|
@@ -1083,6 +1115,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1083
1115
|
collected: list = []
|
|
1084
1116
|
crashed: list = []
|
|
1085
1117
|
heard: list = []
|
|
1118
|
+
spoke: list = [] # a segment with text: speech was heard, word timings or not
|
|
1086
1119
|
|
|
1087
1120
|
def work() -> None:
|
|
1088
1121
|
try:
|
|
@@ -1091,6 +1124,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1091
1124
|
**({"initial_prompt": prompt} if prompt else {}))
|
|
1092
1125
|
heard.append(getattr(decoded, "language", None))
|
|
1093
1126
|
for seg in segments:
|
|
1127
|
+
if str(getattr(seg, "text", "") or "").strip():
|
|
1128
|
+
spoke.append(True)
|
|
1094
1129
|
for w in (getattr(seg, "words", None) or []):
|
|
1095
1130
|
collected.append({"word": str(w.word).strip(),
|
|
1096
1131
|
"start": float(w.start), "end": float(w.end)})
|
|
@@ -1109,6 +1144,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1109
1144
|
tried.append(_failed("faster-whisper", "found but failed: " + line))
|
|
1110
1145
|
failed = failed or "faster-whisper"
|
|
1111
1146
|
else:
|
|
1147
|
+
if not collected and not spoke:
|
|
1148
|
+
die_no_speech("faster-whisper", video)
|
|
1112
1149
|
info(f"word timings from faster-whisper ({len(collected)} words)")
|
|
1113
1150
|
return Transcription(words=[w for w in collected if w["word"]], engine="faster-whisper",
|
|
1114
1151
|
facts=dict(facts, engine="faster-whisper", model=model,
|
|
@@ -1129,6 +1166,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1129
1166
|
doc = os.path.join(tmpdir, "audio.json")
|
|
1130
1167
|
if proc.returncode == 0 and os.path.exists(doc):
|
|
1131
1168
|
words = _words_from_openai_whisper_json(doc)
|
|
1169
|
+
if not words and _whisper_heard_nothing(doc, "segments"):
|
|
1170
|
+
die_no_speech("openai-whisper", video)
|
|
1132
1171
|
info(f"word timings from openai-whisper ({len(words)} words)")
|
|
1133
1172
|
return Transcription(words=words, engine="openai-whisper",
|
|
1134
1173
|
facts=dict(facts, engine="openai-whisper", model=model,
|
|
@@ -114,9 +114,21 @@ def frame_size(ratio: Optional[Fraction], width: Optional[int], height: Optional
|
|
|
114
114
|
SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
# Source containers the tools' own encoders (x264/x265/SVT-AV1 and AAC) cannot be written into: WebM
|
|
118
|
+
# takes only VP8/VP9/AV1 and Vorbis/Opus. A default output name keeps the source's extension
|
|
119
|
+
# except for these, whose default is .mp4 (#415): the dry run planned `clip_loudnorm.webm` and the
|
|
120
|
+
# real run then failed in the muxer.
|
|
121
|
+
NO_DEFAULT_ENCODE_EXT = {".webm"}
|
|
122
|
+
|
|
123
|
+
|
|
117
124
|
def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> str:
|
|
118
125
|
p = Path(input_path)
|
|
119
|
-
|
|
126
|
+
if ext:
|
|
127
|
+
new_ext = ext
|
|
128
|
+
elif p.suffix.lower() in NO_DEFAULT_ENCODE_EXT:
|
|
129
|
+
new_ext = "mp4"
|
|
130
|
+
else:
|
|
131
|
+
new_ext = p.suffix.lstrip(".") or "mp4"
|
|
120
132
|
return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
|
|
121
133
|
|
|
122
134
|
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -61,6 +61,13 @@ def resolve_font(font_name: str) -> "Dict[str, object]":
|
|
|
61
61
|
# common CJK system fonts when the request looks CJK (so Japanese text does not render as
|
|
62
62
|
# boxes in Arial), and Arial only as the last resort.
|
|
63
63
|
wanted = _family_key(font_name)
|
|
64
|
+
# The family's own file first, from the table of what Windows installs under which file
|
|
65
|
+
# name (#414): "Malgun Gothic" is malgun.ttf and "Times New Roman" times.ttf, which no
|
|
66
|
+
# file-name prefix finds -- the prefix rule resolved them to YuGothM.ttc and arial.ttf and
|
|
67
|
+
# reported a font that is installed as substituted.
|
|
68
|
+
for name in windows_family_files(wanted):
|
|
69
|
+
if (fonts / name).is_file():
|
|
70
|
+
return {"file": str(fonts / name), "family": font_name, "substituted": False, "via": "windows_fonts"}
|
|
64
71
|
try:
|
|
65
72
|
files = sorted(fonts.iterdir()) if fonts.is_dir() else []
|
|
66
73
|
except OSError:
|
|
@@ -199,6 +206,25 @@ WINDOWS_FONTS = {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
|
|
209
|
+
# Latin families Windows installs that WINDOWS_FONTS (a per-script table) does not name, by the
|
|
210
|
+
# file each one is installed as: a file name that does not start with the family's name
|
|
211
|
+
WINDOWS_LATIN_FONTS = [("arial.ttf", "Arial"), ("times.ttf", "Times New Roman"), ("cour.ttf", "Courier New"),
|
|
212
|
+
("segoeui.ttf", "Segoe UI"), ("tahoma.ttf", "Tahoma"), ("verdana.ttf", "Verdana"),
|
|
213
|
+
("georgia.ttf", "Georgia"), ("calibri.ttf", "Calibri"), ("cambria.ttc", "Cambria"),
|
|
214
|
+
("consola.ttf", "Consolas"), ("trebuc.ttf", "Trebuchet MS"), ("comic.ttf", "Comic Sans MS"),
|
|
215
|
+
("impact.ttf", "Impact"), ("seguiemj.ttf", "Segoe UI Emoji")]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def windows_family_files(wanted_key: str) -> "List[str]":
|
|
219
|
+
"""The Windows Fonts file names of the family whose _family_key() is `wanted_key`, best first,
|
|
220
|
+
from WINDOWS_FONTS and WINDOWS_LATIN_FONTS ([] for a family neither names)."""
|
|
221
|
+
out: "List[str]" = []
|
|
222
|
+
for name, family in [e for entries in WINDOWS_FONTS.values() for e in entries] + WINDOWS_LATIN_FONTS:
|
|
223
|
+
if wanted_key and _family_key(family) == wanted_key and name not in out:
|
|
224
|
+
out.append(name)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
202
228
|
_SCRIPT_RANGES = (
|
|
203
229
|
("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
|
|
204
230
|
("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
|
package/scripts/_common/probe.py
CHANGED
|
@@ -146,23 +146,40 @@ def _tag(entry: Dict[str, Any], key: str) -> Optional[str]:
|
|
|
146
146
|
return next((v for k, v in (entry.get("tags") or {}).items() if k.lower() == key), None)
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
def _nominal_fps(video: Optional[Dict[str, Any]]) -> Optional[int]:
|
|
150
|
+
"""The integer timecode rate of the picture (30 for 29.97), from avg_frame_rate, else
|
|
151
|
+
r_frame_rate: the order probe()'s `fps` uses, which the timeline export validates against."""
|
|
152
|
+
for key in ("avg_frame_rate", "r_frame_rate"):
|
|
153
|
+
try:
|
|
154
|
+
num, _, den = str((video or {}).get(key) or "").partition("/")
|
|
155
|
+
rate = float(num) / float(den or 1)
|
|
156
|
+
except (ValueError, ZeroDivisionError):
|
|
157
|
+
continue
|
|
158
|
+
if 0 < rate <= 240:
|
|
159
|
+
return int(round(rate))
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
149
163
|
def start_timecode(streams: List[Dict[str, Any]], fmt: Dict[str, Any],
|
|
150
164
|
video: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
151
165
|
"""The first well-formed `timecode` tag, from the video stream, then a `tmcd` data stream,
|
|
152
166
|
then the container: {value, drop_frame, source}. `drop_frame` is the `;` (or `.`) separator
|
|
153
167
|
before the frames field; `source` is `stream:<ffprobe index>` or `format`. None when no
|
|
154
|
-
source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF
|
|
155
|
-
repaired."""
|
|
168
|
+
source carries one (all three null). A tag that is not HH:MM:SS[:;.]FF, or whose frames field
|
|
169
|
+
the picture's rate cannot reach, is skipped, not repaired."""
|
|
156
170
|
candidates = []
|
|
157
171
|
if video is not None:
|
|
158
172
|
candidates.append((video, f"stream:{video.get('index')}"))
|
|
159
173
|
candidates += [(s, f"stream:{s.get('index')}") for s in streams
|
|
160
174
|
if s.get("codec_type") == "data" and str(s.get("codec_tag_string", "")).lower() == "tmcd"]
|
|
161
175
|
candidates.append((fmt, "format"))
|
|
176
|
+
nominal = _nominal_fps(video)
|
|
162
177
|
for entry, source in candidates:
|
|
163
178
|
value = _tag(entry, "timecode")
|
|
164
179
|
m = _TIMECODE.match(value.strip()) if isinstance(value, str) else None
|
|
165
|
-
|
|
180
|
+
# a frames field the picture's rate cannot reach (frame 29 at 25 fps) is no timecode
|
|
181
|
+
# either: the next candidate (a tmcd track's own tag) may still hold a real one (#427)
|
|
182
|
+
if m and not (nominal and int(m.group(5)) >= nominal):
|
|
166
183
|
return {"value": value.strip(), "drop_frame": m.group(4) in ";.", "source": source}
|
|
167
184
|
return {"value": None, "drop_frame": None, "source": None}
|
|
168
185
|
|