From fbc719a79128991e80ce995d7ec357c8bb0d790f Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 19 May 2026 12:18:36 -0400 Subject: [PATCH 1/2] feat: add ?demo mode and recording script for marketing clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an auto-piloted walkthrough triggered by appending ?demo to the URL, plus a Playwright-based recording driver. Used to produce a short demo video showing scenario → values → side-by-side compare with view-transition morphs between screens. Implementation: - guide.js exposes a window.__demo hook (gated on ?demo) and adds the 3↔5 step pair to the morph list so the Personal Values → Compare jump animates smoothly. - demo.js polls for the hook, scripts goToStep/setValue with timed waits, shows a title intro, and signals completion via body.dataset.demoDone. - demo.css hides the top nav / TOC / Back-Next buttons, flattens the background gradient (compresses better), centers headers, and slows view-transition animations to 1.4s so the morph reads on video. - index.html gates demo.js loading on ?demo and sets html.demo + the flat background synchronously so the page never flashes the gradient. - record_demo.py serves static/ on a local port, drives Playwright at logical 1280×600 with DPR=3 (true 4K bitmap), waits for the demo to signal done, and trims the first 0.5s with ffmpeg so the title is on frame 0. Outputs are gitignored. --- .gitignore | 5 +++ README.md | 21 ++++++++++ record_demo.py | 103 ++++++++++++++++++++++++++++++++++++++++++++++ static/demo.css | 78 +++++++++++++++++++++++++++++++++++ static/demo.js | 60 +++++++++++++++++++++++++++ static/guide.js | 18 ++++++-- static/index.html | 16 +++++++ 7 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 record_demo.py create mode 100644 static/demo.css create mode 100644 static/demo.js 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/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 = ` +
ALIGN System
+
Aligning AI with Values
+ `; + document.body.appendChild(title); + return title; +}; + +const run = async () => { + await loadStylesheet("./demo.css"); + const title = showTitle(); + while (!window.__demo) await wait(50); + const d = window.__demo; + await d.ready; + document.body.classList.add("demo-mode"); + + // Title hold (~2.2s) + await d.goToStep(2); + await document.fonts.ready; + document.documentElement.removeAttribute("data-demo"); + await wait(2200); + title.classList.add("fading"); + await wait(600); + title.remove(); + + // Section 1: scenario + baseline decision + await wait(400); + await d.triggerComputation(); + await wait(3500); + + // Section 2: personal values + await d.goToStep(3); + await wait(800); + await d.setValue("personal_safety", "high"); + await wait(1500); + + // Section 3: side-by-side compare (morphs from values) + await d.goToStep(5); + await wait(500); + await d.triggerComputation(); + await wait(4500); + + document.body.dataset.demoDone = "1"; +}; + +run(); diff --git a/static/guide.js b/static/guide.js index 7dc4ed9..3092957 100644 --- a/static/guide.js +++ b/static/guide.js @@ -856,7 +856,9 @@ const goToStep = async (index, { triggerPending = false } = {}) => { (prevIndex === 3 && index === 4) || (prevIndex === 4 && index === 3) || (prevIndex === 4 && index === 5) || - (prevIndex === 5 && index === 4); + (prevIndex === 5 && index === 4) || + (prevIndex === 3 && index === 5) || + (prevIndex === 5 && index === 3); const applyChanges = async () => { const svg = $(".zone-connectors .crossarm-overlay"); @@ -1062,8 +1064,18 @@ const init = async () => { goToStep(0); }; -ready.then(() => { +const initPromise = ready.then(() => { state.scenarioId = SCENARIOS[0].id; state.values = Object.fromEntries(DIMENSIONS.map((d) => [d.id, "medium"])); - init(); + return init(); }); + +if (new URLSearchParams(window.location.search).has("demo")) { + window.__demo = { + ready: initPromise, + state, + goToStep, + triggerComputation: () => triggerComputation(), + setValue: (id, level) => handleValuesChange({ ...state.values, [id]: level }), + }; +} diff --git a/static/index.html b/static/index.html index bbbc5ce..3171691 100644 --- a/static/index.html +++ b/static/index.html @@ -23,6 +23,17 @@ + +