ffmpeg-skill 2.5.1 → 2.7.0

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/scripts/batch.py CHANGED
@@ -33,6 +33,8 @@ from pathlib import Path
33
33
  from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
36
+ from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw
37
+ from _common.emit import absorb_stage_hw, steps_encoder_report
36
38
 
37
39
  HERE = Path(__file__).resolve().parent
38
40
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -73,6 +75,18 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
73
75
  return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
74
76
 
75
77
 
78
+ def gpu_key() -> str:
79
+ """The GPU setting the steps resolve, as a suffix for the item cache key: a VideoToolbox file is
80
+ not the CPU's bytes, so a --hw output is never served to a --no-hw run or the other way round
81
+ (render.py's cache_key folds the same two variables in). Read after apply_common(), which has
82
+ exported --hw/--no-hw to the steps. '' when every step encodes on the CPU -- no flag and no
83
+ $FFMPEG_SKILL_HW, or --no-hw -- so the keys of a cache written before --hw existed still hit."""
84
+ if not env_hw():
85
+ return ""
86
+ # the variable alone leaves export.py's delivery presets on the CPU; --hw puts them on the GPU
87
+ return ":hw" if os.environ.get(HW_FORCED_ENV) == "1" else ":hw-env"
88
+
89
+
76
90
  JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
77
91
 
78
92
  _LOG = threading.local()
@@ -157,6 +171,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
157
171
  pj = work / f"{src.stem}_project.json"
158
172
  pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
159
173
  ok, _doc = run_step(["render.py", str(pj)], budget())
174
+ step_docs = [("render", _doc)]
160
175
  cut_reencoded: List[bool] = []
161
176
  else:
162
177
  steps = recipe.get("steps") or []
@@ -165,11 +180,13 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
165
180
  cur = str(src)
166
181
  ok = True
167
182
  cut_reencoded = []
183
+ step_docs = []
168
184
  for i, step in enumerate(steps):
169
185
  last = i == len(steps) - 1
170
186
  out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
171
187
  argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
172
188
  step_ok, doc = run_step(argv, budget())
189
+ step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
173
190
  # only cut.py's own doc carries `reencoded` -- true if ANY range this call cut needed
174
191
  # the tolerance-triggered hybrid re-encode fallback (cut.py ORs its per-segment results
175
192
  # into one top-level field; it doesn't report which range, so this is per cut.py call,
@@ -183,6 +200,9 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
183
200
  result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
184
201
  if cut_reencoded:
185
202
  result["cut_reencoded"] = cut_reencoded
203
+ # the encoder each item really ran and its GPU facts (--hw / FFMPEG_SKILL_HW: on VideoToolbox,
204
+ # fell back and why): this process encodes nothing itself, so its top-level hw.used is null
205
+ result.update(steps_encoder_report(step_docs))
186
206
  return result
187
207
 
188
208
 
@@ -199,6 +219,7 @@ def main() -> int:
199
219
  "Default 1, which is 1.16's behaviour exactly.")
200
220
  ap.add_argument("--work", help="work directory for intermediates (default: <output_dir>/.work)")
201
221
  add_common(ap)
222
+ add_hw_orchestrator_args(ap)
202
223
  args = ap.parse_args()
203
224
  apply_common(args)
204
225
 
@@ -240,7 +261,7 @@ def main() -> int:
240
261
  cache = json.loads(cache_path.read_text(encoding="utf-8"))
241
262
  except ValueError:
242
263
  cache = {}
243
- rkey = recipe_key(recipe)
264
+ rkey = recipe_key(recipe) + gpu_key()
244
265
  glob = recipe.get("glob") or "*"
245
266
 
246
267
  # --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
@@ -303,6 +324,10 @@ def main() -> int:
303
324
  # write is already atomic: two finishers could otherwise serialise from two different
304
325
  # snapshots and lose an entry.
305
326
  with cache_lock:
