| |
| |
| |
| |
| |
| |
| |
| |
| "use strict"; |
|
|
| |
| |
| |
| 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"), |
| }; |
|
|
| |
| |
| 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, |
| fileName: null, |
| theme: null, |
| mode: "exact", |
| busy: false, |
| }; |
|
|
| |
| 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) { |
| state.theme = null; |
| [...els.chips.children].forEach((c) => c.classList.remove("active")); |
| } else { |
| state.theme = t.key; |
| els.prompt.value = t.prompt; |
| [...els.chips.children].forEach((c) => c.classList.toggle("active", c.dataset.key === t.key)); |
| } |
| els.prompt.dispatchEvent(new Event("input")); |
| } |
|
|
| |
| 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; |
| |
| 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(); |
| } |
|
|
| |
| 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"; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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, |
| theme: state.theme, |
| prompt: els.prompt.value.trim(), |
| mode: state.mode, |
| }; |
|
|
| 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) { |
| |
| 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; |
|
|
| |
| 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"); |
|
|
| |
| 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"; |
| } |
| } |
|
|
| |
| if (out) { |
| els.download.href = normalizeDataUrl(out); |
| els.download.download = (state.fileName || "sprite").replace(/\.png$/i, "") + "_pixellock.png"; |
| els.download.hidden = false; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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 { } |
|
|
| if (!examples || !examples.length) { |
| examples = FALLBACK_EXAMPLES; |
| } |
| renderGallery(examples); |
| } |
|
|
| |
| 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) { |
| |
| 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); |
| } |
| |
| 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])); |
| } |
|
|
| |
| 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 { } |
| |
| 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"); |
| } |
|
|
| |
| function init() { |
| buildChips(); |
| |
|
|
| |
| els.dropzone.addEventListener("click", (e) => { |
| if (e.target.closest(".link-btn")) return; |
| 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); |
| }); |
| |
| window.addEventListener("paste", (e) => { |
| const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); |
| if (item) loadFile(item.getAsFile()); |
| }); |
|
|
| |
| els.toggle.querySelectorAll(".toggle-opt").forEach((btn) => |
| btn.addEventListener("click", () => setMode(btn.dataset.mode, btn))); |
|
|
| |
| els.prompt.addEventListener("input", refreshRun); |
|
|
| |
| els.run.addEventListener("click", runEdit); |
| |
| 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(); |
|
|