| |
| """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" |
|
|
| |
| |
| 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 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/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") |
|
|
| 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())) |
|
|