327
+ # this run just rewrote r["output"]: an entry under another key (another recipe,
328
+ # the other GPU setting) that names the same file now describes bytes that are gone
329
+ for stale in [k for k, v in cache.items() if k != key and isinstance(v, dict) and v.get("output") == r["output"]]:
330
+ del cache[stale]
306
331
  cache[key] = r
307
332
  # write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
308
333
  # a concurrent manual run) could leave a truncated file that json.loads() above
@@ -507,6 +532,10 @@ def main() -> int:
507
532
  results = one_pass()
508
533
  except KeyboardInterrupt:
509
534
  info("watch stopped")
535
+ # the items' GPU facts (asked for, fell back and why) reach the top-level `hw`, as render.py's
536
+ # stages' do; `used` stays null there, because this process encoded nothing itself
537
+ for r in results:
538
+ absorb_stage_hw(Path(r["file"]).name, r)
510
539
  done = sum(1 for r in results if r["ok"])
511
540
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
512
541
  if not args.json:
@@ -49,6 +49,8 @@ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji
49
49
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
50
50
  # shares them). They stay caption.py's public names -- every caller and test that reached for
51
51
  # caption.parse_srt / caption.transcribe / caption.whisper_word_timings before 1.17 still does.
52
+ from _common import asr as _asr
53
+ from _common.asr import ENGINE_CHOICES
52
54
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
53
55
  write_srt)
54
56
  from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
@@ -901,7 +903,7 @@ def main() -> int:
901
903
  "them; .mkv is the honest multi-track container and stores the code you give verbatim")
902
904
  src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
903
905
  src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
904
- src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
906
+ src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (parakeet-mlx / parakeet.cpp / whisper-cli / whisper / faster-whisper); never required")
905
907
  src.add_argument("--language", "--lang", help="language code (e.g. en, ja, zh, ko): the language for --transcribe (default auto), "
906
908
  "the tag on the subtitle stream with --mode mux, and the hint that says whether Han-only "
907
909
  "text is Chinese, Japanese or Korean when a font is picked by script")
@@ -914,7 +916,12 @@ def main() -> int:
914
916
  "(default: none, so no player burns in a language the viewer did not ask for)")
915
917
  src.add_argument("--offset", default="0", help="shift every cue by TIME (seconds, mm:ss, hh:mm:ss.ms or "
916
918
  "hh:mm:ss:ff; a leading - shifts earlier); works for --text, --srt and --ass")
917
- src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
919
+ src.add_argument("--model", default="base",
920
+ help="speech model for --transcribe: a whisper model name/path (default base), or a Parakeet "
921
+ "model (an mlx-community/parakeet-* repo or a .gguf) for the Parakeet engines")
922
+ src.add_argument("--engine", choices=ENGINE_CHOICES, default=None,
923
+ help="speech engine for --transcribe (default: $FFMPEG_SKILL_ASR_ENGINE, else auto = Parakeet "
924
+ "for English speech when installed, else whisper.cpp / faster-whisper / openai-whisper)")
918
925
  src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
919
926
  src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
920
927
  src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
@@ -1241,8 +1248,13 @@ def main() -> int:
1241
1248
  cues = []
1242
1249
  info(f"[dry-run] would transcribe {args.input} and write {srt_path}")
1243
1250
  else:
1244
- cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
1245
- args._word_timings = whisper_word_timings(srt_path)
1251
+ # the call's own result, not module state: engine, routing and words belong to this run
1252
+ asr_run = _asr.transcribe_result(args.input, srt_path, args.language, args.model, args.audio_stream,
1253
+ engine=args.engine)
1254
+ cues = asr_run.cues
1255
+ args._asr = dict(asr_run.facts)
1256
+ args._asr_notes = list(asr_run.notes)
1257
+ args._word_timings = whisper_word_timings(srt_path) or sorted((w["start"], w["end"], w["word"]) for w in asr_run.words)
1246
1258
  cues, changed = lay_out(cues)
1247
1259
  if changed:
