#!/usr/bin/env python3 """Portable builder for the house compare/review page (the a6-review framework). Extracted 2026-08-24 from docs/a6-review/spider/index.html (ComfyUI-MAINodes) so any project can emit one: card grid with hover-play + audio-on-click, sortable metric table with inline bars, head-to-head A/B (side-by-side AND wipe) with drift-glide sync, starred picks exported as markdown, keyboard (space, f, 1/2). Data-driven: a manifest JSON in, one self-contained HTML out (videos/posters stay external by per-row path or a media base URL). python3 build_compare_page.py manifest.json out.html Manifest: {title, h1, lede_html, aspect: "W/H", media: "" (optional base), cols: [[key,label,decimals,sortdir], ...], groups: [...], rows: [{arm, group, desc, src, poster, curve?, ref_curve?, }]} Rows may omit src/poster if `media` is set (then MEDIA/.mp4/.jpg). """ import json, os, sys, html def build(m): cols = m["cols"]; rows = m["rows"]; groups = m.get("groups") or sorted({r["group"] for r in rows}) aspect = m.get("aspect", "16/9"); media = m.get("media", "") grp_opts = '' + "".join(f"" for g in groups) return TEMPLATE.replace("__TITLE__", html.escape(m["title"])) \ .replace("__H1__", m.get("h1", html.escape(m["title"]))) \ .replace("__LEDE__", m.get("lede_html", "")) \ .replace("__ASPECT__", aspect) \ .replace("__GROUPS__", grp_opts) \ .replace("__MEDIA__", json.dumps(media)) \ .replace("__COLS__", json.dumps(cols)) \ .replace("__ROWS__", json.dumps(rows)) \ .replace("__FPS__", str(m.get("fps", 24))) \ .replace("__MUXJS__", _muxjs()) VENDOR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor") MUXERS = ("mp4-muxer.min.js", "webm-muxer.min.js") def _muxjs(): """Inline the two muxers the frame-exact export needs. Resolved next to THIS file, never against the cwd, so the builder works from any working directory. A missing muxer raises instead of shrugging: the page would still build and still export, but it would drop silently to the real-time MediaRecorder path, and that shows up much later as a soft, wrong-length capture. See vendor/NOTICE for what these are. """ out = [] for f in MUXERS: fp = os.path.join(VENDOR, f) if not os.path.exists(fp): raise FileNotFoundError( "missing vendored muxer %s (looked in %s); the frame-exact " "export needs both of %s" % (f, VENDOR, ", ".join(MUXERS))) with open(fp, encoding="utf-8") as fh: out.append(fh.read()) return "\n".join(out) TEMPLATE = r''' __TITLE__

__H1__

__LEDE__

Head to head

A - B -
A
B
0.00 s · frame 0

Table (click a header to sort; click a row to scroll to its card)

Clips

Picks

Starred arms with their numbers, as markdown; copy this back into the session.

''' if __name__ == "__main__": m = json.load(open(sys.argv[1])) out = build(m) open(sys.argv[2], "w").write(out) print("wrote %s (%d bytes, %d rows)" % (sys.argv[2], len(out), len(m["rows"])))