| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Senior LDM β¦ Latent Diffusion</title> |
| <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> |
| <style> |
| :root { --purple:#c9a0ff; --deep:#6a0dad; --panel:#16161f; --line:#3a3a4a; } |
| * { box-sizing:border-box; } |
| body { |
| margin:0; background:#0d0d0d; color:#e0e0e0; |
| font-family:system-ui, sans-serif; |
| } |
| #app { max-width:1000px; margin:0 auto; padding:18px; } |
| |
| header { text-align:center; padding:14px 0 6px; } |
| header h1 { |
| font-family:'Press Start 2P', monospace; font-size:1.25rem; |
| color:var(--purple); letter-spacing:2px; margin:0 0 10px; |
| } |
| .gallery-label { |
| font-family:'Press Start 2P', monospace; font-size:.55rem; |
| color:#888; margin:28px 0 8px; |
| } |
| .gallery { |
| display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:10px; |
| } |
| .g-card { |
| margin:0; background:var(--panel); border:1px solid var(--line); |
| border-radius:6px; overflow:hidden; aspect-ratio:1/1; |
| } |
| .g-card img { width:100%; height:100%; object-fit:cover; image-rendering:auto; display:block; } |
| |
| .ideas-label { |
| font-family:'Press Start 2P', monospace; font-size:.55rem; |
| color:#888; margin:16px 0 6px; |
| } |
| .chips { line-height:2; } |
| .chip { |
| display:inline-block; background:#1e1e2e; border:1px solid var(--line); |
| border-radius:4px; padding:4px 8px; margin:3px; font-size:.72rem; |
| cursor:pointer; transition:background .15s, border-color .15s, color .15s; |
| } |
| .chip:hover { background:#2e2e4e; border-color:var(--purple); color:var(--purple); } |
| |
| .cols { display:flex; gap:18px; margin-top:16px; flex-wrap:wrap; } |
| .col { flex:1 1 360px; } |
| |
| label.field { display:block; font-size:.8rem; color:#aaa; margin:12px 0 4px; } |
| textarea, input[type=range] { width:100%; } |
| textarea { |
| background:var(--panel); color:#eee; border:1px solid var(--line); |
| border-radius:6px; padding:10px; font-size:.95rem; resize:vertical; min-height:90px; |
| } |
| .seed-row { display:flex; align-items:center; gap:10px; } |
| .seed-row output { font-family:'Press Start 2P', monospace; font-size:.6rem; color:var(--purple); min-width:48px; text-align:right; } |
| .select { |
| width:100%; background:var(--panel); color:#eee; border:1px solid var(--line); |
| border-radius:6px; padding:8px; font-size:.85rem; |
| } |
| |
| button.go { |
| margin-top:16px; width:100%; padding:12px; |
| font-family:'Press Start 2P', monospace; font-size:.7rem; |
| color:#fff; background:linear-gradient(90deg,var(--deep),var(--purple)); |
| border:none; border-radius:6px; cursor:pointer; letter-spacing:1px; |
| } |
| button.go:disabled { opacity:.5; cursor:not-allowed; } |
| |
| .viewer { |
| aspect-ratio:1/1; width:100%; |
| background:#0a0a0a; border:1px solid var(--line); border-radius:8px; |
| display:flex; align-items:center; justify-content:center; overflow:hidden; |
| } |
| .viewer img { width:100%; height:100%; object-fit:contain; image-rendering:auto; } |
| .viewer.empty span { color:#444; font-size:.9rem; } |
| |
| |
| #progress-zone { position:relative; height:84px; margin-top:26px; } |
| #rem-sprite { |
| position:absolute; top:0; left:0%; width:56px; |
| image-rendering:pixelated; z-index:10; transition:none; |
| } |
| #progress-track { |
| position:absolute; bottom:0; left:0; width:100%; height:22px; |
| background:#1a1a2e; border:2px solid var(--line); border-radius:2px; overflow:hidden; |
| } |
| #progress-fill { |
| height:100%; width:0%; |
| background:linear-gradient(90deg,var(--deep),var(--purple)); |
| transition:width .35s linear; |
| } |
| #progress-label { |
| position:absolute; bottom:4px; right:8px; |
| font-family:'Press Start 2P', monospace; font-size:.5rem; color:#fff; |
| pointer-events:none; z-index:11; |
| } |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
| <header> |
| <h1>Senior LDM β¦ Latent Diffusion</h1> |
| </header> |
|
|
| <div class="ideas-label">Prompt ideas β click to use</div> |
| <div class="chips"> |
| <span class="chip" v-for="p in cfg.prompts" :key="p" @click="prompt = p">{{ p }}</span> |
| </div> |
|
|
| <div class="cols"> |
| <div class="col"> |
| <label class="field">Prompt</label> |
| <textarea v-model="prompt" placeholder="describe an image..." :disabled="busy"></textarea> |
|
|
| <label class="field">Seed</label> |
| <div class="seed-row"> |
| <input type="range" min="0" max="9999" step="1" v-model.number="seed" :disabled="busy"> |
| <output>{{ seed }}</output> |
| </div> |
|
|
| <label class="field">Denoising steps</label> |
| <div class="seed-row"> |
| <input type="range" min="1" :max="cfg.max_steps" step="1" v-model.number="steps" :disabled="busy"> |
| <output>{{ steps }}</output> |
| </div> |
|
|
| <label class="field">Guidance scale</label> |
| <div class="seed-row"> |
| <input type="range" min="0" max="20" step="0.5" v-model.number="guidance" :disabled="busy"> |
| <output>{{ guidance.toFixed(1) }}</output> |
| </div> |
|
|
| <label class="field">CFG rescale</label> |
| <div class="seed-row"> |
| <input type="range" min="0" max="1" step="0.05" v-model.number="guidanceRescale" :disabled="busy"> |
| <output>{{ guidanceRescale.toFixed(2) }}</output> |
| </div> |
|
|
| <label class="field">Timestep spacing</label> |
| <select class="select" v-model="spacing" :disabled="busy"> |
| <option v-for="s in cfg.spacings" :key="s" :value="s">{{ s }}</option> |
| </select> |
|
|
| <button class="go" @click="run" :disabled="busy || !prompt.trim()"> |
| {{ busy ? 'Generatingβ¦' : 'Generate' }} |
| </button> |
| </div> |
|
|
| <div class="col"> |
| <label class="field">Generated image</label> |
| <div class="viewer" :class="{empty:!image}"> |
| <img v-if="image" :src="image" alt=""> |
| <span v-else>β</span> |
| </div> |
| </div> |
| </div> |
|
|
| <div id="progress-zone"> |
| <img id="rem-sprite" :src="remSrc" alt="rem"> |
| <div id="progress-track"><div id="progress-fill" :style="{width: fillPct + '%'}"></div></div> |
| <div id="progress-label">{{ label }}</div> |
| </div> |
|
|
| <div class="gallery-label" v-if="cfg.gallery.length">Gallery β sample generations</div> |
| <div class="gallery"> |
| <figure class="g-card" v-for="g in cfg.gallery" :key="g.url"> |
| <img :src="g.url" :alt="g.label" :title="g.label" loading="lazy"> |
| </figure> |
| </div> |
| </div> |
|
|
| <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> |
| <script> |
| const { createApp, ref, reactive, computed, onMounted } = Vue; |
| |
| createApp({ |
| setup() { |
| const cfg = reactive({ sprites:null, prompts:[], gallery:[], num_steps:10, |
| max_steps:200, guidance:7.5, guidance_rescale:0.5, |
| spacing:'trailing', spacings:['trailing'] }); |
| const prompt = ref(''); |
| const seed = ref(42); |
| const steps = ref(10); |
| const guidance = ref(7.5); |
| const guidanceRescale = ref(0.5); |
| const spacing = ref('trailing'); |
| const busy = ref(false); |
| const image = ref(null); |
| const fillPct = ref(0); |
| const label = ref('Step 0 / 0'); |
| const remSrc = ref(''); |
| |
| |
| let frame = 0, frameTimer = null, state = 'idle'; |
| let pos = 0, dir = 1, dashing = false, completed = false; |
| |
| function setFrame() { |
| const arr = cfg.sprites[state]; |
| remSrc.value = arr[frame % arr.length]; |
| } |
| function spriteEl() { return document.getElementById('rem-sprite'); } |
| function applyTransform() { |
| const el = spriteEl(); |
| if (el) el.style.transform = dir < 0 ? 'scaleX(-1)' : 'scaleX(1)'; |
| } |
| function setTransition(css) { |
| const el = spriteEl(); |
| if (el) el.style.transition = css; |
| } |
| function startAnim(newState, ms) { |
| if (frameTimer) clearInterval(frameTimer); |
| state = newState; frame = 0; setFrame(); applyTransform(); |
| frameTimer = setInterval(() => { frame++; setFrame(); }, ms); |
| } |
| function placeSprite() { |
| const el = spriteEl(); |
| if (el) el.style.left = pos + '%'; |
| } |
| function idle() { |
| dashing = false; dir = 1; pos = 0; |
| setTransition('none'); placeSprite(); |
| startAnim('idle', 700); |
| } |
| |
| |
| |
| function dashLeftThenRun() { |
| dashing = true; |
| dir = -1; startAnim('run', 70); |
| setTransition('left .3s linear'); |
| spriteEl() && spriteEl().offsetWidth; |
| pos = 0; placeSprite(); |
| setTimeout(() => { |
| setTransition('none'); |
| dir = 1; applyTransform(); |
| startAnim('run', 120); |
| dashing = false; |
| }, 320); |
| } |
| |
| function onStep(step, total) { |
| if (dashing) return; |
| const pct = step / total; |
| const ms = Math.round(140 - 85 * pct); |
| if (state !== 'run') startAnim('run', ms); |
| else { clearInterval(frameTimer); frameTimer = setInterval(() => { frame++; setFrame(); }, ms); } |
| |
| if (dir !== 1) { dir = 1; applyTransform(); } |
| const target = pct * 96 + 2; |
| pos += (target - pos) * 0.5; |
| if (pos > 97) pos = 97; |
| if (pos < 2) pos = 2; |
| placeSprite(); |
| } |
| |
| function onDone() { |
| completed = true; |
| dashing = false; |
| if (frameTimer) clearInterval(frameTimer); |
| state = 'done'; frame = 0; setFrame(); |
| dir = 1; applyTransform(); |
| setTransition('none'); |
| pos = 94; placeSprite(); |
| } |
| |
| |
| async function run() { |
| if (busy.value || !prompt.value.trim()) return; |
| busy.value = true; |
| completed = false; |
| fillPct.value = 0; |
| label.value = 'Step 0 / ' + steps.value; |
| dashLeftThenRun(); |
| |
| try { |
| const resp = await fetch('/api/generate', { |
| method:'POST', |
| headers:{'Content-Type':'application/json'}, |
| body: JSON.stringify({ |
| prompt: prompt.value.trim(), seed: seed.value, |
| steps: steps.value, guidance: guidance.value, |
| guidance_rescale: guidanceRescale.value, spacing: spacing.value, |
| }), |
| }); |
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buf = ''; |
| |
| while (true) { |
| const { value, done } = await reader.read(); |
| if (done) break; |
| buf += decoder.decode(value, { stream:true }); |
| let nl; |
| while ((nl = buf.indexOf('\n')) >= 0) { |
| const line = buf.slice(0, nl).trim(); |
| buf = buf.slice(nl + 1); |
| if (!line) continue; |
| handle(JSON.parse(line)); |
| } |
| } |
| } catch (e) { |
| label.value = 'Error: ' + e.message; |
| } finally { |
| busy.value = false; |
| |
| |
| if (!completed) idle(); |
| } |
| } |
| |
| function handle(msg) { |
| if (msg.type === 'error') { label.value = 'Error: ' + msg.message; return; } |
| const mins = Math.floor(msg.elapsed / 60); |
| const secs = Math.floor(msg.elapsed % 60).toString().padStart(2,'0'); |
| label.value = `Step ${msg.step} / ${msg.total} | ${mins}:${secs}`; |
| fillPct.value = (msg.step / msg.total) * 100; |
| |
| if (msg.type === 'step') { |
| onStep(msg.step, msg.total); |
| } else if (msg.type === 'done') { |
| fillPct.value = 100; |
| onDone(); |
| image.value = msg.image; |
| } |
| } |
| |
| onMounted(async () => { |
| const r = await fetch('/api/config'); |
| const c = await r.json(); |
| Object.assign(cfg, c); |
| |
| steps.value = cfg.num_steps; |
| guidance.value = cfg.guidance; |
| guidanceRescale.value = cfg.guidance_rescale; |
| spacing.value = cfg.spacing; |
| |
| Object.values(cfg.sprites || {}).flat().forEach(src => { const im = new Image(); im.src = src; }); |
| idle(); |
| }); |
| |
| return { cfg, prompt, seed, steps, guidance, guidanceRescale, spacing, |
| busy, image, fillPct, label, remSrc, run }; |
| } |
| }).mount('#app'); |
| </script> |
| </body> |
| </html> |
|
|