1248
1260
  write_srt(cues, srt_path)
@@ -1279,7 +1291,8 @@ def main() -> int:
1279
1291
  # WRITING waits for a real run (the rule every side file in this tool follows), which is why
1280
1292
  # the cues are kept in hand below for the font sample and the ASS generator.
1281
1293
  planned_cues: Optional[List[Tuple[float, float, str]]] = None
1282
- side_notes: List[str] = []
1294
+ # an English-only transcript made on an assumption says so first (asr.assumed_english_note)
1295
+ side_notes: List[str] = list(getattr(args, "_asr_notes", None) or [])
1283
1296
  ass_sample_path = args.ass
1284
1297
  if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
1285
1298
  adjusted, changed = lay_out(parse_srt(srt_path))
@@ -1416,7 +1429,8 @@ def main() -> int:
1416
1429
  result = probe(output, role="output")
1417
1430
  info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, {len(added)} "
1418
1431
  f"subtitle track(s) added, codec {codec})")
1419
- emit(output, tracks=tracks, subtitle_tracks=total, **({"notes": notes} if notes else {}))
1432
+ emit(output, tracks=tracks, subtitle_tracks=total, **({"notes": notes} if notes else {}),
1433
+ **({"transcription": args._asr} if getattr(args, "_asr", None) else {}))
1420
1434
  return 0
1421
1435
 
1422
1436
  # A font that covers the text, before anything is rendered: non-Latin cues in a Latin-only
@@ -1582,6 +1596,8 @@ def main() -> int:
1582
1596
  info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
1583
1597
  "and type size only)")
1584
1598
  extra["caption"] = dict(caption_stats)
1599
+ if getattr(args, "_asr", None):
1600
+ extra["transcription"] = args._asr
1585
1601
  if emoji_plan:
1586
1602
  notes = list(extra.get("notes") or [])
1587
1603
  if emoji_plan["mode"] == "mono":
package/scripts/export.py CHANGED
@@ -37,6 +37,7 @@ from pathlib import Path
37
37
  from typing import Dict, List
38
38
 
39
39
  from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
40
+ from _common.decision import hw_preset_video, restate_last_swap
40
41
  from check import SPECS as PLATFORMS, measure_loudness
41
42
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
42
43
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -190,20 +191,28 @@ def main() -> int:
190
191
  "veryfast" if STATE.fast else "medium", meta)
191
192
  # encoder_args() already applied --fast (its own preset scale per encoder: SVT-AV1 counts
192
193
  # 1..12, not x264's names) and appends +faststart, which this tool adds again for mp4
194
+ as_built = list(video)
193
195
  while "-movflags" in video:
194
196
  i = video.index("-movflags")
195
197
  del video[i:i + 2]
198
+ restate_last_swap(as_built, video)
196
199
  if args.crf is not None and "-crf" in video:
197
200
  video[video.index("-crf") + 1] = str(args.crf)
198
201
  if STATE.fast and "-preset" in video and not p.get("codec"):
199
202
  video[video.index("-preset") + 1] = "veryfast"
203
+ # a fixed preset's line is tagged BT.709 (prores, an editing master, keeps the source's tags)
204
+ tag_bt709 = args.preset not in ("prores",) and not p.get("codec")
205
+ if STATE.hw and not p.get("codec") and video:
206
+ # --hw (never $FFMPEG_SKILL_HW here) puts a fixed preset on VideoToolbox: the same codec,
207
+ # its CRF mapped to -q:v, the preset's frame rate kept; tags come with the VT line itself
208
+ video = hw_preset_video(video, meta, bt709=tag_bt709)
200
209
  cmd += video
201
210
  if args.preset != "copy":
202
211
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
203
212
  # no longer be a copy, and would silently mislabel colour the agent never actually looked at
204
213
  if "-r" not in video:
205
214
  cmd += cfr_args(meta)
206
- if args.preset not in ("prores",) and not p.get("codec"):
215
+ if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
207
216
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
208
217
  if out_ext == "mp4":
