ffmpeg-skill 2.6.0 → 2.8.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 +5 -5
- package/SKILL.md +7 -7
- package/docs/contract.md +49 -8
- package/package.json +1 -1
- package/references/gotchas.md +27 -5
- package/references/scripts.md +121 -13
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/asr.py +747 -94
- package/scripts/_common/decision.py +13 -0
- package/scripts/_common/probe.py +92 -0
- package/scripts/_common/runner.py +35 -12
- package/scripts/_contract.py +67 -12
- package/scripts/batch.py +28 -11
- package/scripts/caption.py +22 -6
- package/scripts/cut.py +1311 -53
- package/scripts/silence.py +21 -6
|
@@ -478,6 +478,19 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
478
478
|
return _maybe_hw("hevc", crf, meta, True, _x265_hdr_default(v, crf, preset))
|
|
479
479
|
|
|
480
480
|
|
|
481
|
+
def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
|
|
482
|
+
keep_hevc: bool = False) -> List[str]:
|
|
483
|
+
"""video_args(), except that with `keep_hevc` (cut.py --keep-hevc) an SDR HEVC source re-encodes
|
|
484
|
+
to HEVC (8-bit, BT.709-tagged, on VideoToolbox when --hw asks) instead of H.264: a cut is a trim,
|
|
485
|
+
so a re-encoded segment can come out in the codec the source (and any copied segment beside it)
|
|
486
|
+
is in. Opt-in within 2.x, where an SDR re-encode stays x264 unless asked. An HDR or BT.2020
|
|
487
|
+
source always takes video_args()'s Main10 line, and --codec always wins."""
|
|
488
|
+
v = (meta or {}).get("video") or {}
|
|
489
|
+
if keep_hevc and not STATE.codec and v.get("codec") == "hevc" and not v.get("bt2020_or_hdr"):
|
|
490
|
+
return encoder_args("hevc", crf, preset, meta)
|
|
491
|
+
return video_args(meta, crf, preset)
|
|
492
|
+
|
|
493
|
+
|
|
481
494
|
def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
482
495
|
cs = v.get("color_space") or "bt2020nc"
|
|
483
496
|
prim = v.get("color_primaries") or "bt2020"
|
package/scripts/_common/probe.py
CHANGED
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import math
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
+
import statistics
|
|
12
13
|
from fractions import Fraction
|
|
13
14
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
14
15
|
from _common.emit import die
|
|
@@ -285,6 +286,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
285
286
|
"nb_frames": _to_int(video.get("nb_frames")),
|
|
286
287
|
# the stream's own length (a container's also counts audio priming and a longer track)
|
|
287
288
|
"duration": _to_float(video.get("duration")),
|
|
289
|
+
# where the stream starts; a video stream that starts after its audio (or the reverse)
|
|
290
|
+
# is how a lossless cut's A/V skew shows up
|
|
291
|
+
"start_time": _to_float(video.get("start_time")),
|
|
288
292
|
"bitrate": _to_int(video.get("bit_rate")),
|
|
289
293
|
}
|
|
290
294
|
if audio:
|
|
@@ -295,6 +299,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
295
299
|
"sample_rate": _to_int(audio.get("sample_rate")),
|
|
296
300
|
"bitrate": _to_int(audio.get("bit_rate")),
|
|
297
301
|
"duration": _to_float(audio.get("duration")),
|
|
302
|
+
"start_time": _to_float(audio.get("start_time")),
|
|
298
303
|
}
|
|
299
304
|
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
300
305
|
out["audio_streams"] = [{
|
|
@@ -343,6 +348,93 @@ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
|
|
|
343
348
|
return sorted(set(out))
|
|
344
349
|
|
|
345
350
|
|
|
351
|
+
# frame timing: is a stream's frame rate really variable? (cut.py's VFR guard)
|
|
352
|
+
TIMING_WINDOWS = 5 # sampled windows per file, fewer on a short one
|
|
353
|
+
TIMING_WINDOW_SECONDS = 6
|
|
354
|
+
TIMING_MIN_DELTAS = 20 # fewer measured intervals than this is no evidence either way
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def complete_pts(packets: Sequence[Dict[str, Any]]) -> List[Any]:
|
|
358
|
+
"""The pts of `packets` (as read, in decode order) whose frames are all there: those at or
|
|
359
|
+
before the last dts read. A read that stops just after a frame stored ahead of the B-frames
|
|
360
|
+
shown before it has missed those B-frames, and their gap would read as a dropped frame; every
|
|
361
|
+
frame shown by the last dts was decoded by then, so it was read. (FFmpeg 9.0's interval reads
|
|
362
|
+
measured whole on MP4; this keeps a demuxer that stops elsewhere from faking VFR.)"""
|
|
363
|
+
dts = [p["dts"] for p in packets if isinstance(p.get("dts"), int)]
|
|
364
|
+
pts = [p.get("pts") for p in packets]
|
|
365
|
+
return pts if not dts else [t for t in pts if isinstance(t, int) and t <= max(dts)]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def classify_frame_timing(windows: Sequence[Sequence[Any]], time_base: Any) -> Dict[str, Any]:
|
|
369
|
+
"""Whether the frame timing in sampled windows is constant. `windows` are lists of pts in
|
|
370
|
+
`time_base` ticks, the first starting at the file's first packet.
|
|
371
|
+
|
|
372
|
+
Each window's pts are sorted (decode order stores B-frames out of sequence) and differenced,
|
|
373
|
+
with repeats and missing values dropped. The file-start window also drops its first interval:
|
|
374
|
+
an edit list or encoder priming can stretch it without the frame rate being variable.
|
|
375
|
+
`sampled_cfr` needs every interval within one tick (at least 1 ms) of its window's median and
|
|
376
|
+
the window medians to agree, so a single dropped or irregular frame is `vfr`. The tolerance
|
|
377
|
+
never exceeds half a frame: a tick that coarse (AVI's 1/fps) cannot round an interval, and one
|
|
378
|
+
tick of slack there would pass a dropped frame. Fewer than
|
|
379
|
+
TIMING_MIN_DELTAS intervals is `inconclusive`. What happens between the windows is not seen."""
|
|
380
|
+
result: Dict[str, Any] = {"measured": "inconclusive", "windows": len(windows), "deltas": 0}
|
|
381
|
+
tick = _fraction(str(time_base)) if time_base else None
|
|
382
|
+
if not tick or tick <= 0:
|
|
383
|
+
return result
|
|
384
|
+
per_window: List[List[float]] = []
|
|
385
|
+
for i, pts in enumerate(windows):
|
|
386
|
+
vals = sorted(float(p) for p in pts if isinstance(p, (int, float)) and math.isfinite(p))
|
|
387
|
+
deltas = [(b - a) * float(tick) for a, b in zip(vals, vals[1:]) if b != a]
|
|
388
|
+
per_window.append(deltas[1:] if i == 0 else deltas)
|
|
389
|
+
per_window = [d for d in per_window if d]
|
|
390
|
+
result["deltas"] = sum(len(d) for d in per_window)
|
|
391
|
+
if result["deltas"] < TIMING_MIN_DELTAS:
|
|
392
|
+
return result
|
|
393
|
+
medians = [statistics.median(d) for d in per_window]
|
|
394
|
+
tol = min(max(0.001, float(tick) + 1e-6), min(medians) / 2)
|
|
395
|
+
steady = all(abs(x - m) <= tol for d, m in zip(per_window, medians) for x in d)
|
|
396
|
+
result["measured"] = "sampled_cfr" if steady and max(medians) - min(medians) <= tol else "vfr"
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def timing_window_starts(duration: float, start: float = 0.0) -> List[float]:
|
|
401
|
+
"""Up to TIMING_WINDOWS non-overlapping window starts spread over the file, the first on its
|
|
402
|
+
first packet. -read_intervals takes timestamps, so a file that starts at 10 s is read from 10."""
|
|
403
|
+
n = max(1, min(TIMING_WINDOWS, int((duration or 0.0) // TIMING_WINDOW_SECONDS)))
|
|
404
|
+
span = (duration or 0.0) - TIMING_WINDOW_SECONDS
|
|
405
|
+
return [round(start + (span * i / (n - 1) if n > 1 else 0.0), 3) for i in range(n)]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def measure_frame_timing(path: str, duration: Optional[float], start: float = 0.0) -> Dict[str, Any]:
|
|
409
|
+
"""classify_frame_timing over sampled windows of `path`'s first video stream that is not cover
|
|
410
|
+
art (probe()'s choice): one packet-only ffprobe per window, no decoding. An interval read seeks
|
|
411
|
+
back to the keyframe before its start and, given a relative end, ends that far past the
|
|
412
|
+
keyframe -- on a long GOP every window read the file's first seconds -- so each window names
|
|
413
|
+
its absolute end and keeps only the frames from its start. The first window does not seek at
|
|
414
|
+
all: a seek to the start of an edit-listed MP4 skips its keyframe, stored before the start
|
|
415
|
+
with a negative pts, and loses the frames that depend on it (seeks near the start behave
|
|
416
|
+
erratically, so no margin is safe). The cost: a file whose video begins long after its first
|
|
417
|
+
packet is demuxed, not decoded, up to the video start. An ffprobe failure is `inconclusive`."""
|
|
418
|
+
ffprobe = require_tool("ffprobe")
|
|
419
|
+
windows: List[List[Any]] = []
|
|
420
|
+
time_base = None
|
|
421
|
+
for i, t in enumerate(timing_window_starts(duration or 0.0, start)):
|
|
422
|
+
proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-show_packets",
|
|
423
|
+
"-show_entries", "packet=pts,dts:stream=time_base", "-of", "json",
|
|
424
|
+
"-read_intervals", f"{t if i else ''}%{t + TIMING_WINDOW_SECONDS:.3f}", path], quiet=True, check=False)
|
|
425
|
+
try:
|
|
426
|
+
doc = json.loads(proc.stdout) if proc.returncode == 0 else None
|
|
427
|
+
except ValueError:
|
|
428
|
+
doc = None
|
|
429
|
+
if not isinstance(doc, dict):
|
|
430
|
+
return {"measured": "inconclusive", "windows": len(windows), "deltas": 0}
|
|
431
|
+
time_base = time_base or ((doc.get("streams") or [{}])[0] or {}).get("time_base")
|
|
432
|
+
tick = float(_fraction(str(time_base)) or 0) if time_base else 0.0
|
|
433
|
+
windows.append([p for p in complete_pts(doc.get("packets") or [])
|
|
434
|
+
if isinstance(p, int) and p * tick >= t - 1e-6])
|
|
435
|
+
return classify_frame_timing(windows, time_base)
|
|
436
|
+
|
|
437
|
+
|
|
346
438
|
def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
|
|
347
439
|
audio_stream: Optional[int] = None) -> Optional[Dict[str, float]]:
|
|
348
440
|
"""Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
|
|
@@ -1158,11 +1158,17 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
|
|
|
1158
1158
|
return cmd
|
|
1159
1159
|
|
|
1160
1160
|
|
|
1161
|
-
def place_output(src: str, dst: str) -> None:
|
|
1161
|
+
def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
1162
1162
|
"""Deliver an already-rendered file to `dst` under the same rules as an ffmpeg output:
|
|
1163
|
-
the path is checked,
|
|
1164
|
-
|
|
1165
|
-
|
|
1163
|
+
the path is checked, the output's lock is held (as run() holds it for an ffmpeg write), an
|
|
1164
|
+
existing file is only replaced through a sibling temp so a failed copy never costs the
|
|
1165
|
+
caller what was there, and the result is remembered as ours. render.py's final
|
|
1166
|
+
`copyfile()` used to bypass all of them.
|
|
1167
|
+
|
|
1168
|
+
`move`: `src` is a temp file the caller wrote next to `dst` (sibling_temp), renamed into place
|
|
1169
|
+
with os.replace() under the lock instead of copied -- a checked `cut.py --segments` join was
|
|
1170
|
+
copied whole, about 2 s for 240 MB. A rename that fails (another filesystem) falls back to
|
|
1171
|
+
the copy."""
|
|
1166
1172
|
import shutil
|
|
1167
1173
|
cmd = ["ffmpeg", dst]
|
|
1168
1174
|
_check_output_path(cmd)
|
|
@@ -1170,18 +1176,35 @@ def place_output(src: str, dst: str) -> None:
|
|
|
1170
1176
|
d, base = os.path.split(dst)
|
|
1171
1177
|
stem, ext = os.path.splitext(base)
|
|
1172
1178
|
tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1179
|
+
with _OutputLock(dst):
|
|
1180
|
+
if move:
|
|
1181
|
+
try:
|
|
1182
|
+
os.replace(src, dst)
|
|
1183
|
+
_remember_output(cmd)
|
|
1184
|
+
return
|
|
1185
|
+
except OSError:
|
|
1186
|
+
pass
|
|
1177
1187
|
try:
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1188
|
+
shutil.copyfile(src, tmp)
|
|
1189
|
+
os.replace(tmp, dst)
|
|
1190
|
+
except OSError as e:
|
|
1191
|
+
try:
|
|
1192
|
+
os.remove(tmp)
|
|
1193
|
+
except OSError:
|
|
1194
|
+
pass
|
|
1195
|
+
die(f"could not place {dst}: {e}", kind="output")
|
|
1182
1196
|
_remember_output(cmd)
|
|
1183
1197
|
|
|
1184
1198
|
|
|
1199
|
+
def sibling_temp(dst: str, tag: str) -> str:
|
|
1200
|
+
"""A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
|
|
1201
|
+
place_output(..., move=True) renames into place: same directory, so the rename never
|
|
1202
|
+
crosses a filesystem."""
|
|
1203
|
+
d, base = os.path.split(dst)
|
|
1204
|
+
stem, ext = os.path.splitext(base)
|
|
1205
|
+
return os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
|
|
1206
|
+
|
|
1207
|
+
|
|
1185
1208
|
_DRAWTEXT_TMPDIR: "Optional[str]" = None
|
|
1186
1209
|
|
|
1187
1210
|
|
package/scripts/_contract.py
CHANGED
|
@@ -54,6 +54,8 @@ ROLES = {
|
|
|
54
54
|
# filter:<name> `ffmpeg -filters`
|
|
55
55
|
# bsf:<name> `ffmpeg -bsfs`
|
|
56
56
|
# external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
|
|
57
|
+
# external:parakeet a local Parakeet engine (parakeet-mlx, or parakeet-cli with a .gguf model);
|
|
58
|
+
# English. Either external engine satisfies --transcribe.
|
|
57
59
|
# "optional" entries name the flag or condition under which the capability is needed.
|
|
58
60
|
FF = ["ffmpeg", "ffprobe"]
|
|
59
61
|
X264 = "encoder:libx264"
|
|
@@ -76,7 +78,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
76
78
|
required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
|
|
77
79
|
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
|
|
78
80
|
"cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family, or audio extracted when -o has an audio extension)"],
|
|
79
|
-
required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"},
|
|
81
|
+
required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"},
|
|
82
|
+
dict(HDR_X265, when=HDR_X265["when"] + "; with --keep-hevc on an HEVC source"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
|
|
80
83
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
81
84
|
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
82
85
|
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
|
|
@@ -145,7 +148,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
145
148
|
required=FF + [X264], optional=[],
|
|
146
149
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
147
150
|
"caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions (--mode burn)", "video artifact with one or several language-tagged soft subtitle streams (--mode mux, --srt file:lang repeated)", "generated .srt / .ass sidecar"],
|
|
148
|
-
required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"},
|
|
151
|
+
required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"}, {"capability": "external:parakeet", "when": "--transcribe (English; either engine family satisfies it)"},
|
|
149
152
|
{"capability": "encoder:mov_text", "when": "--mode mux with a .mp4/.m4v/.mov output"}, {"capability": "encoder:webvtt", "when": "--mode mux with a .webm output"}, {"capability": "encoder:srt", "when": "--mode mux with a .mkv output"}],
|
|
150
153
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
151
154
|
"overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo), text (--text), or a second video (--video) to composite"], outputs=["video artifact with the overlay composited"],
|
|
@@ -172,7 +175,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
172
175
|
video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
173
176
|
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
174
177
|
required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"},
|
|
175
|
-
{"capability": "external:whisper", "when": "--filler --transcribe"}
|
|
178
|
+
{"capability": "external:whisper", "when": "--filler --transcribe"},
|
|
179
|
+
{"capability": "external:parakeet", "when": "--filler --transcribe (English; either engine family satisfies it)"}] + AUDIO_OUT,
|
|
176
180
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
177
181
|
"join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
|
|
178
182
|
required=FF + ["filter:xfade", "filter:acrossfade"],
|
|
@@ -251,7 +255,7 @@ DRY_RUN_NOTES = {
|
|
|
251
255
|
# of the original audio codec never happens on those tools.
|
|
252
256
|
REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
253
257
|
"probe": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
254
|
-
"cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source,
|
|
258
|
+
"cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, --codec, a VFR source (--vfr-guard), a keyframe snap past --tolerance, or a --segments join the parts cannot stream-copy (see cut.py --json: mode, reencode_reason, keyframe_snapped)"),
|
|
255
259
|
"fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
|
|
256
260
|
"crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
|
|
257
261
|
"deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
@@ -409,7 +413,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
409
413
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
|
|
410
414
|
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
411
415
|
elif name == "caption":
|
|
412
|
-
extra = {"
|
|
416
|
+
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
417
|
+
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input"},
|
|
413
418
|
"tracks": {"type": "array", "description": "--mode mux: one entry per subtitle stream in the output ({index, file, language, title, codec, default, cues, kept_from_input}); a stream the input already carried has file null and kept_from_input true (1.16)"},
|
|
414
419
|
"subtitle_tracks": {"type": "integer", "description": "--mode mux: how many subtitle streams the output carries"},
|
|
415
420
|
"emoji": {"type": "object", "description": "how the emoji in the text were drawn (mode, overlays, missing)"},
|
|
@@ -430,7 +435,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
430
435
|
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
|
|
431
436
|
elif name == "silence":
|
|
432
437
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
433
|
-
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"}
|
|
438
|
+
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
|
|
439
|
+
"filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, ...}"},
|
|
440
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
|
|
434
441
|
elif name == "sync":
|
|
435
442
|
extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"},
|
|
436
443
|
"sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given"}}
|
|
@@ -447,7 +454,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
447
454
|
elif name == "verify":
|
|
448
455
|
extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
|
|
449
456
|
elif name == "batch":
|
|
450
|
-
extra = {"results": {"type": "array"
|
|
457
|
+
extra = {"results": {"type": "array", "description": "one row per input; a row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
|
|
458
|
+
"processed": {"type": "integer"}, "total": {"type": "integer"},
|
|
459
|
+
"cut_stream_copy": {"type": ["object", "null"], "description": "cut.py calls across the folder: {calls, stream_copy, reencoded, stream_copy_rate}; null when no cut.py step ran"},
|
|
460
|
+
"cut_reencode_reasons": {"type": ["object", "null"], "description": "how many cut.py calls named each reencode_reason ({} when none re-encoded); a re-encoded call whose row came from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as unknown; null when no cut.py step ran"}}
|
|
451
461
|
elif name == "report":
|
|
452
462
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
|
|
453
463
|
elif name == "export":
|
|
@@ -469,9 +479,24 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
469
479
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
470
480
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
471
481
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
472
|
-
"description": "
|
|
482
|
+
"description": "the whole run's: packet only when nothing re-encoded, the --segments join included (stream copy on a packet/keyframe boundary); sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
|
|
483
|
+
"least_exact_precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
484
|
+
"description": "the least exact segment's precision (the order packet, codec_frame, frame, sample); differs from precision only for an audio-only --segments join of copied and re-encoded parts"},
|
|
485
|
+
"keyframe_snapped": {"type": "boolean", "description": "precision is packet: the run is a stream copy end to end, which lands on keyframes and packets. Whether a copied start really moved is start_snapped"},
|
|
486
|
+
"start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
|
|
473
487
|
"reencoded": {"type": "boolean"},
|
|
474
|
-
"
|
|
488
|
+
"reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "tolerance", "concat_fallback"]},
|
|
489
|
+
"description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
|
|
490
|
+
"segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
|
|
491
|
+
"description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
|
|
492
|
+
"edit_list": {"type": "boolean", "description": "--edit-list on a single MP4/MOV stream copy: it kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (false for every other path, the default included)"},
|
|
493
|
+
"stored_preroll_seconds": {"type": ["number", "null"], "description": "with edit_list: seconds decoded from the keyframe the copy's seek lands on up to the requested start and hidden by the edit list (a player that ignores edit lists shows them); null otherwise"},
|
|
494
|
+
"av_start_skew_seconds": {"type": ["number", "null"], "description": "the output's audio start minus its video start (null when either is missing or under --dry-run); reported, never repaired: on a stream copy only, a warning in notes when it is more than max(2 frames, 0.1 s) away from the source's own skew where the copy's picture starts (the keyframe it began from; the requested start under --edit-list), naming --accurate (and --edit-list for a single MP4/MOV copy whose start snapped)"},
|
|
495
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "stored pre-roll behind an --edit-list edit list, or an --edit-list copy judged by its video; an A/V start skew with its remedies; a source the default --vfr-guard average took for VFR whose sampled timing is constant (naming --vfr-guard sampled), or a sampled measurement that forced or (--vfr-guard off) skipped a re-encode; a --segments segment ended with the video, or held on its last frame for its sound; a --segments join re-cut because the source uses open GOPs, because a later B-frame .mp4/.mov segment starts between keyframes, or because its join check failed"},
|
|
496
|
+
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (the default) only when its nominal and average frame rates differ, which re-encodes it (reason vfr) whatever the sample says; under sampled or off on every such input (absent for audio-only input or output, --accurate, --codec, or a --dry-run input a plan has not written yet): {heuristic: true, measured, method: \"sampled\", guard, windows, deltas}. measured is sampled_cfr (every packet interval in up to five 6 s windows within a tick, at least 1 ms and at most half a frame, of its window's median, and the windows agree; the file-start window's first interval is ignored), vfr (under sampled, forces a re-encode, reason vfr) or inconclusive (fewer than 20 intervals or ffprobe failed; under sampled, reason vfr_inconclusive). --vfr-guard off keeps the copy either way, with a note. Variable timing between the windows is not seen"},
|
|
497
|
+
"lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"},
|
|
498
|
+
"join_check": {"type": ["object", "null"], "description": "--segments video stream-copy join: the written file measured by demuxing, {packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}. expected_packets counts the source's video packets from each part's start keyframe to its end (or the end of the video); max_step_seconds is the largest presentation step (null when vfr_check measured anything but sampled_cfr, e.g. a --vfr-guard off join of variable timing, which checks the count only), and each step at a join must be the step its parts predict within 1 ms; audio_offset_ms is, per part, its sound's shift minus its picture's (+ = sound late) from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than a:0; an MPEG-TS source's AAC is hashed without its ADTS headers) (null for a part with no unique match, e.g. digital silence; null as a whole when the source has no audio or the video check failed), and audio_parts_checked counts the measured parts; ok is the conjunction (counts, steps, every measured part within 5 ms), and false means the join was re-cut from the source (concat_fallback) and this is the failed measurement. null for a single segment, audio, --dry-run, --accurate/--codec/a forced re-encode, a segment that re-encoded, or a join that fell back before a copy join was attempted (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error)"},
|
|
499
|
+
"segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
|
|
475
500
|
elif name == "join":
|
|
476
501
|
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
477
502
|
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
|
@@ -672,6 +697,13 @@ def _whisper_available() -> bool:
|
|
|
672
697
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
673
698
|
|
|
674
699
|
|
|
700
|
+
def _parakeet_available() -> bool:
|
|
701
|
+
"""parakeet-mlx on PATH, or parakeet-cli with a model it can find (PARAKEET_CPP_MODEL or a
|
|
702
|
+
.gguf in ~/.cache/parakeet.cpp) -- the same test the speech bridge applies before running it."""
|
|
703
|
+
from _common.asr import _parakeet_available as runtime_check # the bridge's own test, not a copy of it
|
|
704
|
+
return any(runtime_check(e, shutil) for e in ("parakeet-mlx", "parakeet.cpp"))
|
|
705
|
+
|
|
706
|
+
|
|
675
707
|
def _hw_default() -> Dict[str, Any]:
|
|
676
708
|
"""--hw's machine facts, normalised (never the raw environment value): whether VideoToolbox
|
|
677
709
|
constant-quality encoding can run here (the tools' own check: Apple Silicon hardware, also
|
|
@@ -870,6 +902,8 @@ def doctor() -> Dict[str, Any]:
|
|
|
870
902
|
state[cap] = _from("bsfs", cap[4:])
|
|
871
903
|
elif cap == "external:whisper":
|
|
872
904
|
state[cap] = "available" if _whisper_available() else "missing"
|
|
905
|
+
elif cap == "external:parakeet":
|
|
906
|
+
state[cap] = "available" if _parakeet_available() else "missing"
|
|
873
907
|
else:
|
|
874
908
|
state[cap] = "missing"
|
|
875
909
|
available = sorted(c for c, st in state.items() if st == "available")
|
|
@@ -997,7 +1031,15 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
997
1031
|
return (f"no installed font covers {LANGUAGE_NAMES.get(script, script)} text on this machine; "
|
|
998
1032
|
f"{FONT_INSTALL_HINT} (doctor --json .fonts.scripts lists every script)")
|
|
999
1033
|
if cap == "external:whisper":
|
|
1000
|
-
return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
|
|
1034
|
+
return ("install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe; "
|
|
1035
|
+
"faster-whisper and openai-whisper download their model on first use, and every engine transcribes "
|
|
1036
|
+
"on this machine (the audio is never uploaded)")
|
|
1037
|
+
if cap == "external:parakeet":
|
|
1038
|
+
from _common.asr import PARAKEET_MLX_DEFAULT_MODEL
|
|
1039
|
+
return ("for English --transcribe, install parakeet-mlx (uv tool install parakeet-mlx; its first run downloads "
|
|
1040
|
+
f"{PARAKEET_MLX_DEFAULT_MODEL} from Hugging Face, then it transcribes on this machine and the audio is "
|
|
1041
|
+
"never uploaded) or parakeet-cli with a tdt-0.6b-v2 .gguf in ~/.cache/parakeet.cpp; a local whisper "
|
|
1042
|
+
"covers every language")
|
|
1001
1043
|
return f"'{cap}' is not available; see docs/contract.md"
|
|
1002
1044
|
|
|
1003
1045
|
|
|
@@ -1362,7 +1404,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1362
1404
|
version = skill_version()
|
|
1363
1405
|
tools = [tool_spec(n, version) for n in public_tools()]
|
|
1364
1406
|
wanted = required_capabilities()
|
|
1365
|
-
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
|
|
1407
|
+
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper | external:parakeet"}
|
|
1366
1408
|
if detect:
|
|
1367
1409
|
d = doctor()
|
|
1368
1410
|
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
|
|
@@ -1389,8 +1431,21 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1389
1431
|
"execution": {
|
|
1390
1432
|
"shell": False,
|
|
1391
1433
|
"arbitrary_executables": False,
|
|
1392
|
-
"subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH"
|
|
1434
|
+
"subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH; "
|
|
1435
|
+
"with --transcribe, a local speech engine's CLI (whisper.cpp's whisper-cli / whisper-cpp / main, "
|
|
1436
|
+
"openai-whisper's whisper, parakeet-mlx, parakeet-cli) resolved from PATH",
|
|
1393
1437
|
"network": False,
|
|
1438
|
+
# `network` is about the skill's own code. A speech engine may fetch its model weights the
|
|
1439
|
+
# first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
|
|
1440
|
+
# process. A caller that sandboxes the network must know that before a first --transcribe.
|
|
1441
|
+
"model_downloads": {
|
|
1442
|
+
"when": "first use of a speech engine (--transcribe) whose model is not cached yet; media is never uploaded",
|
|
1443
|
+
"in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
|
|
1444
|
+
"child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
|
|
1445
|
+
"openai-whisper": "OpenAI (its model checkpoint)"},
|
|
1446
|
+
"none": ["whisper.cpp", "parakeet.cpp"],
|
|
1447
|
+
"offline": "HF_HUB_OFFLINE=1 keeps faster-whisper and parakeet-mlx on the cached copy",
|
|
1448
|
+
},
|
|
1394
1449
|
"input_mutation": False,
|
|
1395
1450
|
},
|
|
1396
1451
|
"invocation": {
|
package/scripts/batch.py
CHANGED
|
@@ -173,6 +173,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
173
173
|
ok, _doc = run_step(["render.py", str(pj)], budget())
|
|
174
174
|
step_docs = [("render", _doc)]
|
|
175
175
|
cut_reencoded: List[bool] = []
|
|
176
|
+
cut_reasons: List[str] = []
|
|
176
177
|
else:
|
|
177
178
|
steps = recipe.get("steps") or []
|
|
178
179
|
if not steps:
|
|
@@ -181,18 +182,20 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
181
182
|
ok = True
|
|
182
183
|
cut_reencoded = []
|
|
183
184
|
step_docs = []
|
|
185
|
+
cut_reasons = []
|
|
184
186
|
for i, step in enumerate(steps):
|
|
185
187
|
last = i == len(steps) - 1
|
|
186
188
|
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('.')}")
|
|
187
189
|
argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
|
|
188
190
|
step_ok, doc = run_step(argv, budget())
|
|
189
191
|
step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
|
|
190
|
-
# only cut.py's own doc carries `reencoded` -- true if ANY range this call cut
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
# not per --segments range
|
|
192
|
+
# only cut.py's own doc carries `reencoded` -- true if ANY range this call cut (or its
|
|
193
|
+
# --segments join) re-encoded, for the reasons in its `reencode_reason` (distinct per
|
|
194
|
+
# call, so cut_reencode_reasons lists each reason once per cut.py call that named it).
|
|
195
|
+
# Per cut.py call, not per --segments range.
|
|
194
196
|
if step_ok and argv and argv[0] == "cut.py" and isinstance(doc, dict) and "reencoded" in doc:
|
|
195
197
|
cut_reencoded.append(bool(doc["reencoded"]))
|
|
198
|
+
cut_reasons.extend(r for r in (doc.get("reencode_reason") or []) if isinstance(r, str))
|
|
196
199
|
if not step_ok:
|
|
197
200
|
ok = False
|
|
198
201
|
break
|
|
@@ -200,6 +203,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
200
203
|
result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
|
|
201
204
|
if cut_reencoded:
|
|
202
205
|
result["cut_reencoded"] = cut_reencoded
|
|
206
|
+
result["cut_reencode_reasons"] = cut_reasons
|
|
203
207
|
# the encoder each item really ran and its GPU facts (--hw / FFMPEG_SKILL_HW: on VideoToolbox,
|
|
204
208
|
# fell back and why): this process encodes nothing itself, so its top-level hw.used is null
|
|
205
209
|
result.update(steps_encoder_report(step_docs))
|
|
@@ -563,23 +567,36 @@ def main() -> int:
|
|
|
563
567
|
die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
|
|
564
568
|
kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
|
|
565
569
|
# cut.py's own `reencoded`, rolled up across every cut.py step this batch ran: how much of the
|
|
566
|
-
# folder landed on the fast lossless path (mode "copy") vs
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
#
|
|
570
|
-
# its own per-segment results into one top-level field and doesn't say which range needed it.
|
|
570
|
+
# folder landed on the fast lossless path (mode "copy") vs re-encoded on at least one range.
|
|
571
|
+
# Keyframe placement, VFR sources, --accurate/--codec in the recipe and --segments joins all
|
|
572
|
+
# cause it, so the summary counts cut.py's own `reencode_reason` values rather than guessing
|
|
573
|
+
# one cause. Per cut.py call, not per --segments range.
|
|
571
574
|
all_cuts = [r for res in results for r in (res.get("cut_reencoded") or [])]
|
|
572
575
|
cut_summary = None
|
|
576
|
+
# how many cut.py calls named each reason, beside cut_stream_copy (whose shape is 2.x's,
|
|
577
|
+
# unchanged); null when no cut.py step ran
|
|
578
|
+
reasons: Optional[Dict[str, int]] = None
|
|
573
579
|
if all_cuts:
|
|
574
580
|
copied = sum(1 for r in all_cuts if not r)
|
|
575
581
|
cut_summary = {"calls": len(all_cuts), "stream_copy": copied, "reencoded": len(all_cuts) - copied,
|
|
576
582
|
"stream_copy_rate": round(copied / len(all_cuts), 3)}
|
|
583
|
+
reasons = {}
|
|
584
|
+
for res in results:
|
|
585
|
+
if "cut_reencode_reasons" not in res:
|
|
586
|
+
# a row from a cache an earlier 2.x batch wrote: it says which calls re-encoded,
|
|
587
|
+
# not why, so they count as "unknown" rather than leaving {} ("none re-encoded")
|
|
588
|
+
unknown = sum(1 for r in res.get("cut_reencoded") or [] if r)
|
|
589
|
+
if unknown:
|
|
590
|
+
reasons["unknown"] = reasons.get("unknown", 0) + unknown
|
|
591
|
+
for r in res.get("cut_reencode_reasons") or []:
|
|
592
|
+
reasons[r] = reasons.get(r, 0) + 1
|
|
593
|
+
why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
|
|
577
594
|
info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
|
|
578
|
-
f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied}
|
|
595
|
+
f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
|
|
579
596
|
emit(None, results=results, processed=done, total=len(results),
|
|
580
597
|
jobs=jobs, jobs_requested=requested, wall_seconds=round(time.time() - started, 1),
|
|
581
598
|
item_seconds_total=round(sum(float(r.get("seconds") or 0) for r in results), 1),
|
|
582
|
-
timed_out=timed_out["hit"], cut_stream_copy=cut_summary)
|
|
599
|
+
timed_out=timed_out["hit"], cut_stream_copy=cut_summary, cut_reencode_reasons=reasons)
|
|
583
600
|
return 0
|
|
584
601
|
|
|
585
602
|
|
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":
|