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 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.6`) | any release |
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.6", "execution_mode": "local", "kind": "execution",
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, the refusal is `"<engine> found no speech in <input>"` with `kind: input`,
790
- `reason: "no_speech"` and `engine` in the failure document — distinct from the "no local
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, and `--codec av1`/`prores`'s `encoder_missing` and
869
- `encoder_list_unreadable`), and `error.details`, the facts the message is written from, so a
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.6",
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",
@@ -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). `--speech-aware` keeps the short
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), and the sequence is the project's `frame` sized by
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
- - With `--jobs > 1` each item gets its own work subdirectory
1239
- `<workdir>/<index>-<stem>/`, because step file names are stem-derived and two
1240
- sources sharing a stem would otherwise write over each other. `--jobs 1` (the
1241
- default) keeps the flat layout and is 1.16's path exactly.
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
@@ -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 run_analysis, STATE
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
- # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
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
- run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
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
- new_ext = ext if ext else p.suffix.lstrip(".") or "mp4"
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
 
@@ -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
@@ -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 is skipped, not
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
- if m:
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