pixellock / static /app.js
solarkyle's picture
Deploy PixelLock GPU Space (llama.cpp + GGUF + GBNF + custom UI)
eb90246 verified
Raw
History Blame Contribute Delete
19.3 kB
/* ============================================================
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 = `<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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();