// 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}
The original is not kept after a reload.
Shows after the run.
${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) => `
`)
.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();