ffmpeg-skill 2.11.0 → 2.12.1
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 +1 -1
- package/docs/contract.md +69 -13
- package/mcp/server.py +7 -1
- package/package.json +2 -1
- package/references/scripts.md +79 -14
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +49 -5
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +112 -37
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +122 -12
- package/scripts/audio.py +2 -1
- package/scripts/batch.py +240 -105
- package/scripts/caption.py +68 -18
- package/scripts/color.py +2 -1
- package/scripts/cropdetect.py +19 -4
- package/scripts/export.py +42 -10
- package/scripts/graphics.py +24 -18
- package/scripts/grid.py +7 -3
- package/scripts/join.py +5 -3
- package/scripts/look.py +10 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +133 -44
- package/scripts/report.py +77 -23
- package/scripts/reverse.py +4 -2
- package/scripts/sequence.py +4 -2
- package/scripts/silence.py +19 -7
- package/scripts/sync.py +18 -7
- package/scripts/verify.py +98 -24
- package/scripts/waveform.py +3 -2
package/scripts/silence.py
CHANGED
|
@@ -22,6 +22,7 @@ from typing import List, Tuple
|
|
|
22
22
|
from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS,
|
|
23
23
|
FILLER_PAD, read_text_or_die)
|
|
24
24
|
from _common import asr as _asr
|
|
25
|
+
from _common.emit import warn
|
|
25
26
|
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
|
|
26
27
|
|
|
27
28
|
|
|
@@ -101,13 +102,14 @@ def resolve_filler(args, meta):
|
|
|
101
102
|
"timestamps) or --transcribe. There is no way to find a filler word without them -- "
|
|
102
103
|
"cutting the short quiet blips instead would remove real speech.", kind="input")
|
|
103
104
|
source, engine, raw, transcription = None, None, None, {}
|
|
105
|
+
untimed: list = [] # transcript words without a usable timing (a --words file can carry them)
|
|
104
106
|
if args.words:
|
|
105
107
|
try:
|
|
106
108
|
raw = json.loads(read_text_or_die(args.words, "--words"))
|
|
107
109
|
except ValueError as exc:
|
|
108
110
|
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
109
111
|
source = f"whisper-json:{args.words}"
|
|
110
|
-
words, had_segments = _words_from_transcript(raw)
|
|
112
|
+
words, had_segments, untimed = _words_from_transcript(raw)
|
|
111
113
|
if not words:
|
|
112
114
|
if had_segments:
|
|
113
115
|
die(f"the transcript in {args.words} has segment timings but no word timings; "
|
|
@@ -186,7 +188,8 @@ def resolve_filler(args, meta):
|
|
|
186
188
|
wordlist |= set(_word_list(args.filler_extra))
|
|
187
189
|
wordlist -= set(_word_list(args.filler_keep))
|
|
188
190
|
|
|
189
|
-
|
|
191
|
+
kept: dict = {}
|
|
192
|
+
spans = filler_spans(list(words) + untimed, wordlist, pad=args.filler_pad, stats=kept)
|
|
190
193
|
removed_words = sorted({s["word"] for s in spans})
|
|
191
194
|
warnings = []
|
|
192
195
|
ambiguous = sorted(set(FILLER_AMBIGUOUS.get(lang, ())) & set(
|
|
@@ -210,12 +213,21 @@ def resolve_filler(args, meta):
|
|
|
210
213
|
"removed_seconds": round(sum(s["end"] - s["start"] for s in spans), 3),
|
|
211
214
|
"word_timings": len(words), "list": listname, "removed_words": removed_words,
|
|
212
215
|
"warnings": warnings,
|
|
216
|
+
# listed words this run did not cut, and why: a transcript word with no timing has
|
|
217
|
+
# nothing to cut on, and one longer than a filler is a held vowel someone meant
|
|
218
|
+
"matched_untimed": kept.get("matched_untimed", 0),
|
|
219
|
+
"matched_too_long": kept.get("matched_too_long", 0),
|
|
213
220
|
}
|
|
221
|
+
if block["matched_untimed"]:
|
|
222
|
+
warn(f"--filler: {block['matched_untimed']} listed word(s) in the transcript carry no usable timing and were "
|
|
223
|
+
"not removed (nothing measured to cut on)", "filler_words_untimed", count=block["matched_untimed"])
|
|
214
224
|
return block, [(s["start"], s["end"]) for s in spans]
|
|
215
225
|
|
|
216
226
|
|
|
217
227
|
def _words_from_transcript(data):
|
|
218
|
-
"""([{word,start,end}], whether the document had segments at all
|
|
228
|
+
"""([{word,start,end}], whether the document had segments at all, [{word}] of the words
|
|
229
|
+
that carried no usable timing) from a whisper JSON. The third list lets --filler count a
|
|
230
|
+
listed word it could not cut instead of dropping it unseen."""
|
|
219
231
|
raw, had_segments = [], False
|
|
220
232
|
if isinstance(data, dict):
|
|
221
233
|
raw = list(data.get("words") or [])
|
|
@@ -225,7 +237,7 @@ def _words_from_transcript(data):
|
|
|
225
237
|
raw.extend((seg or {}).get("words") or [])
|
|
226
238
|
elif isinstance(data, list):
|
|
227
239
|
raw = list(data)
|
|
228
|
-
out = []
|
|
240
|
+
out, untimed = [], []
|
|
229
241
|
for w in raw:
|
|
230
242
|
if not isinstance(w, dict):
|
|
231
243
|
continue
|
|
@@ -233,8 +245,8 @@ def _words_from_transcript(data):
|
|
|
233
245
|
out.append({"word": str(w.get("word") or w.get("text") or ""),
|
|
234
246
|
"start": float(w["start"]), "end": float(w["end"])})
|
|
235
247
|
except (KeyError, TypeError, ValueError):
|
|
236
|
-
|
|
237
|
-
return out, had_segments
|
|
248
|
+
untimed.append({"word": str(w.get("word") or w.get("text") or "")})
|
|
249
|
+
return out, had_segments, untimed
|
|
238
250
|
|
|
239
251
|
|
|
240
252
|
def main() -> int:
|
|
@@ -353,7 +365,7 @@ def main() -> int:
|
|
|
353
365
|
f"{filler_info['removed_seconds']:.2f}s, from {filler_info['word_timings']} word timings "
|
|
354
366
|
f"({filler_info['lang']}, list {filler_info['list']})")
|
|
355
367
|
for warning in filler_info.get("warnings") or []:
|
|
356
|
-
|
|
368
|
+
warn(warning, "filler_removal_caution")
|
|
357
369
|
if getattr(args, "_asr_notes", None):
|
|
358
370
|
# an English-only transcript made on an assumption (asr.assumed_english_note): the
|
|
359
371
|
# bridge already logged the warning; the result document carries it at the top level
|
package/scripts/sync.py
CHANGED
|
@@ -33,6 +33,7 @@ import sys
|
|
|
33
33
|
from typing import Dict, List
|
|
34
34
|
|
|
35
35
|
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
|
|
36
|
+
from _common.emit import record_skip, warn
|
|
36
37
|
|
|
37
38
|
SR = 8000 # decode sample rate
|
|
38
39
|
|
|
@@ -218,6 +219,9 @@ def measure_one(reference: str, path: str, args) -> Dict:
|
|
|
218
219
|
offset, score = measure_offset(reference, path, 0.0, args.analyze_seconds, args.step_ms, args.max_offset, args.fine_ms)
|
|
219
220
|
drift_ratio = 1.0
|
|
220
221
|
drift_info = None
|
|
222
|
+
# why a requested --fix-drift was not applied: too_short (no tail window far enough from
|
|
223
|
+
# the head to measure over) or low_confidence (the tail did not correlate); None when applied
|
|
224
|
+
skipped_reason = None
|
|
221
225
|
if args.fix_drift:
|
|
222
226
|
ref_dur = probe(reference)["duration"] or 0.0
|
|
223
227
|
sec_dur = probe(path)["duration"] or 0.0
|
|
@@ -225,7 +229,10 @@ def measure_one(reference: str, path: str, args) -> Dict:
|
|
|
225
229
|
head_len = min(args.analyze_seconds, overlap_end)
|
|
226
230
|
tail_start = overlap_end - args.drift_window
|
|
227
231
|
if tail_start <= head_len / 2 + 5:
|
|
228
|
-
|
|
232
|
+
skipped_reason = "too_short"
|
|
233
|
+
record_skip(f"{path} too short to measure drift reliably; skipping drift correction", "drift_correction_skipped",
|
|
234
|
+
path=path, reason=skipped_reason, overlap_seconds=round(overlap_end, 3),
|
|
235
|
+
drift_window=args.drift_window)
|
|
229
236
|
else:
|
|
230
237
|
ref_start = tail_start
|
|
231
238
|
sec_start = tail_start - offset
|
|
@@ -251,10 +258,13 @@ def measure_one(reference: str, path: str, args) -> Dict:
|
|
|
251
258
|
abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
|
|
252
259
|
"confidence": round(end_score, 3)}
|
|
253
260
|
else:
|
|
254
|
-
|
|
261
|
+
skipped_reason = "low_confidence"
|
|
262
|
+
record_skip(f"could not measure drift with confidence for {path}; skipping drift correction",
|
|
263
|
+
"drift_correction_skipped", path=path, reason=skipped_reason,
|
|
264
|
+
confidence=round(max(0.0, end_score), 3), measured_over_seconds=round(elapsed, 2))
|
|
255
265
|
return {"path": path, "offset_seconds": round(offset, 4), "offset_s": round(offset, 4),
|
|
256
266
|
"confidence": round(max(0.0, min(1.0, score)), 3), "drift_ratio": drift_ratio, "drift": drift_info,
|
|
257
|
-
"drift_ppm": (drift_info["drift_ppm"] if drift_info else None)}
|
|
267
|
+
"drift_ppm": (drift_info["drift_ppm"] if drift_info else None), "drift_skipped_reason": skipped_reason}
|
|
258
268
|
|
|
259
269
|
|
|
260
270
|
def main() -> int:
|
|
@@ -301,10 +311,11 @@ def main() -> int:
|
|
|
301
311
|
measured = [measure_one(args.reference, p, args) for p in args.sources]
|
|
302
312
|
for p, m in zip(args.sources, measured):
|
|
303
313
|
if m["confidence"] < 0.1:
|
|
304
|
-
|
|
314
|
+
warn(f"{p}: low correlation confidence; check that it shares an audio event with the reference",
|
|
315
|
+
"sync_low_confidence", path=p, confidence=m["confidence"])
|
|
305
316
|
|
|
306
317
|
sources_block = [{"path": p, "offset_s": m["offset_s"], "confidence": m["confidence"],
|
|
307
|
-
**({"drift_ppm": m["drift_ppm"]} if args.fix_drift else {})}
|
|
318
|
+
**({"drift_ppm": m["drift_ppm"], "drift_skipped_reason": m["drift_skipped_reason"]} if args.fix_drift else {})}
|
|
308
319
|
for p, m in zip(args.sources, measured)]
|
|
309
320
|
|
|
310
321
|
if n_sources == 1:
|
|
@@ -323,8 +334,8 @@ def main() -> int:
|
|
|
323
334
|
}
|
|
324
335
|
if drift_info:
|
|
325
336
|
result["drift"] = drift_info
|
|
326
|
-
if
|
|
327
|
-
|
|
337
|
+
if args.fix_drift:
|
|
338
|
+
result["drift_skipped_reason"] = m["drift_skipped_reason"]
|
|
328
339
|
else:
|
|
329
340
|
offset = drift_ratio = drift_info = None # not used below; output writing is 1-source only
|
|
330
341
|
result = {"reference": args.reference, "sources": sources_block}
|
package/scripts/verify.py
CHANGED
|
@@ -15,6 +15,8 @@ Examples:
|
|
|
15
15
|
python3 verify.py clip.mov --keep --out ./verify_out
|
|
16
16
|
"""
|
|
17
17
|
import argparse
|
|
18
|
+
import contextlib
|
|
19
|
+
import io
|
|
18
20
|
import json
|
|
19
21
|
import os
|
|
20
22
|
import subprocess
|
|
@@ -22,7 +24,7 @@ import sys
|
|
|
22
24
|
import tempfile
|
|
23
25
|
import time
|
|
24
26
|
from pathlib import Path
|
|
25
|
-
from typing import Dict, List
|
|
27
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
28
|
|
|
27
29
|
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
28
30
|
|
|
@@ -67,26 +69,77 @@ def collect(paths: List[str]) -> List[Path]:
|
|
|
67
69
|
return files
|
|
68
70
|
|
|
69
71
|
|
|
72
|
+
def _cell(message: str) -> str:
|
|
73
|
+
"""A failure message as one Markdown table cell: one line, no pipes, at most 200 characters."""
|
|
74
|
+
return " / ".join(line.strip() for line in str(message).splitlines() if line.strip()).replace("|", "/")[:200]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _probe_capturing(path: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, Any]]":
|
|
78
|
+
"""probe() in this process, with its failure kept: (the probe, {}) or (None, the failure's
|
|
79
|
+
kind/reason/message). probe() dies on an unreadable file; its failure document is read back
|
|
80
|
+
instead of a fixed string standing in for it (one JSON document per run: it is not printed)."""
|
|
81
|
+
was_json, STATE.json = STATE.json, True
|
|
82
|
+
out, err = io.StringIO(), io.StringIO()
|
|
83
|
+
try:
|
|
84
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
85
|
+
return probe(path), {}
|
|
86
|
+
except SystemExit as exc:
|
|
87
|
+
try:
|
|
88
|
+
doc = json.loads(out.getvalue())
|
|
89
|
+
except ValueError:
|
|
90
|
+
doc = {}
|
|
91
|
+
e = doc.get("error") if isinstance(doc.get("error"), dict) else {}
|
|
92
|
+
failure = {k: e[k] for k in ("kind", "reason", "message") if e.get(k)}
|
|
93
|
+
failure.setdefault("message", (err.getvalue().strip().splitlines() or [f"probe exited {exc.code}"])[-1])
|
|
94
|
+
return None, failure
|
|
95
|
+
finally:
|
|
96
|
+
STATE.json = was_json
|
|
97
|
+
|
|
98
|
+
|
|
70
99
|
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
100
|
+
"""Run one step and keep what its tool said: `ok` (it completed), and on a failure the tool's
|
|
101
|
+
own `kind`, `reason` and `message` (its --json failure document), `error` being that message
|
|
102
|
+
for the table. A completed step also carries the tool's `verified`."""
|
|
71
103
|
t0 = time.time()
|
|
72
104
|
if argv[0] == "__check_hdr__":
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
105
|
+
meta, failure = _probe_capturing(argv[1])
|
|
106
|
+
if meta is None:
|
|
107
|
+
return dict({"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": _cell(failure.get("message", ""))}, **failure)
|
|
108
|
+
v = meta.get("video") or {}
|
|
109
|
+
ok = bool(v.get("bt2020_or_hdr")) and v.get("bit_depth", 8) >= 10
|
|
110
|
+
r: Dict[str, Any] = {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1),
|
|
111
|
+
"error": "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"}
|
|
112
|
+
if not ok:
|
|
113
|
+
r.update(kind="verification", reason="hdr_lost", message=r["error"])
|
|
114
|
+
return r
|
|
83
115
|
try:
|
|
84
|
-
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
85
|
-
|
|
86
|
-
err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
|
|
116
|
+
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
117
|
+
text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
|
87
118
|
except subprocess.TimeoutExpired:
|
|
88
|
-
|
|
89
|
-
|
|
119
|
+
msg = f"timeout after {timeout:.0f}s"
|
|
120
|
+
return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
|
|
121
|
+
"kind": "timeout", "reason": "step_timeout", "message": msg}
|
|
122
|
+
try:
|
|
123
|
+
doc = json.loads(proc.stdout)
|
|
124
|
+
except ValueError:
|
|
125
|
+
doc = None
|
|
126
|
+
doc = doc if isinstance(doc, dict) else {}
|
|
127
|
+
ok = proc.returncode == 0
|
|
128
|
+
r = {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": ""}
|
|
129
|
+
if ok:
|
|
130
|
+
if "verified" in doc:
|
|
131
|
+
r["verified"] = doc["verified"]
|
|
132
|
+
return r
|
|
133
|
+
e = doc.get("error") if isinstance(doc.get("error"), dict) else {}
|
|
134
|
+
r["message"] = str(e.get("message") or (proc.stderr.strip().splitlines() or [f"exit {proc.returncode}"])[-1])
|
|
135
|
+
r["error"] = _cell(r["message"])
|
|
136
|
+
for k in ("kind", "reason", "details"):
|
|
137
|
+
if e.get(k) is not None:
|
|
138
|
+
r[k] = e[k]
|
|
139
|
+
if not e:
|
|
140
|
+
r["reason"] = "step_output_unparsed"
|
|
141
|
+
r["exit_code"] = proc.returncode
|
|
142
|
+
return r
|
|
90
143
|
|
|
91
144
|
|
|
92
145
|
def main() -> int:
|
|
@@ -117,16 +170,13 @@ def main() -> int:
|
|
|
117
170
|
results = []
|
|
118
171
|
for f in files:
|
|
119
172
|
info(f"=== {f}")
|
|
120
|
-
entry: Dict = {"file": str(f), "steps": []}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
|
|
173
|
+
entry: Dict = {"file": str(f), "steps": [], "skipped": []}
|
|
174
|
+
meta, failure = _probe_capturing(str(f))
|
|
175
|
+
if meta is None:
|
|
176
|
+
entry["steps"].append(dict({"step": "probe", "ok": False, "seconds": 0, "error": _cell(failure.get("message", ""))}, **failure))
|
|
177
|
+
entry["skipped"].append({"step": "*", "reason": "input_unreadable", "message": "no other step runs on a file probe cannot read"})
|
|
126
178
|
results.append(entry)
|
|
127
179
|
continue
|
|
128
|
-
finally:
|
|
129
|
-
STATE.json = was_json
|
|
130
180
|
entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
|
|
131
181
|
entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
|
|
132
182
|
entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
|
|
@@ -138,6 +188,10 @@ def main() -> int:
|
|
|
138
188
|
seg_end = min(dur, args.seconds) if dur else args.seconds
|
|
139
189
|
fast = ["--fast"]
|
|
140
190
|
plan = []
|
|
191
|
+
skipped: List[Dict[str, str]] = entry["skipped"]
|
|
192
|
+
|
|
193
|
+
def skip(step_name: str, reason: str, message: str) -> None:
|
|
194
|
+
skipped.append({"step": step_name, "reason": reason, "message": message})
|
|
141
195
|
plan.append(("cut copy", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "-o", cut]))
|
|
142
196
|
if has_v:
|
|
143
197
|
plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
|
|
@@ -152,12 +206,30 @@ def main() -> int:
|
|
|
152
206
|
plan.append(("color to-sdr", ["color.py", f"{stem}_acc.mp4", "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
|
|
153
207
|
plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
|
|
154
208
|
plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
|
|
209
|
+
else:
|
|
210
|
+
for n in ("overlay text", "look sheet", "color to-sdr", "hdr preserved", "probe analyze"):
|
|
211
|
+
skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
|
|
212
|
+
if not args.quick and not (meta.get("video") or {}).get("bt2020_or_hdr"):
|
|
213
|
+
for n in ("color to-sdr", "hdr preserved"):
|
|
214
|
+
skip(n, "not_hdr", "the file is not HDR")
|
|
155
215
|
plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
|
|
216
|
+
else:
|
|
217
|
+
for n in ("cut accurate", "fit 9:16", "caption", "overlay text", "look sheet", "color to-sdr", "hdr preserved",
|
|
218
|
+
"probe analyze", "export x"):
|
|
219
|
+
skip(n, "no_video", "the file has no video stream")
|
|
156
220
|
if has_a and not args.quick:
|
|
157
221
|
plan.append(("loudness measure", ["loudness.py", str(f), "--measure-only"]))
|
|
158
222
|
plan.append(("silence list", ["silence.py", cut, "--list"]))
|
|
159
223
|
if (meta.get("audio") or {}).get("channels", 0) > 2:
|
|
160
224
|
plan.append(("audio downmix", ["audio.py", cut, "--downmix", "-o", f"{stem}_st.mp4"]))
|
|
225
|
+
else:
|
|
226
|
+
skip("audio downmix", "not_multichannel", "the audio has 2 channels or fewer")
|
|
227
|
+
elif not has_a:
|
|
228
|
+
for n in ("loudness measure", "silence list", "audio downmix"):
|
|
229
|
+
skip(n, "no_audio", "the file has no audio stream")
|
|
230
|
+
else:
|
|
231
|
+
for n in ("loudness measure", "silence list", "audio downmix"):
|
|
232
|
+
skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
|
|
161
233
|
for name, argv in plan:
|
|
162
234
|
r = step(name, argv, args.timeout)
|
|
163
235
|
entry["steps"].append(r)
|
|
@@ -179,6 +251,8 @@ def main() -> int:
|
|
|
179
251
|
lines.append("|---|---|---|---|")
|
|
180
252
|
for s in e["steps"]:
|
|
181
253
|
lines.append(f"| {s['step']} | {'PASS' if s['ok'] else 'FAIL'} | {s['seconds']}s | {s['error']} |")
|
|
254
|
+
for s in e.get("skipped") or []:
|
|
255
|
+
lines.append(f"| {s['step']} | SKIP | - | {s['message']} |")
|
|
182
256
|
lines.append("")
|
|
183
257
|
report = "\n".join(lines)
|
|
184
258
|
if args.report:
|
package/scripts/waveform.py
CHANGED
|
@@ -38,7 +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, draws_chroma, emit, ffmpeg_base,
|
|
39
39
|
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, 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
|
+
from _common.emit import absorb_stage_hw, warn
|
|
42
42
|
|
|
43
43
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
44
44
|
|
|
@@ -272,7 +272,8 @@ def main() -> int:
|
|
|
272
272
|
f"{overlap}px: the captions or the app's own UI will sit over the waveform "
|
|
273
273
|
f"(lower --vis-height to {max(0.05, (args.height - safe_px) / args.height):.2f})")
|
|
274
274
|
if silent_peak is not None:
|
|
275
|
-
|
|
275
|
+
warn(f"{args.input} is silent (peak {silent_peak:g} dBFS); the visualisation is a flat line", "silent_input",
|
|
276
|
+
path=args.input, peak_db=silent_peak)
|
|
276
277
|
notes.append(f"the input audio is silent (peak {silent_peak:g} dBFS, at or below --silence-threshold "
|
|
277
278
|
f"{args.silence_threshold:g}): the visualisation is a flat line; pass --on-silent fail to refuse instead")
|
|
278
279
|
duration_ok = True
|