/* ============================================================ 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 ============================================================ */ "use strict"; /* 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 = `${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 = `starter sprites unavailable`; } } function renderAssets(assets) { if (!els.assetStrip) return; els.assetStrip.innerHTML = ""; if (!assets.length) { els.assetStrip.innerHTML = `no starter sprites found`; return; } assets.forEach((asset) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "asset-tile"; btn.innerHTML = ` ${escapeHtml(asset.title || asset.id || "sprite")}`; 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 = ``; 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 = `
${inputSrc ? `` : ""}
${outputSrc ? `` : ""}

${escapeHtml(ex.title || ex.id || "example")}

${escapeHtml(ex.prompt || "")}

`; 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();