|
| 1 | +const REFERENCE_SIZE = 511; |
| 2 | +const MAX_UPLOAD_BYTES = 5_000_000; |
| 3 | +const POLL_INTERVAL_MS = 2000; |
| 4 | +const MAX_POLLS = 150; |
| 5 | +const MAX_POLL_FAILURES = 3; |
| 6 | +const TYPE_LABELS = { |
| 7 | + "image/png": "PNG", |
| 8 | + "image/jpeg": "JPEG", |
| 9 | + "image/webp": "WebP", |
| 10 | +}; |
| 11 | +const ACCEPTED_TYPES = Object.keys(TYPE_LABELS); |
| 12 | + |
| 13 | +const byId = (id) => document.getElementById(id); |
| 14 | +const uploadForm = byId("upload-form"); |
| 15 | +const fileInput = byId("file-input"); |
| 16 | +const submitButton = byId("submit-button"); |
| 17 | +const uploadStatus = byId("upload-status"); |
| 18 | +const preview = byId("preview"); |
| 19 | +const previewImage = byId("preview-image"); |
| 20 | +const previewCaption = byId("preview-caption"); |
| 21 | +const compare = byId("compare"); |
| 22 | +const compareEmpty = byId("compare-empty"); |
| 23 | +const compareMeta = byId("compare-meta"); |
| 24 | +const originalImage = byId("original-image"); |
| 25 | +const outputImage = byId("output-image"); |
| 26 | +const gallery = byId("gallery"); |
| 27 | +const galleryStatus = byId("gallery-status"); |
| 28 | +const refreshButton = byId("refresh-button"); |
| 29 | + |
| 30 | +let rawUpload = null; |
| 31 | +let previewUrl = null; |
| 32 | +let selectedJobId = null; |
| 33 | + |
| 34 | +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 35 | +const shortId = (jobId) => jobId.slice(0, 8); |
| 36 | + |
| 37 | +function setStatus(element, message, tone = "info") { |
| 38 | + element.textContent = message; |
| 39 | + element.dataset.tone = tone; |
| 40 | +} |
| 41 | + |
| 42 | +function formatTime(isoString) { |
| 43 | + const date = new Date(isoString); |
| 44 | + if (Number.isNaN(date.getTime())) return "unknown time"; |
| 45 | + return date.toLocaleString([], { |
| 46 | + month: "short", |
| 47 | + day: "numeric", |
| 48 | + hour: "2-digit", |
| 49 | + minute: "2-digit", |
| 50 | + }); |
| 51 | +} |
| 52 | + |
| 53 | +function formatBytes(bytes) { |
| 54 | + const kilobytes = bytes / 1024; |
| 55 | + if (kilobytes < 1000) return `${kilobytes.toFixed(1)} KB`; |
| 56 | + return `${(kilobytes / 1024).toFixed(2)} MB`; |
| 57 | +} |
| 58 | + |
| 59 | +async function requestJson(path, options) { |
| 60 | + let response; |
| 61 | + try { |
| 62 | + response = await fetch(path, options); |
| 63 | + } catch { |
| 64 | + throw new Error("Network error. Is the Worker still running?"); |
| 65 | + } |
| 66 | + |
| 67 | + const data = await response.json().catch(() => null); |
| 68 | + if (!response.ok) { |
| 69 | + const detail = typeof data?.detail === "string" ? data.detail : null; |
| 70 | + throw new Error(detail ?? `Request failed with status ${response.status}`); |
| 71 | + } |
| 72 | + return data; |
| 73 | +} |
| 74 | + |
| 75 | +// Decodes only to prove the bytes are an image and to read the source size. |
| 76 | +// The bitmap is discarded; the original File is what gets uploaded. |
| 77 | +async function readSourceSize(file) { |
| 78 | + let bitmap; |
| 79 | + try { |
| 80 | + bitmap = await createImageBitmap(file); |
| 81 | + } catch { |
| 82 | + throw new Error("That file could not be decoded as an image."); |
| 83 | + } |
| 84 | + const size = { width: bitmap.width, height: bitmap.height }; |
| 85 | + bitmap.close?.(); |
| 86 | + return size; |
| 87 | +} |
| 88 | + |
| 89 | +async function inspectFile(file) { |
| 90 | + if (!ACCEPTED_TYPES.includes(file.type)) { |
| 91 | + throw new Error("Only PNG, JPEG and WebP are supported."); |
| 92 | + } |
| 93 | + if (file.size > MAX_UPLOAD_BYTES) { |
| 94 | + throw new Error( |
| 95 | + `That file is ${file.size.toLocaleString()} bytes, over the ${MAX_UPLOAD_BYTES.toLocaleString()} byte limit. Try a smaller picture.`, |
| 96 | + ); |
| 97 | + } |
| 98 | + |
| 99 | + const { width, height } = await readSourceSize(file); |
| 100 | + const label = TYPE_LABELS[file.type]; |
| 101 | + return { |
| 102 | + file, |
| 103 | + caption: |
| 104 | + `${width}x${height} ${label} (${file.type}), ${formatBytes(file.size)}`, |
| 105 | + }; |
| 106 | +} |
| 107 | + |
| 108 | +async function showPreview() { |
| 109 | + rawUpload = null; |
| 110 | + preview.hidden = true; |
| 111 | + if (previewUrl) URL.revokeObjectURL(previewUrl); |
| 112 | + previewUrl = null; |
| 113 | + |
| 114 | + const file = fileInput.files?.[0]; |
| 115 | + if (!file) { |
| 116 | + setStatus(uploadStatus, ""); |
| 117 | + return; |
| 118 | + } |
| 119 | + |
| 120 | + setStatus(uploadStatus, "Checking your picture..."); |
| 121 | + try { |
| 122 | + rawUpload = await inspectFile(file); |
| 123 | + previewUrl = URL.createObjectURL(rawUpload.file); |
| 124 | + previewImage.src = previewUrl; |
| 125 | + previewImage.alt = |
| 126 | + "The original picture you chose, shown at full size before it is uploaded."; |
| 127 | + previewCaption.textContent = rawUpload.caption; |
| 128 | + preview.hidden = false; |
| 129 | + setStatus(uploadStatus, 'Ready. Press "Redraw it!".'); |
| 130 | + } catch (error) { |
| 131 | + setStatus(uploadStatus, error.message, "error"); |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +// Resolves with the terminal job object so the caller can read job.reason. |
| 136 | +async function pollJob(jobId) { |
| 137 | + let failures = 0; |
| 138 | + |
| 139 | + for (let attempt = 1; attempt <= MAX_POLLS; attempt += 1) { |
| 140 | + await sleep(POLL_INTERVAL_MS); |
| 141 | + |
| 142 | + let job; |
| 143 | + try { |
| 144 | + job = await requestJson(`/api/jobs/${jobId}`); |
| 145 | + failures = 0; |
| 146 | + } catch (error) { |
| 147 | + failures += 1; |
| 148 | + if (failures >= MAX_POLL_FAILURES) { |
| 149 | + throw new Error(`Lost contact with the Worker. ${error.message}`); |
| 150 | + } |
| 151 | + continue; |
| 152 | + } |
| 153 | + |
| 154 | + if (job.status === "complete" || job.status === "failed") return job; |
| 155 | + setStatus( |
| 156 | + uploadStatus, |
| 157 | + `Job ${shortId(jobId)} is ${job.status}... (checked ${attempt} times)`, |
| 158 | + ); |
| 159 | + } |
| 160 | + |
| 161 | + throw new Error("This redraw is taking too long. Try Refresh later."); |
| 162 | +} |
| 163 | + |
| 164 | +function selectJob(job) { |
| 165 | + selectedJobId = job.jobId; |
| 166 | + originalImage.src = job.originalUrl; |
| 167 | + originalImage.alt = `The picture you uploaded for job ${shortId(job.jobId)}.`; |
| 168 | + outputImage.src = job.outputUrl; |
| 169 | + outputImage.alt = `Workers AI redraw of job ${shortId(job.jobId)}, in a clumsy MS Paint style.`; |
| 170 | + compareMeta.textContent = `Job ${job.jobId} - finished ${formatTime(job.completedAt)}`; |
| 171 | + compare.hidden = false; |
| 172 | + compareEmpty.hidden = true; |
| 173 | + |
| 174 | + for (const card of gallery.querySelectorAll(".card")) { |
| 175 | + card.setAttribute("aria-pressed", String(card.dataset.jobId === job.jobId)); |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +function createCard(job) { |
| 180 | + const thumb = document.createElement("img"); |
| 181 | + thumb.src = job.outputUrl; |
| 182 | + thumb.loading = "lazy"; |
| 183 | + thumb.alt = `Redrawn picture from job ${shortId(job.jobId)}`; |
| 184 | + |
| 185 | + const time = document.createElement("span"); |
| 186 | + time.textContent = formatTime(job.completedAt); |
| 187 | + |
| 188 | + const card = document.createElement("button"); |
| 189 | + card.type = "button"; |
| 190 | + card.className = "card"; |
| 191 | + card.dataset.jobId = job.jobId; |
| 192 | + card.setAttribute("aria-pressed", String(job.jobId === selectedJobId)); |
| 193 | + card.append(thumb, time); |
| 194 | + card.addEventListener("click", () => selectJob(job)); |
| 195 | + |
| 196 | + const item = document.createElement("li"); |
| 197 | + item.append(card); |
| 198 | + return item; |
| 199 | +} |
| 200 | + |
| 201 | +function renderGallery(jobs) { |
| 202 | + if (jobs.length === 0) { |
| 203 | + const empty = document.createElement("li"); |
| 204 | + empty.className = "empty"; |
| 205 | + empty.textContent = "No pictures yet. Upload one to start the gallery."; |
| 206 | + gallery.replaceChildren(empty); |
| 207 | + return; |
| 208 | + } |
| 209 | + gallery.replaceChildren(...jobs.map(createCard)); |
| 210 | +} |
| 211 | + |
| 212 | +async function loadGallery(jobIdToSelect) { |
| 213 | + refreshButton.disabled = true; |
| 214 | + setStatus(galleryStatus, "Loading gallery..."); |
| 215 | + |
| 216 | + try { |
| 217 | + const jobs = (await requestJson("/api/jobs"))?.jobs ?? []; |
| 218 | + renderGallery(jobs); |
| 219 | + setStatus( |
| 220 | + galleryStatus, |
| 221 | + jobs.length === 1 ? "1 picture saved." : `${jobs.length} pictures saved.`, |
| 222 | + ); |
| 223 | + |
| 224 | + const target = jobs.find((job) => job.jobId === jobIdToSelect); |
| 225 | + if (target) selectJob(target); |
| 226 | + } catch (error) { |
| 227 | + setStatus(galleryStatus, error.message, "error"); |
| 228 | + } finally { |
| 229 | + refreshButton.disabled = false; |
| 230 | + } |
| 231 | +} |
| 232 | + |
| 233 | +uploadForm.addEventListener("submit", async (event) => { |
| 234 | + event.preventDefault(); |
| 235 | + if (!rawUpload) await showPreview(); |
| 236 | + if (!rawUpload) { |
| 237 | + if (!fileInput.files?.length) { |
| 238 | + setStatus(uploadStatus, "Choose a picture first.", "error"); |
| 239 | + } |
| 240 | + fileInput.focus(); |
| 241 | + return; |
| 242 | + } |
| 243 | + |
| 244 | + const upload = rawUpload.file; |
| 245 | + fileInput.disabled = true; |
| 246 | + submitButton.disabled = true; |
| 247 | + try { |
| 248 | + setStatus(uploadStatus, "Uploading to the Worker..."); |
| 249 | + const created = await requestJson("/api/jobs", { |
| 250 | + method: "POST", |
| 251 | + headers: { "Content-Type": upload.type }, |
| 252 | + body: upload, |
| 253 | + }); |
| 254 | + |
| 255 | + setStatus(uploadStatus, `Job ${shortId(created.jobId)} is queued...`); |
| 256 | + const job = await pollJob(created.jobId); |
| 257 | + if (job.status === "complete") { |
| 258 | + setStatus(uploadStatus, "Finished! Behold the artwork.", "done"); |
| 259 | + await loadGallery(job.jobId); |
| 260 | + } else { |
| 261 | + // The backend only ever sends a reason it is happy to show a visitor. |
| 262 | + const reason = |
| 263 | + typeof job.reason === "string" && job.reason |
| 264 | + ? job.reason |
| 265 | + : "The Workflow gave up on that one."; |
| 266 | + setStatus(uploadStatus, reason, "error"); |
| 267 | + } |
| 268 | + } catch (error) { |
| 269 | + setStatus(uploadStatus, error.message, "error"); |
| 270 | + } finally { |
| 271 | + fileInput.disabled = false; |
| 272 | + submitButton.disabled = false; |
| 273 | + } |
| 274 | +}); |
| 275 | + |
| 276 | +fileInput.addEventListener("change", () => void showPreview()); |
| 277 | +refreshButton.addEventListener("click", () => void loadGallery()); |
| 278 | + |
| 279 | +void loadGallery(); |
0 commit comments