ffmpeg-skill 0.2.0 → 0.4.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 +15 -4
- package/SKILL.md +80 -6
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/_common.py +137 -3
- package/scripts/audio.py +4 -2
- package/scripts/caption.py +73 -6
- package/scripts/color.py +24 -3
- package/scripts/cut.py +5 -3
- package/scripts/export.py +5 -3
- package/scripts/fit.py +5 -2
- package/scripts/join.py +111 -0
- package/scripts/look.py +101 -0
- package/scripts/loudness.py +4 -2
- package/scripts/multicam.py +196 -0
- package/scripts/overlay.py +4 -2
- package/scripts/probe.py +10 -1
- package/scripts/silence.py +123 -0
- package/scripts/sync.py +4 -3
- package/scripts/verify.py +157 -0
package/scripts/probe.py
CHANGED
|
@@ -12,7 +12,7 @@ Examples:
|
|
|
12
12
|
import argparse
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
-
from _common import print_json, probe
|
|
15
|
+
from _common import analyze_levels, print_json, probe
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def main() -> int:
|
|
@@ -20,9 +20,14 @@ def main() -> int:
|
|
|
20
20
|
ap.add_argument("inputs", nargs="+", help="media file(s) to inspect")
|
|
21
21
|
ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
|
|
22
22
|
ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
|
|
23
|
+
ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
|
|
23
24
|
args = ap.parse_args()
|
|
24
25
|
|
|
25
26
|
results = [probe(p) for p in args.inputs]
|
|
27
|
+
if args.analyze:
|
|
28
|
+
for r in results:
|
|
29
|
+
if r.get("video"):
|
|
30
|
+
r["levels"] = analyze_levels(r["file"])
|
|
26
31
|
|
|
27
32
|
if args.field:
|
|
28
33
|
for r in results:
|
|
@@ -41,6 +46,10 @@ def main() -> int:
|
|
|
41
46
|
line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
|
|
42
47
|
if v.get("variable_frame_rate_suspected"):
|
|
43
48
|
line += " (VFR?)"
|
|
49
|
+
if v.get("hdr"):
|
|
50
|
+
line += f" [{v.get('hdr_format')}]"
|
|
51
|
+
if r.get("levels", {}).get("looks_like_log"):
|
|
52
|
+
line += " [Log?]"
|
|
44
53
|
else:
|
|
45
54
|
line += " | no video"
|
|
46
55
|
if a:
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remove silences / dead air (jump-cut editing) or just list them.
|
|
3
|
+
|
|
4
|
+
Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
|
|
5
|
+
side so words are not clipped, drops gaps shorter than --min-silence, and
|
|
6
|
+
writes a frame-accurate re-encode in one pass (select/aselect filters).
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.15 s margin
|
|
10
|
+
python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
|
|
11
|
+
python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
|
|
12
|
+
python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Tuple
|
|
18
|
+
|
|
19
|
+
from _common import aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
|
|
20
|
+
|
|
21
|
+
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
|
|
25
|
+
ffmpeg = require_tool("ffmpeg")
|
|
26
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
|
|
27
|
+
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
28
|
+
proc = run(cmd, quiet=True, check=False)
|
|
29
|
+
if proc.returncode != 0:
|
|
30
|
+
die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}")
|
|
31
|
+
silences: List[Tuple[float, float]] = []
|
|
32
|
+
start = None
|
|
33
|
+
for kind, val in SIL_RE.findall(proc.stderr):
|
|
34
|
+
if kind == "start":
|
|
35
|
+
start = float(val)
|
|
36
|
+
elif start is not None:
|
|
37
|
+
silences.append((start, float(val)))
|
|
38
|
+
start = None
|
|
39
|
+
if start is not None: # silence runs to the end
|
|
40
|
+
silences.append((start, float("inf")))
|
|
41
|
+
return silences
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
|
|
45
|
+
keeps: List[Tuple[float, float]] = []
|
|
46
|
+
cursor = 0.0
|
|
47
|
+
for s, e in silences:
|
|
48
|
+
s_adj = max(cursor, s + margin)
|
|
49
|
+
if s_adj - cursor >= min_keep:
|
|
50
|
+
keeps.append((cursor, s_adj))
|
|
51
|
+
cursor = min(duration, e - margin) if e != float("inf") else duration
|
|
52
|
+
if duration - cursor >= min_keep:
|
|
53
|
+
keeps.append((cursor, duration))
|
|
54
|
+
return keeps
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> int:
|
|
58
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
59
|
+
ap.add_argument("input")
|
|
60
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_tight.<ext>)")
|
|
61
|
+
ap.add_argument("--threshold", type=float, default=-35.0, help="silence level in dBFS (default -35; use -40..-45 for quiet rooms)")
|
|
62
|
+
ap.add_argument("--min-silence", type=float, default=0.6, help="only remove gaps at least this long in seconds (default 0.6)")
|
|
63
|
+
ap.add_argument("--margin", type=float, default=0.15, help="seconds of silence to keep on each side of speech (default 0.15)")
|
|
64
|
+
ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
|
|
65
|
+
ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
|
|
66
|
+
ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
|
|
67
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
68
|
+
ap.add_argument("--preset", default="medium")
|
|
69
|
+
add_common(ap)
|
|
70
|
+
args = ap.parse_args()
|
|
71
|
+
apply_common(args)
|
|
72
|
+
|
|
73
|
+
meta = probe(args.input)
|
|
74
|
+
if not meta.get("audio"):
|
|
75
|
+
die("input has no audio stream to analyse")
|
|
76
|
+
duration = meta.get("duration") or 0.0
|
|
77
|
+
silences = detect(args.input, args.threshold, args.min_silence)
|
|
78
|
+
keeps = keep_ranges(silences, duration, args.margin, args.min_keep)
|
|
79
|
+
kept = sum(e - s for s, e in keeps)
|
|
80
|
+
removed = max(0.0, duration - kept)
|
|
81
|
+
summary = {
|
|
82
|
+
"silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
|
|
83
|
+
"keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
|
|
84
|
+
"input_duration": round(duration, 3),
|
|
85
|
+
"kept_duration": round(kept, 3),
|
|
86
|
+
"removed_seconds": round(removed, 3),
|
|
87
|
+
}
|
|
88
|
+
info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
|
|
89
|
+
|
|
90
|
+
if args.edl:
|
|
91
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
92
|
+
for s, e in keeps:
|
|
93
|
+
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
94
|
+
info(f"wrote {args.edl}")
|
|
95
|
+
|
|
96
|
+
if args.list:
|
|
97
|
+
if args.json:
|
|
98
|
+
emit(None, **summary)
|
|
99
|
+
else:
|
|
100
|
+
print_json(summary)
|
|
101
|
+
return 0
|
|
102
|
+
if not keeps:
|
|
103
|
+
die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
|
|
104
|
+
if not silences or removed < 0.05:
|
|
105
|
+
info("no removable silence found; output would equal the input")
|
|
106
|
+
|
|
107
|
+
output = args.output or default_output(args.input, "tight")
|
|
108
|
+
expr = "+".join(f"between(t,{s:.3f},{e:.3f})" for s, e in keeps)
|
|
109
|
+
vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
|
|
110
|
+
af = f"aselect='{expr}',asetpts=N/SR/TB"
|
|
111
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
112
|
+
if meta.get("video"):
|
|
113
|
+
cmd += ["-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
|
|
114
|
+
cmd += ["-af", af] + aac_args() + [output]
|
|
115
|
+
run(cmd)
|
|
116
|
+
r = probe(output)
|
|
117
|
+
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
|
|
118
|
+
emit(output, **summary)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
package/scripts/sync.py
CHANGED
|
@@ -26,7 +26,7 @@ import subprocess
|
|
|
26
26
|
import sys
|
|
27
27
|
from typing import List
|
|
28
28
|
|
|
29
|
-
from _common import aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
29
|
+
from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
30
30
|
|
|
31
31
|
SR = 8000 # decode sample rate
|
|
32
32
|
|
|
@@ -160,12 +160,13 @@ def main() -> int:
|
|
|
160
160
|
ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
|
|
161
161
|
ap.add_argument("--fix-drift", action="store_true", help="also measure the offset near the END and correct clock drift by resampling the second file")
|
|
162
162
|
ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
|
|
163
|
-
ap.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
164
163
|
mode = ap.add_mutually_exclusive_group()
|
|
165
164
|
mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
|
|
166
165
|
mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
|
|
167
166
|
ap.add_argument("--crf", type=int, default=18)
|
|
167
|
+
add_common(ap)
|
|
168
168
|
args = ap.parse_args()
|
|
169
|
+
apply_common(args)
|
|
169
170
|
|
|
170
171
|
for p in (args.reference, args.second):
|
|
171
172
|
if not probe(p).get("audio"):
|
|
@@ -286,7 +287,7 @@ def main() -> int:
|
|
|
286
287
|
info(f"wrote {output}")
|
|
287
288
|
|
|
288
289
|
if args.json:
|
|
289
|
-
|
|
290
|
+
emit(result.get("output"), **{k: v for k, v in result.items() if k != "output"})
|
|
290
291
|
else:
|
|
291
292
|
print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
|
|
292
293
|
if drift_info:
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Real-footage verification kit: run the whole toolchain against your own
|
|
3
|
+
files (phone HDR, GoPro, OBS screen captures, Log footage, Zoom recordings)
|
|
4
|
+
and get a pass/fail table. Synthetic test media never shows what a real
|
|
5
|
+
container does; this does.
|
|
6
|
+
|
|
7
|
+
Every file gets: probe, lossless cut, accurate cut, fit (9:16 pad), caption
|
|
8
|
+
burn, overlay text, loudness measurement, silence listing, export (x preset),
|
|
9
|
+
look (contact sheet), plus color --to-sdr when the file is HDR and
|
|
10
|
+
audio --downmix when it has more than 2 channels.
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
python3 verify.py ~/Footage/*.MOV ~/Footage/*.mp4
|
|
14
|
+
python3 verify.py fixtures/ --quick --report verify.md
|
|
15
|
+
python3 verify.py clip.mov --keep --out ./verify_out
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, List
|
|
26
|
+
|
|
27
|
+
from _common import add_common, apply_common, die, emit, info, probe
|
|
28
|
+
|
|
29
|
+
HERE = Path(__file__).resolve().parent
|
|
30
|
+
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def collect(paths: List[str]) -> List[Path]:
|
|
34
|
+
files: List[Path] = []
|
|
35
|
+
for p in paths:
|
|
36
|
+
pp = Path(p)
|
|
37
|
+
if pp.is_dir():
|
|
38
|
+
files += sorted(x for x in pp.rglob("*") if x.suffix.lower() in MEDIA_EXT and x.is_file())
|
|
39
|
+
elif pp.is_file():
|
|
40
|
+
files.append(pp)
|
|
41
|
+
else:
|
|
42
|
+
die(f"not found: {p}")
|
|
43
|
+
if not files:
|
|
44
|
+
die("no media files found")
|
|
45
|
+
return files
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
49
|
+
t0 = time.time()
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
|
52
|
+
ok = proc.returncode == 0
|
|
53
|
+
err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
ok, err = False, f"timeout after {timeout:.0f}s"
|
|
56
|
+
return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
61
|
+
ap.add_argument("paths", nargs="+", help="media files and/or folders")
|
|
62
|
+
ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
|
|
63
|
+
ap.add_argument("--keep", action="store_true", help="keep the outputs")
|
|
64
|
+
ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
|
|
65
|
+
ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
|
|
66
|
+
ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds")
|
|
67
|
+
ap.add_argument("--report", help="write a Markdown report here")
|
|
68
|
+
add_common(ap)
|
|
69
|
+
args = ap.parse_args()
|
|
70
|
+
apply_common(args)
|
|
71
|
+
|
|
72
|
+
files = collect(args.paths)
|
|
73
|
+
tmp = None
|
|
74
|
+
if args.out:
|
|
75
|
+
outdir = Path(args.out)
|
|
76
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
else:
|
|
78
|
+
tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
|
|
79
|
+
outdir = Path(tmp.name)
|
|
80
|
+
|
|
81
|
+
results = []
|
|
82
|
+
for f in files:
|
|
83
|
+
info(f"=== {f}")
|
|
84
|
+
entry: Dict = {"file": str(f), "steps": []}
|
|
85
|
+
try:
|
|
86
|
+
meta = probe(str(f))
|
|
87
|
+
except SystemExit:
|
|
88
|
+
entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
|
|
89
|
+
results.append(entry)
|
|
90
|
+
continue
|
|
91
|
+
entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
|
|
92
|
+
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")}
|
|
93
|
+
entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
|
|
94
|
+
entry["steps"].append({"step": "probe", "ok": True, "seconds": 0, "error": ""})
|
|
95
|
+
dur = meta.get("duration") or 0.0
|
|
96
|
+
has_v, has_a = bool(meta.get("video")), bool(meta.get("audio"))
|
|
97
|
+
stem = outdir / f.stem
|
|
98
|
+
cut = f"{stem}_cut.mp4"
|
|
99
|
+
seg_end = min(dur, args.seconds) if dur else args.seconds
|
|
100
|
+
fast = ["--fast"]
|
|
101
|
+
plan = []
|
|
102
|
+
plan.append(("cut copy", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "-o", cut]))
|
|
103
|
+
if has_v:
|
|
104
|
+
plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
|
|
105
|
+
plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
|
|
106
|
+
cues = outdir / f"{f.stem}_cues.txt"
|
|
107
|
+
cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
|
|
108
|
+
plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
|
|
109
|
+
if not args.quick:
|
|
110
|
+
plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
|
|
111
|
+
plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
|
|
112
|
+
if (meta.get("video") or {}).get("hdr"):
|
|
113
|
+
plan.append(("color to-sdr", ["color.py", cut, "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
|
|
114
|
+
plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
|
|
115
|
+
plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
|
|
116
|
+
if has_a and not args.quick:
|
|
117
|
+
plan.append(("loudness measure", ["loudness.py", str(f), "--measure-only"]))
|
|
118
|
+
plan.append(("silence list", ["silence.py", cut, "--list"]))
|
|
119
|
+
if (meta.get("audio") or {}).get("channels", 0) > 2:
|
|
120
|
+
plan.append(("audio downmix", ["audio.py", cut, "--downmix", "-o", f"{stem}_st.mp4"]))
|
|
121
|
+
for name, argv in plan:
|
|
122
|
+
r = step(name, argv, args.timeout)
|
|
123
|
+
entry["steps"].append(r)
|
|
124
|
+
info(f" {'PASS' if r['ok'] else 'FAIL'} {name:16s} {r['seconds']:6.1f}s {r['error']}")
|
|
125
|
+
results.append(entry)
|
|
126
|
+
|
|
127
|
+
total = sum(len(e["steps"]) for e in results)
|
|
128
|
+
failed = sum(1 for e in results for s in e["steps"] if not s["ok"])
|
|
129
|
+
lines = ["# ffmpeg-skill verification", "", f"{len(files)} files, {total} steps, {failed} failed", ""]
|
|
130
|
+
for e in results:
|
|
131
|
+
p = e.get("probe", {})
|
|
132
|
+
v, a = p.get("video", {}), p.get("audio", {})
|
|
133
|
+
lines.append(f"## {e['file']}")
|
|
134
|
+
lines.append(f"{p.get('duration')}s, {v.get('codec')} {v.get('width')}x{v.get('height')} @ {v.get('fps')} {v.get('pix_fmt')}"
|
|
135
|
+
+ (f" [{v.get('hdr_format')}]" if v.get("hdr_format") else "") + (" [VFR?]" if v.get("variable_frame_rate_suspected") else "")
|
|
136
|
+
+ (f" rot {v.get('rotation')}" if v.get("rotation") else "") + f", audio {a.get('codec')} {a.get('channels')}ch")
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append("| step | result | time | error |")
|
|
139
|
+
lines.append("|---|---|---|---|")
|
|
140
|
+
for s in e["steps"]:
|
|
141
|
+
lines.append(f"| {s['step']} | {'PASS' if s['ok'] else 'FAIL'} | {s['seconds']}s | {s['error']} |")
|
|
142
|
+
lines.append("")
|
|
143
|
+
report = "\n".join(lines)
|
|
144
|
+
if args.report:
|
|
145
|
+
Path(args.report).write_text(report, encoding="utf-8")
|
|
146
|
+
info(f"wrote {args.report}")
|
|
147
|
+
if args.json:
|
|
148
|
+
emit(None, report=args.report, files=results, failed=failed, total=total)
|
|
149
|
+
else:
|
|
150
|
+
print(report)
|
|
151
|
+
if tmp and not args.keep:
|
|
152
|
+
tmp.cleanup()
|
|
153
|
+
return 1 if failed else 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main())
|