209
218
  cmd += ["-movflags", "+faststart"]
package/scripts/render.py CHANGED
@@ -65,6 +65,8 @@ from typing import Any, Dict, List, Optional, Sequence
65
65
  from export import PRESETS, PLATFORM_OF
66
66
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
67
67
  from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
68
+ from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
69
+ from _common.emit import absorb_stage_hw
68
70
  import subprocess
69
71
  from _contract import CONTRACT_VERSION
70
72
  from batch import file_key
@@ -354,6 +356,7 @@ def render_pack(names: List[str], args) -> int:
354
356
  if line.startswith("$ ") or line.startswith("[dry-run]"):
355
357
  STATE.commands.append(line[2:] if line.startswith("$ ") else line)
356
358
  STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
359
+ absorb_stage_hw(name, doc)
357
360
  out = doc.get("output") or dest_out
358
361
  chk = doc.get("check") or {}
359
362
  ok = proc.returncode == 0 and doc.get("status") == "completed"
@@ -541,16 +544,29 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
541
544
  # needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
542
545
  err = doc.get("error") or {}
543
546
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
547
+ extra_fields.update(_stage_failure_hw(Path(script).stem, doc))
544
548
  die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
545
549
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
546
550
  _LAST_DOC.clear()
547
551
  _LAST_DOC.update(doc if isinstance(doc, dict) else {})
552
+ absorb_stage_hw(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) reach this result
548
553
  out_path = str(doc.get("output") or "")
549
554
  if key:
550
555
  cache_store(stage, key, out_path or (dest or ""), time.time() - started)
551
556
  return out_path
552
557
 
553
558
 
559
+ def _stage_failure_hw(stage: str, doc: Any) -> Dict[str, Any]:
560
+ """A failed stage's GPU facts, for render's own failure document: the stage's `hw` joins the
561
+ earlier stages' (a fallback before the failure is not lost), and its `encoder` -- the command
562
+ that failed -- is forwarded. die() then adds `hw` for a kind: ffmpeg failure, as a tool's own
563
+ run() failure carries it."""
564
+ if not isinstance(doc, dict):
565
+ return {}
566
+ absorb_stage_hw(stage, doc)
567
+ return {"encoder": doc["encoder"]} if doc.get("encoder") else {}
568
+
569
+
554
570
  # ------------------------------------------------------------------ the stage cache (1.17)
555
571
  #
556
572
  # Opt-in only: --cache DIR. There is no default directory -- a cache that appears on someone's
@@ -623,6 +639,10 @@ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[
623
639
  "inputs": [{"hash": _content_hash(p)} for p in inputs],
624
640
  "child": [a for a in child_args() if a != "--dry-run"],
625
641
  "codec": STATE.codec, "ext": Path(dest).suffix if dest else None,
642
+ # what a stage's encoder resolves from: $FFMPEG_SKILL_HW (inherited) and whether the outer
643
+ # command made it explicit (--hw/--no-hw, which reaches export.py too). A GPU encode is not
644
+ # the CPU's bytes, so a --hw artifact is never served to a CPU run or the other way round.
645
+ "hw": [os.environ.get(HW_ENV, "").strip().lower() in ("1", "true", "yes", "on"), os.environ.get(HW_FORCED_ENV) == "1"],
626
646
  "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
627
647
  }
628
648
  return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
@@ -724,7 +744,8 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
724
744
  if proc.returncode != 0 or doc.get("status") != "completed":
725
745
  err = doc.get("error") or {}
726
746
  die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
727
- kind=err.get("kind", "ffmpeg"), plan=path, tool=tool)
747
+ kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, **_stage_failure_hw(tool, doc))
748
+ absorb_stage_hw(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) reach this result
728
749
  output = doc.get("output") or plan.get("output")
729
750
  check_result = None
730
751
  exit_code = 0
@@ -863,6 +884,7 @@ def main() -> int:
863
884
  "or 'all' its directory is where the pack is written")
