ffmpeg-skill 2.10.11 → 2.12.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 -3
- package/docs/contract.md +107 -11
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +92 -20
- package/scripts/_common/__init__.py +6 -4
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +198 -2
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +246 -68
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +168 -10
- 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/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +223 -74
- 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/report.py
CHANGED
|
@@ -17,32 +17,57 @@ import os
|
|
|
17
17
|
import sys
|
|
18
18
|
import tempfile
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import Any, Dict, List, Optional
|
|
20
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
21
21
|
|
|
22
22
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, read_text_or_die, run_tool
|
|
23
|
+
from _common.emit import record_skip, warn
|
|
23
24
|
|
|
24
25
|
HERE = Path(__file__).resolve().parent
|
|
25
26
|
|
|
26
27
|
|
|
27
|
-
def
|
|
28
|
+
def _child_failure(proc: Any, doc: Any) -> Dict[str, Any]:
|
|
29
|
+
"""Why a sibling tool produced nothing usable: its own error (kind, reason, message), or the
|
|
30
|
+
last stderr line and the exit code when it printed no failure document."""
|
|
31
|
+
err = (doc or {}).get("error") if isinstance(doc, dict) and isinstance(doc.get("error"), dict) else {}
|
|
32
|
+
out: Dict[str, Any] = {"exit_code": proc.returncode,
|
|
33
|
+
"message": str(err.get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1])[:300]}
|
|
34
|
+
for k in ("kind", "reason"):
|
|
35
|
+
if err.get(k):
|
|
36
|
+
out[k] = err[k]
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> "Tuple[Optional[str], Dict[str, Any]]":
|
|
41
|
+
"""(the contact sheet as base64, or None; why there is none)."""
|
|
42
|
+
if STATE.dry_run:
|
|
43
|
+
return None, {"reason": "dry_run", "message": "a dry run draws no contact sheet"}
|
|
28
44
|
with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
|
|
29
45
|
png = os.path.join(tmp, "sheet.png")
|
|
30
|
-
proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png] + child_args())
|
|
46
|
+
proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png, "--json"] + child_args())
|
|
47
|
+
try:
|
|
48
|
+
doc = json.loads(proc.stdout)
|
|
49
|
+
except ValueError:
|
|
50
|
+
doc = None
|
|
31
51
|
if proc.returncode != 0 or not os.path.exists(png):
|
|
32
|
-
return None
|
|
33
|
-
return base64.b64encode(Path(png).read_bytes()).decode("ascii")
|
|
52
|
+
return None, dict(_child_failure(proc, doc), reason=((doc or {}).get("error") or {}).get("reason") or "look_failed")
|
|
53
|
+
return base64.b64encode(Path(png).read_bytes()).decode("ascii"), {}
|
|
34
54
|
|
|
35
55
|
|
|
36
|
-
def loudness(path: str) -> Dict[str, Any]:
|
|
56
|
+
def loudness(path: str) -> "Tuple[Dict[str, Any], Dict[str, Any]]":
|
|
57
|
+
"""(lufs/tp/lra, or {}; why there is no measurement)."""
|
|
37
58
|
proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"] + child_args())
|
|
38
59
|
try:
|
|
39
60
|
d = json.loads(proc.stdout)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
except ValueError:
|
|
62
|
+
d = None
|
|
63
|
+
try:
|
|
64
|
+
return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}, {}
|
|
65
|
+
except (TypeError, ValueError, KeyError):
|
|
66
|
+
return {}, dict(_child_failure(proc, d), reason="loudness_not_measured")
|
|
43
67
|
|
|
44
68
|
|
|
45
|
-
def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
|
|
69
|
+
def check(path: str, platform: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, Any]]":
|
|
70
|
+
"""(check.py's document, or None when it could not run at all; why)."""
|
|
46
71
|
proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"] + child_args())
|
|
47
72
|
try:
|
|
48
73
|
doc = json.loads(proc.stdout)
|
|
@@ -51,10 +76,10 @@ def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
|
|
|
51
76
|
if not isinstance(doc, dict) or "checks" not in doc:
|
|
52
77
|
# check.py could not run at all (missing ffmpeg, unreadable file): its failure document
|
|
53
78
|
# has no rows to render. A failed *verification* still carries its rows and is shown.
|
|
54
|
-
|
|
55
|
-
info(f"check.py could not run: {
|
|
56
|
-
return None
|
|
57
|
-
return doc
|
|
79
|
+
why = dict(_child_failure(proc, doc), reason=((doc or {}).get("error") or {}).get("reason") or "check_failed")
|
|
80
|
+
info(f"check.py could not run: {why['message'][:200]}")
|
|
81
|
+
return None, why
|
|
82
|
+
return doc, {}
|
|
58
83
|
|
|
59
84
|
|
|
60
85
|
def fmt_dur(sec: Optional[float]) -> str:
|
|
@@ -167,15 +192,44 @@ def main() -> int:
|
|
|
167
192
|
die("--after is required (or --pack FILE for a social pack table)")
|
|
168
193
|
after = probe(args.after)
|
|
169
194
|
before = probe(args.before) if args.before else None
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
195
|
+
# every section the report would show is either in it or accounted for: a section that could
|
|
196
|
+
# not be built is a failing verification step (and a warning), never just absent from the page
|
|
197
|
+
verification: List[Dict[str, Any]] = []
|
|
198
|
+
|
|
199
|
+
def section(step: str, which: Optional[str], why: Dict[str, Any], what: str) -> None:
|
|
200
|
+
entry: Dict[str, Any] = {"step": step, "ok": not why}
|
|
201
|
+
if which:
|
|
202
|
+
entry["file"] = which
|
|
203
|
+
if why:
|
|
204
|
+
if why.get("reason") == "dry_run":
|
|
205
|
+
record_skip(f"{what}: {why['message']}", "report_section_dry_run", section=step, file=which)
|
|
206
|
+
return
|
|
207
|
+
entry.update({k: v for k, v in why.items() if k != "message"}, message=why.get("message"))
|
|
208
|
+
warn(f"the report has no {what}: {why.get('message')}", "report_section_missing", section=step, file=which,
|
|
209
|
+
reason=why.get("reason"))
|
|
210
|
+
verification.append(entry)
|
|
211
|
+
|
|
212
|
+
ld_after: Dict[str, Any] = {}
|
|
213
|
+
ld_before: Dict[str, Any] = {}
|
|
214
|
+
if after.get("audio"):
|
|
215
|
+
ld_after, why = loudness(args.after)
|
|
216
|
+
section("loudness", "after", why, "loudness for --after")
|
|
217
|
+
if before and before.get("audio"):
|
|
218
|
+
ld_before, why = loudness(args.before)
|
|
219
|
+
section("loudness", "before", why, "loudness for --before")
|
|
220
|
+
chk = None
|
|
221
|
+
if args.platform:
|
|
222
|
+
chk, why = check(args.after, args.platform)
|
|
223
|
+
section("compliance", None, why, f"{args.platform} compliance table")
|
|
224
|
+
if chk is not None and verification:
|
|
225
|
+
# ok: the table is in the report; passed: what the table says about the file
|
|
226
|
+
verification[-1].update(platform=args.platform, passed=bool(chk.get("ok")))
|
|
227
|
+
sheets: Dict[str, Optional[str]] = {}
|
|
174
228
|
if not args.no_sheets:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
229
|
+
for which, path, meta in (("before", args.before, before), ("after", args.after, after)):
|
|
230
|
+
if meta and meta.get("video"):
|
|
231
|
+
sheets[which], why = sheet_b64(path)
|
|
232
|
+
section("contact_sheet", which, why, f"{which} contact sheet")
|
|
179
233
|
commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
|
|
180
234
|
notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
|
|
181
235
|
title = args.title or f"Delivery report — {Path(args.after).name}"
|
|
@@ -235,7 +289,7 @@ def main() -> int:
|
|
|
235
289
|
except OSError as e:
|
|
236
290
|
die(f"cannot write {output}: {e}", kind="output")
|
|
237
291
|
info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
|
|
238
|
-
emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] if not STATE.dry_run else []))
|
|
292
|
+
emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] + verification if not STATE.dry_run else []))
|
|
239
293
|
if not args.json:
|
|
240
294
|
print(output)
|
|
241
295
|
return 0
|
package/scripts/reverse.py
CHANGED
|
@@ -15,6 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
17
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
18
|
+
from _common.emit import warn
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def main() -> int:
|
|
@@ -35,8 +36,9 @@ def main() -> int:
|
|
|
35
36
|
|
|
36
37
|
output = args.output or default_output(args.input, "reverse")
|
|
37
38
|
if (meta.get("duration") or 0) > 60:
|
|
38
|
-
|
|
39
|
-
f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)"
|
|
39
|
+
warn(f"reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
|
|
40
|
+
f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)",
|
|
41
|
+
"memory_heavy", duration=meta["duration"])
|
|
40
42
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
|
|
41
43
|
if has_audio:
|
|
42
44
|
cmd += ["-af", "areverse"]
|
package/scripts/sequence.py
CHANGED
|
@@ -21,6 +21,7 @@ import tempfile
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
|
|
24
|
+
from _common.emit import warn
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def even(n: float) -> int:
|
|
@@ -97,8 +98,9 @@ def main() -> int:
|
|
|
97
98
|
# to be dropped silently, giving a shorter video; name the gap and what was left out
|
|
98
99
|
later = _frames_after(directory, args.pattern, i)
|
|
99
100
|
if later:
|
|
100
|
-
|
|
101
|
-
f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them"
|
|
101
|
+
warn(f"frame {args.pattern % i} is missing; {len(later)} later frame(s) "
|
|
102
|
+
f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them", "sequence_gap",
|
|
103
|
+
missing=args.pattern % i, frames_left_out=len(later), first_left_out=later[0].name)
|
|
102
104
|
|
|
103
105
|
frame_meta = probe(str(frames[0]))
|
|
104
106
|
if not frame_meta.get("video"):
|
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
|