diff --git a/.gitignore b/.gitignore index ccfd7f4..764fcb1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ static/data/ .venv/ __pycache__/ .playwright-mcp/ + +# Demo recording outputs and temp dir +spark-demo-*.mp4 +spark-demo-*.webm +_video/ diff --git a/README.md b/README.md index 8f1e581..0fade38 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,24 @@ The manifest is built from experiment data and stored as a GitHub release asset. - Vanilla ES modules (no build step) - [Web Awesome](https://www.webawesome.com/) v3.2.1 for UI components - CSS custom properties for theming + +## Recording a demo clip + +Append `?demo` to the URL to run an auto-piloted walkthrough: title intro → Implicit Values → Personal Values → Compare (with view-transition morphs between screens). The TOC, top nav, and Back/Next buttons are hidden in this mode. Used for marketing/demo videos. + +To capture it as a video: + +```bash +pip install playwright +playwright install chromium +python3 record_demo.py +``` + +Output: `spark-demo-4k.webm` (3840×1800). Convert to MP4 / HD with ffmpeg: + +```bash +ffmpeg -i spark-demo-4k.webm -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p spark-demo-4k.mp4 +ffmpeg -i spark-demo-4k.webm -vf "scale=1920:900:flags=lanczos" -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p spark-demo-hd.mp4 +``` + +Implementation: `static/demo.js` drives `goToStep` and value changes via a `window.__demo` hook (gated on `?demo` in `guide.js`); `static/demo.css` hides chrome and applies the flat background. The recording script trims the first 0.5s so the title is on frame 0. diff --git a/record_demo.py b/record_demo.py new file mode 100644 index 0000000..fe63700 --- /dev/null +++ b/record_demo.py @@ -0,0 +1,103 @@ +"""Record the Spark demo at 4K via Playwright. + +Serves static/ on a local port, opens /?demo, waits for the demo script to set +body[data-demo-done="1"], saves the WebM next to this script. +""" + +import http.server +import socketserver +import subprocess +import threading +import time +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).parent / "static" +OUT = Path(__file__).parent / "spark-demo-4k.webm" +PORT = 8765 +# Render at a smaller logical viewport so content fills more of the frame. +# DPR=3 keeps the captured bitmap at true 4K (3840x2160). +LOGICAL_W, LOGICAL_H = 1280, 600 +DPR = 3 +W, H = LOGICAL_W * DPR, LOGICAL_H * DPR +# Chromium needs ~0.42s after page load to paint the title overlay; trim it +# so the recording starts with the title visible on the first frame. +TRIM_SECONDS = 0.5 + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a, **kw): + pass + + +def serve(): + handler = lambda *a, **kw: QuietHandler(*a, directory=str(ROOT), **kw) + with socketserver.TCPServer(("127.0.0.1", PORT), handler) as srv: + srv.serve_forever() + + +def main(): + server_thread = threading.Thread(target=serve, daemon=True) + server_thread.start() + time.sleep(0.5) + + video_dir = Path(__file__).parent / "_video" + video_dir.mkdir(exist_ok=True) + + with sync_playwright() as p: + browser = p.chromium.launch( + headless=True, + args=[ + f"--force-device-scale-factor={DPR}", + f"--window-size={W},{H}", + ], + ) + context = browser.new_context( + viewport={"width": LOGICAL_W, "height": LOGICAL_H}, + device_scale_factor=DPR, + record_video_dir=str(video_dir), + record_video_size={"width": W, "height": H}, + ) + page = context.new_page() + page.on("console", lambda m: print(f"[{m.type}] {m.text}")) + page.on("pageerror", lambda e: print(f"[pageerror] {e}")) + page.goto(f"http://127.0.0.1:{PORT}/?demo") + page.wait_for_function( + "document.body.dataset.demoDone === '1'", timeout=60_000 + ) + time.sleep(0.5) + path_in_context = page.video.path() + context.close() + browser.close() + + raw = Path(path_in_context) + # -ss AFTER -i = accurate decode-then-discard seek; the first output frame + # is the actual decoded frame at TRIM_SECONDS (avoids encoder keyframe + # artifacts that input-side -ss can produce). + subprocess.run( + [ + "ffmpeg", + "-y", + "-loglevel", + "error", + "-i", + str(raw), + "-ss", + str(TRIM_SECONDS), + "-c:v", + "libvpx", + "-b:v", + "2M", + str(OUT), + ], + check=True, + ) + for stray in video_dir.glob("*.webm"): + stray.unlink() + video_dir.rmdir() + print(f"Saved: {OUT}") + + +if __name__ == "__main__": + main() diff --git a/spark-demo-hd.mp4 b/spark-demo-hd.mp4 new file mode 100644 index 0000000..41f7016 Binary files /dev/null and b/spark-demo-hd.mp4 differ diff --git a/static/demo.css b/static/demo.css new file mode 100644 index 0000000..8f7afca --- /dev/null +++ b/static/demo.css @@ -0,0 +1,78 @@ +@import url("https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap"); + +body.demo-mode { + font-family: "Source Sans 3", "Noto Color Emoji", system-ui, sans-serif; + background: #f1f5f8; +} + +body.demo-mode .decider-node-icon, +body.demo-mode .decision-panel-badge { + font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; +} + +body.demo-mode .showcase-nav, +body.demo-mode .guide-toc, +body.demo-mode .step-nav { + display: none !important; +} + +body.demo-mode .guide-layout { + grid-template-columns: 1fr; + max-width: none; +} + +body.demo-mode .guide-viewport { + height: 100vh; +} + +body.demo-mode .step-content { + padding: 8px 32px; + max-width: 1400px; +} + +body.demo-mode .step-header { + text-align: center; + margin-bottom: 12px; +} + +::view-transition-group(*), +::view-transition-old(*), +::view-transition-new(*) { + animation-duration: 1.4s !important; +} + +.demo-title { + position: fixed; + inset: 0; + background: #f1f5f8; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 9999; + font-family: "Space Grotesk", "Source Sans 3", system-ui, sans-serif; + transition: opacity 0.6s ease-out; +} + +.demo-title-main { + font-size: 4.5rem; + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(120deg, #0067c7, #3eae2b); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.demo-title-sub { + font-size: 1.4rem; + font-weight: 400; + color: #5a6772; + margin-top: 8px; + letter-spacing: 0.02em; +} + +.demo-title.fading { + opacity: 0; + pointer-events: none; +} diff --git a/static/demo.js b/static/demo.js new file mode 100644 index 0000000..f31d057 --- /dev/null +++ b/static/demo.js @@ -0,0 +1,60 @@ +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +const loadStylesheet = (href) => + new Promise((resolve) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.onload = resolve; + document.head.appendChild(link); + }); + +const showTitle = () => { + const title = document.createElement("div"); + title.className = "demo-title"; + title.innerHTML = ` +