ffmpeg-skill 0.4.1 → 0.6.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.
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Build a single-file HTML delivery report: what went in, what came out,
3
+ before/after contact sheets, loudness, compliance and the exact commands.
4
+ The agent hands this to the user instead of a wall of text.
5
+
6
+ Examples:
7
+ python3 report.py --before raw.mov --after final.mp4 -o report.html
8
+ python3 report.py --after final.mp4 --platform reels --title "Episode 12 — Reels cut" -o report.html
9
+ python3 report.py --before raw.mov --after final.mp4 --commands commands.txt --notes notes.md
10
+ """
11
+ import argparse
12
+ import base64
13
+ import html
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from _common import add_common, apply_common, die, emit, info, probe
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+
26
+
27
+ def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
28
+ with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
29
+ png = os.path.join(tmp, "sheet.png")
30
+ proc = subprocess.run([sys.executable, str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png],
31
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
32
+ if proc.returncode != 0 or not os.path.exists(png):
33
+ return None
34
+ return base64.b64encode(Path(png).read_bytes()).decode("ascii")
35
+
36
+
37
+ def loudness(path: str) -> Dict[str, Any]:
38
+ proc = subprocess.run([sys.executable, str(HERE / "loudness.py"), path, "--measure-only"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
39
+ try:
40
+ d = json.loads(proc.stdout)
41
+ return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
42
+ except (ValueError, KeyError):
43
+ return {}
44
+
45
+
46
+ def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
47
+ proc = subprocess.run([sys.executable, str(HERE / "check.py"), path, "--platform", platform, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
48
+ try:
49
+ return json.loads(proc.stdout)
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def fmt_dur(sec: Optional[float]) -> str:
55
+ if not sec:
56
+ return "?"
57
+ m, s = divmod(sec, 60)
58
+ h, m = divmod(int(m), 60)
59
+ return f"{h}:{m:02d}:{s:05.2f}" if h else f"{m}:{s:05.2f}"
60
+
61
+
62
+ def media_rows(meta: Dict[str, Any], ld: Dict[str, Any]) -> List[List[str]]:
63
+ v, a = meta.get("video") or {}, meta.get("audio") or {}
64
+ rows = [
65
+ ["Duration", fmt_dur(meta.get("duration"))],
66
+ ["Size", f"{(meta.get('size_bytes') or 0) / 1024 / 1024:.1f} MB"],
67
+ ["Video", f"{v.get('codec')} {v.get('width')}×{v.get('height')} @ {v.get('fps')} fps, {v.get('pix_fmt')}" if v else "none"],
68
+ ["Colour", (f"{v.get('color_primaries')}/{v.get('color_transfer')}" + (f" — {v.get('hdr_format')}" if v.get("hdr") else " (SDR)")) if v else "—"],
69
+ ["Frame rate", ("variable (suspected)" if v.get("variable_frame_rate_suspected") else "constant") if v else "—"],
70
+ ["Audio", f"{a.get('codec')} {a.get('channels')} ch {a.get('sample_rate')} Hz" if a else "none"],
71
+ ]
72
+ if ld:
73
+ rows.append(["Loudness", f"{ld['lufs']} LUFS, TP {ld['tp']} dBTP, LRA {ld['lra']} LU"])
74
+ return rows
75
+
76
+
77
+ def main() -> int:
78
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
79
+ ap.add_argument("--after", required=True, help="the deliverable")
80
+ ap.add_argument("--before", help="the source (optional)")
81
+ ap.add_argument("-o", "--output", help="report path (default: <after>_report.html)")
82
+ ap.add_argument("--title", help="report title")
83
+ ap.add_argument("--platform", help="run check.py for this platform and include the table")
84
+ ap.add_argument("--commands", help="text file with the commands that were run (one per line)")
85
+ ap.add_argument("--notes", help="text/markdown file with notes to include verbatim")
86
+ ap.add_argument("--no-sheets", action="store_true", help="skip contact sheets (faster, smaller)")
87
+ add_common(ap)
88
+ args = ap.parse_args()
89
+ apply_common(args)
90
+
91
+ after = probe(args.after)
92
+ before = probe(args.before) if args.before else None
93
+ ld_after = loudness(args.after) if after.get("audio") else {}
94
+ ld_before = loudness(args.before) if before and before.get("audio") else {}
95
+ chk = check(args.after, args.platform) if args.platform else None
96
+ sheets = {}
97
+ if not args.no_sheets:
98
+ if before and before.get("video"):
99
+ sheets["before"] = sheet_b64(args.before)
100
+ if after.get("video"):
101
+ sheets["after"] = sheet_b64(args.after)
102
+ commands = Path(args.commands).read_text(encoding="utf-8").splitlines() if args.commands else []
103
+ notes = Path(args.notes).read_text(encoding="utf-8") if args.notes else ""
104
+ title = args.title or f"Delivery report — {Path(args.after).name}"
105
+ output = args.output or str(Path(args.after).with_name(Path(args.after).stem + "_report.html"))
106
+
107
+ def table(rows: List[List[str]]) -> str:
108
+ return "<table>" + "".join(f"<tr><th>{html.escape(k)}</th><td>{html.escape(str(v))}</td></tr>" for k, v in rows) + "</table>"
109
+
110
+ parts: List[str] = []
111
+ parts.append(f"<h1>{html.escape(title)}</h1>")
112
+ parts.append(f"<p class='meta'>{html.escape(os.path.abspath(args.after))}</p>")
113
+ cols = []
114
+ if before:
115
+ cols.append(f"<div class='col'><h2>Before</h2><p class='file'>{html.escape(Path(args.before).name)}</p>{table(media_rows(before, ld_before))}"
116
+ + (f"<img src='data:image/png;base64,{sheets['before']}' alt='before contact sheet'>" if sheets.get("before") else "") + "</div>")
117
+ cols.append(f"<div class='col'><h2>After</h2><p class='file'>{html.escape(Path(args.after).name)}</p>{table(media_rows(after, ld_after))}"
118
+ + (f"<img src='data:image/png;base64,{sheets['after']}' alt='after contact sheet'>" if sheets.get("after") else "") + "</div>")
119
+ parts.append("<div class='cols'>" + "".join(cols) + "</div>")
120
+ if chk:
121
+ rows = "".join(
122
+ f"<tr class='{r['status'].lower()}'><td class='st'>{r['status']}</td><td>{html.escape(r['check'])}</td><td>{html.escape(str(r['value']))}</td><td>{html.escape(str(r['expected']))}</td><td>{html.escape(r.get('fix') or '') if r['status'] != 'PASS' else ''}</td></tr>"
123
+ for r in chk["checks"])
124
+ verdict = "READY" if chk.get("ok") else f"{chk.get('failed')} FAIL"
125
+ parts.append(f"<h2>Compliance — {html.escape(args.platform)} <span class='verdict {'ok' if chk.get('ok') else 'bad'}'>{verdict}</span></h2>"
126
+ f"<table class='checks'><tr><th></th><th>check</th><th>value</th><th>expected</th><th>fix</th></tr>{rows}</table>")
127
+ if notes:
128
+ parts.append("<h2>Notes</h2><pre class='notes'>" + html.escape(notes) + "</pre>")
129
+ if commands:
130
+ parts.append("<h2>Commands</h2><pre class='cmd'>" + html.escape("\n".join(commands)) + "</pre>")
131
+ parts.append("<p class='foot'>Generated by ffmpeg-skill · local FFmpeg, no cloud.</p>")
132
+
133
+ css = """
134
+ :root{--bg:#F4F6F8;--paper:#fff;--ink:#161B21;--ink2:#4B5661;--line:#D8DEE4;--ok:#2C8A5B;--warn:#C48519;--bad:#B4362F;--accent:#1E6F8E}
135
+ @media (prefers-color-scheme:dark){:root{--bg:#111518;--paper:#191E23;--ink:#E8ECEF;--ink2:#AEB6BE;--line:#2A3138;--ok:#5CC38C;--warn:#E3A63C;--bad:#F07A73;--accent:#5FB2D4}}
136
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans JP",sans-serif}
137
+ .wrap{max-width:1100px;margin:0 auto;padding:32px 20px 60px}
138
+ h1{font-size:26px;margin:0 0 4px}h2{font-size:18px;margin:28px 0 10px}
139
+ .meta{color:var(--ink2);font-size:13px;margin:0 0 20px;word-break:break-all}
140
+ .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
141
+ .col{background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:14px 16px}
142
+ .col h2{margin-top:0}.file{color:var(--ink2);font-size:13px;margin:0 0 8px}
143
+ table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
144
+ th{color:var(--ink2);font-weight:500;width:34%}
145
+ img{max-width:100%;border-radius:4px;margin-top:12px;border:1px solid var(--line)}
146
+ .checks{background:var(--paper);border:1px solid var(--line);border-radius:6px;overflow:hidden}.checks th{width:auto}
147
+ .st{font-weight:700;font-family:ui-monospace,Menlo,monospace}tr.pass .st{color:var(--ok)}tr.warn .st{color:var(--warn)}tr.fail .st{color:var(--bad)}
148
+ .verdict{font-size:13px;padding:2px 8px;border-radius:3px;margin-left:8px;vertical-align:middle}.verdict.ok{background:var(--ok);color:#fff}.verdict.bad{background:var(--bad);color:#fff}
149
+ pre{background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:12px 14px;overflow-x:auto;font-size:12.5px;line-height:1.5}
150
+ .foot{color:var(--ink2);font-size:12px;margin-top:36px;border-top:1px solid var(--line);padding-top:10px}
151
+ """
152
+ doc = f"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{html.escape(title)}</title><style>{css}</style></head><body><div class='wrap'>{''.join(parts)}</div></body></html>"
153
+ Path(output).write_text(doc, encoding="utf-8")
154
+ info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
155
+ emit(None, report=output, check=chk)
156
+ if not args.json:
157
+ print(output)
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ sys.exit(main())
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Find scene changes and loud moments, and propose highlight candidates so the
3
+ agent can plan an edit or a digest without watching the whole file.
4
+
5
+ Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
6
+ of the audio. Highlight candidates are the scenes ranked by audio energy
7
+ (and, optionally, by motion).
8
+
9
+ Examples:
10
+ python3 scenes.py talk.mp4 # scenes + peaks, JSON
11
+ python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
12
+ python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
13
+ python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
14
+ """
15
+ import argparse
16
+ import math
17
+ import os
18
+ import re
19
+ import struct
20
+ import subprocess
21
+ import sys
22
+ from typing import Dict, List, Tuple
23
+
24
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
25
+
26
+ SCENE_RE = re.compile(r"lavfi\.scd\.time=([0-9.]+)")
27
+
28
+
29
+ def detect_scenes(path: str, threshold: float, min_len: float, duration: float) -> List[float]:
30
+ ffmpeg = require_tool("ffmpeg")
31
+ proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
32
+ f"scale=320:-2,scdet=threshold={threshold}:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
33
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
34
+ times = [float(t) for t in SCENE_RE.findall(proc.stdout)]
35
+ cuts = [0.0]
36
+ for t in times:
37
+ if t - cuts[-1] >= min_len:
38
+ cuts.append(t)
39
+ if duration - cuts[-1] < min_len and len(cuts) > 1:
40
+ cuts.pop()
41
+ return cuts
42
+
43
+
44
+ def audio_envelope(path: str, step_s: float) -> List[float]:
45
+ ffmpeg = require_tool("ffmpeg")
46
+ proc = subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
47
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
48
+ n = len(proc.stdout) // 2
49
+ if n == 0:
50
+ return []
51
+ samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
52
+ step = max(1, int(8000 * step_s))
53
+ env = []
54
+ for i in range(0, n, step):
55
+ block = samples[i:i + step]
56
+ env.append(math.sqrt(sum(x * x for x in block) / len(block)) / 32768.0)
57
+ return env
58
+
59
+
60
+ def main() -> int:
61
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
62
+ ap.add_argument("input")
63
+ ap.add_argument("--threshold", type=float, default=10.0, help="scdet threshold 0-100 (default 10; lower = more cuts)")
64
+ ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
65
+ ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
66
+ ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
67
+ ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
68
+ ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
69
+ ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
70
+ add_common(ap)
71
+ args = ap.parse_args()
72
+ apply_common(args)
73
+
74
+ meta = probe(args.input)
75
+ if not meta.get("video"):
76
+ die("input has no video stream")
77
+ dur = meta.get("duration") or 0.0
78
+ cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur)
79
+ bounds = cuts + [dur]
80
+ step_s = 0.5
81
+ env = audio_envelope(args.input, step_s) if meta.get("audio") else []
82
+
83
+ scenes = []
84
+ for i in range(len(bounds) - 1):
85
+ s, e = bounds[i], bounds[i + 1]
86
+ if e - s <= 0.05:
87
+ continue
88
+ seg = env[int(s / step_s): max(int(s / step_s) + 1, int(e / step_s))] if env else []
89
+ energy = (sum(seg) / len(seg)) if seg else 0.0
90
+ peak = max(seg) if seg else 0.0
91
+ scenes.append({"index": len(scenes), "start": round(s, 3), "end": round(e, 3), "duration": round(e - s, 3),
92
+ "audio_rms": round(energy, 4), "audio_peak": round(peak, 4)})
93
+ peaks = []
94
+ if env:
95
+ thr = sorted(env)[int(len(env) * 0.9)] if len(env) > 10 else max(env)
96
+ for i, val in enumerate(env):
97
+ if val >= thr and val > 0.02 and (i == 0 or env[i - 1] < val) and (i == len(env) - 1 or env[i + 1] <= val):
98
+ peaks.append({"time": round(i * step_s, 2), "rms": round(val, 4)})
99
+ peaks = sorted(peaks, key=lambda p: -p["rms"])[:20]
100
+ peaks.sort(key=lambda p: p["time"])
101
+
102
+ result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
103
+ info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
104
+
105
+ if args.highlights:
106
+ ranked = sorted(scenes, key=lambda sc: (-sc["audio_rms"], sc["start"]))[: args.highlights]
107
+ picks: List[Tuple[float, float]] = []
108
+ budget = args.target if args.target else None
109
+ per = (budget / max(1, len(ranked))) if budget else args.max_scene
110
+ for sc in ranked:
111
+ length = min(sc["duration"], per, args.max_scene)
112
+ # take the loudest window inside the scene
113
+ best_s = sc["start"]
114
+ if env and length < sc["duration"]:
115
+ best, best_s = -1.0, sc["start"]
116
+ win = max(1, int(length / step_s))
117
+ lo, hi = int(sc["start"] / step_s), max(int(sc["start"] / step_s) + 1, int(sc["end"] / step_s) - win)
118
+ for i in range(lo, hi + 1):
119
+ val = sum(env[i:i + win])
120
+ if val > best:
121
+ best, best_s = val, i * step_s
122
+ picks.append((round(best_s, 2), round(min(sc["end"], best_s + length), 2)))
123
+ picks.sort()
124
+ result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
125
+ result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
126
+ info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
127
+ if args.edl:
128
+ with open(args.edl, "w", encoding="utf-8") as fh:
129
+ for s, e in picks:
130
+ fh.write(f"{s:.2f}-{e:.2f}\n")
131
+ info(f"wrote {args.edl}")
132
+
133
+ if args.sheet:
134
+ n = len(scenes)
135
+ cols = min(4, max(1, n))
136
+ rows = max(1, math.ceil(n / cols))
137
+ tile_w = 1280 // cols // 2 * 2
138
+ # exactly one frame per scene: the frame index at the scene start
139
+ fps = meta["video"].get("fps") or 30.0
140
+ expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
141
+ vf = (f"select='{expr}',scale={tile_w}:-2,drawtext=text='%{{pts\\:hms}}':fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6,"
142
+ f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
143
+ run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
144
+ info(f"wrote {args.sheet}")
145
+ result["sheet"] = args.sheet
146
+
147
+ if args.json:
148
+ emit(None, **result)
149
+ else:
150
+ print_json(result)
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())