// Shared client of the MegaPotato demo Spaces: the demo API (config, quote, upload, poll, result), the Cloudflare // human check, the Hub's login state and the upload preparation. No page markup here: each Space draws its own UI. import { readImageInfo } from "./imageinfo.js"; export { readImageInfo }; export const API = "https://demo-api.megapotato.app"; const HF_ORIGIN = "https://huggingface.co"; export const MAX_FILE_BYTES = 50 * 1024 * 1024; export const MAX_SOURCE_PIXELS = 60_000_000; const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; // the site's and the API's upload limit // Browsers cap a canvas (Safari: 16.7 MP); a photo larger than that is drawn at that size instead of failing. const CANVAS_MAX_PIXELS = 16_777_216; const JOB_DEADLINE_MS = 30 * 60_000; const MIN_POLL_MS = 2000; export const NBSP = " "; export const fmt = (n) => Math.round(n).toLocaleString("en-US").replace(/,/g, NBSP); export const dims = (w, h) => `${fmt(w)}${NBSP}×${NBSP}${fmt(h)}`; export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); export const usd = (x) => (x >= 0.1 ? `$${x.toFixed(2)}` : `$${x.toFixed(3).replace(/0$/, "")}`); export function untilText(ms) { if (!ms) return "later"; const d = new Date(ms); const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}${NBSP}UTC`; return d.toISOString().slice(0, 10) === new Date().toISOString().slice(0, 10) ? `at ${hhmm}` : `tomorrow at ${hhmm}`; } export class DemoError extends Error { constructor(kind, extra = {}) { super(kind); this.kind = kind; Object.assign(this, extra); } } const RESUMABLE = new Set(["network", "server", "timeout"]); export const isResumable = (err) => RESUMABLE.has(err.kind); export const INFO_KINDS = new Set(["hourly", "daily", "budget", "spend_limit", "auth", "credits", "busy", "crowded", "login", "reduced"]); function randomHex(bytes = 16) { return Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (b) => b.toString(16).padStart(2, "0")).join(""); } function withTimeout(promise, ms, kind) { return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new DemoError(kind)), ms))]); } async function api(path, init = {}, timeoutMs = 30_000) { let resp; try { resp = await fetch(API + path, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } catch { throw new DemoError("network"); } let data = {}; try { data = await resp.json(); } catch { data = {}; } if (!resp.ok) throw new DemoError(data.error || (resp.status >= 500 ? "server" : "network"), data); return data; } /** * One demo page's connection to the service. * `slug`: the Space ("photo-enhancer", "4k-upscaler"), used for storage keys and site attribution. * `onPhase(phase, info)`: "preparing" | "uploading" | "queued" | "running" | "loading" (info: { elapsed, eta, cold }). * `onQuota()`: the visitor's allowance changed (re-read after a run or when a limit ends). */ export class Demo { constructor({ slug, turnstileEl, turnstileTheme = "auto", onPhase = () => {}, onQuota = () => {} }) { this.slug = slug; this.sessionKey = `mp-${slug}-active`; this.passKey = "mp-demo-pass"; this.turnstileEl = turnstileEl; this.turnstileTheme = turnstileTheme; this.onPhase = onPhase; this.onQuota = onQuota; this.config = null; this.configFailed = false; this.hfUser = null; this.active = null; this.busy = false; this.limitedUntil = 0; this.turnstile = { widget: null, pending: null, ready: null }; this.loginWallNoted = false; this.turnstileFailNoted = false; this.prepared = null; // an upload prepared smaller than quoted, kept for the confirming click this.quotaTimer = 0; } // ------------------------------------------------------------------ links /** A megapotato.app link with this Space's attribution slug (the site reads ?s=, not UTM). */ site(path = "/tool", content = "") { const s = content ? `hf-${this.slug}-${content}` : `hf-${this.slug}`; return `https://megapotato.app${path}?s=${s.slice(0, 40)}`; } // ------------------------------------------------------------------ start async init() { const login = this.hfLoginState(); this.hfUser = await login; try { this.config = await api("/config", { headers: this.hfHeaders() }, 10_000); } catch { this.configFailed = true; return null; } if (this.config.turnstile_site_key) this.loadTurnstile(this.config.turnstile_site_key); this.watchQuota(); return this.config; } async refresh() { try { this.config = await api("/config", { headers: this.hfHeaders() }, 10_000); this.configFailed = false; } catch { // keep the last config } this.watchQuota(); return this.config; } /** When a limit ends (a refused run's retry time, or the allowance's reset), read the allowance again. */ watchQuota() { clearTimeout(this.quotaTimer); const now = Date.now(); const me = this.config?.me; const ends = [this.limitedUntil, me && me.left <= 0 ? me.retry_at : 0].filter((t) => Number.isFinite(t) && t > now); if (!ends.length) return; this.quotaTimer = setTimeout( async () => { await this.refresh(); this.onQuota(); }, Math.min(Math.min(...ends) - now + 1500, 6 * 3600_000), ); } hfHeaders() { return this.hfUser ? { "X-HF-User": this.hfUser } : {}; } /** Ask the Hugging Face page whether the visitor is logged in ("anonymous", "free" or "pro"); only the Hub answers. */ hfLoginState() { if (window.self === window.top) return Promise.resolve("anonymous"); return new Promise((resolve) => { const done = (user) => { clearTimeout(timer); window.removeEventListener("message", onMessage); resolve(user); }; const onMessage = (e) => { if (e.origin !== HF_ORIGIN || e.data?.type !== "USER_PLAN") return; const user = e.data.plan?.user; done(user === "free" || user === "pro" ? user : "anonymous"); }; const timer = setTimeout(() => done("anonymous"), 2000); window.addEventListener("message", onMessage); try { window.parent.postMessage({ type: "USER_PLAN_REQUEST" }, HF_ORIGIN); } catch { done("anonymous"); } }); } loginNeeded() { const c = this.config; const me = c?.me; return (!!c?.anon_jobs || !!c?.login_required) && !!me && !me.logged_in && me.left <= 0; } /** No runs left in the current allowance (logged in or not), until its reset time when the service gave one. */ quotaOut() { const me = this.config?.me; return !!me && me.left <= 0 && !(me.retry_at && Date.now() >= me.retry_at); } limited() { return Date.now() < this.limitedUntil || this.loginNeeded() || this.quotaOut(); } runsLeft() { return this.config?.me?.left ?? null; } /** Once per visit: the login wall stopped someone who wanted to run (the page blocks the button, nothing is sent). */ noteLoginWall() { if (this.loginWallNoted) return; this.loginWallNoted = true; this.event({ kind: "login_wall" }); } event(body) { fetch(`${API}/event`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => {}); } // ------------------------------------------------------------------ human check loadTurnstile(siteKey) { if (this.turnstile.ready) return; this.turnstile.ready = new Promise((resolve, reject) => { window.mpTurnstileReady = () => { try { this.turnstile.widget = window.turnstile.render(this.turnstileEl, { sitekey: siteKey, action: "upscale", appearance: "interaction-only", execution: "execute", theme: typeof this.turnstileTheme === "function" ? this.turnstileTheme() : this.turnstileTheme, // Retries are ours (counted), not Cloudflare's timer: a generic failure often passes on a later run. retry: "never", callback: (token) => this.turnstile.pending?.resolve(token), "error-callback": (code) => { this.retryTurnstile(`error:${String(code ?? "unknown").slice(0, 12)}`); return true; }, "timeout-callback": () => this.retryTurnstile("interaction-timeout"), "expired-callback": () => window.turnstile.reset(this.turnstile.widget), }); resolve(); } catch { reject(new DemoError("turnstile")); } }; const s = document.createElement("script"); s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=mpTurnstileReady"; s.async = true; s.onerror = () => { this.noteTurnstileFail("script"); reject(new DemoError("turnstile")); }; document.head.appendChild(s); }); this.turnstile.ready.catch(() => undefined); } retryTurnstile(reason) { const p = this.turnstile.pending; if (!p) return; if (p.attempts < 5) { p.attempts += 1; try { window.turnstile.reset(this.turnstile.widget); window.turnstile.execute(this.turnstile.widget); return; } catch { // fall through: give up } } this.noteTurnstileFail(reason); p.reject(new DemoError("challenge")); } noteTurnstileFail(code) { if (this.turnstileFailNoted) return; this.turnstileFailNoted = true; this.event({ kind: "turnstile_fail", code }); } async turnstileToken() { if (!this.turnstile.ready) return ""; await withTimeout(this.turnstile.ready, 20_000, "turnstile"); const t = window.turnstile; t.reset(this.turnstile.widget); try { return await withTimeout( new Promise((resolve, reject) => { this.turnstile.pending = { resolve, reject, attempts: 1 }; t.execute(this.turnstile.widget); }), 90_000, "turnstile", ); } finally { this.turnstile.pending = null; } } // A passed check is good for an hour (signed by the service, bound to this visitor). Kept for the tab only. currentPass() { try { const p = JSON.parse(sessionStorage.getItem(this.passKey) || "null"); return p && typeof p.t === "string" && p.until - Date.now() > 120_000 ? p.t : null; } catch { return null; } } savePass(t, ttl) { try { if (typeof t === "string" && Number.isFinite(ttl)) sessionStorage.setItem(this.passKey, JSON.stringify({ t, until: Date.now() + ttl })); } catch { // no storage: the next upload runs the check again } } clearPass() { try { sessionStorage.removeItem(this.passKey); } catch { // ignore } } // ------------------------------------------------------------------ file and plan /** Header facts of a chosen file, or a DemoError to show. */ async inspect(file) { if (file.size > MAX_FILE_BYTES) throw new DemoError("too_large"); let info = null; try { info = await readImageInfo(file); } catch { info = null; } if (!info) throw new DemoError(/hei[cf]|avif|gif/i.test(file.type + file.name) ? "unsupported" : "unreadable"); if (info.animated) throw new DemoError("animated"); if (info.width * info.height > MAX_SOURCE_PIXELS) throw new DemoError("huge"); return info; } async quote(width, height, factor) { const plan = await api( "/quote", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ width, height, factor }) }, 20_000, ); return { ...plan, factor, source: { width, height } }; } // ------------------------------------------------------------------ run /** * Prepare the upload exactly as sent (PNG, or JPEG when the PNG is over the upload limit) and start the run. When * the browser can only prepare a smaller image than quoted, nothing is sent: the smaller size is quoted and thrown * as DemoError("reduced", { plan, from, file }) for the visitor to confirm with the next start (which reuses it). */ async start(file, plan) { if (this.busy) throw new DemoError("busy"); this.busy = true; let prepared; try { this.onPhase("preparing", {}); const kept = this.prepared; this.prepared = null; prepared = kept && kept.file === file && kept.w === plan.in_w && kept.h === plan.in_h ? kept : await normalize(file, plan); const { width: w, height: h } = prepared.canvas; if (w !== plan.in_w || h !== plan.in_h) { const smaller = await this.quote(w, h, plan.factor); if (smaller.in_w === w && smaller.in_h === h) this.prepared = { file, w, h, blob: prepared.blob, canvas: prepared.canvas }; throw new DemoError("reduced", { plan: { ...smaller, source: plan.source, resized: true, browserLimited: true }, from: plan, file }); } } catch (err) { this.busy = false; throw err instanceof DemoError ? err : new DemoError("unreadable"); } this.forget(); this.active = { requestId: randomHex(), factor: plan.factor, in_w: prepared.canvas.width, in_h: prepared.canvas.height, blob: prepared.blob, beforeUrl: URL.createObjectURL(prepared.blob), canvas: prepared.canvas, op: null, at: Date.now(), }; this.save(); this.busy = false; return this.continue(true); } /** Resume a saved run after a reload; null when there is nothing to resume. */ async resume() { let saved = null; try { saved = JSON.parse(sessionStorage.getItem(this.sessionKey) ?? "null"); } catch { saved = null; } if (!saved?.requestId || !/^[0-9a-f]{32}$/.test(saved.requestId) || Date.now() - saved.at > 24 * 3600_000) return null; this.active = { requestId: saved.requestId, factor: saved.factor, in_w: saved.in_w, in_h: saved.in_h, blob: null, beforeUrl: null, canvas: null, op: saved.op ? { op: saved.op, status: "queued" } : null, at: saved.at, }; return this.continue(); } /** Drop an upload kept from a reduced preparation (another photo was chosen). */ dropPrepared() { this.prepared = null; } hasSaved() { try { return !!sessionStorage.getItem(this.sessionKey); } catch { return false; } } /** * Upload (if needed), wait and load the result: { op, beforeUrl, afterUrl, canvas }. Throws DemoError. The run is * kept (also across a reload) until the page reports it shown with shown(), so a failed display can load it again. */ async continue(fresh = false) { const a = this.active; if (!a) throw new DemoError("unconfirmed"); if (this.busy) throw new DemoError("busy"); this.busy = true; try { if (!a.op && !fresh) { try { a.op = await api(`/requests/${a.requestId}`, {}, 20_000); } catch (err) { if (err.kind !== "not_found") throw err; } } if (!a.op && !a.blob) { this.forget(); throw new DemoError("unconfirmed"); } if (a.op?.status === "unknown" && !this.canResend(a)) throw new DemoError("unconfirmed"); if (!a.op || a.op.status === "unknown") { this.onPhase("uploading", {}); const token = a.op || this.currentPass() ? "" : await this.turnstileToken(); a.op = await this.submit(a, token); this.save(); } a.op = await this.follow(a); this.save(); this.onPhase("loading", {}); const afterUrl = await loadResult(a.op.result); return { op: a.op, beforeUrl: a.beforeUrl, afterUrl, canvas: a.canvas }; } catch (err) { const e = err instanceof DemoError ? err : new DemoError("server"); if (e.op && !a.op) a.op = { op: e.op, status: "unknown" }; // A refusal without an operation id means nothing was created: no resume, a retry is a fresh attempt. const settled = ["failed", "stale", "expired", "not_found", "unconfirmed"].includes(e.kind) || (["content", "check_unavailable"].includes(e.kind) && !e.op); const refusedBeforeCreation = !a.op && !e.op && !RESUMABLE.has(e.kind) && e.kind !== "download"; if (settled || refusedBeforeCreation) this.forget(); if (e.kind === "login" && this.config?.me) this.config.me = { logged_in: false, left: 0, retry_at: null }; if (["daily", "hourly", "budget", "spend_limit"].includes(e.kind)) { this.limitedUntil = e.retry_at ?? Date.now() + 3600_000; this.watchQuota(); } throw e; } finally { this.busy = false; } } /** * The page has shown the result of `done`: the run is over. Counts it once against the shown allowance (only a run * started in this tab; after a reload the allowance read at start already includes it), then re-reads it. */ shown(done) { const a = this.active; if (!a || a.op?.op !== done.op.op) return; const me = this.config?.me; if (a.beforeUrl && me) me.left = Math.max(0, me.left - 1); this.active = null; // its before image now belongs to the page try { sessionStorage.removeItem(this.sessionKey); } catch { // ignore } this.refresh().then(() => this.onQuota()); } canResend(a) { return !!a.blob && (!a.op?.recoverable_until || Date.now() < a.op.recoverable_until); } async submit(a, token) { let lastErr = null; for (let attempt = 0; attempt < 3; attempt++) { const headers = { "Content-Type": a.blob.type || "image/png", "X-Factor": String(a.factor), "X-Request-Id": a.requestId, ...this.hfHeaders() }; const pass = token ? null : this.currentPass(); if (token) headers["X-Turnstile"] = token; else if (pass) headers["X-Pass"] = pass; try { const op = await api("/ops", { method: "POST", headers, body: a.blob }, 120_000); if (op?.pass) this.savePass(op.pass, op.pass_ttl_ms); return op; } catch (err) { lastErr = err; if (err.kind === "challenge" && attempt === 0 && this.turnstile.ready) { this.clearPass(); // an expired or refused pass: run the check after all token = await this.turnstileToken(); continue; } const unclear = err.kind === "network" || (err.kind === "server" && err.op); if (!unclear) throw err; await sleep(2000 * (attempt + 1)); } } throw lastErr ?? new DemoError("server"); } async follow(a) { const started = Date.now(); let backoff = 3000; let op = a.op; while (true) { if (op.status === "completed") return op; if (op.status === "failed") throw new DemoError("failed"); if (op.status === "stale") throw new DemoError("stale"); if (Date.now() - started > JOB_DEADLINE_MS) throw new DemoError("timeout"); if (op.status === "unknown") { if (!this.canResend(a)) throw new DemoError("unconfirmed"); await sleep(backoff); backoff = Math.min(backoff * 2, 20_000); op = await this.submit(a, ""); a.op = op; continue; } const running = op.status === "running"; this.onPhase(running ? "running" : "queued", { at: a.at, eta: op.eta ?? null, cold: !running && Date.now() - a.at > 20_000, }); const t0 = Date.now(); try { op = await api(`/ops/${op.op}`, {}, 45_000); a.op = op; backoff = 3000; const spent = Date.now() - t0; if (spent < MIN_POLL_MS && op.status !== "completed") await sleep(MIN_POLL_MS - spent); } catch (err) { if (["expired", "not_found"].includes(err.kind)) throw err; await sleep(err.kind === "slow_down" ? 10_000 : backoff); backoff = Math.min(backoff * 2, 20_000); } } } save() { const a = this.active; if (!a) return; try { sessionStorage.setItem( this.sessionKey, JSON.stringify({ requestId: a.requestId, op: a.op?.op ?? null, factor: a.factor, in_w: a.in_w, in_h: a.in_h, at: a.at }), ); } catch { // ignore } } forget() { if (this.active?.beforeUrl) URL.revokeObjectURL(this.active.beforeUrl); this.active = null; try { sessionStorage.removeItem(this.sessionKey); } catch { // ignore } } } // ---------------------------------------------------------------------- upload preparation async function normalize(file, plan) { const { in_w: w, in_h: h } = plan; try { return await encodeUpload(file, w, h); } catch (err) { if (w * h <= CANVAS_MAX_PIXELS || (err instanceof DemoError && err.kind === "too_big_for_demo")) throw err; } const k = Math.sqrt(CANVAS_MAX_PIXELS / (w * h)); return encodeUpload(file, Math.max(1, Math.floor(w * k)), Math.max(1, Math.floor(h * k))); } // PNG, as the site sends; a photo whose PNG is over the upload limit goes as a high-quality JPEG instead. async function encodeUpload(file, w, h) { let source; try { source = await createImageBitmap(file, { imageOrientation: "from-image", resizeWidth: w, resizeHeight: h, resizeQuality: "high" }); } catch { throw new DemoError("unreadable"); } const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d", { alpha: false, colorSpace: "srgb" }); if (!ctx) throw new DemoError("unreadable"); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, w, h); ctx.drawImage(source, 0, 0, w, h); source.close(); let blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); if (!blob || blob.type !== "image/png") throw new DemoError("unreadable"); if (blob.size > MAX_UPLOAD_BYTES) { blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.97)); if (!blob || blob.type !== "image/jpeg") throw new DemoError("unreadable"); } if (blob.size > MAX_UPLOAD_BYTES) throw new DemoError("too_big_for_demo"); return { blob, canvas }; } async function loadResult(path) { let resp; try { // The full-size JPEG (~20 MB at 16K); the demo API sends the PNG instead when there is no JPEG for this run. const url = API + path + (path.includes("?") ? "&" : "?") + "format=jpeg"; resp = await fetch(url, { signal: AbortSignal.timeout(30 * 60_000) }); // a full-size PNG can be hundreds of MB } catch { throw new DemoError("download"); } const type = resp.headers.get("Content-Type"); if (!resp.ok || (type !== "image/png" && type !== "image/jpeg")) throw new DemoError("download"); try { return URL.createObjectURL(await resp.blob()); } catch { throw new DemoError("download"); } } /** A small preview of a chosen file (object URL), or null. */ export async function thumbnail(file, info, edge = 160) { try { const k = Math.min(1, edge / Math.max(info.width, info.height)); const bmp = await createImageBitmap(file, { imageOrientation: "from-image", resizeQuality: "medium", resizeWidth: Math.max(1, Math.round(info.width * k)), resizeHeight: Math.max(1, Math.round(info.height * k)), }); const c = document.createElement("canvas"); c.width = bmp.width; c.height = bmp.height; c.getContext("2d").drawImage(bmp, 0, 0); bmp.close(); const blob = await new Promise((r) => c.toBlob(r, "image/jpeg", 0.85)); return blob ? URL.createObjectURL(blob) : null; } catch { return null; } } // ---------------------------------------------------------------------- copy /** * Title and note for an error. `t`: { verb: "enhance", runs: "enhancements", site: (content) => url, inHub: bool }. * Returns [title, noteHtml]. */ export function errorCopy(err, demo, t) { const link = (content, text = "megapotato.app") => `${text}`; const perDay = `${demo.config?.jobs_per_day ?? 2}${NBSP}${t.runs}`; const loginHint = window.self !== window.top ? `Log in to Hugging Face (top right) for ${perDay} a day.` : `Open this demo on Hugging Face and log in for ${perDay} a day.`; const more = `Want more? ${link("limit", "Sign up on megapotato.app")} for downloads and full access.`; const still = "You can still explore the examples."; const table = { network: ["Could not reach the service.", "Check your connection, then press Resume."], server: ["The service had a hiccup.", "Press Resume: the same upload is picked up, not charged again."], download: ["The result could not be loaded.", "Press Load again."], auth: ["The live demo is paused right now.", still], credits: ["The live demo is paused right now.", still], spend_limit: ["The live demo is paused right now.", still], budget: ["The live demo is paused right now.", still], login: [`Log in to Hugging Face to ${t.verb} your own photo.`, `${loginHint} ${still}`], content: [ "This image can't be processed.", "Our automated review found that it may not meet our content rules. If you believe this is a mistake, contact support@megapotato.app.", ], check_unavailable: ["We can't accept images right now.", "Please try again in a minute."], hourly: ["You've used today's free runs here.", `Another run becomes available ${untilText(err.retry_at)}. ${more}`], daily: ["You've used today's free runs here.", `Another run becomes available ${untilText(err.retry_at)}. ${more}`], busy: ["Your previous run is still going.", "Wait for it to finish, then start the next one."], crowded: ["The demo is busy right now.", "Please try again in a minute."], rate_limit: ["The demo is busy right now.", "Please try again in a minute."], slow_down: ["Too many requests in a short time.", "Please wait a moment and try again."], challenge: [ "Cloudflare's quick human check didn't pass.", "This usually happens with an ad or privacy blocker, or a VPN. Allow challenges.cloudflare.com, turn the VPN off or try another browser, then try again.", ], turnstile: [ "Cloudflare's quick human check couldn't load.", "An ad or privacy blocker may be blocking challenges.cloudflare.com. Allow it, turn the VPN off or try another browser, then reload.", ], too_small: ["This image is too small.", "Please use an image at least 16 pixels on each side."], too_big_for_demo: ["This image is too large to process here.", `Upload the original file on ${link("too-big")}.`], too_large: ["This file is too large.", "Please use an image under 50 MB."], unsupported: ["This file type is not supported here.", "Use a PNG, JPEG or WebP image."], unreadable: ["This file could not be read as an image.", "Use a PNG, JPEG or WebP image."], animated: ["Animated images are not supported.", "Export a single frame as PNG or JPEG and try again."], huge: ["This image is too large to open in the browser.", "Please use an image under 60 megapixels."], failed: ["That run did not succeed.", "Nothing was charged. Please try again, or try another image."], stale: ["This is taking much longer than usual.", "Please try again later."], timeout: ["This is taking longer than usual.", "The job keeps running. Press Resume to check again."], unconfirmed: ["That upload could not be confirmed.", "Nothing more will happen with it. Please start again."], expired: ["This result has expired.", "Results stay available here for about an hour after the upload."], not_found: ["This result is no longer available.", "Results stay available here for about an hour after the upload."], invalid: ["The image could not be processed.", "Try another image."], reduced: [ "This browser can't prepare the image at full size.", err.plan ? `It would go at ${dims(err.plan.in_w, err.plan.in_h)}, for an output of ${dims(err.plan.out_w, err.plan.out_h)}. Press the button again to continue.` : "Press the button again to continue at a smaller size.", ], }; return table[err.kind] ?? table.server; }