Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>LiveWan — steerable streaming video</title> | |
| <style> | |
| :root{ | |
| --bg:#0c0d10; --panel:#14161b; --panel2:#1b1e25; --line:#282c36; | |
| --ink:#e8eaf0; --dim:#9aa1b1; --faint:#666d7e; | |
| --accent:#5b8cff; --accent2:#37d39b; --warn:#f2b544; --bad:#ef5f6b; | |
| --radius:12px; | |
| } | |
| *{box-sizing:border-box} | |
| body{margin:0;background:var(--bg);color:var(--ink); | |
| font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,sans-serif} | |
| button,input,select{font:inherit;color:inherit} | |
| .wrap{max-width:1320px;margin:0 auto;padding:20px} | |
| header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:18px} | |
| h1{font-size:20px;margin:0;letter-spacing:-.01em} | |
| h1 span{color:var(--accent)} | |
| .sub{color:var(--dim);font-size:13px} | |
| .pill{margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--panel); | |
| border:1px solid var(--line);border-radius:999px;padding:5px 12px;font-size:12px;color:var(--dim)} | |
| .dot{width:8px;height:8px;border-radius:50%;background:var(--faint)} | |
| .dot.live{background:var(--accent2);box-shadow:0 0 0 3px rgba(55,211,155,.16)} | |
| .dot.load{background:var(--warn);animation:pulse 1s infinite} | |
| .dot.err{background:var(--bad)} | |
| @keyframes pulse{50%{opacity:.35}} | |
| .grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:18px} | |
| @media(max-width:1040px){.grid{grid-template-columns:1fr}} | |
| .stage{background:#000;border:1px solid var(--line);border-radius:var(--radius); | |
| position:relative;overflow:hidden;aspect-ratio:640/368} | |
| .stage canvas{width:100%;height:100%;display:block} | |
| .overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center; | |
| justify-content:center;gap:0;background:rgba(8,9,12,.96);backdrop-filter:blur(6px); | |
| text-align:center;padding:24px;z-index:10} | |
| .overlay.hidden{display:none} | |
| .overlay:before{content:"";position:absolute;inset:0; | |
| background:radial-gradient(circle at 50% 42%,rgba(91,140,255,.14),transparent 62%)} | |
| .spinner{width:46px;height:46px;border:3px solid rgba(255,255,255,.09); | |
| border-top-color:var(--accent);border-radius:50%;animation:spin .75s linear infinite; | |
| margin-bottom:20px;position:relative} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .ov-title{font-size:20px;font-weight:650;letter-spacing:-.01em;position:relative} | |
| .ov-detail{color:var(--dim);font-size:13.5px;max-width:460px;margin-top:8px;position:relative} | |
| .ov-log{color:var(--faint);font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace; | |
| margin-top:14px;position:relative} | |
| /* staged progress: shows which step of a multi-stage load is running */ | |
| .ov-steps{display:flex;gap:0;margin-top:20px;position:relative;align-items:center} | |
| .ov-step{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--faint); | |
| padding:0 4px} | |
| .ov-step i{width:7px;height:7px;border-radius:50%;background:var(--line);flex:none; | |
| transition:background .25s,box-shadow .25s} | |
| .ov-step.done{color:var(--dim)} | |
| .ov-step.done i{background:var(--accent2)} | |
| .ov-step.now{color:var(--ink);font-weight:600} | |
| .ov-step.now i{background:var(--accent);box-shadow:0 0 0 4px rgba(91,140,255,.2); | |
| animation:pulse 1.1s infinite} | |
| .ov-sep{width:22px;height:1px;background:var(--line)} | |
| .ov-bar{width:min(330px,72%);height:3px;border-radius:2px;background:rgba(255,255,255,.08); | |
| margin-top:20px;overflow:hidden;position:relative} | |
| .ov-bar i{display:block;height:100%;border-radius:2px;background:var(--accent); | |
| width:30%;animation:slide 1.5s ease-in-out infinite} | |
| @keyframes slide{0%{transform:translateX(-110%)}100%{transform:translateX(370%)}} | |
| .ov-bar.hide{display:none} | |
| .ov-hint{margin-top:16px;font-size:11.5px;color:var(--faint);max-width:420px; | |
| position:relative;line-height:1.6} | |
| .bar{display:flex;gap:14px;flex-wrap:wrap;margin-top:10px;padding:10px 13px; | |
| background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);font-size:12px} | |
| .bar div{display:flex;gap:6px} | |
| .bar b{font-weight:600;font-variant-numeric:tabular-nums} | |
| .bar span{color:var(--faint)} | |
| .now{margin-top:10px;padding:12px 14px;background:var(--panel);border:1px solid var(--line); | |
| border-radius:var(--radius)} | |
| .now .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint)} | |
| .now .txt{margin-top:3px;font-size:13.5px} | |
| .tag{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:999px; | |
| border:1px solid var(--line);color:var(--dim);margin-left:7px;vertical-align:1px} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius); | |
| padding:14px;margin-bottom:14px} | |
| .card h2{margin:0 0 3px;font-size:13px;letter-spacing:.02em} | |
| .card p.hint{margin:0 0 11px;color:var(--dim);font-size:12px} | |
| .worlds{display:grid;grid-template-columns:1fr 1fr;gap:9px} | |
| .world{position:relative;border:1.5px solid var(--line);border-radius:9px;overflow:hidden; | |
| cursor:pointer;background:#000;padding:0;text-align:left;transition:border-color .15s} | |
| .world:hover{border-color:var(--accent)} | |
| .world.on{border-color:var(--accent2)} | |
| .world img{width:100%;aspect-ratio:640/368;object-fit:cover;display:block;opacity:.85} | |
| .world .cap{padding:6px 8px;font-size:11px;color:var(--dim);line-height:1.35} | |
| .world .cap span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical; | |
| overflow:hidden;height:2.7em} | |
| .wbadge{position:absolute;top:5px;left:5px;font-size:9.5px;padding:2px 6px;border-radius:999px; | |
| background:rgba(55,211,155,.9);color:#06281c;font-weight:650;letter-spacing:.02em} | |
| .wdel{position:absolute;top:4px;right:4px;width:18px;height:18px;border-radius:50%; | |
| background:rgba(0,0,0,.62);color:#fff;font-size:13px;line-height:17px;text-align:center; | |
| opacity:0;transition:opacity .15s} | |
| .world:hover .wdel{opacity:1} | |
| .wdel:hover{background:var(--bad)} | |
| .tabs{display:flex;gap:4px;margin-bottom:11px;background:var(--panel2);padding:3px;border-radius:9px} | |
| .tabs button{flex:1;background:none;border:0;padding:6px;border-radius:7px;color:var(--dim); | |
| cursor:pointer;font-size:12.5px} | |
| .tabs button.on{background:var(--panel);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.4)} | |
| input[type=text],input[type=search],input[type=number]{width:100%;background:var(--panel2); | |
| border:1px solid var(--line);border-radius:8px;padding:8px 10px;outline:none} | |
| input:focus{border-color:var(--accent)} | |
| .row{display:flex;gap:8px;align-items:center} | |
| .row+.row{margin-top:8px} | |
| .btn{background:var(--accent);border:0;color:#fff;padding:8px 14px;border-radius:8px; | |
| cursor:pointer;font-weight:600;font-size:13px;white-space:nowrap} | |
| .btn:disabled{opacity:.4;cursor:not-allowed} | |
| .btn.ghost{background:var(--panel2);border:1px solid var(--line);color:var(--ink);font-weight:500} | |
| .chips{max-height:262px;overflow-y:auto;margin-top:9px;padding-right:3px} | |
| .chips::-webkit-scrollbar{width:7px} | |
| .chips::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px} | |
| .theme{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint); | |
| margin:9px 0 5px;position:sticky;top:0;background:var(--panel);padding:2px 0} | |
| .chip{display:block;width:100%;text-align:left;background:var(--panel2);border:1px solid transparent; | |
| border-radius:7px;padding:6px 9px;margin-bottom:4px;cursor:pointer;font-size:12.2px;color:var(--dim); | |
| line-height:1.4} | |
| .chip:hover{border-color:var(--accent);color:var(--ink)} | |
| .chip.on{border-color:var(--accent2);color:var(--ink)} | |
| .chip em{font-style:normal;color:var(--faint);margin-right:6px;font-variant-numeric:tabular-nums} | |
| details{border-top:1px solid var(--line);margin-top:11px;padding-top:10px} | |
| summary{cursor:pointer;color:var(--dim);font-size:12px;list-style:none} | |
| summary::-webkit-details-marker{display:none} | |
| summary:before{content:"▸ ";color:var(--faint)} | |
| details[open] summary:before{content:"▾ "} | |
| .adv{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:9px} | |
| .adv label{font-size:11.5px;color:var(--dim);display:block;margin-bottom:3px} | |
| .pprow{display:grid;grid-template-columns:74px 1fr 42px;align-items:center;gap:9px; | |
| margin-bottom:7px} | |
| .pprow label{font-size:11.5px;color:var(--dim)} | |
| .pprow b{font-size:11.5px;text-align:right;font-variant-numeric:tabular-nums;color:var(--dim)} | |
| .pprow input[type=range]{width:100%;accent-color:var(--accent)} | |
| .ftfrom{margin-top:11px} | |
| .ftlabel{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint)} | |
| .ftchips{display:flex;gap:5px;margin-top:6px;flex-wrap:wrap} | |
| .ftchips button{flex:1 1 0;min-width:0;background:var(--panel2);border:1px solid var(--line); | |
| border-radius:7px;padding:5px 7px;cursor:pointer;font-size:11px;color:var(--dim); | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:border-color .15s} | |
| .ftchips button:hover{border-color:var(--accent);color:var(--ink)} | |
| .ftchips button.on{border-color:var(--accent2);color:var(--ink);background:rgba(55,211,155,.08)} | |
| .tip{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px; | |
| border-radius:50%;border:1px solid var(--line);color:var(--faint);font-size:9.5px; | |
| cursor:help;margin-left:5px;vertical-align:1px;position:relative} | |
| .tip:hover .tiptext{opacity:1;visibility:visible} | |
| .tiptext{position:absolute;bottom:130%;left:50%;transform:translateX(-50%);width:250px; | |
| background:#20242d;border:1px solid var(--line);border-radius:8px;padding:8px 10px; | |
| font-size:11.5px;color:var(--ink);line-height:1.5;opacity:0;visibility:hidden; | |
| transition:opacity .13s;z-index:20;text-align:left;box-shadow:0 6px 20px rgba(0,0,0,.5); | |
| font-weight:400} | |
| .note{margin-top:9px;padding:8px 10px;border-radius:8px;font-size:11.5px;line-height:1.5; | |
| background:rgba(242,181,68,.09);border:1px solid rgba(242,181,68,.28);color:#f0cd8b} | |
| .note.hidden{display:none} | |
| .err{background:rgba(239,95,107,.1);border-color:rgba(239,95,107,.3);color:#f5a3aa} | |
| footer{margin-top:20px;color:var(--faint);font-size:11.5px;line-height:1.7} | |
| footer code{background:var(--panel);padding:1px 5px;border-radius:4px} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Post-processing runs in the browser compositor, not on the GPU that is generating | |
| frames: an unsharp convolution here, brightness/contrast/saturation as CSS filter | |
| functions on the canvas. Per-frame JS cost is zero, so the stream stays at 1x. --> | |
| <svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs> | |
| <filter id="pp-sharpen" x="-5%" y="-5%" width="110%" height="110%" | |
| color-interpolation-filters="sRGB"> | |
| <feConvolveMatrix id="ppKernel" order="3" preserveAlpha="true" divisor="1" | |
| kernelMatrix="0 0 0 0 1 0 0 0 0"/> | |
| </filter> | |
| </defs></svg> | |
| <div class="wrap"> | |
| <header> | |
| <h1>Live<span>Wan</span></h1> | |
| <div class="sub">streaming text-to-video · Wan2.1-1.3B student · <span id="ckpt">step 3000</span></div> | |
| <div class="pill"><i class="dot" id="dot"></i><span id="pilltext">connecting</span></div> | |
| </header> | |
| <div class="grid"> | |
| <div> | |
| <div class="stage"> | |
| <canvas id="cv" width="640" height="368"></canvas> | |
| <div class="overlay" id="ov"> | |
| <div class="spinner" id="ovSpin"></div> | |
| <div class="ov-title" id="ovTitle">Loading the model</div> | |
| <div class="ov-detail" id="ovDetail">This takes about 90 seconds on first start.</div> | |
| <div class="ov-steps" id="ovSteps"></div> | |
| <div class="ov-bar" id="ovBar"><i></i></div> | |
| <div class="ov-log" id="ovLog"></div> | |
| <div class="ov-hint" id="ovHint"></div> | |
| </div> | |
| </div> | |
| <div class="bar"> | |
| <div><span>block</span><b id="mBlock">—</b></div> | |
| <div><span>speed</span><b id="mSpeed">—</b></div> | |
| <div><span>streamed</span><b id="mLen">—</b></div> | |
| <div><span>latent frames</span><b id="mLat">—</b></div> | |
| <div><span>K/V cache</span><b id="mKv">—</b></div> | |
| <div><span>buffer</span><b id="mBuf">—</b></div> | |
| </div> | |
| <div class="now"> | |
| <div class="lbl">Now conditioning on</div> | |
| <div class="txt" id="nowPrompt">—<span class="tag" id="nowTag"></span></div> | |
| </div> | |
| <div class="note hidden" id="note"></div> | |
| <div class="card" style="margin-top:10px"> | |
| <h2>What you are looking at</h2> | |
| <p class="hint" style="margin:0"> | |
| A 1.3 B student, distilled from a Wan2.1-14B teacher, generating video | |
| <em>continuously</em> instead of as a fixed clip. Every 750 ms it produces | |
| another block of 12 frames conditioned on a rolling K/V cache of what it | |
| already made — so the picture never restarts. | |
| </p> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:11px;margin-top:12px"> | |
| <div style="background:var(--panel2);border-radius:9px;padding:10px 11px"> | |
| <div style="font-size:11px;color:var(--accent2);font-weight:600;margin-bottom:3px">STEER · continues</div> | |
| <div style="font-size:11.5px;color:var(--dim);line-height:1.5"> | |
| Swaps the text, keeps the cache. The scene morphs in place. Best between | |
| related scenes. | |
| </div> | |
| </div> | |
| <div style="background:var(--panel2);border-radius:9px;padding:10px 11px"> | |
| <div style="font-size:11px;color:var(--accent);font-weight:600;margin-bottom:3px">SCENE · cuts</div> | |
| <div style="font-size:11.5px;color:var(--dim);line-height:1.5"> | |
| Clears the cache and reopens from a cached world. Use it when you want a | |
| genuinely different picture. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="card"> | |
| <h2>1 · Scene | |
| <span class="tip">?<span class="tiptext">A <b>scene</b> is one of four cached worlds — 21 latent | |
| frames the base model generated once, so a stream can open instantly instead of | |
| waiting ~60 s. Picking one <b>cuts</b>: the stream restarts and the K/V cache is cleared.</span></span> | |
| </h2> | |
| <p class="hint">Pick a starting world. This cuts to a new stream.</p> | |
| <div class="worlds" id="worlds"></div> | |
| <details id="genBox" style="display:none"> | |
| <summary>New scene from text</summary> | |
| <p class="hint" style="margin:9px 0"> | |
| Generates a brand-new world with the Wan2.1 base model — the same way the four | |
| above were made. This is the one slow step in the project: about a minute, not | |
| real time. It is then streamable forever like any other scene. | |
| </p> | |
| <input type="text" id="genText" placeholder="A lighthouse on a cliff in a storm…"> | |
| <div class="row" style="margin-top:8px"> | |
| <label style="font-size:11.5px;color:var(--dim)">Steps | |
| <span class="tip">?<span class="tiptext">Base-model denoising steps. 30 takes | |
| ~52 s and looks good; 50 takes ~87 s for a little more detail.</span></span> | |
| </label> | |
| <select id="genSteps" style="background:var(--panel2);border:1px solid var(--line); | |
| border-radius:8px;padding:6px 8px"> | |
| <option value="20">20 · ~35 s</option> | |
| <option value="30" selected>30 · ~52 s</option> | |
| <option value="50">50 · ~87 s</option> | |
| </select> | |
| <input type="number" id="genSeed" value="0" title="seed" style="width:74px"> | |
| <button class="btn" id="btnGen" style="flex:1">Generate</button> | |
| </div> | |
| </details> | |
| </div> | |
| <div class="card"> | |
| <h2>2 · Steer | |
| <span class="tip">?<span class="tiptext">Steering swaps the text conditioning but <b>keeps</b> the | |
| K/V cache, so the scene carries on and morphs instead of cutting. Nearby scenes blend | |
| beautifully; a violent jump (day → night) tends to smear.</span></span> | |
| </h2> | |
| <p class="hint">Change the text mid-stream. The scene continues.</p> | |
| <div class="tabs"> | |
| <button class="on" data-tab="bank">Prompt bank</button> | |
| <button data-tab="text">Free text</button> | |
| </div> | |
| <div id="tabBank"> | |
| <input type="search" id="search" placeholder="Search 96 prompts…" autocomplete="off"> | |
| <div class="chips" id="chips"></div> | |
| </div> | |
| <div id="tabText" style="display:none"> | |
| <input type="text" id="freeText" placeholder="A lighthouse in a storm, waves breaking…"> | |
| <div class="ftfrom"> | |
| <span class="ftlabel">Opens from | |
| <span class="tip">?<span class="tiptext">Every stream has to open from one of the four | |
| cached worlds — they supply the first 21 latent frames. Your text conditions what | |
| happens <b>next</b>, it cannot invent the opening picture, so pick the world that | |
| starts closest to what you want.</span></span> | |
| </span> | |
| <div class="ftchips" id="ftWorlds"></div> | |
| </div> | |
| <div class="row" style="margin-top:10px"> | |
| <button class="btn" id="btnFree">Steer the live stream</button> | |
| <button class="btn ghost" id="btnFreeStart">Open as new scene</button> | |
| </div> | |
| <div class="note" style="display:block"> | |
| The opening frames always come from the world above — text steers from there rather than | |
| creating the picture, so a prompt far from that world will not teleport. | |
| Free text is encoded with umt5-xxl on this machine (11.4 GB, loaded once on first use); | |
| the 96 bank prompts are the conditioning the model was trained and measured under. | |
| </div> | |
| </div> | |
| <div class="row" style="margin-top:11px"> | |
| <label style="font-size:11.5px;color:var(--dim);flex:1"> | |
| Crossfade | |
| <span class="tip">?<span class="tiptext">Blend the old and new conditioning over N blocks | |
| instead of switching at once. Softens big jumps; 0 is the instant swap the model card | |
| describes.</span></span> | |
| <input type="range" id="xfade" min="0" max="12" value="0" style="width:100%"> | |
| </label> | |
| <b id="xfadeVal" style="font-size:12px;width:56px;text-align:right">instant</b> | |
| </div> | |
| <details> | |
| <summary>Advanced</summary> | |
| <div class="adv"> | |
| <div><label>Denoising steps</label><input type="number" id="steps" value="2" min="1" max="8"></div> | |
| <div><label>Seed</label><input type="number" id="seed" value="0"></div> | |
| <div><label>K/V window (blocks)</label><input type="number" id="window" value="6" min="1" max="12"></div> | |
| <div><label>Latent norm</label><input type="number" id="lnorm" value="1.0" step="0.1" min="0" max="1"></div> | |
| </div> | |
| <p class="hint" style="margin-top:9px">Defaults match the published command. Steps above 2 cost | |
| proportionally more time without fixing hard steers; latent norm 0 lets long streams blow out.</p> | |
| <button class="btn ghost" id="btnStop" style="margin-top:4px">Stop stream</button> | |
| </details> | |
| </div> | |
| <div class="card"> | |
| <h2>3 · Look | |
| <span class="tip">?<span class="tiptext">Display-only post-processing, applied in | |
| your browser as frames arrive. It costs the generator nothing, so the stream | |
| stays at 1× — but it also does not change what the model produced. Useful | |
| against the softness long streams drift into.</span></span> | |
| </h2> | |
| <p class="hint">Post-processing on the picture. Costs no GPU time.</p> | |
| <div class="tabs" id="ppPresets"> | |
| <button class="on" data-pp="off">Off</button> | |
| <button data-pp="subtle">Subtle</button> | |
| <button data-pp="crisp">Crisp</button> | |
| <button data-pp="punch">Punch</button> | |
| </div> | |
| <div id="ppSliders"> | |
| <div class="pprow"><label>Sharpen</label> | |
| <input type="range" id="ppSharp" min="0" max="150" value="0"><b id="ppSharpV">0</b></div> | |
| <div class="pprow"><label>Contrast</label> | |
| <input type="range" id="ppCon" min="70" max="150" value="100"><b id="ppConV">1.00</b></div> | |
| <div class="pprow"><label>Saturation</label> | |
| <input type="range" id="ppSat" min="0" max="200" value="100"><b id="ppSatV">1.00</b></div> | |
| <div class="pprow"><label>Brightness</label> | |
| <input type="range" id="ppBri" min="70" max="130" value="100"><b id="ppBriV">1.00</b></div> | |
| </div> | |
| <div class="row" style="margin-top:10px"> | |
| <button class="btn ghost" id="ppCompare" style="flex:1">Hold to compare</button> | |
| <button class="btn ghost" id="ppReset">Reset</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <footer> | |
| Block = 3 latent frames = 12 pixel frames = 750 ms of 640×368 at 16 fps. | |
| Streams are capped at 1024 latent frames (~4.3 min) by <code>WanModel.freqs</code>. | |
| Quality holds for roughly a minute; after that sharpness drifts. | |
| </footer> | |
| </div> | |
| <script> | |
| const $ = s => document.querySelector(s); | |
| const cv = $('#cv'), ctx = cv.getContext('2d'); | |
| let INFO = null, state = 'boot', curWorld = null, curIdx = null, tab = 'bank'; | |
| /* ---------- frame buffer: decouples network jitter from playback ---------- */ | |
| const buf = []; | |
| const TARGET = 1000/16; | |
| let playing = false; | |
| function pushFrame(blob){ | |
| createImageBitmap(blob).then(bm => { | |
| buf.push(bm); | |
| while (buf.length > 48) buf.shift().close(); // hard cap: never lag by >3 s | |
| if (!playing) { playing = true; requestAnimationFrame(tick); } | |
| }).catch(()=>{}); | |
| } | |
| let last = 0; | |
| function tick(now){ | |
| if (!last) last = now; | |
| let step = TARGET; | |
| if (buf.length > 32) step = TARGET * 0.75; // gently catch up when behind | |
| if (now - last >= step) { | |
| if (buf.length) { | |
| const bm = buf.shift(); | |
| ctx.drawImage(bm, 0, 0, cv.width, cv.height); | |
| bm.close(); | |
| last = now; | |
| } else { last = now; } | |
| } | |
| requestAnimationFrame(tick); | |
| } | |
| /* ------------------------------- transport ------------------------------- */ | |
| function connect(){ | |
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | |
| const ws = new WebSocket(`${proto}://${location.host}/ws`); | |
| ws.binaryType = 'blob'; | |
| ws.onmessage = ev => { | |
| if (typeof ev.data === 'string') applyStatus(JSON.parse(ev.data)); | |
| else pushFrame(ev.data); | |
| }; | |
| ws.onclose = () => setTimeout(connect, 1200); | |
| setInterval(() => { if (ws.readyState === 1) ws.send('.'); }, 15000); | |
| } | |
| /* --------------------------------- boot ---------------------------------- */ | |
| async function boot(){ | |
| let r; | |
| try { r = await (await fetch('/api/boot')).json(); } | |
| catch(e){ overlay('Cannot reach the server', String(e), {isErr:true}); return; } | |
| if (r.error){ overlay('Failed to load', r.error, {isErr:true}); return; } | |
| if (!r.done){ | |
| const msg = (r.messages||[]).slice(-1)[0] || ''; | |
| overlay('Starting up', 'Loading 2.6 GB of weights and compiling the VAE decoder. About 90 seconds on first start — it only happens once.', | |
| {stages:'boot', active:msg, log:msg, | |
| hint:'The four scene thumbnails appear as soon as this finishes.'}); | |
| setTimeout(boot, 600); | |
| return; | |
| } | |
| INFO = await (await fetch('/api/info')).json(); | |
| $('#ckpt').textContent = `step ${INFO.step}`; | |
| buildWorlds(); buildChips(); buildFtWorlds(); | |
| overlay('Pick a scene', 'Choose one of the four cached worlds on the right to open a stream.', | |
| {isErr:true}); | |
| $('#ovSpin').style.display = 'none'; | |
| $('#dot').className = 'dot'; $('#pilltext').textContent = 'ready'; | |
| connect(); | |
| } | |
| /* The two multi-stage waits worth showing progress for. Each entry is matched | |
| against the server's own progress message, so the dots track real work. */ | |
| const STAGES = { | |
| boot: [ | |
| ['prompt bank', /prompt bank/i], | |
| ['student', /student/i], | |
| ['VAE', /VAE/i], | |
| ['compile', /compil/i], | |
| ], | |
| start: [ | |
| ['world', /opening the world/i], | |
| ['K\/V cache', /priming/i], | |
| ['first frames', /^$/], | |
| ], | |
| }; | |
| function overlay(title, detail, opts={}){ | |
| const {isErr=false, stages=null, active='', log='', hint='', pct=null} = opts; | |
| const ov = $('#ov'); | |
| ov.classList.remove('hidden'); | |
| $('#ovTitle').textContent = title; | |
| $('#ovDetail').textContent = detail || ''; | |
| $('#ovSpin').style.display = isErr ? 'none' : ''; | |
| const bar = $('#ovBar'), fill = bar.firstElementChild; | |
| bar.classList.toggle('hide', isErr || (!stages && pct === null)); | |
| if (pct === null){ fill.style.animation = ''; fill.style.width = '30%'; } | |
| else { fill.style.animation = 'none'; fill.style.width = Math.round(pct*100)+'%'; } | |
| $('#ovLog').textContent = log; | |
| $('#ovHint').textContent = hint; | |
| const el = $('#ovSteps'); | |
| el.innerHTML = ''; | |
| if (!stages) return; | |
| const list = STAGES[stages] || []; | |
| let idx = list.findIndex(([, re]) => re.test(active)); | |
| if (idx < 0) idx = 0; | |
| list.forEach(([label], i) => { | |
| if (i) { const s = document.createElement('div'); s.className='ov-sep'; el.appendChild(s); } | |
| const d = document.createElement('div'); | |
| d.className = 'ov-step ' + (i < idx ? 'done' : i === idx ? 'now' : ''); | |
| d.innerHTML = `<i></i>${label}`; | |
| el.appendChild(d); | |
| }); | |
| } | |
| const hideOverlay = () => $('#ov').classList.add('hidden'); | |
| /* --------------------------------- build --------------------------------- */ | |
| function buildWorlds(){ | |
| const el = $('#worlds'); el.innerHTML = ''; | |
| INFO.worlds.forEach(w => { | |
| const b = document.createElement('button'); | |
| b.className = 'world' + (w.idx === curWorld ? ' on' : ''); | |
| b.dataset.world = w.idx; | |
| const badge = w.generated | |
| ? `<span class="wbadge">generated${w.gen_seconds ? ' · '+Math.round(w.gen_seconds)+'s' : ''}</span>` : ''; | |
| const del = w.generated ? `<span class="wdel" title="delete this world">×</span>` : ''; | |
| b.innerHTML = `<img src="/api/thumb/${w.idx}" alt="">${badge}${del} | |
| <div class="cap"><span>${w.prompt}</span></div>`; | |
| b.onclick = ev => { | |
| if (ev.target.classList.contains('wdel')) { ev.stopPropagation(); delWorld(w.idx); return; } | |
| startWorld(w.idx); | |
| }; | |
| el.appendChild(b); | |
| }); | |
| $('#genBox').style.display = INFO.worldgen ? '' : 'none'; | |
| } | |
| async function delWorld(w){ | |
| if (!confirm('Delete this generated world?')) return; | |
| const r = await fetch('/api/world/'+w, {method:'DELETE'}); | |
| if (r.ok){ INFO = {...INFO, ...(await r.json())}; if (curWorld===w) curWorld=null; | |
| buildWorlds(); buildFtWorlds(); } | |
| } | |
| /* Which world the free-text tab opens from. Kept explicit and visible, because a | |
| typed prompt cannot produce the opening frames -- a cached world always does. */ | |
| let ftWorld = null; | |
| const WORLD_SHORT = {0:'Woman', 44:'Owl', 60:'Waterfall', 82:'Court'}; | |
| /* Short label for the picker: a hand-picked word for the four shipped worlds, | |
| otherwise the first couple of meaningful words of the prompt it was made from. */ | |
| const STOP = new Set(['a','an','the','of','in','on','at','with','and','to']); | |
| function worldLabel(w){ | |
| if (WORLD_SHORT[w.idx]) return WORLD_SHORT[w.idx]; | |
| const words = (w.prompt||'').split(/\s+/).filter(x => x && !STOP.has(x.toLowerCase())); | |
| return words.slice(0,2).join(' ').replace(/[,.]$/,'') || `World ${w.idx}`; | |
| } | |
| function setFtWorld(w){ | |
| ftWorld = w; | |
| document.querySelectorAll('#ftWorlds button').forEach(b => | |
| b.classList.toggle('on', +b.dataset.world === w)); | |
| } | |
| function buildFtWorlds(){ | |
| const el = $('#ftWorlds'); el.innerHTML = ''; | |
| INFO.worlds.forEach(w => { | |
| const b = document.createElement('button'); | |
| b.dataset.world = w.idx; | |
| b.textContent = worldLabel(w); | |
| b.title = w.prompt; | |
| b.onclick = () => setFtWorld(w.idx); | |
| el.appendChild(b); | |
| }); | |
| setFtWorld(ftWorld ?? curWorld ?? INFO.worlds[0].idx); | |
| } | |
| function buildChips(filter=''){ | |
| const el = $('#chips'); el.innerHTML = ''; | |
| const f = filter.trim().toLowerCase(); | |
| let theme = null; | |
| INFO.prompts.filter(p => !f || p.text.toLowerCase().includes(f)).forEach(p => { | |
| if (p.theme !== theme){ | |
| theme = p.theme; | |
| const h = document.createElement('div'); | |
| h.className = 'theme'; h.textContent = theme; | |
| el.appendChild(h); | |
| } | |
| const b = document.createElement('button'); | |
| b.className = 'chip' + (p.idx === curIdx ? ' on' : ''); | |
| b.innerHTML = `<em>${p.idx}</em>${p.text}`; | |
| b.onclick = () => steer({prompt_idx: p.idx}, p); | |
| el.appendChild(b); | |
| }); | |
| if (!el.children.length) el.innerHTML = '<div class="theme">no match</div>'; | |
| } | |
| /* -------------------------------- actions -------------------------------- */ | |
| function advanced(){ | |
| return { | |
| seed: +$('#seed').value || 0, | |
| steps: +$('#steps').value || 2, | |
| window: +$('#window').value || 6, | |
| latent_norm: parseFloat($('#lnorm').value), | |
| }; | |
| } | |
| async function post(url, body){ | |
| const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify(body)}); | |
| if (!r.ok){ | |
| const t = await r.text(); | |
| note(t.replace(/^\{"detail":"|"\}$/g,''), true); | |
| throw new Error(t); | |
| } | |
| return r.json(); | |
| } | |
| async function startWorld(w, text){ | |
| // Clicking a scene card opens it on that world's own bank prompt. If free text is | |
| // sitting unused in the other tab, say so rather than silently ignoring it. | |
| const pending = $('#freeText').value.trim(); | |
| // Only the four shipped worlds have ids that double as bank indices. A generated | |
| // world's id (1000+) is not a prompt, so send no prompt at all and let the server | |
| // use the embedding stored with the world. | |
| const entry = (INFO?.worlds || []).find(x => x.idx === w); | |
| const isGen = !!(entry && entry.generated); | |
| curWorld = w; curIdx = (text || isGen) ? null : w; | |
| setFtWorld(w); | |
| if (!text && pending) | |
| note('Opened this scene on its own bank prompt. To use the text you typed, ' + | |
| 'go to Free text and press "Open as new scene" (or "Steer the live stream").'); | |
| else note(''); | |
| document.querySelectorAll('.world').forEach(e => | |
| e.classList.toggle('on', +e.dataset.world === w)); | |
| const needsT5 = !!text && !INFO.encoder_loaded; | |
| overlay('Opening the scene', | |
| needsT5 ? 'Loading umt5-xxl (11.4 GB) to encode your prompt, then priming the K/V cache. First free-text prompt only.' | |
| : 'Priming the K/V cache from 21 cached latent frames, then generating the first block.', | |
| {stages:'start', active:'opening the world', | |
| hint:'Takes about 5 seconds' + (needsT5 ? ', plus ~15 s for the text encoder.' : '.')}); | |
| buf.splice(0).forEach(b=>b.close()); | |
| const body = {world: w, ...advanced()}; | |
| if (text) body.prompt_text = text; | |
| else if (!isGen) body.prompt_idx = w; | |
| await post('/api/start', body); | |
| buildChips($('#search').value); | |
| } | |
| async function steer(what, p){ | |
| if (state !== 'streaming'){ | |
| note('Start a scene first — steering needs a running stream.', true); return; | |
| } | |
| if (p){ curIdx = p.idx; buildChips($('#search').value); } | |
| note(''); | |
| await post('/api/steer', {...what, crossfade: +$('#xfade').value}); | |
| const wTheme = INFO.world_themes[curWorld]; | |
| if (p && wTheme && p.theme !== wTheme) | |
| note(`Steering from ${wTheme.toLowerCase()} to ${p.theme.toLowerCase()} is a big jump — expect the picture to smear. A crossfade softens it; cutting to a new scene stays clean.`); | |
| } | |
| /* --------------------------------- status -------------------------------- */ | |
| function applyStatus(s){ | |
| state = s.state; | |
| const d = $('#dot'); | |
| d.className = 'dot ' + (s.state==='streaming' ? 'live' | |
| : s.state==='loading' ? 'load' | |
| : s.state==='error' ? 'err' : ''); | |
| $('#pilltext').textContent = s.state==='streaming' ? 'live' | |
| : s.state==='error' ? 'error' : (s.detail || s.state); | |
| // hold the overlay until frames are actually arriving, not merely promised | |
| if (s.state === 'streaming' && buf.length) hideOverlay(); | |
| else if (s.state === 'streaming') | |
| overlay('Opening the scene', 'Generating the first block…', | |
| {stages:'start', active:''}); | |
| if (s.state === 'loading') | |
| overlay('Opening the scene', s.detail || 'Working…', | |
| {stages:'start', active:s.detail || ''}); | |
| if (s.state === 'generating') | |
| overlay('Generating a new world', | |
| s.detail || 'Running the Wan2.1 base model…', | |
| {pct: s.progress ?? 0, | |
| log: s.progress != null ? Math.round(s.progress*100)+'%' : '', | |
| hint:'This is the one slow step — the base model, not the student. ' | |
| + 'Once it exists you can stream from it forever at 1x.'}); | |
| if (s.state === 'error'){ overlay('Stream failed', s.error, {isErr:true}); note(s.error, true); } | |
| if (s.state === 'idle' && s.detail && s.detail !== 'ready' && s.detail !== 'stopped') | |
| overlay('Stream ended', s.detail, {isErr:true}); | |
| if (s.prompt){ | |
| $('#nowPrompt').childNodes[0].nodeValue = s.prompt; | |
| $('#nowTag').textContent = s.prompt_source === 'text' ? 'free text' : 'bank'; | |
| } | |
| const st = s.stats || {}; | |
| if (st.total_s){ | |
| $('#mBlock').textContent = Math.round(st.total_s*1000)+' ms'; | |
| $('#mSpeed').textContent = (0.75/st.total_s).toFixed(2)+'×'; | |
| } | |
| if (st.seconds != null) $('#mLen').textContent = st.seconds.toFixed(1)+' s'; | |
| if (st.latent_frames != null) | |
| $('#mLat').textContent = st.latent_frames+' / '+st.latent_frames_max; | |
| if (st.kv_mb) $('#mKv').textContent = Math.round(st.kv_mb)+' MB'; | |
| $('#mBuf').textContent = buf.length+' fr'; | |
| } | |
| function note(msg, isErr){ | |
| const n = $('#note'); | |
| n.className = 'note' + (msg ? '' : ' hidden') + (isErr ? ' err' : ''); | |
| n.textContent = msg; | |
| } | |
| /* ---------------------------------- wire --------------------------------- */ | |
| $('#search').oninput = e => buildChips(e.target.value); | |
| $('#xfade').oninput = e => { | |
| const v = +e.target.value; | |
| $('#xfadeVal').textContent = v ? v+' blocks' : 'instant'; | |
| }; | |
| document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => { | |
| tab = b.dataset.tab; | |
| document.querySelectorAll('.tabs button').forEach(x => x.classList.toggle('on', x===b)); | |
| $('#tabBank').style.display = tab==='bank' ? '' : 'none'; | |
| $('#tabText').style.display = tab==='text' ? '' : 'none'; | |
| }); | |
| $('#btnFree').onclick = async () => { | |
| const t = $('#freeText').value.trim(); | |
| if (!t) return note('Type a prompt first.', true); | |
| if (!INFO.encoder_loaded) note('Loading umt5-xxl (11.4 GB) — first free-text prompt only…'); | |
| curIdx = null; | |
| await steer({prompt_text: t}); | |
| INFO.encoder_loaded = true; | |
| }; | |
| $('#btnFreeStart').onclick = async () => { | |
| const t = $('#freeText').value.trim(); | |
| if (!t) return note('Type a prompt first.', true); | |
| if (!INFO.encoder_loaded) note('Loading umt5-xxl (11.4 GB) — first free-text prompt only…'); | |
| await startWorld(ftWorld ?? curWorld ?? 60, t); | |
| INFO.encoder_loaded = true; | |
| }; | |
| $('#btnGen').onclick = async () => { | |
| const t = $('#genText').value.trim(); | |
| if (!t) return note('Describe the scene you want first.', true); | |
| const steps = +$('#genSteps').value, seed = +$('#genSeed').value || 0; | |
| note(''); | |
| overlay('Generating a new world', 'Encoding the prompt…', {pct:0}); | |
| try { | |
| const r = await post('/api/world/new', {prompt_text:t, steps, seed}); | |
| INFO = {...INFO, ...r}; | |
| buildWorlds(); buildFtWorlds(); | |
| await startWorld(r.world); // open the thing we just made | |
| } catch(e){ | |
| overlay('Generation failed', String(e), {isErr:true}); | |
| } | |
| }; | |
| $('#btnStop').onclick = async () => { | |
| await post('/api/stop', {}); | |
| overlay('Stopped', 'Pick a scene to start again.', {isErr:true}); | |
| }; | |
| $('#freeText').addEventListener('keydown', e => { if (e.key==='Enter') $('#btnFree').click(); }); | |
| /* ----------------------------- post-processing ---------------------------- */ | |
| const PP_PRESETS = { | |
| off: {sharp:0, con:100, sat:100, bri:100}, | |
| subtle: {sharp:35, con:105, sat:106, bri:100}, | |
| crisp: {sharp:80, con:110, sat:104, bri:100}, | |
| punch: {sharp:110, con:120, sat:130, bri:102}, | |
| }; | |
| let ppBypass = false; | |
| function ppApply(){ | |
| const a = ppBypass ? 0 : +$('#ppSharp').value / 100; | |
| const con = ppBypass ? 1 : +$('#ppCon').value / 100; | |
| const sat = ppBypass ? 1 : +$('#ppSat').value / 100; | |
| const bri = ppBypass ? 1 : +$('#ppBri').value / 100; | |
| // unsharp: centre 1+4a, four-neighbour -a. Sums to 1, so overall exposure holds. | |
| $('#ppKernel').setAttribute('kernelMatrix', | |
| `0 ${-a} 0 ${-a} ${1 + 4*a} ${-a} 0 ${-a} 0`); | |
| const parts = []; | |
| if (a > 0) parts.push('url(#pp-sharpen)'); | |
| if (bri !== 1) parts.push(`brightness(${bri})`); | |
| if (con !== 1) parts.push(`contrast(${con})`); | |
| if (sat !== 1) parts.push(`saturate(${sat})`); | |
| cv.style.filter = parts.join(' ') || 'none'; | |
| $('#ppSharpV').textContent = Math.round(a * 100); | |
| $('#ppConV').textContent = con.toFixed(2); | |
| $('#ppSatV').textContent = sat.toFixed(2); | |
| $('#ppBriV').textContent = bri.toFixed(2); | |
| } | |
| function ppSet(name){ | |
| const p = PP_PRESETS[name]; if (!p) return; | |
| $('#ppSharp').value = p.sharp; $('#ppCon').value = p.con; | |
| $('#ppSat').value = p.sat; $('#ppBri').value = p.bri; | |
| ppApply(); | |
| } | |
| document.querySelectorAll('#ppPresets button').forEach(b => b.onclick = () => { | |
| document.querySelectorAll('#ppPresets button').forEach(x => x.classList.toggle('on', x===b)); | |
| ppSet(b.dataset.pp); | |
| }); | |
| ['ppSharp','ppCon','ppSat','ppBri'].forEach(id => $('#'+id).oninput = () => { | |
| // hand-editing a slider means no preset is exactly active any more | |
| document.querySelectorAll('#ppPresets button').forEach(x => x.classList.remove('on')); | |
| ppApply(); | |
| }); | |
| const ppHold = on => { ppBypass = on; ppApply(); }; | |
| $('#ppCompare').addEventListener('mousedown', () => ppHold(true)); | |
| $('#ppCompare').addEventListener('touchstart', e => { e.preventDefault(); ppHold(true); }); | |
| ['mouseup','mouseleave','touchend','touchcancel'].forEach(ev => | |
| $('#ppCompare').addEventListener(ev, () => ppHold(false))); | |
| $('#ppReset').onclick = () => { | |
| document.querySelectorAll('#ppPresets button').forEach(x => | |
| x.classList.toggle('on', x.dataset.pp === 'off')); | |
| ppSet('off'); | |
| }; | |
| ppApply(); | |
| boot(); | |
| </script> | |
| </body> | |
| </html> | |