cairn / build_space.py
NagaYu's picture
Cairn: browser-only demo (Pyodide) — leave, come back, same world
13b9d08 verified
Raw
History Blame Contribute Delete
17.3 kB
#!/usr/bin/env python3
"""Assemble the static browser Space: ``index.html`` + the ``cairn`` wheel.
The Space is a plain Pyodide page, not a Gradio-lite one. That was not the first
choice; it is the choice that works. Gradio-lite imports ``gradio`` *before* it
installs the page's requirements, and gradio 5.x currently cannot be resolved
against ``huggingface-hub`` 1.x inside Pyodide:
* 5.42+ pin ``huggingface-hub<1.0`` while their bundled ``gradio_client`` asks
only for ``>=0.19.3``; micropip gathers the two concurrently, resolves the
unbounded one to 1.x, then the capped one raises
``Requested 'huggingface-hub<1.0,>=0.33.5', but huggingface-hub==1.27.0 is
already installed``.
* Older builds (<= 5.38.2) leave it uncapped, so the install succeeds and the
*import* fails instead: ``huggingface_hub`` 1.x does ``import httpcore``, and
Pyodide's patched ``httpx`` does not bring httpcore with it.
Neither is reachable from ``<gradio-requirements>``, because that is installed
after the import. Driving Pyodide directly fixes the ordering, and drops
gradio's whole dependency stack (pandas, pydantic, orjson, ...) from the
download, so the page loads faster as a side effect.
python space/build_space.py # writes space/index.html
python space/build_space.py --serve # ...and serves it for local testing
``browser_app.py`` stays a real, importable, compile-checked Python file; this
script embeds it and refuses to emit HTML if it does not parse.
Claim: O -- the free, zero-install path has to be maintainable, or it rots and
"anyone can check this" quietly stops being true.
"""
from __future__ import annotations
import argparse
import os
import shutil
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
WHEEL = "cairn-0.1.0-py3-none-any.whl"
# Pinned: a silent bump in a CDN dependency would break the Space with no commit
# to point at. Pyodide 0.27.x ships numpy, scipy and pillow as prebuilt wasm.
PYODIDE = "0.27.3"
TEMPLATE = r"""<!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/v{pyodide}/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 = {app!r};
async function start() {{
try {{
say("Loading Pyodide…");
py = await loadPyodide({{ indexURL: "https://cdn.jsdelivr.net/pyodide/v{pyodide}/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("{wheel}", 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>
"""
def build(serve: bool = False, port: int = 8000) -> str:
app_path = os.path.join(HERE, "browser_app.py")
with open(app_path) as f:
app_src = f.read()
compile(app_src, app_path, "exec") # fail loudly rather than shipping broken HTML
html = TEMPLATE.format(pyodide=PYODIDE, wheel=WHEEL, app=app_src)
out = os.path.join(HERE, "index.html")
with open(out, "w") as f:
f.write(html)
wheel_src = os.path.join(ROOT, "dist", WHEEL)
if not os.path.exists(wheel_src):
raise SystemExit(f"missing {wheel_src}; run `python -m build --wheel -o dist` first")
shutil.copy2(wheel_src, os.path.join(HERE, WHEEL))
print(f"wrote {out} ({len(html) / 1024:.0f} KB) and {WHEEL}")
if serve:
import http.server
import socketserver
os.chdir(HERE)
with socketserver.TCPServer(("", port), http.server.SimpleHTTPRequestHandler) as httpd:
print(f"serving {HERE} at http://localhost:{port}/ (ctrl-c to stop)")
httpd.serve_forever()
return out
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--serve", action="store_true")
ap.add_argument("--port", type=int, default=8000)
build(**vars(ap.parse_args()))