// Face Enhancer (MegaPotato): enlarge a photo ×2, ×4 or ×8, box the faces on the whole photo and compare each one // before and after, same spot, same scale. The boxes are placed by the visitor: nothing here detects faces. import { DemoError, Demo, INFO_KINDS, NBSP, dims, errorCopy, escapeHtml, isResumable, limitActions, signupBonus, thumbnail, usd } from "./core.js"; const SPACE_URL = "https://huggingface.co/spaces/MegaPotatoAI/Face-Enhancer"; const COPY = { verb: "enhance", runs: "runs", spaceUrl: SPACE_URL }; const MAX_FACES = 12; const SIZE_STEP = 1.2; const $ = (sel) => document.querySelector(sel); const els = { notice: $("#notice"), file: $("#file"), thumb: $("#drop-thumb"), fileLabel: $("#file-label"), fileMeta: $("#file-meta"), outSize: $("#out-size"), plan: $("#plan"), run: $("#run"), status: $("#status"), quota: $("#quota"), caption: $("#caption"), examples: $("#examples"), credits: $("#credits"), count: $("#face-count"), facesEmpty: $("#faces-empty"), clear: $("#clear"), smaller: $("#box-smaller"), larger: $("#box-larger"), pairs: $("#pairs"), }; // selection: the chosen photo; quote: the plan for it (a new quote never cancels a selection still being read). // subject: what the board shows ("example", "file" before its run, "result"). selectionKey names the chosen photo; // runKeys remembers which photo each run belongs to, so a resumed run never inherits another photo's boxes. const state = { file: null, info: null, thumbUrl: null, factor: 4, plan: null, selection: 0, selectionKey: null, runKeys: {}, quote: 0, view: 0, presenting: false, examples: [], exampleError: null, tick: null, subject: null }; const resumeKey = () => (demo.active ? (state.runKeys[demo.active.requestId] ?? `run-${demo.active.requestId}`) : null); const PICK = { label: els.fileLabel.textContent, meta: els.fileMeta.innerHTML }; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const phone = () => matchMedia("(max-width: 760px)").matches; // An image still loading or decoding → fails its pending decoded() promise when the image is emptied // (removing src aborts the load without firing load or error). const aborts = new WeakMap(); /** Load and decode `src` into a new image; the element is listed in `staged` first, so it can be emptied if the load is abandoned. */ const decoded = (src, alt, staged) => { if (!src) return Promise.resolve(null); const img = new Image(); img.alt = alt; img.draggable = false; staged.push(img); return new Promise((resolve, reject) => { aborts.set(img, () => reject(new Error("aborted"))); img.onload = () => img.decode().then(() => resolve(img), reject); // a rejected decode() is a failed display img.onerror = () => reject(new Error("load")); img.src = src; }); }; /** Give an image element's picture up (aborts a load still in flight, frees a decoded one). */ const empty = (img) => { if (!img) return; aborts.get(img)?.(); aborts.delete(img); img.removeAttribute("src"); }; /** An object URL has done its job once the image is decoded: the element keeps the picture, the blob can go. */ function revoke(url) { if (url && url.startsWith("blob:")) URL.revokeObjectURL(url); } // ---------------------------------------------------------------------------- the board: the photo and its faces /** * Coordinates. The comparison space is the result: W × H pixels. A face is { u, v, s }: its centre as a fraction * of the photo (so it survives a change of output size) and the side of its square box in result pixels. The * overview shows the photo with object-fit: contain inside an element of the same aspect ratio, so the box drawn * at (sx·k, sy·k) and the crops drawn from (sx, sy, s) are the same region; k comes from the element's own size. * * Memory. A visitor's result can be 16 400 px on a side, about 1 GB once decoded, so only one such result is ever * held: before a new result is fetched (a rerun, a resume) the old one is released (element emptied, URL revoked); * an example or a newly chosen photo (both small) is decoded first and the old result released once the new view * is on screen, so a failed switch leaves the old result in place. Images are decoded one at a time, Before then * After, and an abandoned load is emptied at once. The compressed blob is let go as soon as the picture is decoded. * The face crops are small canvases, redrawn on demand. */ class Board { constructor() { this.overview = $("#screen"); this.imgAfter = this.overview.querySelector(".is-after"); this.imgBefore = this.overview.querySelector(".is-before"); this.marks = this.overview.querySelector(".marks"); this.cursorEl = this.overview.querySelector(".cursor"); this.pairs = els.pairs; this.W = 0; this.H = 0; this.factor = 1; this.before = null; // decoded of the input (drawn enlarged, plainly) or null after a reload this.after = null; // decoded of the result, or null before the run this.faces = []; this.key = null; // which photo the boxes belong to (an example, a chosen file, or that file's run) this.lastSide = 0; // the size a new box gets: the default for this photo, or the last size the visitor set this.cursor = { u: 0.5, v: 0.5 }; this.drag = null; this.sizing = null; this.token = 0; this.staged = []; // images of the show() in flight, so a newer call or a failure can empty them this.raf = 0; this.bind(); const ro = new ResizeObserver(() => this.schedule()); ro.observe(this.overview); ro.observe(this.pairs); } geom() { const vw = this.overview.clientWidth; const vh = this.overview.clientHeight; const k = Math.min(vw / this.W, vh / this.H); return { k, ox: (vw - this.W * k) / 2, oy: (vh - this.H * k) / 2 }; } /** A new box: a seventh of the short side, about a head in a group photo; the visitor resizes from there. */ defaultSide() { return Math.round(Math.min(this.W, this.H) / 7); } clampSide(s) { const short = Math.min(this.W, this.H); return clamp(Math.round(s), Math.min(short, Math.max(24, Math.round(short / 40))), short); } blank(cls, alt) { const img = document.createElement("img"); img.className = `overview-image ${cls}`; img.alt = alt; img.draggable = false; img.hidden = true; return img; } /** Does the board hold a visitor's full-size result (an object URL), as opposed to an example's small file? */ holdsUserResult() { return !!this.after && this.after.src.startsWith("blob:"); } /** * Drop a visitor's result from memory before another picture is fetched or decoded. The photo stays as its * Before, the boxes stay; the After side waits for the next result. */ releaseUserResult() { if (!this.holdsUserResult()) return; const old = this.after; const url = old.src; const blank = this.blank("is-after", "After"); old.replaceWith(blank); empty(old); // the element gives its picture up this.imgAfter = blank; this.after = null; revoke(url); this.setWhole(this.before ? "before" : null); this.renderLabels(); this.schedule(); } /** Empty the images a show() still has in flight (a newer call took over, or the call failed). */ discardStaged() { for (const img of this.staged) empty(img); this.staged = []; } /** * Show a photo: `before` (input) and `after` (result) URLs, either may be missing; `width`/`height` of the result; * `key` names the photo. The boxes are kept only when the photo already on the board has the same key (the same * photo, now with its result, or run again); otherwise `faces` as [{u, v, s}] replace them. The board changes * all at once: a failed decode or first draw leaves the previous picture, size, key and boxes in place. Images * are decoded one after the other, and never more than one show() decodes at a time. */ async show({ before, after, width, height, factor, key, faces = [] }) { const token = ++this.token; const keepFaces = !!key && key === this.key; this.discardStaged(); // whatever an earlier call was still decoding stops now const staged = []; this.staged = staged; const pair = [null, null]; try { pair[0] = await decoded(before, "Before", staged); if (token !== this.token) throw new Error("superseded"); pair[1] = await decoded(after, "After", staged); } catch { if (this.staged === staged) this.discardStaged(); else for (const img of staged) empty(img); return token !== this.token ? "superseded" : "error"; } if (token !== this.token) { for (const img of staged) empty(img); return "superseded"; } this.staged = []; // the board as it is, so a failed first draw can put everything back const prev = { imgBefore: this.imgBefore, imgAfter: this.imgAfter, before: this.before, after: this.after, W: this.W, H: this.H, factor: this.factor, key: this.key, lastSide: this.lastSide, faces: this.faces.map((f) => ({ u: f.u, v: f.v, s: f.s })), whole: this.after && !this.imgAfter.hidden ? "after" : this.before && !this.imgBefore.hidden ? "before" : null, ar: this.overview.style.getPropertyValue("--ar"), }; const swap = (img, cls, alt, current) => { const next = img ?? this.blank(cls, alt); if (img) img.className = `overview-image ${cls}`; current.replaceWith(next); return next; }; this.imgBefore = swap(pair[0], "is-before", "Before", this.imgBefore); this.imgAfter = swap(pair[1], "is-after", "After", this.imgAfter); this.before = pair[0]; this.after = pair[1]; const prevW = this.W; this.W = width; this.H = height; this.factor = factor; this.key = key ?? null; this.overview.style.setProperty("--ar", (width / height).toFixed(4)); this.setWhole(this.after ? "after" : this.before ? "before" : null); if (keepFaces) { if (prevW && prevW !== width) this.rescale(width / prevW); this.renderFaces(); } else { this.lastSide = this.defaultSide(); this.setFaces(faces); } try { this.apply(); // on screen: the pictures that were replaced (a visitor's earlier result among them) are released now if (prev.imgBefore !== this.imgBefore) empty(prev.imgBefore); if (prev.imgAfter !== this.imgAfter) empty(prev.imgAfter); return "shown"; } catch { // put the previous board back: pictures, size, key, boxes for (const img of pair) empty(img); this.imgBefore.replaceWith(prev.imgBefore); this.imgAfter.replaceWith(prev.imgAfter); Object.assign(this, { imgBefore: prev.imgBefore, imgAfter: prev.imgAfter, before: prev.before, after: prev.after, W: prev.W, H: prev.H, factor: prev.factor, key: prev.key, lastSide: prev.lastSide }); if (prev.ar) this.overview.style.setProperty("--ar", prev.ar); this.faces = prev.faces; this.renderFaces(); this.setWhole(prev.whole); try { this.apply(); } catch { // the previous board drew before; if it cannot now, the boxes simply wait for the next draw } return "error"; } } /** The same photo, another output size (the factor or the plan changed before the run): boxes keep their place. */ resize(width, height, factor) { if (!this.W) return; const ratio = width / this.W; this.W = width; this.H = height; this.factor = factor; this.overview.style.setProperty("--ar", (width / height).toFixed(4)); this.rescale(ratio); this.renderLabels(); this.schedule(); } rescale(ratio) { for (const f of this.faces) f.s = this.clampSide(f.s * ratio); this.lastSide = this.clampSide(this.lastSide * ratio); } /** Which whole picture is up: "before", "after", or null for none. */ setWhole(which) { if (which === "before" && !this.before) return; if (which === "after" && !this.after) return; this.imgBefore.hidden = which !== "before"; this.imgAfter.hidden = which !== "after"; document.querySelectorAll("[data-whole]").forEach((b) => { b.setAttribute("aria-pressed", String(b.dataset.whole === which)); b.disabled = b.dataset.whole === "before" ? !this.before : !this.after; b.title = b.disabled ? (b.dataset.whole === "before" ? "The original is not kept after a reload." : "Shows after the run.") : ""; }); } // ---------------------------------------------------------------- faces setFaces(list) { this.faces = list.slice(0, MAX_FACES).map(({ u, v, s }) => ({ u: clamp(u, 0, 1), v: clamp(v, 0, 1), s: this.clampSide(s || this.lastSide) })); this.renderFaces(); } add(u, v) { if (!this.W || this.faces.length >= MAX_FACES) return; this.faces.push({ u, v, s: this.clampSide(this.lastSide || this.defaultSide()) }); this.renderFaces(); this.schedule(); } remove(i) { this.faces.splice(i, 1); this.renderFaces(); this.schedule(); } clear() { this.faces = []; this.renderFaces(); } setSide(f, s) { f.s = this.clampSide(s); this.lastSide = f.s; this.schedule(); } scaleAll(ratio) { if (!this.W) return; for (const f of this.faces) f.s = this.clampSide(f.s * ratio); this.lastSide = this.clampSide((this.lastSide || this.defaultSide()) * ratio); this.schedule(); } renderFaces() { const n = this.faces.length; this.marks.replaceChildren( ...this.faces.map((f, i) => { const b = document.createElement("button"); b.type = "button"; b.className = "mark"; b.dataset.i = String(i); b.setAttribute("aria-label", `Face ${i + 1}. Drag to move it; arrow keys nudge it; plus and minus resize it; Delete removes it.`); const box = document.createElement("span"); box.className = "mark-box"; box.dataset.n = String(i + 1); box.setAttribute("aria-hidden", "true"); // decoration: the button itself is announced const handle = document.createElement("span"); handle.className = "mark-handle"; box.append(handle); b.append(box); f.mark = b; f.box = box; return b; }), ); this.pairs.replaceChildren( ...this.faces.map((f, i) => { const li = document.createElement("li"); li.className = "pair"; li.innerHTML = `
${i + 1} BeforeAfter
`; const tiles = li.querySelectorAll(".tile"); [f.cB, f.cA] = [tiles[0].querySelector("canvas"), tiles[1].querySelector("canvas")]; [f.noteB, f.noteA] = [tiles[0].querySelector(".tile-note"), tiles[1].querySelector(".tile-note")]; f.x = li.querySelector(".x"); return li; }), ); this.pairs.querySelectorAll(".remove").forEach((b) => b.addEventListener("click", () => this.remove(Number(b.dataset.i)))); this.renderLabels(); els.count.textContent = n ? String(n) : ""; els.facesEmpty.hidden = n > 0; this.pairs.hidden = n === 0; els.clear.disabled = n === 0; els.smaller.disabled = n === 0; els.larger.disabled = n === 0; } renderLabels() { for (const f of this.faces) { f.noteB.hidden = !!this.before; f.noteA.hidden = !!this.after; f.x.textContent = this.after ? `×${this.factor}` : ""; } } // ---------------------------------------------------------------- drawing schedule() { if (!this.W || this.raf) return; this.raf = requestAnimationFrame(() => { this.raf = 0; this.apply(); }); } apply() { if (!this.W) return; const { k, ox, oy } = this.geom(); const tile = this.faces[0]?.cA.parentElement.clientWidth || 0; const dpr = Math.min(2, window.devicePixelRatio || 1); const kb = this.before ? this.before.naturalWidth / this.W : 0; const draw = (c, img, kk, sx, sy, s) => { if (!tile) return; const px = Math.round(tile * dpr); if (c.width !== px || c.height !== px) { c.width = px; c.height = px; } const ctx = c.getContext("2d"); ctx.clearRect(0, 0, px, px); if (!img) return; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, sx * kk, sy * kk, s * kk, s * kk, 0, 0, px, px); }; for (const f of this.faces) { const s = this.clampSide(f.s); // the window: centred on the face, kept inside the photo const sx = clamp(Math.round(f.u * this.W - s / 2), 0, this.W - s); const sy = clamp(Math.round(f.v * this.H - s / 2), 0, this.H - s); f.win = { sx, sy, s }; const size = s * k; Object.assign(f.mark.style, { left: `${ox + (sx + s / 2) * k}px`, top: `${oy + (sy + s / 2) * k}px`, width: `${Math.max(44, size)}px`, height: `${Math.max(44, size)}px` }); Object.assign(f.box.style, { width: `${size}px`, height: `${size}px` }); f.box.classList.toggle("is-small", size < 56); draw(f.cB, this.before, kb, sx, sy, s); draw(f.cA, this.after, 1, sx, sy, s); if (tile) f.cA.title = f.cB.title = `${s} × ${s} px of the result, shown at ${Math.round((tile / s) * 100)}%`; } Object.assign(this.cursorEl.style, { left: `${ox + this.cursor.u * this.W * k}px`, top: `${oy + this.cursor.v * this.H * k}px` }); } // ---------------------------------------------------------------- pointer and keyboard addAt(clientX, clientY) { if (!this.W) return; const r = this.overview.getBoundingClientRect(); const { k, ox, oy } = this.geom(); const u = (clientX - r.left - ox) / (this.W * k); const v = (clientY - r.top - oy) / (this.H * k); if (u < 0 || u > 1 || v < 0 || v > 1) return; this.add(u, v); } showCursor(on) { this.cursorEl.hidden = !on || !this.W; } bind() { const ov = this.overview; // a click or a tap (no real movement) on the photo adds a face; swipes scroll the page let tap = null; ov.addEventListener("pointerdown", (e) => { if (e.target.closest(".mark")) return; tap = { x: e.clientX, y: e.clientY, id: e.pointerId }; }); ov.addEventListener("pointercancel", () => (tap = null)); ov.addEventListener("pointerup", (e) => { if (tap && tap.id === e.pointerId && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) < 8) this.addAt(e.clientX, e.clientY); tap = null; }); // dragging a box moves that face; dragging its corner resizes it around its centre this.marks.addEventListener("pointerdown", (e) => { const m = e.target.closest(".mark"); if (!m) return; e.preventDefault(); try { m.setPointerCapture(e.pointerId); } catch { // no active pointer (synthetic event): the move still follows the pointer while it stays over the box } const face = this.faces[Number(m.dataset.i)]; if (e.target.closest(".mark-handle")) this.sizing = { face, id: e.pointerId, x: e.clientX, y: e.clientY, s0: face.s }; else this.drag = { face, id: e.pointerId, x: e.clientX, y: e.clientY }; }); this.marks.addEventListener("pointermove", (e) => { const { k } = this.W ? this.geom() : { k: 1 }; const z = this.sizing; if (z && z.id === e.pointerId) { // the corner follows the pointer: moving it out by d grows the side by 2d (the centre stays) this.setSide(z.face, z.s0 + ((e.clientX - z.x + (e.clientY - z.y)) / k)); return; } const d = this.drag; if (!d || d.id !== e.pointerId) return; d.face.u = clamp(d.face.u + (e.clientX - d.x) / (k * this.W), 0, 1); d.face.v = clamp(d.face.v + (e.clientY - d.y) / (k * this.H), 0, 1); d.x = e.clientX; d.y = e.clientY; this.schedule(); }); const stop = (e) => { if (this.drag?.id === e.pointerId) this.drag = null; if (this.sizing?.id === e.pointerId) this.sizing = null; }; this.marks.addEventListener("pointerup", stop); this.marks.addEventListener("pointercancel", stop); this.marks.addEventListener("keydown", (e) => { const m = e.target.closest(".mark"); if (!m) return; const i = Number(m.dataset.i); const f = this.faces[i]; const step = e.shiftKey ? 0.05 : 0.01; const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }; if (moves[e.key]) { f.u = clamp(f.u + moves[e.key][0], 0, 1); f.v = clamp(f.v + moves[e.key][1], 0, 1); this.schedule(); } else if (e.key === "+" || e.key === "=") this.setSide(f, f.s * SIZE_STEP); else if (e.key === "-" || e.key === "_") this.setSide(f, f.s / SIZE_STEP); else if (e.key === "Delete" || e.key === "Backspace") { this.remove(i); ov.focus(); } else return; e.preventDefault(); }); // keyboard on the photo itself: a cursor to place faces with ov.addEventListener("keydown", (e) => { if (e.target !== ov || !this.W || e.altKey || e.ctrlKey || e.metaKey) return; const step = e.shiftKey ? 0.05 : 0.01; const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }; if (moves[e.key]) { this.cursor.u = clamp(this.cursor.u + moves[e.key][0], 0, 1); this.cursor.v = clamp(this.cursor.v + moves[e.key][1], 0, 1); this.showCursor(true); this.schedule(); } else if (e.key === "Enter" || e.key === " ") this.add(this.cursor.u, this.cursor.v); else if (e.key === "Backspace" || e.key === "Delete") { if (this.faces.length) this.remove(this.faces.length - 1); } else return; e.preventDefault(); }); ov.addEventListener("focus", () => this.showCursor(ov.matches(":focus-visible"))); ov.addEventListener("blur", () => this.showCursor(false)); } } const board = new Board(); // ---------------------------------------------------------------------------- status and notices: one line each, in the band const demo = new Demo({ slug: "face-enhancer", onQuota: () => update(), turnstileEl: "#turnstile", turnstileTheme: "dark", // the widget sits in the green band onPhase: (phase, info) => { update(); const text = { preparing: "Preparing…", uploading: "Uploading…", queued: "Waiting for a GPU…", running: "Enhancing…", loading: "Loading result…" }[phase] ?? "Working…"; status({ busy: true, text, at: info.at ?? demo.active?.at, note: info.cold ? "The first run after a quiet spell can take 2–3 minutes." : "" }); }, }); /** `kind` names what the line is about, so that only its owner clears it later. */ function status({ busy = false, error = false, text, note = "", actions = [], at = null, kind = "" }) { clearInterval(state.tick); els.status.hidden = false; els.status.dataset.state = error ? "error" : busy ? "busy" : "idle"; els.status.dataset.kind = kind; const acts = actions.length ? `${actions .map((a, i) => a.href ? `${a.label}` : ``, ) .join("")}` : ""; els.status.innerHTML = `${busy ? '' : ""}${text}${note ? ` ${note}` : ""}${acts}`; els.status.querySelectorAll("button[data-i]").forEach((b) => b.addEventListener("click", () => actions[Number(b.dataset.i)].run())); if (at) { const el = els.status.querySelector(".elapsed"); const upd = () => (el.textContent = ` · ${Math.round((Date.now() - at) / 1000)}${NBSP}s`); upd(); state.tick = setInterval(upd, 1000); } } function hideStatus(kind) { if (els.status.dataset.kind === kind) els.status.hidden = true; } function notice(text, kind = "") { els.notice.hidden = !text; els.notice.textContent = text || ""; els.notice.dataset.kind = text ? kind : ""; } function clearNotice(kind) { if (els.notice.dataset.kind === kind) notice(""); } function showError(err) { const [title, note] = errorCopy(err, demo, COPY); if (INFO_KINDS.has(err.kind) && !demo.active?.op) { // no runs left here: the way on is the product, as a button next to the message if (err.kind === "daily" || err.kind === "hourly") return showLimit(err.retry_at ?? demo.limitedUntil); if (err.kind === "budget" || err.kind === "spend_limit") return showPaused(err.kind); // the service's pause, in its own words return status({ text: title, note, kind: "info" }); } const actions = []; if (isResumable(err) && demo.active) actions.push({ label: "Resume", primary: true, run: () => go(() => demo.continue(), resumeKey()) }); if (err.kind === "download" && demo.active?.op) actions.push({ label: "Load again", primary: true, run: () => go(() => demo.continue(), resumeKey()) }); if (demo.active && actions.length) actions.push({ label: "Start over", run: () => { demo.forget(); els.status.hidden = true; update(); }, }); status({ error: true, text: title, note, actions, kind: "run" }); } // ---------------------------------------------------------------------------- photo and plan async function selectFile(file) { if (!file || demo.busy) return; const sel = ++state.selection; state.quote++; // a quote still running is for the previous photo state.file = null; state.info = null; state.plan = null; demo.dropPrepared(); update(); if (!demo.active) els.status.hidden = true; let info; try { info = await demo.inspect(file); } catch (err) { if (sel !== state.selection) return; clearPhoto(); return planError(err); } const thumb = await thumbnail(file, info, 120); if (sel !== state.selection) return thumb && URL.revokeObjectURL(thumb); if (state.thumbUrl) URL.revokeObjectURL(state.thumbUrl); state.file = file; state.info = info; state.thumbUrl = thumb; els.thumb.hidden = !thumb; if (thumb) els.thumb.src = thumb; els.fileLabel.textContent = "Change photo"; els.fileMeta.innerHTML = `${escapeHtml(file.name || "Pasted photo")} · ${dims(info.width, info.height)}`; state.selectionKey = `file-${sel}`; refreshPlan(); // The photo goes on the board right away: faces picked now are compared as soon as the result arrives. const view = ++state.view; const url = URL.createObjectURL(file); const outcome = await board.show({ before: url, after: null, width: info.width * state.factor, height: info.height * state.factor, factor: state.factor, key: state.selectionKey }); revoke(url); // decoded (or not): the element has it, the URL is not needed if (view !== state.view || sel !== state.selection) return; if (outcome !== "shown") { // the header said image, the browser could not decode it: no photo, the board keeps what it showed clearPhoto(); return planError(new DemoError("unreadable")); } // shown: the selection, its plan (if the quote is already in) and the caption are committed together state.subject = "file"; markExample(null); commitFileView(); } /** The board shows the chosen photo before its run: size it by the plan when there is one, caption it to match. */ function commitFileView() { const { info, plan } = state; if (!info || state.subject !== "file" || board.key !== state.selectionKey) return; if (plan) { board.resize(plan.out_w, plan.out_h, plan.factor); setCaption("Your photo", `${dims(plan.in_w, plan.in_h)} → ${dims(plan.out_w, plan.out_h)} · ×${plan.factor} after the run`); } else { board.resize(info.width * state.factor, info.height * state.factor, state.factor); setCaption("Your photo", `${dims(info.width, info.height)} → ${dims(info.width * state.factor, info.height * state.factor)} · ×${state.factor} after the run`); } } /** No photo: the file, its size, its plan, its preview and any quote still on its way go together. */ function clearPhoto() { if (state.thumbUrl) URL.revokeObjectURL(state.thumbUrl); state.quote++; Object.assign(state, { file: null, info: null, plan: null, thumbUrl: null, selectionKey: null }); els.thumb.hidden = true; els.thumb.removeAttribute("src"); els.fileLabel.textContent = PICK.label; els.fileMeta.innerHTML = PICK.meta; els.outSize.textContent = "—"; update(); } function setCaption(title, meta) { els.caption.innerHTML = `${escapeHtml(title)}${meta}`; } function planError(err, current = () => true) { if (!current()) return; const [title, note] = errorCopy(err, demo, COPY); els.outSize.textContent = "—"; els.plan.innerHTML = `${title} ${note}`; state.plan = null; update(); } function applyPlan(plan) { state.plan = plan; const price = demo.config.price; const api = plan.chips ? ` · ${plan.chips}${NBSP}chip${plan.chips === 1 ? "" : "s"} via the API${price ? ` (${usd(plan.chips * price.min)}–${usd(plan.chips * price.max)})` : ""}` : ""; const reduced = plan.browserLimited ? ` · reduced from ${dims(plan.source.width, plan.source.height)} to what this browser can prepare` : plan.resized ? ` · reduced from ${dims(plan.source.width, plan.source.height)}` : ""; els.outSize.textContent = dims(plan.out_w, plan.out_h); els.plan.textContent = `${dims(plan.in_w, plan.in_h)} → ×${plan.factor}${reduced}${api}`; commitFileView(); // only touches the board when it shows this very photo } async function refreshPlan() { const { file, info } = state; if (!file || !info) return; if (demo.loginNeeded()) demo.noteLoginWall(); const sel = state.selection; const q = ++state.quote; const current = () => q === state.quote && sel === state.selection && state.file === file; state.plan = null; update(); if (!demo.config) return; // start() quotes again once the service answers try { const plan = await demo.quote(info.width, info.height, state.factor); if (!current()) return; applyPlan(plan); } catch (err) { planError(err, current); } update(); } function update() { const login = demo.loginNeeded(); const limited = demo.limited(); const working = demo.busy || state.presenting; const pending = !!demo.active && !working; els.run.disabled = working || (!pending && (!state.file || !state.plan || demo.configFailed || limited)); els.run.textContent = working ? "Enhancing…" : pending ? "Resume" : login ? "Log in to enhance" : limited ? (demo.limitState()?.kind === "paused" ? "Paused right now" : "No runs left today") : "Enhance faces"; syncLimitNotice(); } /** * A visitor with no runs left here (today's are used, or a run was just refused) sees where to continue, unless the * status line is busy with something else: a login notice, a run, or a result. */ function syncLimitNotice() { const idle = !!demo.config && !demo.loginNeeded() && !demo.active && !demo.busy && !state.presenting; const limit = idle ? demo.limitState() : null; const mine = !els.status.hidden && (els.status.dataset.kind === "limit" || els.status.dataset.kind === "paused"); if (limit) { if (!els.status.hidden && !mine) return; // a login notice, a run or a result is there if (limit.kind === "quota") showLimit(limit.retryAt); else showPaused(limit.reason); } else if (mine && !demo.limited()) { els.status.hidden = true; // runs are possible again } } /** The factor the visitor has chosen (2, 4 or 8); the site links carry it. */ function limitScale() { return state.factor; } /** * The limit line: the message, when a run comes back, the offer, and the product as the primary button. Drawn again * only when something it shows changed (the time, the bonus, the factor in its links). */ function showLimit(retryAt) { const scale = limitScale(); const sig = `${scale ?? ""}|${retryAt ?? ""}|${signupBonus(demo) ?? ""}`; if (!els.status.hidden && els.status.dataset.kind === "limit" && els.status.dataset.sig === sig) return; const [text, note] = errorCopy(new DemoError("daily", { retry_at: retryAt || null }), demo, { ...COPY, scale }); redraw(sig, { text, note, actions: limitActions(demo, scale), kind: "limit" }); } /** The service refused for its own reasons (budget, spend limit): say that, not that the visitor's runs are used. */ function showPaused(reason) { if (!els.status.hidden && els.status.dataset.kind === "paused" && els.status.dataset.sig === reason) return; const [text, note] = errorCopy(new DemoError(reason), demo, COPY); redraw(reason, { text, note, kind: "paused" }); } /** Draw a limit or pause line; a link that had the focus and is still the same link keeps it. */ function redraw(sig, line) { const links = [...els.status.querySelectorAll("a")]; const at = links.indexOf(document.activeElement); const href = at >= 0 ? links[at].getAttribute("href") : null; status(line); els.status.dataset.sig = sig; const again = at >= 0 ? els.status.querySelectorAll("a")[at] : null; if (again && again.getAttribute("href") === href) again.focus(); } // ---------------------------------------------------------------------------- run /** Run `step` for the photo named `key` (the chosen file, or the photo a resumed run belongs to) and show its result. */ async function go(step, key) { board.releaseUserResult(); // the previous result goes before the next one is fetched update(); try { const done = await step(); state.presenting = true; update(); await present(done, key); } catch (err) { const e = err instanceof DemoError ? err : new DemoError("server"); // The browser could prepare only a smaller image: show that plan; the next press sends it. if (e.kind === "reduced" && e.plan && e.file === state.file && e.from === state.plan) applyPlan(e.plan); showError(e); } finally { if (key && demo.active?.requestId) state.runKeys[demo.active.requestId] = key; // a later Resume finds its photo state.presenting = false; update(); } } async function present(done, key) { const view = ++state.view; const op = done.op; // The boxes stay only when the board shows this very photo (picked before the run, or the same photo run again). const outcome = await board.show({ before: done.beforeUrl, after: done.afterUrl, width: op.out_w, height: op.out_h, factor: op.factor, key }); if (outcome !== "shown" || view !== state.view) { // Not on screen: the run stays saved, so it can be loaded again (Load again, Resume, or after a reload). revoke(done.afterUrl); if (outcome === "error") throw new DemoError("download"); if (demo.active?.op?.op === op.op) status({ text: "Your result is ready.", actions: [{ label: "Show it", primary: true, run: () => go(() => demo.continue(), key) }] }); return; } demo.shown(done); // decoded and on screen: the blobs behind the URLs can go revoke(done.afterUrl); revoke(done.beforeUrl); state.subject = "result"; markExample(null); setCaption("Your photo", `${dims(op.in_w, op.in_h)} → ${dims(op.out_w, op.out_h)} · ×${op.factor}`); const left = demo.runsLeft(); const actions = [{ label: "Open megapotato.app ↗", href: demo.site("/tool", "get-png", op.factor) }]; if (phone()) actions.push({ label: "View result", run: () => $(".photo").scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }) }); status({ text: `Ready${left !== null ? ` · ${left} run${left === 1 ? "" : "s"} left today` : ""}`, note: `${board.faces.length ? "" : "Tap a face on the photo to compare it. "}Upload your photo on megapotato.app to download it.`, actions, kind: "run", }); } // ---------------------------------------------------------------------------- examples async function loadExamples() { try { state.examples = await (await fetch("examples/examples.json")).json(); } catch { state.examples = []; } els.examples.innerHTML = state.examples .map( (ex, i) => `
  • `, ) .join(""); els.credits.innerHTML = state.examples .map((ex) => `
  • ${escapeHtml(ex.credit)} Source. ${escapeHtml(ex.licence ?? "")}
  • `) .join(""); els.examples.querySelectorAll(".example").forEach((b) => b.addEventListener("click", () => showExample(Number(b.dataset.i)))); } function markExample(index) { els.examples.querySelectorAll(".example").forEach((b, i) => b.setAttribute("aria-pressed", String(i === index))); } async function showExample(i) { const ex = state.examples[i]; if (!ex) return; const view = ++state.view; const chip = els.examples.querySelectorAll(".example")[i]; chip?.setAttribute("data-loading", "true"); // boxes: [cx, cy, side] in pixels of the result const faces = (ex.boxes ?? []).map(([cx, cy, s]) => ({ u: cx / ex.out[0], v: cy / ex.out[1], s })); const outcome = await board.show({ before: ex.input, after: ex.result, width: ex.out[0], height: ex.out[1], factor: ex.factor, key: `example-${ex.id}`, faces }); chip?.removeAttribute("data-loading"); if (view !== state.view) return; // something newer took the board if (outcome !== "shown") { // the board keeps what it showed; say so, with a way to try again (unless a run is reporting in the same line) const text = "This example could not be loaded."; state.exampleError = i; if (demo.busy || state.presenting || demo.active) notice(`${text} Check your connection and try again.`, "example-error"); else status({ error: true, text, note: "Check your connection and try again.", actions: [{ label: "Try again", primary: true, run: () => showExample(i) }], kind: "example-error" }); return; } // shown: commit the selection, the caption and the subject; an earlier example error is over if (state.exampleError !== null) { state.exampleError = null; hideStatus("example-error"); clearNotice("example-error"); } markExample(i); state.subject = "example"; setCaption(ex.title, `${dims(ex.in[0], ex.in[1])} → ${dims(ex.out[0], ex.out[1])} · ×${ex.factor}`); } // ---------------------------------------------------------------------------- wiring function wire() { els.file.addEventListener("change", () => selectFile(els.file.files?.[0])); // the whole page is the drop target let depth = 0; const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes("Files"); document.addEventListener("dragenter", (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth += 1; document.body.dataset.dragover = "true"; }); document.addEventListener("dragover", (e) => { if (!hasFiles(e)) return; e.preventDefault(); }); document.addEventListener("dragleave", (e) => { if (!hasFiles(e)) return; depth = Math.max(0, depth - 1); if (!depth) delete document.body.dataset.dragover; }); document.addEventListener("drop", (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth = 0; delete document.body.dataset.dragover; selectFile(e.dataTransfer?.files?.[0]); }); document.addEventListener("paste", (e) => { const item = [...(e.clipboardData?.items ?? [])].find((it) => it.kind === "file" && it.type.startsWith("image/")); if (item) selectFile(item.getAsFile()); }); document.querySelectorAll('input[name="factor"]').forEach((r) => r.addEventListener("change", () => { state.factor = Number(r.value); state.plan = null; commitFileView(); // the photo on the board, if it is the chosen one, previews the new size at once update(); refreshPlan(); }), ); document.querySelectorAll("[data-whole]").forEach((b) => b.addEventListener("click", () => board.setWhole(b.dataset.whole))); els.smaller.addEventListener("click", () => board.scaleAll(1 / SIZE_STEP)); els.larger.addEventListener("click", () => board.scaleAll(SIZE_STEP)); els.clear.addEventListener("click", () => board.clear()); els.run.addEventListener("click", () => { if (demo.active) return go(() => demo.continue(), resumeKey()); if (state.file && state.plan) go(() => demo.start(state.file, state.plan), state.selectionKey); }); } async function start() { wire(); const saved = demo.hasSaved(); const view0 = state.view; await loadExamples(); // the first example opens only if the visitor has not put a photo on the board meanwhile if (state.examples.length && state.view === view0 && !state.file) showExample(0); const c = await demo.init(); if (!c) { notice("Live enhancing is unavailable right now. The examples still work.", "service"); return update(); } els.quota.textContent = c.login_required ? `Preview only · ${c.jobs_per_day} free runs a day · Log in to Hugging Face (top right) to run` : `${c.jobs_per_day} free runs a day`; update(); refreshPlan(); // a photo chosen while the page was still starting gets its plan now if (!saved && demo.loginNeeded()) showError(new DemoError("login")); if (saved) { status({ busy: true, text: "Picking up your last run…" }); await go( () => demo.resume().then((r) => r ?? Promise.reject(new DemoError("unconfirmed"))), `run-${(() => { try { return JSON.parse(sessionStorage.getItem(demo.sessionKey) ?? "{}").requestId; } catch { return ""; } })()}`, ); } } start();