Photo-Enhancer / core.js
MegaPotatoAI's picture
Initial release
fab7cea verified
Raw History Blame Contribute Delete
28.4 kB
// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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") => `<a href="${demo.site("/tool", content)}" target="_blank" rel="noopener">${text}</a>`;
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 <a href="${t.spaceUrl}" target="_blank" rel="noopener">Hugging Face</a> 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;
}