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/README.md +4 -4
- package/SKILL.md +5 -5
- package/docs/contract.md +68 -13
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +44 -10
- package/scripts/_common/asr.py +747 -94
- package/scripts/_common/decision.py +125 -2
- package/scripts/_common/emit.py +145 -1
- package/scripts/_common/runner.py +258 -13
- package/scripts/_contract.py +73 -12
- package/scripts/batch.py +30 -1
- package/scripts/caption.py +22 -6
- package/scripts/export.py +10 -1
- package/scripts/render.py +23 -1
- package/scripts/silence.py +21 -6
- package/scripts/waveform.py +19 -8
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:
|
package/scripts/caption.py
CHANGED
|
@@ -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",
|
|
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
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/scripts/silence.py
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
-
|
|
128
|
-
|
|
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 "
|
package/scripts/waveform.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|