864
885
  tpl.add_argument("--write-project", metavar="FILE", help="write the filled project.json for editing and stop (no render)")
865
886
  add_common(ap)
887
+ add_hw_orchestrator_args(ap)
866
888
  args = ap.parse_args()
867
889
  apply_common(args)
868
890
 
@@ -20,7 +20,8 @@ from typing import List, Tuple
20
20
  # `detect` moved into _common/probe.py in 1.16.0 so metadata.py --auto-chapters can measure the
21
21
  # same silences without importing this tool; the body is unchanged and the name still lives here.
22
22
  from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS,
23
- FILLER_PAD, transcribe_words, read_text_or_die)
23
+ FILLER_PAD, read_text_or_die)
24
+ from _common import asr as _asr
24
25
  from _common import detect_silences as detect, STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, run, X264_PRESETS, measured_level_dbfs, fmt_secs
25
26
 
26
27
 
@@ -99,7 +100,7 @@ def resolve_filler(args, meta):
99
100
  die("--filler needs word timings: pass --words transcript.json (a whisper JSON with word "
100
101
  "timestamps) or --transcribe. There is no way to find a filler word without them -- "
101
102
  "cutting the short quiet blips instead would remove real speech.", kind="input")
102
- source, engine, raw = None, None, None
103
+ source, engine, raw, transcription = None, None, None, {}
103
104
  if args.words:
104
105
  try:
105
106
  raw = json.loads(read_text_or_die(args.words, "--words"))
@@ -118,14 +119,20 @@ def resolve_filler(args, meta):
118
119
  '{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside '
119
120
  '"segments").', kind="input")
120
121
  else:
121
- # No pre-check for an installed engine here: transcribe_words() probes for one and
122
+ # No pre-check for an installed engine here: transcribe_words_result() probes for one and
122
123
  # raises the same die_no_engine() refusal when there is none. Two places deciding "is
123
124
  # whisper here" is two places to disagree.
124
125
  # The engine is driven with ITS word-timestamp option (whisper.cpp --output-json-full,
125
126
  # faster-whisper word_timestamps=True, openai-whisper --word_timestamps True). An SRT
126
127
  # cannot answer this question: a cue has a start and an end, a word does not.
127
- words, engine = transcribe_words(args.input, args.filler_lang if args.filler_lang != "auto" else None)
128
- source = f"whisper:{engine}" if engine else "whisper"
128
+ # The call's own result, not module state: engine, routing and notes belong to this run.
129
+ asr_run = _asr.transcribe_words_result(args.input, args.filler_lang if args.filler_lang != "auto" else None,
130
+ engine=args.engine)
131
+ words, engine = asr_run.words, asr_run.engine
132
+ transcription = dict(asr_run.facts)
133
+ args._asr_notes = list(asr_run.notes)
134
+ family = "parakeet" if engine in _asr.PARAKEET_ENGINES else "whisper"
135
+ source = f"{family}:{engine}" if engine else "whisper"
129
136
  if not words:
130
137
  die(f"{engine or 'the local engine'} ran but produced no word-level timings, so there "
131
138
  "is nothing for --filler to cut on. Some builds do not support word timestamps. "
@@ -168,6 +175,7 @@ def resolve_filler(args, meta):
168
175
  "disfluency -- this will cut real sentences.")
