cairn / index.html
NagaYu's picture
Cairn: browser-only demo (Pyodide) — leave, come back, same world
13b9d08 verified
Raw
History Blame Contribute Delete
27 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cairn — leave, come back, same world</title>
<meta name="description" content="An explicit world ledger makes video generation return-consistent and editable. Runs entirely in your browser." />
<style>
:root { --fg:#1a1a1a; --mut:#666; --line:#e3e3e3; --accent:#2f7ab8; --bg:#fff; --soft:#f7f9fb; }
* { box-sizing: border-box; }
body { margin:0; font:15px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
color:var(--fg); background:var(--bg); }
.wrap { max-width: 62rem; margin: 0 auto; padding: 2rem 1.25rem 5rem; }
h1 { font-size:1.9rem; margin:.2rem 0 .2rem; letter-spacing:-.01em; }
.tag { color:var(--mut); font-size:1.05rem; margin:0 0 1.2rem; }
h3 { margin:1.6rem 0 .5rem; font-size:1.05rem; }
h4 { margin:0 0 .4rem; font-size:.95rem; }
p { margin:.5rem 0; }
a { color:var(--accent); }
code { background:var(--soft); padding:.08em .35em; border-radius:3px; font-size:.86em; }
.lede { background:var(--soft); border-left:3px solid var(--accent); padding:.9rem 1.1rem;
border-radius:0 6px 6px 0; }
.controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));
gap:.9rem 1.4rem; margin:1.2rem 0; padding:1rem; border:1px solid var(--line);
border-radius:8px; }
.ctl label { display:block; font-size:.8rem; color:var(--mut); margin-bottom:.25rem; }
.ctl input[type=range] { width:100%; }
.val { font-variant-numeric:tabular-nums; color:var(--fg); font-weight:600; }
select, button { font:inherit; }
button.run { background:var(--accent); color:#fff; border:0; border-radius:6px;
padding:.6rem 1.1rem; cursor:pointer; font-weight:600; }
button.run:disabled { background:#9bb8cd; cursor:progress; }
.tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--line); margin:1.6rem 0 1rem;
flex-wrap:wrap; }
.tabs button { background:none; border:0; border-bottom:2px solid transparent; cursor:pointer;
padding:.6rem .9rem; color:var(--mut); font-weight:600; }
.tabs button.on { color:var(--accent); border-bottom-color:var(--accent); }
.panel { display:none; } .panel.on { display:block; }
img.strip { width:100%; border:1px solid var(--line); border-radius:6px;
display:block; margin:.3rem 0 1rem; }
.caption, .note { color:var(--mut); font-size:.87rem; }
.verdict { border:1px solid var(--line); border-radius:6px; padding:.7rem .9rem; margin:.6rem 0 1.2rem; }
.verdict ul { margin:.3rem 0 0; padding-left:1.1rem; }
.verdict li { margin:.15rem 0; }
.ok { color:#136f3b; font-weight:700; } .bad { color:#b3261e; font-weight:700; }
.warn { color:#8a6100; font-weight:700; }
table { border-collapse:collapse; width:100%; font-size:.85rem; margin:.5rem 0 1rem;
display:block; overflow-x:auto; }
th,td { border-bottom:1px solid var(--line); padding:.35rem .5rem; text-align:left;
white-space:nowrap; }
th { color:var(--mut); font-weight:600; }
.sw { display:inline-block; width:.7em; height:.7em; border-radius:2px; margin-right:.4em;
border:1px solid rgba(128,128,128,.4); }
#boot { padding:1rem 1.2rem; border:1px solid var(--line); border-radius:8px;
background:var(--soft); color:var(--mut); }
#boot .bar { height:4px; background:rgba(128,128,128,.2); border-radius:2px; overflow:hidden;
margin-top:.7rem; }
#boot .bar i { display:block; height:100%; width:30%; background:var(--accent);
animation:slide 1.4s ease-in-out infinite; }
@keyframes slide { 0%{margin-left:-30%} 100%{margin-left:100%} }
footer { margin-top:2.5rem; padding-top:1.2rem; border-top:1px solid var(--line);
color:var(--mut); font-size:.88rem; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8e8; --mut:#9aa0a6; --line:#333; --bg:#141414; --soft:#1d1f21;
--accent:#5aa9e6; }
}
</style>
</head>
<body>
<div class="wrap">
<h1>Cairn — leave, come back, same world</h1>
<p class="tag">An explicit world ledger makes video generation return-consistent, and editable.</p>
<div class="lede">
<p style="margin-top:0">Video world models forget. Turn the camera away from a chair for a few
seconds and turn back, and it is a different chair, somewhere else, or gone. The usual fixes
give the model <i>more implicit memory</i> — a longer context window, or a compressed latent.
Both decay with how long you looked away.</p>
<p><b>Cairn takes the world out of the weights.</b> Objects live in an explicit external
ledger — persistent id, pose, appearance, provenance — written by perception on the model's
<i>own generated frames</i>, and read back to <b>coerce</b> generation when the camera returns.
A table lookup costs the same whether you looked away for 4 frames or 400.</p>
<p style="margin-bottom:0">Over 5 seeds, at 128 frames away, every baseline returns a broken
world <b>0%</b> of the time and Cairn <b>100%</b>, with a flat 3.6&nbsp;cm error. Try to break
it below.</p>
</div>
<div id="boot">
<b>Starting Python in your browser…</b>
<div id="bootmsg">Loading Pyodide, numpy, scipy and the <code>cairn</code> package. The first
load takes roughly 30 seconds and is cached afterwards. Nothing is sent to a server — the whole
benchmark runs on your machine.</div>
<div class="bar"><i></i></div>
</div>
<div id="app" hidden>
<div class="controls">
<div class="ctl"><label>scene seed <span class="val" id="seedv">0</span></label>
<input type="range" id="seed" min="0" max="40" step="1" value="0"></div>
<div class="ctl"><label>objects in the room <span class="val" id="nobjv">8</span></label>
<input type="range" id="nobj" min="4" max="8" step="1" value="8"></div>
<div class="ctl"><label>object to leave &amp; return to <span class="val" id="tgtv">0</span></label>
<input type="range" id="tgt" min="0" max="7" step="1" value="0"></div>
<div class="ctl"><label>frames to look away <span class="val" id="absv">48</span></label>
<input type="range" id="abs" min="4" max="128" step="4" value="48"></div>
</div>
<img class="strip" id="preview" alt="the room, from four directions">
<p class="caption">The room, from four directions.</p>
<div class="tabs">
<button class="on" data-tab="t1">1 · Leave &amp; return</button>
<button data-tab="t2">2 · Edit the world</button>
<button data-tab="t3">3 · The ledger</button>
</div>
<section class="panel on" id="t1">
<p><label>compare Cairn against
<select id="baseline">
<option value="B" selected>(B) Context-window</option>
<option value="A">(A) Vanilla</option>
<option value="C">(C) Compressed-memory</option>
</select></label>
&nbsp; <button class="run" id="go1">Look away, then look back</button></p>
<div id="out1"></div>
</section>
<section class="panel" id="t2">
<p><label>command to issue while the object is off screen
<select id="kind">
<option value="move" selected>move it somewhere else</option>
<option value="remove">delete it</option>
<option value="set_attr">recolour it</option>
</select></label>
&nbsp; <button class="run" id="go2">Issue the command, then look back</button></p>
<div id="out2"></div>
</section>
<section class="panel" id="t3">
<p class="caption">Cairn's entire memory is this table plus its transaction log. It is
written by perception running on the generated frames — never from ground truth — and every
mutation is invertible, so the world rewinds to any instant.</p>
<p><label>rewind the world to frame <span class="val" id="rwv">20</span></label>
<input type="range" id="rw" min="0" max="160" step="1" value="20" style="width:16rem">
&nbsp; <button class="run" id="go3">Run, then rewind</button></p>
<div id="out3"></div>
</section>
</div>
<footer>
<p><b>Reading the numbers.</b> <i>Moved on return</i> compares where the object is when the
camera comes back against where <b>the same run</b> showed it before leaving —
self-consistency, measured from generated pixels by the same detector for every condition,
never from the ledger. <i>Consistency debt</i> is the per-frame divergence between the video
and the committed record; Cairn closes a control loop on it, the baselines can only be
measured by it.</p>
<p><b>What you are watching.</b> A surrogate generator that reproduces how autoregressive
video drifts (random walk + prior pull + salience decay), not a real video backbone — that is
the trade that buys exact ground truth and a benchmark you can run on a laptop. The same
<code>cairn</code> library wraps a real diffusers video pipeline in one line:
<code>CairnPipeline.from_pipeline(pipe)</code>.</p>
<p><a href="https://github.com/NagaYu/cairn">Code</a> &middot;
<a href="https://huggingface.co/datasets/NagaYu/cairn-departure-return">Dataset</a> &middot;
full benchmark, ablation and honest limitations are in the README.</p>
</footer>
</div>
<script type="module">
import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.27.3/full/pyodide.mjs";
const $ = (id) => document.getElementById(id);
const boot = $("boot"), bootmsg = $("bootmsg");
const say = (m) => { bootmsg.innerHTML = m; };
let py = null;
const APP_SRC = '"""Cairn\'s compute layer for the browser build (Pyodide, no server, no gradio).\n\nWhy this exists instead of Gradio-lite: gradio-lite imports ``gradio`` *before*\nit installs the page\'s requirements, and gradio 5.x currently cannot be resolved\nagainst ``huggingface-hub`` 1.x inside Pyodide -- the capped builds fail to\ninstall, the uncapped ones import and then die on a missing ``httpcore``. Since\nthe boot order is not ours to change there, we skip the framework and drive\nPyodide directly. The page ends up lighter too: no pandas, pydantic or orjson,\njust numpy, scipy, pillow and the ``cairn`` wheel.\n\nEvery function here returns plain JSON-able data (HTML fragments and base64 PNG\ndata URIs) which ``index.html`` drops into the DOM. All of the science is\nimported unchanged from the ``cairn`` package; nothing is reimplemented for the\nbrowser.\n\nClaim: R/E -- lets anyone check the two headline claims themselves, for free,\nwith no GPU, no install and no account.\n"""\n\nfrom __future__ import annotations\n\nimport base64\nimport io\nfrom typing import Any, Dict, List, Tuple\n\nimport numpy as np\nfrom PIL import Image\n\nfrom cairn.runner import CONDITION_NAMES, RunConfig, run_condition\nfrom cairn.world import (\n make_departure_return_trajectory,\n make_scene,\n render,\n schedule_edit,\n usable_targets,\n)\n\nGAP = 6\nBASELINES = {"A": "Vanilla", "B": "Context-window", "C": "Compressed-memory"}\n\n\n# --------------------------------------------------------------------------\n# rendering helpers\n# --------------------------------------------------------------------------\n\n\ndef _png(arr: np.ndarray) -> str:\n """``(H, W, 3)`` float image -> base64 PNG data URI for an ``<img>`` tag."""\n a = (np.clip(np.asarray(arr), 0.0, 1.0) * 255).astype(np.uint8)\n buf = io.BytesIO()\n Image.fromarray(a).save(buf, format="PNG", optimize=True)\n return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")\n\n\ndef _filmstrip(frames: np.ndarray, idx: List[int]) -> str:\n """Selected frames side by side, separated by pale gutters.\n\n Pyodide has no ffmpeg, so there is no video. A strip is arguably the better\n medium for this claim anyway: "before leaving" and "after returning" sit in\n one glance instead of several seconds apart in a loop.\n\n Claim: R -- the experiment, in one picture.\n """\n idx = [i for i in idx if 0 <= i < len(frames)]\n if not idx:\n return _png(np.zeros((8, 8, 3)))\n h = frames[0].shape[0]\n gutter = np.full((h, GAP, 3), 0.93, dtype=np.float32)\n panels: List[np.ndarray] = []\n for k, i in enumerate(idx):\n if k:\n panels.append(gutter)\n panels.append(frames[i])\n return _png(np.concatenate(panels, axis=1))\n\n\ndef _windows(traj) -> List[int]:\n return [\n 0,\n max(0, traj.observe_frames[1] - 1),\n (traj.departure_frame + traj.return_frame) // 2,\n traj.return_frame + 1,\n min(len(traj) - 1, traj.return_frame + 7),\n ]\n\n\nPANEL_CAPTION = (\n "opening shot &middot; last frame before leaving &middot; "\n "<b>looking away</b> &middot; just back &middot; settled after return"\n)\n\n\ndef _ledger_html(res) -> str:\n rows = []\n for e in res.ledger.entries(include_absent=True):\n p = e.pose.position\n state = "present" if e.present else \'<b style="color:#b3261e">REMOVED</b>\'\n rows.append(\n f"<tr><td>{e.object_id}</td><td>{p[0]:.2f}</td><td>{p[2]:.2f}</td>"\n f"<td>{e.pose.yaw:+.2f}</td>"\n f"<td><span class=\'sw\' style=\'background:rgb("\n f"{int(e.appearance[0]*255)},{int(e.appearance[1]*255)},{int(e.appearance[2]*255)})\'></span>"\n f"{e.appearance[0]:.2f}, {e.appearance[1]:.2f}, {e.appearance[2]:.2f}</td>"\n f"<td>{e.n_observations}</td><td>{e.confidence:.2f}</td><td>{state}</td></tr>"\n )\n return (\n "<table><thead><tr><th>id</th><th>x</th><th>z</th><th>yaw</th><th>rgb</th>"\n "<th>seen</th><th>conf</th><th>state</th></tr></thead><tbody>"\n + "".join(rows)\n + "</tbody></table>"\n )\n\n\ndef _cfg(cond: str, seed: int) -> RunConfig:\n # reference_video=False skips rendering a second full clip that only the\n # FVD-proxy consumes, and this page never shows that number. Roughly halves\n # the work per click in the browser.\n return RunConfig(condition=cond, seed=1000 + int(seed), reference_video=False)\n\n\ndef _resolve_target(scene, requested: int, seed: int) -> Tuple[int, str]:\n """Snap the slider to an object that can actually host an episode.\n\n Claim: R -- the demo shows the same well-posed episodes the benchmark scores.\n """\n ok = usable_targets(scene, seed=int(seed))\n if not ok:\n raise ValueError("No object in this room can be left and returned to — try another seed.")\n if int(requested) in ok:\n return int(requested), ""\n chosen = min(ok, key=lambda o: abs(o - int(requested)))\n return chosen, (\n f"<p class=\'note\'>Object #{int(requested)} is permanently hidden behind another object "\n f"in this room, so it cannot host a leave-and-return episode. Showing object "\n f"#{chosen} instead.</p>"\n )\n\n\n# --------------------------------------------------------------------------\n# public entry points (called from JavaScript)\n# --------------------------------------------------------------------------\n\n\ndef scene_preview(seed: int, n_objects: int) -> str:\n """Four views of the room, so you can see what you are about to test."""\n from cairn.types import CameraPose\n\n scene = make_scene(int(n_objects), seed=int(seed))\n cams = [\n CameraPose(np.array([5.0, 1.55, 5.0]), a)\n for a in np.linspace(-np.pi, np.pi, 4, endpoint=False)\n ]\n frames = np.stack([render(scene.states(), c, scene.settings).rgb for c in cams])\n return _filmstrip(frames, list(range(4)))\n\n\ndef _verdict_html(res, label: str) -> str:\n m = res.metrics\n drawn = m["return_observed"] > 0.5\n ok = m["return_success"] > 0.5\n err = "not drawn at all" if not drawn else f"{m[\'return_self_trans\']:.2f} m"\n badge = (\n "<span class=\'ok\'>consistent</span>" if ok else "<span class=\'bad\'>inconsistent</span>"\n )\n return (\n f"<div class=\'verdict\'><h4>{label} &nbsp;{badge}</h4><ul>"\n f"<li>moved on return: <b>{err}</b></li>"\n f"<li>identity preserved: {\'yes\' if m[\'return_identity_preserved\'] else \'<b>no</b>\'}</li>"\n f"<li>yaw error {m[\'return_self_yaw\']:.2f} rad &middot; "\n f"appearance error {m[\'return_self_appearance\']:.3f}</li>"\n f"<li>integrated consistency debt {m[\'debt_area\']:.1f} (peak {m[\'debt_peak\']:.2f})</li>"\n f"</ul></div>"\n )\n\n\ndef compare(seed: int, n_objects: int, absence: int, target: int, baseline: str) -> Dict[str, Any]:\n """Same scene, same trajectory, same generator seed — only the memory differs.\n\n Claim: R -- the interactive form of the headline experiment.\n """\n seed, n_objects, absence = int(seed), int(n_objects), int(absence)\n scene = make_scene(n_objects, seed=seed)\n target, note = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)\n traj = make_departure_return_trajectory(scene, target, absence, seed=seed)\n\n off = run_condition(scene, traj, _cfg(baseline, seed))\n on = run_condition(scene, traj, _cfg("D", seed))\n idx = _windows(traj)\n\n header = (\n f"<p>Camera left object <b>#{target}</b> at frame {traj.departure_frame} and came back "\n f"at frame {traj.return_frame} — <b>{traj.absence_frames} frames away</b>. "\n f"The return viewpoint is deliberately <i>not</i> the departure viewpoint, so neither "\n f"method can win by replaying its last frame.</p>"\n f"<p class=\'caption\'>Panels: {PANEL_CAPTION}</p>{note}"\n )\n return {\n "header": header,\n "off_img": _filmstrip(off.frames, idx),\n "on_img": _filmstrip(on.frames, idx),\n "off_label": f"Cairn OFF — ({baseline}) {CONDITION_NAMES[baseline]}",\n "on_label": "Cairn ON — (D) explicit world ledger",\n "off_verdict": _verdict_html(off, f"Cairn OFF — ({baseline}) {BASELINES[baseline]}"),\n "on_verdict": _verdict_html(on, "Cairn ON — (D) explicit world ledger"),\n "ledger": _ledger_html(on),\n }\n\n\ndef edit(seed: int, n_objects: int, absence: int, target: int, kind: str) -> Dict[str, Any]:\n """Issue an edit while the object is off screen, then score what came back.\n\n Claim: E -- the operation conditions A–C cannot express at all.\n """\n seed, n_objects, absence = int(seed), int(n_objects), int(absence)\n scene = make_scene(n_objects, seed=seed)\n target, note = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)\n traj = make_departure_return_trajectory(scene, target, absence, seed=seed)\n ev = schedule_edit(traj, scene, kind, seed=seed)\n traj.edits = [ev]\n\n on = run_condition(scene, traj, _cfg("D", seed))\n off = run_condition(scene, traj, _cfg("A", seed))\n idx = _windows(traj)\n\n if kind == "move":\n d = float(np.linalg.norm(np.asarray(ev.payload["position"]) - scene.get(target).pose.position))\n what = f"move object #{target} {d:.1f} m and turn it"\n elif kind == "remove":\n what = f"delete object #{target} from the world"\n else:\n v = np.asarray(ev.payload["value"], dtype=float)\n what = (\n f"recolour object #{target} to "\n f"<span class=\'sw\' style=\'background:rgb({int(v[0]*255)},{int(v[1]*255)},{int(v[2]*255)})\'></span>"\n f"RGB {np.round(v, 2).tolist()}"\n )\n\n rows = []\n for res, cond in ((off, "A"), (on, "D")):\n for s in res.edit_scores:\n expressible = (\n "yes" if cond == "D" else "<b>no</b> — no addressable world state"\n )\n if s.complied:\n v = "<span class=\'ok\'>yes</span>"\n elif s.ledger_correct:\n v = "<span class=\'warn\'>written, not confirmable</span>"\n else:\n v = "<span class=\'bad\'>no</span>"\n rows.append(\n f"<tr><td>({cond}) {CONDITION_NAMES[cond]}</td><td>{expressible}</td>"\n f"<td>{v}</td><td><code>{s.detail}</code></td></tr>"\n )\n\n header = (\n f"<p><b>Command:</b> {what}</p>"\n f"<p>Issued at frame <b>{ev.frame}</b>, while the object is off screen "\n f"(frames {traj.departure_frame}–{traj.return_frame}).</p>"\n f"<p class=\'caption\'>Panels: {PANEL_CAPTION}</p>{note}"\n )\n table = (\n "<table><thead><tr><th>condition</th><th>can express it?</th><th>obeyed?</th>"\n "<th>evidence</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"\n f"<p class=\'note\'>Conditions A–C hold the world implicitly, in activations. There is no "\n f\'row named "object #{target}" to write to, so <code>move</code>/<code>remove</code>/\'\n f"<code>set_attr</code> are not merely hard for them — they are undefined.</p>"\n )\n return {\n "header": header,\n "off_img": _filmstrip(off.frames, idx),\n "on_img": _filmstrip(on.frames, idx),\n "table": table,\n "ledger": _ledger_html(on),\n }\n\n\ndef ledger_view(seed: int, n_objects: int, absence: int, target: int, rewind_to: int) -> Dict[str, Any]:\n """Show the ledger, its transaction log, and the effect of a rewind.\n\n Claim: E -- the world is a table with an audit trail; rewinding is one call.\n """\n seed, n_objects, absence = int(seed), int(n_objects), int(absence)\n scene = make_scene(n_objects, seed=seed)\n tgt, _ = _resolve_target(scene, int(np.clip(target, 0, n_objects - 1)), seed)\n traj = make_departure_return_trajectory(scene, tgt, absence, seed=seed)\n res = run_condition(scene, traj, _cfg("D", seed))\n led = res.ledger\n\n before = _ledger_html(res)\n log_rows = "".join(\n f"<tr><td>{t.index}</td><td>{t.t}</td><td><code>{t.op}</code></td>"\n f"<td>{t.object_id}</td><td>{t.source}</td><td>{t.note or \'\'}</td></tr>"\n for t in led.log[-40:]\n )\n log = (\n "<table><thead><tr><th>#</th><th>frame</th><th>op</th><th>object</th>"\n "<th>source</th><th>note</th></tr></thead><tbody>" + log_rows + "</tbody></table>"\n )\n v0 = led.version\n undone = led.rollback_to_time(int(rewind_to))\n note = (\n f"<p><b>Rewound to frame {int(rewind_to)}</b>: undid {undone} of {v0} transactions. "\n f"The ledger is now exactly as it stood at that instant, object for object. No learned "\n f"memory offers this operation — its state is entangled across every object and every "\n f"timestep at once.</p>"\n )\n return {"before": before, "log": log, "after": _ledger_html(res), "note": note}\n';
async function start() {
try {
say("Loading Pyodide…");
py = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.3/full/" });
say("Loading numpy, scipy and pillow…");
await py.loadPackage(["numpy", "scipy", "pillow", "micropip"]);
say("Installing the <code>cairn</code> package…");
const micropip = py.pyimport("micropip");
// Absolute URL: micropip resolves relative paths against its own base, not
// the page's, so a bare filename 404s both locally and on the Space.
const wheelUrl = new URL("cairn-0.1.0-py3-none-any.whl", window.location.href).href;
await micropip.install(wheelUrl);
say("Starting…");
py.runPython(APP_SRC);
boot.hidden = true;
$("app").hidden = false;
preview();
} catch (e) {
boot.innerHTML = "<b>Could not start.</b><p>" + String(e).slice(0, 900) +
"</p><p class='note'>This page needs WebAssembly and about 40 MB of downloads. " +
"Everything here also runs locally — see the " +
"<a href='https://github.com/NagaYu/cairn'>repository</a>.</p>";
throw e;
}
}
// Call a Python function with JSON-able args, get a plain JS object back.
function call(fn, ...args) {
const f = py.globals.get(fn);
const r = f(...args);
const out = (r && r.toJs) ? r.toJs({ dict_converter: Object.fromEntries }) : r;
if (r && r.destroy) r.destroy();
if (f && f.destroy) f.destroy();
return out;
}
async function busy(btn, work) {
const label = btn.textContent;
btn.disabled = true;
btn.textContent = "running…";
// Yield so the browser paints the disabled state before the synchronous Python
// call blocks the main thread. Deliberately a timer and not
// requestAnimationFrame: rAF is paused in background and hidden tabs, so a
// user who switches away mid-click would never get their result back.
await new Promise(r => setTimeout(r, 30));
try { work(); }
catch (e) { alert(String(e).slice(0, 700)); }
finally { btn.disabled = false; btn.textContent = label; }
}
const v = (id) => parseInt($(id).value, 10);
const strip = (label, src) => `<h4>${label}</h4><img class="strip" src="${src}" alt="${label}">`;
function preview() { $("preview").src = call("scene_preview", v("seed"), v("nobj")); }
for (const [id, out] of [["seed","seedv"],["nobj","nobjv"],["tgt","tgtv"],["abs","absv"],["rw","rwv"]]) {
$(id).addEventListener("input", () => { $(out).textContent = $(id).value; });
}
let timer = null;
for (const id of ["seed", "nobj"]) {
$(id).addEventListener("change", () => {
$("tgt").max = String(v("nobj") - 1);
if (v("tgt") > v("nobj") - 1) {
$("tgt").value = String(v("nobj") - 1);
$("tgtv").textContent = $("tgt").value;
}
clearTimeout(timer);
timer = setTimeout(preview, 120);
});
}
for (const b of document.querySelectorAll(".tabs button")) {
b.addEventListener("click", () => {
document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x === b));
document.querySelectorAll(".panel").forEach(p => p.classList.toggle("on", p.id === b.dataset.tab));
});
}
$("go1").addEventListener("click", () => busy($("go1"), () => {
const r = call("compare", v("seed"), v("nobj"), v("abs"), v("tgt"), $("baseline").value);
$("out1").innerHTML = r.header
+ strip(r.off_label, r.off_img) + r.off_verdict
+ strip(r.on_label, r.on_img) + r.on_verdict
+ "<h3>The ledger Cairn built from its own generated frames</h3>" + r.ledger;
}));
$("go2").addEventListener("click", () => busy($("go2"), () => {
const r = call("edit", v("seed"), v("nobj"), v("abs"), v("tgt"), $("kind").value);
$("out2").innerHTML = r.header
+ strip("Vanilla — command not expressible", r.off_img)
+ strip("Cairn — command written to the ledger", r.on_img)
+ r.table + "<h3>Ledger after the edit</h3>" + r.ledger;
}));
$("go3").addEventListener("click", () => busy($("go3"), () => {
const r = call("ledger_view", v("seed"), v("nobj"), v("abs"), v("tgt"), v("rw"));
$("out3").innerHTML = "<h3>Ledger at end of run</h3>" + r.before
+ "<h3>Transaction log (last 40)</h3>" + r.log
+ "<h3>Ledger after rewind</h3>" + r.after + r.note;
}));
start();
</script>
</body>
</html>