Spaces:
Running
Running
Download core.js from MegaPotatoAI/Photo-Enhancer: direct link, hf CLI and curl.
- Browser
- Download file 28.4 kB
-
https://huggingface.co/spaces/MegaPotatoAI/Photo-Enhancer/resolve/main/core.js
- Command line
-
hf download hf://spaces/MegaPotatoAI/Photo-Enhancer/core.js
-
curl -L -o core.js https://huggingface.co/spaces/MegaPotatoAI/Photo-Enhancer/resolve/main/core.js
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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; | |
| } | |