Spaces:
Sleeping
Sleeping
| /* ============================================================ | |
| PixelLock custom frontend | |
| - drop/upload a PNG | |
| - theme chips -> prompt | |
| - retheme / upscale toggle | |
| - POST /api/edit { image(base64), theme, prompt, mode } | |
| - GET /api/examples -> before/after gallery | |
| ============================================================ */ | |
| ; | |
| /* Theme presets. `key` mirrors the THEMES dict keys in app/app.py | |
| (emoji-prefixed) so the backend's THEMES.get(theme_key) resolves the | |
| instruction even if the prompt field is blank. `swatch` is purely visual. */ | |
| const THEMES = [ | |
| { key: "🌋 Molten lava", label: "Lava", swatch: "#ff5a1f", | |
| prompt: "a molten lava theme — glowing magma core, charred black edges, white-hot highlights, bold orange-red ramp" }, | |
| { key: "❄️ Frozen ice", label: "Ice", swatch: "#8fe6ff", | |
| prompt: "a frozen ice theme — pale cyan, frosted surface, white frost highlights, cool blue shadows" }, | |
| { key: "🪙 Solid gold", label: "Gold", swatch: "#ffcf45", | |
| prompt: "a solid gold royal theme — shimmering gold ramp, warm highlights, deep amber shadows" }, | |
| { key: "☢️ Toxic", label: "Toxic", swatch: "#7dff3a", | |
| prompt: "a toxic radioactive theme — sickly neon green, glowing hazard spots, dark slime shadows" }, | |
| { key: "🍂 Autumn dusk", label: "Autumn", swatch: "#e0673a", | |
| prompt: "an autumn dusk theme — warm amber and crimson with deep purple shadows" }, | |
| { key: "🌌 Cosmic galaxy", label: "Cosmic", swatch: "#a06bff", | |
| prompt: "a cosmic galaxy theme — deep space-purple with tiny star speckles and glowing cyan accents" }, | |
| { key: "🖤 Dark emo", label: "Emo", swatch: "#ff3ad1", | |
| prompt: "a dark emo theme — near-black base with glowing magenta and purple accents, moody" }, | |
| { key: "🌊 Deep ocean", label: "Ocean", swatch: "#1fd4c4", | |
| prompt: "a deep ocean theme — teal and aqua, blue-green ramp, soft glow" }, | |
| ]; | |
| const $ = (id) => document.getElementById(id); | |
| const els = { | |
| dropzone: $("dropzone"), | |
| file: $("file"), | |
| dzEmpty: $("dz-empty"), | |
| dzLoaded: $("dz-loaded"), | |
| dzPreview: $("dz-preview"), | |
| dzName: $("dz-name"), | |
| dzClear: $("dz-clear"), | |
| chips: $("chips"), | |
| assetStrip:$("asset-strip"), | |
| prompt: $("prompt"), | |
| toggle: $("mode-toggle"), | |
| glider: $("toggle-glider"), | |
| run: $("run"), | |
| runHint: $("run-hint"), | |
| imgIn: $("img-in"), | |
| imgOut: $("img-out"), | |
| badge: $("badge"), | |
| status: $("status"), | |
| download: $("download"), | |
| loader: $("frame-loader"), | |
| loaderText:$("loader-text"), | |
| frameEmpty:$("frame-empty"), | |
| gallery: $("gallery"), | |
| serverDot: $("server-dot"), | |
| serverLbl: $("server-label"), | |
| silCanvas: $("sil-canvas"), | |
| wireOut: $("wire-out"), | |
| }; | |
| /* draw the input's preserved silhouette (opaque pixels -> pale outline) into | |
| the SILHOUETTE LOCKED panel — the visual proof the shape never changes. */ | |
| function renderSilhouette(src) { | |
| const c = els.silCanvas; | |
| if (!c || !src) return; | |
| const img = new Image(); | |
| img.onload = () => { | |
| const w = img.naturalWidth, h = img.naturalHeight; | |
| c.width = w; c.height = h; | |
| const ctx = c.getContext("2d"); | |
| ctx.clearRect(0, 0, w, h); | |
| ctx.drawImage(img, 0, 0); | |
| const d = ctx.getImageData(0, 0, w, h), px = d.data; | |
| for (let i = 0; i < px.length; i += 4) { | |
| if (px[i + 3] >= 128) { px[i] = 206; px[i + 1] = 232; px[i + 2] = 255; px[i + 3] = 255; } | |
| else { px[i + 3] = 0; } | |
| } | |
| ctx.putImageData(d, 0, 0); | |
| }; | |
| img.src = src; | |
| } | |
| function clearSilhouette() { | |
| const c = els.silCanvas; if (!c) return; | |
| const ctx = c.getContext("2d"); ctx && ctx.clearRect(0, 0, c.width, c.height); | |
| } | |
| const state = { | |
| imageDataUrl: null, // base64 data URL of the loaded sprite | |
| fileName: null, | |
| theme: null, // optional — null = use the prompt only | |
| mode: "exact", | |
| busy: false, | |
| }; | |
| /* ------------------------------------------------------------ chips */ | |
| function buildChips() { | |
| THEMES.forEach((t) => { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "chip"; | |
| b.dataset.key = t.key; | |
| b.setAttribute("role", "option"); | |
| b.innerHTML = `<span class="swatch" style="background:${t.swatch};color:${t.swatch}"></span>${t.label}`; | |
| b.addEventListener("click", () => selectTheme(t)); | |
| els.chips.appendChild(b); | |
| }); | |
| } | |
| function selectTheme(t) { | |
| if (state.theme === t.key) { // re-click deselects — theme is optional | |
| state.theme = null; | |
| [...els.chips.children].forEach((c) => c.classList.remove("active")); | |
| } else { | |
| state.theme = t.key; | |
| els.prompt.value = t.prompt; // theme is a prompt shortcut | |
| [...els.chips.children].forEach((c) => c.classList.toggle("active", c.dataset.key === t.key)); | |
| } | |
| els.prompt.dispatchEvent(new Event("input")); | |
| } | |
| /* ------------------------------------------------------------ file loading */ | |
| function loadFile(file) { | |
| if (!file) return; | |
| if (!/image\//.test(file.type) && !/\.png$/i.test(file.name)) { | |
| setHint("please choose a PNG / image file", true); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| state.imageDataUrl = e.target.result; | |
| state.fileName = file.name; | |
| els.dzPreview.src = state.imageDataUrl; | |
| els.dzName.textContent = file.name; | |
| els.dzEmpty.hidden = true; | |
| els.dzLoaded.hidden = false; | |
| // mirror into the INPUT compare frame + silhouette panel immediately | |
| els.imgIn.src = state.imageDataUrl; | |
| renderSilhouette(state.imageDataUrl); | |
| refreshRun(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function clearFile() { | |
| state.imageDataUrl = null; | |
| state.fileName = null; | |
| els.file.value = ""; | |
| els.dzEmpty.hidden = false; | |
| els.dzLoaded.hidden = true; | |
| els.imgIn.removeAttribute("src"); | |
| clearSilhouette(); | |
| refreshRun(); | |
| } | |
| /* ------------------------------------------------------------ mode toggle */ | |
| function setMode(mode, btn) { | |
| state.mode = mode; | |
| [...els.toggle.querySelectorAll(".toggle-opt")].forEach((o) => { | |
| const on = o === btn; | |
| o.classList.toggle("active", on); | |
| o.setAttribute("aria-selected", String(on)); | |
| }); | |
| els.glider.classList.toggle("right", mode === "upscale2x"); | |
| els.run.querySelector(".run-label").textContent = | |
| mode === "upscale2x" ? "▶ UPSCALE 2×" : "▶ RUN RETEXTURE"; | |
| } | |
| /* ------------------------------------------------------------ run state */ | |
| function refreshRun() { | |
| const hasInstruction = !!state.theme || els.prompt.value.trim().length > 0; | |
| const ready = !!state.imageDataUrl && hasInstruction && !state.busy; | |
| els.run.disabled = !ready; | |
| if (!state.imageDataUrl) setHint("load a sprite to begin"); | |
| else if (!hasInstruction) setHint("pick a theme or type a prompt"); | |
| else if (!state.busy) setHint("ready · footprint stays locked"); | |
| } | |
| function setHint(text, isError = false) { | |
| els.runHint.textContent = text; | |
| els.runHint.classList.toggle("error", isError); | |
| } | |
| function setBusy(on) { | |
| state.busy = on; | |
| els.run.classList.toggle("busy", on); | |
| els.run.disabled = on || !state.imageDataUrl; | |
| els.loader.hidden = !on; | |
| if (on) { | |
| els.frameEmpty.hidden = true; | |
| cycleLoaderText(); | |
| } | |
| } | |
| let loaderTimer = null; | |
| function cycleLoaderText() { | |
| const msgs = ["reading pixels…", "building footprint grammar…", "calling the model…", "locking the silhouette…", "rendering true-res PNG…"]; | |
| let i = 0; | |
| els.loaderText.textContent = msgs[0]; | |
| clearInterval(loaderTimer); | |
| loaderTimer = setInterval(() => { | |
| i = (i + 1) % msgs.length; | |
| if (state.busy) els.loaderText.textContent = msgs[i]; | |
| else clearInterval(loaderTimer); | |
| }, 2200); | |
| } | |
| /* ------------------------------------------------------------ RUN -> /api/edit */ | |
| async function runEdit() { | |
| if (!state.imageDataUrl || state.busy) return; | |
| setBusy(true); | |
| setHint("working…"); | |
| els.status.textContent = "running…"; | |
| els.status.classList.remove("error"); | |
| els.badge.hidden = true; | |
| els.download.hidden = true; | |
| const payload = { | |
| image: state.imageDataUrl, // data URL; backend strips the prefix | |
| theme: state.theme, // resolves THEMES.get(theme_key) | |
| prompt: els.prompt.value.trim(), | |
| mode: state.mode, // "exact" | "upscale2x" | |
| }; | |
| try { | |
| const res = await fetch("/api/edit", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!res.ok) { | |
| const detail = await safeError(res); | |
| throw new Error(detail || `HTTP ${res.status}`); | |
| } | |
| const data = await res.json(); | |
| renderResult(data); | |
| } catch (err) { | |
| els.status.textContent = "✗ " + (err.message || "edit failed"); | |
| els.status.classList.add("error"); | |
| setHint("try again", true); | |
| } finally { | |
| clearInterval(loaderTimer); | |
| setBusy(false); | |
| refreshRun(); | |
| } | |
| } | |
| async function safeError(res) { | |
| try { | |
| const j = await res.json(); | |
| return j.error || j.detail || j.message || j.status || ""; | |
| } catch { return ""; } | |
| } | |
| function renderResult(data) { | |
| // Output image: accept several shapes for backend flexibility. | |
| const out = data.image || data.output || data.output_png || data.data_url; | |
| if (out) els.imgOut.src = normalizeDataUrl(out); | |
| if (data.input) els.imgIn.src = normalizeDataUrl(data.input); | |
| if (out) els.frameEmpty.hidden = true; | |
| // status line | |
| const footOk = (data.footprint_ok ?? data.fp_ok ?? data.footprint_perfect); | |
| const bits = []; | |
| if (data.width && data.height) bits.push(`${data.width}×${data.height}`); | |
| if (data.colors != null) bits.push(`${data.colors} colors`); | |
| if (data.latency != null) bits.push(`${Number(data.latency).toFixed(1)}s`); | |
| els.status.textContent = data.status || (bits.length ? bits.join(" · ") : "done"); | |
| els.status.classList.remove("error"); | |
| // badge | |
| if (footOk !== undefined && footOk !== null) { | |
| els.badge.hidden = false; | |
| if (footOk) { | |
| els.badge.textContent = "✓ silhouette preserved"; | |
| els.badge.className = "badge ok"; | |
| } else { | |
| els.badge.textContent = "⚠ silhouette changed"; | |
| els.badge.className = "badge warn"; | |
| } | |
| } | |
| // download | |
| if (out) { | |
| els.download.href = normalizeDataUrl(out); | |
| els.download.download = (state.fileName || "sprite").replace(/\.png$/i, "") + "_pixellock.png"; | |
| els.download.hidden = false; | |
| } | |
| // show the model's raw text output (the "it's a language model" reveal) | |
| if (data.wire && els.wireOut) els.wireOut.textContent = data.wire; | |
| } | |
| function normalizeDataUrl(s) { | |
| if (typeof s !== "string") return s; | |
| if (s.startsWith("data:") || s.startsWith("http") || s.startsWith("/")) return s; | |
| return "data:image/png;base64," + s; // bare base64 | |
| } | |
| /* ------------------------------------------------------------ gallery -> /api/examples */ | |
| const FALLBACK_EXAMPLES = [ | |
| { id: "lava_trident", title: "Lava Trident", theme: "🌋 Molten lava", | |
| prompt: "Molten lava trident: glowing magma prongs with white-hot tips, charred black shaft, ember glow." }, | |
| { id: "cosmic_cat", title: "Cosmic Cat", theme: "🌌 Cosmic galaxy", | |
| prompt: "Cosmic galaxy cat: deep space-purple fur speckled with tiny stars, glowing cyan eyes." }, | |
| { id: "frozen_apple", title: "Frozen Apple", theme: "❄️ Frozen ice", | |
| prompt: "Frozen crystal apple: icy pale blue, frosted surface, glowing white highlight." }, | |
| { id: "golden_rabbit", title: "Golden Rabbit", theme: "🪙 Solid gold", | |
| prompt: "Golden royal rabbit: shimmering gold fur with a soft cream belly, regal and warm." }, | |
| { id: "autumn_bark", title: "Autumn Bark", theme: "🍂 Autumn dusk", | |
| prompt: "Autumn dusk bark: warm amber and crimson tones with deep purple shadows." }, | |
| { id: "diamond_sword", title: "Diamond Sword", theme: "❄️ Frozen ice", | |
| prompt: "Diamond sword: pale icy-cyan crystal blade with white glints and a glowing aqua edge." }, | |
| ]; | |
| async function loadGallery() { | |
| let examples = null; | |
| try { | |
| const res = await fetch("/api/examples", { method: "GET" }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| examples = Array.isArray(data) ? data : (data.examples || null); | |
| } | |
| } catch { /* offline / not wired yet */ } | |
| if (!examples || !examples.length) { | |
| examples = FALLBACK_EXAMPLES; | |
| } | |
| renderGallery(examples); | |
| } | |
| /* ------------------------------------------------------------ starter sprites */ | |
| async function loadAssets() { | |
| if (!els.assetStrip) return; | |
| try { | |
| const res = await fetch("/api/assets", { method: "GET" }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const assets = await res.json(); | |
| renderAssets(Array.isArray(assets) ? assets : []); | |
| } catch { | |
| els.assetStrip.innerHTML = `<span class="asset-empty">starter sprites unavailable</span>`; | |
| } | |
| } | |
| function renderAssets(assets) { | |
| if (!els.assetStrip) return; | |
| els.assetStrip.innerHTML = ""; | |
| if (!assets.length) { | |
| els.assetStrip.innerHTML = `<span class="asset-empty">no starter sprites found</span>`; | |
| return; | |
| } | |
| assets.forEach((asset) => { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "asset-tile"; | |
| btn.innerHTML = ` | |
| <span class="asset-img checker"><img class="pixelated" src="${normalizeDataUrl(asset.input)}" alt=""></span> | |
| <span class="asset-name">${escapeHtml(asset.title || asset.id || "sprite")}</span>`; | |
| btn.addEventListener("click", () => { | |
| state.imageDataUrl = normalizeDataUrl(asset.input); | |
| state.fileName = (asset.id || "starter") + ".png"; | |
| els.dzPreview.src = state.imageDataUrl; | |
| els.dzName.textContent = state.fileName; | |
| els.dzEmpty.hidden = true; | |
| els.dzLoaded.hidden = false; | |
| els.imgIn.src = state.imageDataUrl; | |
| renderSilhouette(state.imageDataUrl); | |
| refreshRun(); | |
| }); | |
| els.assetStrip.appendChild(btn); | |
| }); | |
| } | |
| function renderGallery(examples) { | |
| els.gallery.innerHTML = ""; | |
| if (!examples.length) { | |
| els.gallery.innerHTML = `<p class="gallery-empty">no examples available</p>`; | |
| return; | |
| } | |
| examples.forEach((ex) => { | |
| const inputSrc = ex.input ? normalizeDataUrl(ex.input) : ""; | |
| const outputSrc = ex.output ? normalizeDataUrl(ex.output) : ""; | |
| const card = document.createElement("div"); | |
| card.className = "card"; | |
| card.tabIndex = 0; | |
| card.setAttribute("role", "button"); | |
| card.innerHTML = ` | |
| <div class="card-imgs"> | |
| <div class="cell" data-tag="IN">${inputSrc ? `<img class="pixelated" src="${inputSrc}" alt="">` : ""}</div> | |
| <div class="cell" data-tag="OUT">${outputSrc ? `<img class="pixelated" src="${outputSrc}" alt="">` : ""}</div> | |
| </div> | |
| <p class="card-title">${escapeHtml(ex.title || ex.id || "example")}</p> | |
| <p class="card-prompt">${escapeHtml(ex.prompt || "")}</p>`; | |
| const activate = () => useExample(ex, inputSrc); | |
| card.addEventListener("click", activate); | |
| card.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }); | |
| els.gallery.appendChild(card); | |
| }); | |
| } | |
| function useExample(ex, inputSrc) { | |
| // load the input sprite into the lab | |
| if (inputSrc) { | |
| state.imageDataUrl = inputSrc; | |
| state.fileName = (ex.id || "example") + ".png"; | |
| els.dzPreview.src = inputSrc; | |
| els.dzName.textContent = state.fileName; | |
| els.dzEmpty.hidden = true; | |
| els.dzLoaded.hidden = false; | |
| els.imgIn.src = inputSrc; | |
| renderSilhouette(inputSrc); | |
| } | |
| // prompt + theme | |
| if (ex.prompt) els.prompt.value = ex.prompt; | |
| if (ex.theme) { | |
| const match = THEMES.find((t) => t.key === ex.theme || t.label.toLowerCase() === String(ex.theme).toLowerCase()); | |
| if (match) { | |
| state.theme = match.key; | |
| [...els.chips.children].forEach((c) => c.classList.toggle("active", c.dataset.key === match.key)); | |
| } | |
| } | |
| if (ex.mode) setModeByValue(ex.mode); | |
| refreshRun(); | |
| document.querySelector(".lab")?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| } | |
| function setModeByValue(mode) { | |
| const want = mode === "upscale2x" || /upscale/i.test(mode) ? "upscale2x" : "exact"; | |
| const btn = els.toggle.querySelector(`.toggle-opt[data-mode="${want}"]`); | |
| if (btn) setMode(want, btn); | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, (c) => | |
| ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| } | |
| /* ------------------------------------------------------------ server status */ | |
| async function pingServer() { | |
| try { | |
| const res = await fetch("/api/health", { method: "GET" }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setServer(Boolean(data.model_online), data.model_online ? "online" : "model waking"); | |
| return; | |
| } | |
| } catch { /* fall through */ } | |
| // /api/examples succeeding also means the app server is up | |
| try { | |
| const res = await fetch("/api/examples", { method: "GET" }); | |
| setServer(res.ok, res.ok ? "ui online" : "offline"); | |
| } catch { setServer(false, "offline"); } | |
| } | |
| function setServer(up, label) { | |
| els.serverDot.className = "dot " + (up ? "up" : "down"); | |
| els.serverLbl.textContent = label || (up ? "online" : "offline"); | |
| } | |
| /* ------------------------------------------------------------ wire up DOM */ | |
| function init() { | |
| buildChips(); | |
| // no default theme — it's optional; the user can just type a prompt | |
| // dropzone | |
| els.dropzone.addEventListener("click", (e) => { | |
| if (e.target.closest(".link-btn")) return; // "change" handled separately | |
| els.file.click(); | |
| }); | |
| els.dropzone.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { e.preventDefault(); els.file.click(); } | |
| }); | |
| els.file.addEventListener("change", (e) => loadFile(e.target.files[0])); | |
| els.dzClear.addEventListener("click", (e) => { e.stopPropagation(); clearFile(); }); | |
| ["dragenter", "dragover"].forEach((ev) => | |
| els.dropzone.addEventListener(ev, (e) => { e.preventDefault(); els.dropzone.classList.add("dragover"); })); | |
| ["dragleave", "drop"].forEach((ev) => | |
| els.dropzone.addEventListener(ev, (e) => { e.preventDefault(); els.dropzone.classList.remove("dragover"); })); | |
| els.dropzone.addEventListener("drop", (e) => { | |
| const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; | |
| if (f) loadFile(f); | |
| }); | |
| // paste an image from clipboard | |
| window.addEventListener("paste", (e) => { | |
| const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); | |
| if (item) loadFile(item.getAsFile()); | |
| }); | |
| // mode toggle | |
| els.toggle.querySelectorAll(".toggle-opt").forEach((btn) => | |
| btn.addEventListener("click", () => setMode(btn.dataset.mode, btn))); | |
| // prompt edits clear active chip only if it diverges (keeps it simple: leave chip, prompt is source of truth) | |
| els.prompt.addEventListener("input", refreshRun); | |
| // run | |
| els.run.addEventListener("click", runEdit); | |
| // ctrl/cmd+enter to run | |
| window.addEventListener("keydown", (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && !els.run.disabled) runEdit(); | |
| }); | |
| refreshRun(); | |
| loadAssets(); | |
| loadGallery(); | |
| pingServer(); | |
| } | |
| if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); | |
| else init(); | |