LiveWan / wanstreamer /serve /web /index.html
JonathanColetti's picture
LiveWan streaming demo on ZeroGPU
36cdb93 verified
Raw
History Blame Contribute Delete
38.5 kB
<!doctype html>
<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>