Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ static/data/
.venv/
__pycache__/
.playwright-mcp/

# Demo recording outputs and temp dir
spark-demo-*.mp4
spark-demo-*.webm
_video/
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
103 changes: 103 additions & 0 deletions record_demo.py
Original file line number Diff line number Diff line change
@@ -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()
Binary file added spark-demo-hd.mp4
Binary file not shown.
78 changes: 78 additions & 0 deletions static/demo.css
Original file line number Diff line number Diff line change
@@ -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;
}
60 changes: 60 additions & 0 deletions static/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="demo-title-main">ALIGN System</div>
<div class="demo-title-sub">Aligning AI with Values</div>
`;
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();
18 changes: 15 additions & 3 deletions static/guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 }),
};
}
16 changes: 16 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@awesome.me/webawesome@3.2.1/dist-cdn/components/option/option.js" />
<link rel="stylesheet" href="./shared.css" />
<link rel="stylesheet" href="./guide.css" />
<style id="demo-hide">
html.demo, html.demo body { background: #f1f5f8; }
html.demo .showcase-nav,
html[data-demo] .guide-layout { visibility: hidden; }
</style>
<script>
if (new URLSearchParams(location.search).has("demo")) {
document.documentElement.setAttribute("data-demo", "1");
document.documentElement.classList.add("demo");
}
</script>
</head>
<body>
<nav class="showcase-nav">
Expand Down Expand Up @@ -133,5 +144,10 @@ <h3 class="pillar-title">
</div>

<script type="module" src="./guide.js"></script>
<script type="module">
if (new URLSearchParams(location.search).has("demo")) {
await import("./demo.js");
}
</script>
</body>
</html>