| <!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 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 & 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 & 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> |
| <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> |
| <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"> |
| <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> · |
| <a href="https://huggingface.co/datasets/NagaYu/cairn-departure-return">Dataset</a> · |
| 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 · last frame before leaving · "\n "<b>looking away</b> · just back · 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} {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 · "\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> |
|
|