169
176
  block = {
170
177
  "lang": lang, "source": source, "engine": engine,
178
+ **({"transcription": transcription} if engine and transcription else {}),
171
179
  "words": sorted(wordlist), "removed": [dict(s) for s in spans],
172
180
  "removed_count": len(spans),
173
181
  "removed_seconds": round(sum(s["end"] - s["start"] for s in spans), 3),
@@ -235,8 +243,11 @@ def main() -> int:
235
243
  fil.add_argument("--words", metavar="FILE",
236
244
  help="a whisper JSON with word-level timings, for --filler")
237
245
  fil.add_argument("--transcribe", action="store_true",
238
- help="produce the word timings with a local whisper (never required; the same "
246
+ help="produce the word timings with a local speech engine, Parakeet or whisper (never required; the same "
239
247
  "bridge caption.py uses)")
248
+ fil.add_argument("--engine", choices=_asr.ENGINE_CHOICES, default=None,
249
+ help="speech engine for --transcribe (default: $FFMPEG_SKILL_ASR_ENGINE, else auto = Parakeet "
250
+ "for English speech when installed, else whisper)")
240
251
  fil.add_argument("--filler-list", action="store_true",
241
252
  help="report what --filler would remove and write nothing")
242
253
  fil.add_argument("--max-cuts", type=int, default=400,
@@ -311,6 +322,10 @@ def main() -> int:
311
322
  f"({filler_info['lang']}, list {filler_info['list']})")
312
323
  for warning in filler_info.get("warnings") or []:
313
324
  info("warning: " + warning)
325
+ if getattr(args, "_asr_notes", None):
326
+ # an English-only transcript made on an assumption (asr.assumed_english_note): the
327
+ # bridge already logged the warning; the result document carries it at the top level
328
+ summary["notes"] = list(args._asr_notes)
314
329
  if len(keeps) > args.max_cuts:
315
330
  die(f"{len(keeps)} keep ranges is above --max-cuts {args.max_cuts}: the filter graph grows "
316
331
  "with every range and a graph this size is slow and fragile. Raise --max-cuts if you "
@@ -38,6 +38,7 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
39
39
  info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
40
40
  fmt_secs, child_args, run_tool, measured_level_dbfs)
41
+ from _common.emit import absorb_stage_hw
41
42
 
42
43
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
43
44
 
@@ -307,20 +308,30 @@ def _child(script_name: str, argv: "list") -> None:
307
308
  """Run one of this skill's own tools as a second process, so the code path it owns (the ASS
308
309
  generator, the drawtext template) stays the only one there is. The shared flags (--overwrite,
309
310
  --timeout, --fast, --dry-run) reach it through child_args(), as render.py's stages get them,
310
- and a failure is re-raised with the child's own kind, exit code and hint."""
311
+ and a failure is re-raised with the child's own kind, exit code and hint.
312
+
313
+ The GPU choice reaches it too: an explicit --hw/--no-hw is passed on (the child would
314
+ otherwise resolve $FFMPEG_SKILL_HW for itself, and the last stage writes the deliverable),
315
+ and the child's commands and `hw` facts join this result, so `encoder`/`hw` describe the
316
+ file delivered rather than the intermediate render."""
311
317
  here = os.path.dirname(os.path.abspath(__file__))
312
- cmd = [os.path.join(here, script_name)] + [str(a) for a in argv] + child_args() + ["--json"]
318
+ hw = (["--hw"] if STATE.hw else ["--no-hw"]) if STATE.hw_source == "flag" else []
319
+ cmd = [os.path.join(here, script_name)] + [str(a) for a in argv] + hw + child_args() + ["--json"]
313
320
  info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[:-1]))
314
321
  proc = run_tool(cmd)
322
+ try:
323
+ doc = json.loads(proc.stdout.strip() or "{}")
324
+ except ValueError:
325
+ doc = {}
326
+ if not isinstance(doc, dict):
327
+ doc = {}
328
+ STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
329
+ absorb_stage_hw(os.path.splitext(script_name)[0], doc)
315
330
  if proc.returncode != 0:
316
- try:
317
- doc = json.loads(proc.stdout.strip() or "{}")
318
- except ValueError:
319
- doc = {}
320
- if not isinstance(doc, dict):
321
- doc = {}
322
331
  err = doc.get("error") or {}
323
332
  extra = {"hint": err["hint"]} if err.get("hint") else {}
333
+ if doc.get("encoder"):
334
+ extra["encoder"] = doc["encoder"]
324
335
  die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
325
336
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name, **extra)
326
337