返回 CodeWhale
check-media-assets.py
根目录 / scripts / media / check-media-assets.py
1 #!/usr/bin/env python3
2 """Check recorded media against MEDIA_BUDGETS before it can be published (#4906).
3
4 The acceptance checklist in docs/releases/v0.9.2-media-plan.md is prose, and
5 about half of it is mechanically checkable. This turns that half into a command,
6 so flipping the manifest to `published` stops depending on someone remembering
7 to measure a GIF.
8
9 It deliberately does NOT judge the take. Whether the session is worth showing is
10 a human call and the whole point of the issue. This only answers: does the file
11 satisfy the contract the site already advertises?
12
13 Budgets are read from web/lib/media-manifest.ts rather than duplicated here, so
14 the gate cannot drift from the contract the web tests enforce.
15
16 Usage:
17 python3 scripts/media/check-media-assets.py --dir .media-out
18 python3 scripts/media/check-media-assets.py --dir web/public/media --strict
19 """
20
21 from __future__ import annotations
22
23 import argparse
24 import json
25 import re
26 import shutil
27 import struct
28 import subprocess
29 import sys
30 from pathlib import Path
31
32 REPO_ROOT = Path(__file__).resolve().parent.parent.parent
33 MANIFEST = REPO_ROOT / "web" / "lib" / "media-manifest.ts"
34 ASSET_ID = "first-fleet-session"
35
36
37 def parse_budgets() -> dict:
38 """Read MEDIA_BUDGETS out of the TypeScript manifest."""
39 text = MANIFEST.read_text(encoding="utf-8")
40 match = re.search(r"MEDIA_BUDGETS\s*=\s*\{(.*?)\n\}", text, re.DOTALL)
41 if not match:
42 sys.exit(f"could not find MEDIA_BUDGETS in {MANIFEST}")
43 body = match.group(1)
44
45 def number(field: str, group: str | None = None) -> int | None:
46 scope = body
47 if group:
48 gm = re.search(rf"{group}:\s*\{{(.*?)\}}", body, re.DOTALL)
49 if not gm:
50 return None
51 scope = gm.group(1)
52 nm = re.search(rf"\b{field}:\s*([0-9_]+)", scope)
53 return int(nm.group(1).replace("_", "")) if nm else None
54
55 return {
56 "poster": {
57 "width": number("width", "poster"),
58 "height": number("height", "poster"),
59 "maxBytes": number("maxBytes", "poster"),
60 },
61 "video": {
62 "width": number("width", "video"),
63 "height": number("height", "video"),
64 "maxBytes": number("maxBytes", "video"),
65 "maxDurationSeconds": number("maxDurationSeconds", "video"),
66 },
67 "gifFallback": {"maxBytes": number("maxBytes", "gifFallback")},
68 "captionLocales": re.findall(
69 r'"([a-zA-Z-]+)"', re.search(r"captionLocales:\s*\[(.*?)\]", body, re.DOTALL).group(1)
70 )
71 if re.search(r"captionLocales:\s*\[(.*?)\]", body, re.DOTALL)
72 else [],
73 }
74
75
76 def png_dimensions(path: Path) -> tuple[int, int] | None:
77 """Read width/height from a PNG IHDR without pulling in a dependency."""
78 with path.open("rb") as handle:
79 header = handle.read(24)
80 if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
81 return None
82 return struct.unpack(">II", header[16:24])
83
84
85 def probe_video(path: Path) -> dict | None:
86 if not shutil.which("ffprobe"):
87 return None
88 try:
89 raw = subprocess.run(
90 [
91 "ffprobe", "-v", "error",
92 "-select_streams", "v:0",
93 "-show_entries", "stream=width,height:format=duration",
94 "-of", "json", str(path),
95 ],
96 capture_output=True, text=True, check=True,
97 ).stdout
98 except subprocess.CalledProcessError:
99 return None
100 data = json.loads(raw)
101 stream = (data.get("streams") or [{}])[0]
102 duration = data.get("format", {}).get("duration")
103 return {
104 "width": stream.get("width"),
105 "height": stream.get("height"),
106 "duration": float(duration) if duration else None,
107 }
108
109
110 def main() -> int:
111 parser = argparse.ArgumentParser(description=__doc__)
112 parser.add_argument("--dir", required=True, help="directory holding the recorded assets")
113 parser.add_argument(
114 "--strict",
115 action="store_true",
116 help="also require captions and transcript (use before flipping to published)",
117 )
118 args = parser.parse_args()
119
120 root = Path(args.dir)
121 if not root.is_dir():
122 sys.exit(f"not a directory: {root}")
123
124 budgets = parse_budgets()
125 failures: list[str] = []
126 notes: list[str] = []
127
128 def check(ok: bool, message: str) -> None:
129 print(f" {'PASS' if ok else 'FAIL'} {message}")
130 if not ok:
131 failures.append(message)
132
133 print(f"Budgets from {MANIFEST.relative_to(REPO_ROOT)}")
134 print(f"Assets in {root}\n")
135
136 # --- poster -------------------------------------------------------------
137 print("poster")
138 poster = root / f"{ASSET_ID}.png"
139 if not poster.exists():
140 check(False, f"{poster.name} exists")
141 else:
142 size = poster.stat().st_size
143 check(size <= budgets["poster"]["maxBytes"],
144 f"{poster.name} is {size:,} B (max {budgets['poster']['maxBytes']:,})")
145 dims = png_dimensions(poster)
146 if dims is None:
147 check(False, f"{poster.name} is a readable PNG")
148 else:
149 want = (budgets["poster"]["width"], budgets["poster"]["height"])
150 check(dims == want, f"{poster.name} is {dims[0]}x{dims[1]} (want {want[0]}x{want[1]})")
151
152 # --- video --------------------------------------------------------------
153 print("\nvideo")
154 video = root / f"{ASSET_ID}.mp4"
155 if not video.exists():
156 check(False, f"{video.name} exists")
157 else:
158 size = video.stat().st_size
159 check(size <= budgets["video"]["maxBytes"],
160 f"{video.name} is {size:,} B (max {budgets['video']['maxBytes']:,})")
161 probe = probe_video(video)
162 if probe is None:
163 notes.append(
164 "ffprobe unavailable — video dimensions and duration were NOT verified. "
165 "The media plan requires measuring both before publishing."
166 )
167 print(" SKIP dimensions/duration (ffprobe not installed)")
168 else:
169 want = (budgets["video"]["width"], budgets["video"]["height"])
170 check((probe["width"], probe["height"]) == want,
171 f"{video.name} is {probe['width']}x{probe['height']} (want {want[0]}x{want[1]})")
172 if probe["duration"] is None:
173 check(False, f"{video.name} reports a duration")
174 else:
175 limit = budgets["video"]["maxDurationSeconds"]
176 check(probe["duration"] <= limit,
177 f"{video.name} runs {probe['duration']:.1f}s (max {limit}s)")
178
179 # --- gif ----------------------------------------------------------------
180 print("\ngif fallback")
181 gif = root / f"{ASSET_ID}.gif"
182 if not gif.exists():
183 check(False, f"{gif.name} exists")
184 else:
185 size = gif.stat().st_size
186 check(size <= budgets["gifFallback"]["maxBytes"],
187 f"{gif.name} is {size:,} B (max {budgets['gifFallback']['maxBytes']:,})")
188 # #4906 asks for a README GIF under ~3 MB; that is a stricter, separate
189 # budget than the site's fallback, so report rather than fail.
190 if size > 3_000_000:
191 notes.append(
192 f"{gif.name} is {size:,} B — over the ~3 MB the issue wants for the "
193 "README GIF. Fine for the site fallback; re-encode or shorten for the README."
194 )
195
196 # --- captions and transcript -------------------------------------------
197 print("\ncaptions / transcript")
198 for locale in budgets["captionLocales"]:
199 vtt = root / f"{ASSET_ID}.{locale}.vtt"
200 if not vtt.exists():
201 (check if args.strict else lambda ok, m: print(f" TODO {m}"))(
202 False, f"{vtt.name} exists"
203 )
204 else:
205 body = vtt.read_text(encoding="utf-8", errors="replace").strip()
206 has_cue = "-->" in body
207 check(bool(body) and has_cue, f"{vtt.name} is non-empty and has at least one cue")
208
209 transcript = REPO_ROOT / "docs" / "evidence" / "v092-first-fleet-session-transcript.md"
210 if args.strict:
211 check(transcript.exists(), f"{transcript.relative_to(REPO_ROOT)} exists")
212 elif not transcript.exists():
213 print(f" TODO {transcript.relative_to(REPO_ROOT)} exists")
214
215 # --- capture receipt ----------------------------------------------------
216 print("\nprovenance")
217 receipt = root / "capture.json"
218 if receipt.exists():
219 data = json.loads(receipt.read_text(encoding="utf-8"))
220 commit = str(data.get("recorded_from_commit", ""))
221 check(len(commit) == 40, f"capture.json names a full 40-hex source commit ({commit[:12] or 'missing'})")
222 else:
223 (check if args.strict else lambda ok, m: print(f" TODO {m}"))(
224 False, "capture.json exists (written by scripts/media/record-session.sh)"
225 )
226
227 print()
228 for note in notes:
229 print(f"NOTE: {note}")
230
231 if failures:
232 print(f"\n{len(failures)} check(s) failed.")
233 return 1
234
235 print("\nAll mechanical checks passed.")
236 print(
237 "This does NOT mean the asset is ready. Still human-only:\n"
238 " - is the take actually worth showing?\n"
239 " - is every frame real output, with no credential or private path visible?\n"
240 " - do the caption cues match what the session actually did?\n"
241 "See docs/releases/v0.9.2-media-plan.md."
242 )
243 return 0
244
245
246 if __name__ == "__main__":
247 sys.exit(main())
248
248 lines PYTHON