1/** 2 * ImgMin background removal â page controller. 3 * 4 * Owns the UI only. All model and inference work happens in bg-worker.js so a 5 * 110 MB download and a multi-second GPU pass never block the page. 6 */ 7import './bg-mask.js?v=20260906d'; 8 9const M = globalThis.ImgMinBgMask; 10 11// All user-facing copy lives here, keyed by locale. The rest of the file is a 12// single shared implementation for every language â duplicating the whole 13// controller per locale would mean the state machine, the watchdog and the 14// IndexedDB handoff (each fixed after a real bug) drift out of sync between 15// copies. Locale is read once, from the page's own <html lang>, so English and 16// Chinese pages load the identical script and simply select a different row. 17const STRINGS = { 18 en: { 19 // Real first-use cost, not just the model file: the ONNX runtime pulls 20 // a wasm binary too, and quoting only the model would understate it by 21 // ~25 MB. 22 tierInfo: { 23 quality: { label: 'high quality', totalMB: 135 }, 24 fallback: { label: 'lightweight', totalMB: 16 } 25 }, 26 // Downgrades are expected on plenty of hardware. They are reported as 27 // information, never as an error. 28 downgrade: { 29 no_webgpu: 'Your browser does not support WebGPU, so the lightweight model is being used. Edges will be a little coarser.', 30 no_adapter: 'No compatible GPU was found, so the lightweight model is being used.', 31 adapter_below_spec: 'This deviceâs GPU cannot run the high-quality model, so the lightweight one is being used.', 32 low_memory: 'To avoid running out of memory on this device, the lightweight model is being used.', 33 save_data: 'Data Saver is on, so the lightweight model is being used.', 34 runtime_downgrade: 'The high-quality model could not start on this device, so the lightweight one was used instead.', 35 user: 'Using the lightweight model.' 36 }, 37 error: { 38 image_too_large: 'That image is too large. The limit is 30 MB, 40 megapixels, and 12000 px on a side. Resize it first and try again.', 39 unsupported_input: 'That file type is not supported here. Use a JPG, PNG or WebP.', 40 heic_input: 'HEIC photos need converting first. Open the compressor, drop the photo there, then come back.', 41 decode_failed: 'That image could not be read. It may be corrupt.', 42 model_fetch_failed: 'The model could not be downloaded. Check your connection and try again.', 43 model_corrupt: 'The downloaded model was incomplete. Try again.', 44 oom: 'This device ran out of memory on an image that size. Try a smaller one.', 45 timeout: 'Processing took too long and was stopped. Try the lightweight model, or a smaller image.', 46 runtime_switch_required: 'The model could not switch cleanly. Please try again.', 47 session_create_failed: 'The model could not be started on this device.' 48 }, 49 phase: { 50 idle: 'Ready', 51 decoding: 'Reading imageâ¦', 52 downloading: 'Downloading modelâ¦', 53 warming: 'Starting modelâ¦', 54 inferring: 'Removing backgroundâ¦', 55 composing: 'Finishingâ¦', 56 done: 'Done', 57 error: 'Could not finish' 58 }, 59 shaderHint: 'First run compiles GPU shaders â this takes a moment.', 60 cacheWriteFailed: 'There was not enough storage to cache the model, so it will download again next time.', 61 firstUse: (mb, label) => `First use downloads about ${mb} MB (${label} model). It is cached afterwards and works offline.`, 62 downscaledSuffix: 'downscaled from the original to fit this device', 63 handoffFailed: 'Could not hand this image to the compressor.', 64 downloadTips: [ 65 'This is a one-time download. The model is cached on your device and the page works offline afterwards.', 66 'While you wait: no image has been uploaded. It never leaves this tab.', 67 'The download is about the size of one lossless song â but you only fetch it once.', 68 'The high-quality model earns its size on hair, fur and semi-transparent edges.', 69 'In a hurry? The lightweight model (16 MB) is one click away â slightly coarser edges, same privacy.' 70 ], 71 patQuips: [ 72 'Clicking the bar does not speed it up, but the model appreciates the encouragement.', 73 'Still not faster â thanks for keeping it company though.', 74 'Pressure received. Motivation +1.', 75 'Curiosity is cached instantly. The model almost is, too.' 76 ], 77 downloadCancelled: 'Model download cancelled.', 78 tierQualityUnavailable: 'High quality needs WebGPU, which this device does not support. The lightweight model works everywhere.' 79 }, 80 zh: { 81 tierInfo: { 82 quality: { label: 'é«è´¨é', totalMB: 135 }, 83 fallback: { label: 'è½»é', totalMB: 16 } 84 }, 85 downgrade: { 86 no_webgpu: 'ä½ çæµè§å¨ä¸æ¯æ WebGPUï¼å·²èªå¨ä½¿ç¨è½»é模åï¼è¾¹ç¼ç»èä¼ç¥ç²ã', 87 no_adapter: 'æªæ¾å°å ¼å®¹ç GPUï¼å·²èªå¨ä½¿ç¨è½»é模åã', 88 adapter_below_spec: 'è¿å°è®¾å¤ç GPU æ æ³è¿è¡é«è´¨é模åï¼å·²èªå¨ä½¿ç¨è½»é模åã',
89 low_memory: '为é¿å 设å¤å åä¸è¶³ï¼å·²èªå¨ä½¿ç¨è½»é模åã', 90 save_data: 'å·²å¼å¯çæµé模å¼ï¼æ£å¨ä½¿ç¨è½»é模åã', 91 runtime_downgrade: 'é«è´¨éæ¨¡åæªè½å¨æ¤è®¾å¤ä¸å¯å¨ï¼å·²èªå¨æ¹ç¨è½»é模åã', 92 user: 'æ£å¨ä½¿ç¨è½»é模åã' 93 }, 94 error: { 95 image_too_large: 'å¾çè¿å¤§ãéå¶ä¸º 30 MBã4000 ä¸åç´ ãåè¾¹ 12000 åç´ ä»¥å ã请å 缩å°å¾çåè¯ã', 96 unsupported_input: 'æä¸æ¯æè¯¥æä»¶ç±»åï¼è¯·ä½¿ç¨ JPGãPNG æ WebPã', 97 heic_input: 'HEIC ç §çéè¦å è½¬æ¢æ ¼å¼ã请å¨åç¼©å·¥å ·ä¸æå¼è¯¥ç §çå®æè½¬æ¢ï¼ååå°è¿éã', 98 decode_failed: 'æ æ³è¯»å该å¾çï¼æä»¶å¯è½å·²æåã', 99 model_fetch_failed: '模åä¸è½½å¤±è´¥ï¼è¯·æ£æ¥ç½ç»åéè¯ã', 100 model_corrupt: 'ä¸è½½ç模åä¸å®æ´ï¼è¯·éè¯ã', 101 oom: 'è¿å¼ å¾ç对å½åè®¾å¤æ¥è¯´è¿å¤§ï¼å¯¼è´å åä¸è¶³ã请å°è¯æ´å°çå¾çã', 102 timeout: 'å¤çèæ¶è¿é¿ï¼å·²åæ¢ãå¯å°è¯è½»é模åï¼ææ¢ä¸å¼ æ´å°çå¾çã', 103 runtime_switch_required: 'æ¨¡åæªè½æ£å¸¸åæ¢ï¼è¯·éè¯ã', 104 session_create_failed: 'æ¨¡åæªè½å¨æ¤è®¾å¤ä¸å¯å¨ã' 105 }, 106 phase: { 107 idle: 'åå¤å°±ç»ª', 108 decoding: 'æ£å¨è¯»åå¾çâ¦', 109 downloading: 'æ£å¨ä¸è½½æ¨¡åâ¦', 110 warming: 'æ£å¨å¯å¨æ¨¡åâ¦', 111 inferring: 'æ£å¨å»é¤èæ¯â¦', 112 composing: 'æ£å¨å®æå¤çâ¦', 113 done: '已宿', 114 error: 'æªè½å®æ' 115 }, 116 shaderHint: '馿¬¡è¿è¡éè¦ç¼è¯ GPU çè²å¨ï¼è¯·ç¨åã', 117 cacheWriteFailed: 'åå¨ç©ºé´ä¸è¶³ï¼æ¨¡åæªè½ç¼åï¼ä¸æ¬¡ä»ééæ°ä¸è½½ã', 118 firstUse: (mb, label) => `馿¬¡ä½¿ç¨éä¸è½½çº¦ ${mb} MBï¼${label}模åï¼ï¼ä¸è½½åä¼ç¼åå¹¶å¯ç¦»çº¿ä½¿ç¨ã`, 119 downscaledSuffix: 'å·²ä»åå¾ééæ ·ä»¥éé æ¤è®¾å¤', 120 handoffFailed: 'æªè½å°å¾ç交ç»åç¼©å·¥å ·ï¼è¯·éè¯ã', 121 downloadTips: [ 122 'è¿æ¯ä¸æ¬¡æ§ä¸è½½ï¼æ¨¡åä¼ç¼å卿¬æºï¼ä¹åè¿ä¸ªé¡µé¢ç¦»çº¿ä¹è½ç¨ã', 123 'çå¾ æé´æ²¡æä»»ä½å¾ç被ä¸ä¼ ââå®ä¸ç´çå¨å½åæ ç¾é¡µéã', 124 'ä¸è½½é大约ç¸å½äºä¸é¦æ æææ²ï¼ä½åªéè¦ä¸è½½ä¸æ¬¡ã', 125 'é«è´¨éçå·®å«ä¸»è¦å¨æ¯åãç¾½æ¯ååéæè¾¹ç¼ââå¼å¾è¿ä¸æ¬¡çå¾ ã', 126 'èµ¶æ¶é´çè¯ï¼å¯ä»¥éæ¶æ¹ç¨è½»é模åï¼16 MBï¼ï¼è¾¹ç¼ç¥ç²ï¼éç§ä¸åã' 127 ], 128 patQuips: [ 129 'ç¹è¿åº¦æ¡ä¸è½å éï¼ä½æ¨¡åæ¶å°äºä½ çé¼å±ã', 130 'åç¹ä¹ä¸ä¼æ´å¿«ââä¸è¿è°¢è°¢éªä¼´ã', 131 'è¿åº¦æ¡è¡¨ç¤ºï¼ååå·²æ¶å°ï¼å¨å +1ã', 132 '好å¥å¿ä¼è¢«ç«å»ç¼åï¼æ¨¡åä¹å¿«äºã' 133 ], 134 downloadCancelled: 'å·²åæ¶æ¨¡åä¸è½½ã', 135 tierQualityUnavailable: 'é«è´¨é模åéè¦è®¾å¤æ¯æ WebGPUï¼å½å设å¤ä¸æ¯æãè½»éæ¨¡å卿æè®¾å¤ä¸å¯ç¨ã' 136 } 137}; 138 139const LOCALE = (document.documentElement.lang || '').toLowerCase().startsWith('zh') ? 'zh' : 'en'; 140const T = STRINGS[LOCALE]; 141 142const TIER_INFO = T.tierInfo; 143 144const TIER_KEY = 'imgmin_bg_tier'; 145// Per-phase watchdog ceilings. Downloading is exempt: it reports real progress 146// and the user can cancel it. Everything else is opaque, so a stall there has 147// to surface as a failure the user can act on. 148const PHASE_TIMEOUT_MS = { 149 decoding: 30000, 150 warming: 180000, // first session build compiles shaders; genuinely slow 151 inferring: 120000, 152 composing: 60000 153}; 154const SHADER_HINT_MS = 20000; 155const TIP_ROTATE_MS = 7000; 156const IS_LOCAL = ['localhost', '127.0.0.1'].includes(location.hostname); 157 158const DOWNGRADE_NOTE = T.downgrade; 159const ERROR_NOTE = T.error; 160 161const el = id => document.getElementById(id); 162const dom = { 163 dropZone: el('dropZone'), fileInput: el('fileInput'), 164 panel: el('bgPanel'), state: el('bgState'), note: el('bgNote'), 165 progress: el('bgProgress'), progressFill: el('bgProgressFill'), 166 progressActions: el('bgProgressActions'), cancel: el('bgCancel'), useLite: el('bgUseLite'), 167 stage: el('bgStage'), before: el('bgBefore'), after: el('bgAfter'), split: el('bgSplit'), 168 meta: el('bgMeta'), actions: el('bgActions'), 169 download: el('bgDownload'), compress: el('bgCompress'), reset: el('bgReset'), 170 retryActions: el('bgRetryActions'), retry: el('bgRetry'), retryLite: el('bgRetryLite'), 171 tierFallback: el('bgTierFallback'), tierQuality: el('bgTierQuality'), tierHint: el('bgTierHint'), 172 downloadRow: el('bgDownloadRow'), tip: el('bgTip'), bytes: el('bgBytes') 173}; 174 175const state = { 176 phase: 'idle', 177 tier: null, 178 capability: null, 179 jobId: 0, 180 file: null, 181 beforeUrl: null, 182 afterUrl: null, 183 result: null, 184 pct: 0, 185 loaded: 0, 186 total: 0, 187 note: '', 188 error: null, 189 timeoutId: null, 190 hintId: null, 191 tipId: null, 192 tipIdx: 0, 193 quipIdx: 0, 194 quip: null 195}; 196 197let worker = null; 198 199// --- worker ---------------------------------------------------------------- 200 201function ensureWorker() { 202 if (worker) return worker; 203 worker = new Worker('/bg-worker.js?v=20260910b', { type: 'module' }); 204 worker.addEventListener('message', onWorkerMessage); 205 worker.addEventListener('error', () => fail('session_create_failed')); 206 return worker; 207} 208 209/** 210 * Tear the worker down and start a fresh one. 211 * 212 * Required on a tier change: the two tiers load different ONNX Runtime builds, 213 * and a worker that has already imported one cannot cleanly import the other â 214 * the WebGPU EP stops being applied and the 1024px graph crawls on CPU with no 215 * error. A new worker gets a clean module graph. 216 */ 217function recreateWorker() { 218 if (worker) { 219 worker.removeEventListener('message', onWorkerMessage); 220 try { worker.postMessage({ type: 'dispose' }); } catch (_) { /* already gone */ } 221 worker.terminate(); 222 worker = null; 223 } 224 return ensureWorker(); 225} 226 227function onWorkerMessage(event) { 228 const msg = event.data || {}; 229 230 switch (msg.type) { 231 case 'capability': 232 state.capability = msg;
233 if (!state.tier) state.tier = msg.tier; 234 if (msg.tier === 'fallback' && msg.reason !== 'ok') state.note = DOWNGRADE_NOTE[msg.reason] || ''; 235 track('bg_capability', { bg_tier: msg.tier, bg_reason: msg.reason, bg_cached: !!msg.cached }); 236 // The tier picker's active/disabled state depends on this probe, 237 // and the probe resolves after the initial render. 238 render(); 239 break; 240 241 case 'download': 242 if (msg.cached) break; 243 setPhase('downloading'); 244 state.pct = msg.pct; 245 state.loaded = msg.loaded || 0; 246 state.total = msg.total || 0; 247 render(); 248 break; 249 250 case 'stage': 251 if (msg.stage === 'warming') setPhase('warming'); 252 else if (msg.stage === 'infer') setPhase('inferring'); 253 else if (msg.stage === 'compose' || msg.stage === 'encode') setPhase('composing'); 254 render(); 255 break; 256 257 case 'ready': 258 state.tier = msg.tier; 259 track('bg_model_ready', { bg_tier: msg.tier, bg_ms: msg.ms }); 260 break; 261 262 case 'warn': 263 if (msg.code === 'runtime_downgrade') { 264 state.tier = 'fallback'; 265 state.note = DOWNGRADE_NOTE.runtime_downgrade; 266 } else if (msg.code === 'cache_write_failed') { 267 state.note = T.cacheWriteFailed; 268 } 269 render(); 270 break; 271 272 case 'result': 273 if (msg.jobId !== state.jobId) break; 274 onResult(msg); 275 break; 276 277 case 'cancelled': 278 clearTimer(); 279 setPhase(state.result ? 'done' : 'idle'); 280 state.pct = 0; 281 render(); 282 break; 283 284 case 'error': 285 fail(msg.code); 286 break; 287 288 default: 289 break; 290 } 291} 292 293// --- flow ------------------------------------------------------------------ 294 295/** 296 * Enter a phase and re-arm the watchdog. 297 * 298 * One timer per phase, reset on every transition â an earlier nested-timer 299 * version could be re-armed by a repeated stage message and then never fire, 300 * leaving the spinner up indefinitely. 301 */ 302function setPhase(phase) { 303 const changed = state.phase !== phase; 304 state.phase = phase; 305 if (!changed) return; 306 307 clearTimer(); 308 if (phase === 'downloading') { 309 state.tipIdx = 0; 310 state.quipIdx = 0; 311 state.quip = null; 312 state.tipId = setInterval(() => { 313 state.tipIdx = (state.tipIdx + 1) % T.downloadTips.length; 314 state.quip = null; // a pat quip shows once, then rotation resumes 315 render(); 316 }, TIP_ROTATE_MS); 317 } 318 if (phase === 'inferring' || phase === 'warming') { 319 state.hintId = setTimeout(() => { 320 if (state.phase !== phase) return; 321 state.note = T.shaderHint; 322 render(); 323 }, SHADER_HINT_MS); 324 } 325 326 const limit = PHASE_TIMEOUT_MS[phase]; 327 if (!limit) return; 328 state.timeoutId = setTimeout(() => { 329 if (state.phase !== phase) return; 330 state.jobId += 1; // orphan the stalled job 331 recreateWorker(); // the stalled session is unrecoverable 332 fail('timeout'); 333 }, limit); 334} 335 336function clearTimer() { 337 if (state.timeoutId) { clearTimeout(state.timeoutId); state.timeoutId = null; } 338 if (state.hintId) { clearTimeout(state.hintId); state.hintId = null; } 339 if (state.tipId) { clearInterval(state.tipId); state.tipId = null; } 340} 341 342async function handleFile(file, method = null) { 343 if (!file) return; 344 345 if (/heic|heif/i.test(file.type) || /\.hei[cf]$/i.test(file.name)) return fail('heic_input'); 346 if (!/^image\/(png|jpeg|webp)$/i.test(file.type)) return fail('unsupported_input'); 347 if (method) window.imgminAnalytics?.track('bg_file_accepted', { method }); 348 349 // A new file or tier choice invalidates every earlier decode/model task. 350 // Check this token after the async decode so a cancelled task cannot post 351 // its bitmap into the newly created worker. 352 const jobId = ++state.jobId; 353 resetResult(); 354 state.file = file; 355 state.error = null; 356 dom.panel.hidden = false;
357 setPhase('decoding'); 358 render(); 359 360 let bitmap; 361 try { 362 bitmap = await createImageBitmap(file); 363 } catch (_) { 364 return fail('decode_failed'); 365 } 366 if (jobId !== state.jobId) { bitmap.close(); return; } 367 368 // Runs before any model fetch, so an oversized drop never costs a download. 369 try { 370 M.guardInput({ bytes: file.size, w: bitmap.width, h: bitmap.height }); 371 } catch (err) { 372 bitmap.close(); 373 return fail(err.code || 'image_too_large'); 374 } 375 376 state.beforeUrl = URL.createObjectURL(file); 377 dom.before.src = state.beforeUrl; 378 379 const tier = resolveTier(); 380 state.tier = tier; 381 track('bg_run_start', { bg_tier: tier }); 382 383 setPhase(state.capability && state.capability.cached ? 'warming' : 'downloading'); 384 render(); 385 386 ensureWorker().postMessage({ 387 type: 'run', 388 jobId, 389 tier, 390 bitmap, 391 bytes: file.size, 392 coarsePointer: matchMedia('(pointer: coarse)').matches 393 }, [bitmap]); 394} 395 396function resolveTier() { 397 const stored = localStorage.getItem(TIER_KEY); 398 if (stored === 'fallback' || stored === 'quality') return stored; 399 return (state.capability && state.capability.tier) || 'fallback'; 400} 401 402function onResult(msg) { 403 clearTimer(); 404 state.result = msg; 405 if (state.afterUrl) URL.revokeObjectURL(state.afterUrl); 406 state.afterUrl = URL.createObjectURL(msg.blob); 407 dom.after.src = state.afterUrl; 408 setPhase('done'); 409 render(); 410 track('bg_result', { bg_tier: msg.tier, bg_ms: msg.ms, bg_downscaled: !!msg.downscaled }); 411} 412 413function fail(code) { 414 clearTimer(); 415 state.error = code; 416 setPhase('error'); 417 dom.panel.hidden = false; 418 render(); 419 track('bg_error', { bg_code: code }); 420} 421 422function resetResult() { 423 if (state.beforeUrl) { URL.revokeObjectURL(state.beforeUrl); state.beforeUrl = null; } 424 if (state.afterUrl) { URL.revokeObjectURL(state.afterUrl); state.afterUrl = null; } 425 state.result = null; 426 state.pct = 0; 427 state.error = null; 428} 429 430// --- rendering ------------------------------------------------------------- 431 432const PHASE_TEXT = T.phase; 433 434function render() { 435 const busy = ['decoding', 'downloading', 'warming', 'inferring', 'composing'].includes(state.phase); 436 437 dom.state.textContent = PHASE_TEXT[state.phase] || ''; 438 dom.state.className = 'bg-state ' + 439 (state.phase === 'error' ? 'bg-state--error' : state.phase === 'done' ? 'bg-state--done' : busy ? 'bg-state--working' : 'bg-state--info'); 440 441 const note = state.phase === 'error' ? (ERROR_NOTE[state.error] || ERROR_NOTE.session_create_failed) : firstUseNote(); 442 dom.note.textContent = note; 443 dom.note.hidden = !note; 444 445 dom.progress.hidden = !busy; 446 dom.progress.classList.toggle('bg-progress--indeterminate', busy && state.phase !== 'downloading'); 447 dom.progressFill.style.setProperty('--bg-pct', `${state.phase === 'downloading' ? state.pct : 100}%`); 448 449 dom.progressActions.hidden = !busy; 450 451 // Download companion: rotating tip + live byte counter, and the progress 452 // bar becomes pat-able only while bytes are actually flowing. 453 const downloading = state.phase === 'downloading'; 454 dom.downloadRow.hidden = !downloading; 455 if (downloading) { 456 dom.bytes.textContent = formatBytesPair(state.loaded, state.total); 457 dom.tip.textContent = state.quip || T.downloadTips[state.tipIdx % T.downloadTips.length]; 458 } 459 dom.progress.classList.toggle('bg-progress--pat-able', downloading); 460 dom.useLite.hidden = state.tier !== 'quality' || state.phase !== 'downloading'; 461 462 const hasResult = state.phase === 'done' && state.result; 463 dom.stage.hidden = !hasResult; 464 dom.split.hidden = !hasResult; 465 dom.actions.hidden = !hasResult; 466 dom.meta.hidden = !hasResult; 467 dom.retryActions.hidden = state.phase !== 'error' || !state.file; 468 469 if (hasResult) dom.meta.textContent = describeResult(state.result); 470 471 // Tier picker: highlight the effective tier and gate the quality option on 472 // the hardware probe. The picker lives outside the panel so it renders even 473 // in the idle state, before the first image is dropped.
474 const activeTier = state.tier || resolveTier(); 475 dom.tierFallback.classList.toggle('active', activeTier === 'fallback'); 476 dom.tierQuality.classList.toggle('active', activeTier === 'quality'); 477 const qualityBlocked = !!(state.capability && state.capability.qualityCapable === false); 478 dom.tierQuality.disabled = qualityBlocked; 479 dom.tierHint.textContent = qualityBlocked ? T.tierQualityUnavailable : ''; 480 dom.tierHint.hidden = !qualityBlocked; 481} 482 483function firstUseNote() { 484 if (state.phase === 'downloading') { 485 const info = TIER_INFO[state.tier] || TIER_INFO.fallback; 486 return T.firstUse(info.totalMB, info.label); 487 } 488 return state.note; 489} 490 491function formatBytesPair(loaded, total) { 492 if (!total) return ''; 493 return `${(loaded / 1e6).toFixed(1)} / ${Math.round(total / 1e6)} MB`; 494} 495 496function describeResult(result) { 497 const info = TIER_INFO[result.tier] || TIER_INFO.fallback; 498 const parts = [ 499 `${info.label} model`, 500 `${(result.ms / 1000).toFixed(1)}s`, 501 `${result.width}Ã${result.height}` 502 ]; 503 if (result.downscaled) { 504 // Never change the output size without saying so. 505 parts.push(T.downscaledSuffix); 506 } 507 return parts.join(' · '); 508} 509 510// --- actions --------------------------------------------------------------- 511 512function download() { 513 if (!state.result) return; 514 const a = document.createElement('a'); 515 a.href = state.afterUrl; 516 a.download = M.outputName(state.file && state.file.name); 517 document.body.appendChild(a); 518 a.click(); 519 a.remove(); 520 track('bg_export', { bg_method: 'download', bg_tier: state.result.tier }); 521} 522 523// Mirrors ImagePersistence in script.js exactly. Opening without the version, 524// or without creating the store, can leave a store-less database behind and 525// break the homepage on load. 526const DB = { name: 'ImgMinDB', version: 1, store: 'files' }; 527 528function openDB() { 529 return new Promise((resolve, reject) => { 530 const request = indexedDB.open(DB.name, DB.version); 531 request.onupgradeneeded = event => { 532 const db = event.target.result; 533 if (!db.objectStoreNames.contains(DB.store)) db.createObjectStore(DB.store, { keyPath: 'id' }); 534 }; 535 request.onsuccess = event => resolve(event.target.result); 536 request.onerror = () => reject(request.error); 537 }); 538} 539 540/** 541 * Hand the cutout to the homepage compressor. 542 * 543 * The target is `?from=removebg`, deliberately not `?format=png`: every key in 544 * ['targetKB','format','preset','w','h','fit'] makes script.js re-compress the 545 * whole restored queue, and normalizeMimeType() already keeps PNG â and its 546 * alpha â when the format is 'auto'. 547 */ 548async function handoff(blob, name, width, height) { 549 const file = new File([blob], M.outputName(name), { type: 'image/png', lastModified: Date.now() }); 550 const record = M.buildHandoffRecord({ blob: file, name, w: width, h: height }); 551 const db = await openDB(); 552 try { 553 await new Promise((resolve, reject) => { 554 const tx = db.transaction([DB.store], 'readwrite'); 555 // Resolve on the transaction, not on the request: request.onsuccess 556 // fires before the write is committed, and this call site navigates 557 // immediately afterwards, which aborts an uncommitted transaction 558 // and silently loses the image. 559 tx.oncomplete = () => resolve(); 560 tx.onerror = () => reject(tx.error); 561 tx.onabort = () => reject(tx.error || new Error('handoff transaction aborted')); 562 tx.objectStore(DB.store).put(record); 563 }); 564 } finally { 565 db.close(); 566 } 567 return record; 568} 569 570async function sendToCompressor() { 571 if (!state.result) return; 572 dom.compress.disabled = true; 573 try { 574 await handoff(state.result.blob, state.file && state.file.name, state.result.width, state.result.height); 575 track('bg_export', { bg_method: 'handoff', bg_tier: state.result.tier }); 576 location.href = '/?from=removebg'; 577 } catch (_) { 578 dom.compress.disabled = false;
579 toast(T.handoffFailed, 'error'); 580 } 581} 582 583/** 584 * Switch tiers. 585 * 586 * The two tiers load different ONNX Runtime builds, and once a worker has initialised one, the 587 * other does not get a clean WebGPU device â inference silently falls back to 588 * CPU and a 1024px graph then takes minutes instead of seconds. Replacing the 589 * Worker gives the same clean isolation while retaining the selected File. 590 */ 591function useTier(tier) { 592 localStorage.setItem(TIER_KEY, tier); 593 track('bg_tier_pick', { bg_tier: tier }); 594 595 // Terminating the worker aborts an in-flight model fetch immediately. The 596 // replacement worker gets a clean ONNX Runtime module graph, so WebGPU and 597 // WASM never share runtime state. The selected image can then continue 598 // under the newly chosen tier without refreshing the page. 599 const file = state.file; 600 const canRerun = !!file && !state.error; 601 state.jobId += 1; 602 clearTimer(); 603 resetResult(); 604 state.tier = tier; 605 state.note = ''; 606 state.error = null; 607 recreateWorker(); 608 609 // Re-running a file that failed validation would fail identically. Skipping 610 // it also prevents an old async validation from overwriting a newer drop. 611 if (canRerun) handleFile(file); 612 else { 613 setPhase('idle'); 614 render(); 615 } 616} 617 618function cancelDownload() { 619 if (state.phase !== 'downloading') return; 620 state.jobId += 1; 621 clearTimer(); 622 if (worker) { 623 worker.terminate(); 624 worker = null; 625 } 626 resetResult(); 627 state.file = null; 628 state.loaded = 0; 629 state.total = 0; 630 state.quip = null; 631 setPhase('idle'); 632 dom.panel.hidden = true; 633 render(); 634 toast(T.downloadCancelled); 635} 636 637// --- toast (mirrors showToast in script.js, which is closure-scoped) -------- 638 639function toast(message, type = 'info') { 640 const styles = { 641 info: 'background: rgba(0, 0, 0, 0.8)', 642 success: 'background: rgba(46, 204, 113, 0.9)', 643 error: 'background: rgba(231, 76, 60, 0.9)' 644 }; 645 const existing = document.querySelector('.clipboard-toast'); 646 if (existing) existing.remove(); 647 648 const node = document.createElement('div'); 649 node.className = 'clipboard-toast'; 650 node.textContent = message; 651 node.style.cssText = `position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); 652 padding: 10px 20px; border-radius: 50px; color: #fff; font-size: .9rem; z-index: 10000; 653 ${styles[type] || styles.info}`; 654 document.body.appendChild(node); 655 setTimeout(() => node.remove(), type === 'error' ? 3000 : 2000); 656} 657 658function track(event, params) { 659 // Never include the file name â analytics payloads stay free of user data. 660 if (typeof gtag === 'function') { 661 try { gtag('event', event, params || {}); } catch (_) { /* analytics must never break the tool */ } 662 } 663 try { 664 if (!window.imgminAnalytics) return; 665 if (event === 'bg_run_start') window.imgminAnalytics.track(event, { tier: params.bg_tier }); 666 if (event === 'bg_result') window.imgminAnalytics.track(event, { tier: params.bg_tier, downscaled: !!params.bg_downscaled }); 667 if (event === 'bg_error') window.imgminAnalytics.track(event, { reason: params.bg_code }); 668 if (event === 'bg_export') window.imgminAnalytics.track(event, { method: params.bg_method, tier: params.bg_tier }); 669 } catch (_) { /* analytics must never break the tool */ } 670} 671 672// --- wiring ---------------------------------------------------------------- 673 674dom.dropZone.addEventListener('click', () => { 675 window.imgminAnalytics?.track('bg_upload_intent', { method: 'select' }); 676 dom.fileInput.click(); 677}); 678dom.dropZone.addEventListener('keydown', event => { 679 if (event.key === 'Enter' || event.key === ' ') { 680 event.preventDefault(); 681 window.imgminAnalytics?.track('bg_upload_intent', { method: 'select' }); 682 dom.fileInput.click(); 683 } 684}); 685dom.fileInput.addEventListener('change', event => { 686 handleFile(event.target.files[0], 'select'); 687 event.target.value = ''; 688}); 689 690dom.dropZone.addEventListener('dragover', event => { event.preventDefault(); dom.dropZone.classList.add('dragover'); }); 691dom.dropZone.addEventListener('dragleave', () => dom.dropZone.classList.remove('dragover')); 692dom.dropZone.addEventListener('drop', event => { 693 event.preventDefault(); 694 dom.dropZone.classList.remove('dragover'); 695 window.imgminAnalytics?.track('bg_upload_intent', { method: 'drop' }); 696 handleFile(event.dataTransfer.files[0], 'drop'); 697}); 698 699document.addEventListener('paste', event => { 700 for (const item of event.clipboardData.items) { 701 if (item.type.startsWith('image/')) { 702 window.imgminAnalytics?.track('bg_upload_intent', { method: 'paste' }); 703 handleFile(item.getAsFile(), 'paste'); 704 break; 705 } 706 } 707}); 708 709dom.split.addEventListener('input', () => { 710 dom.stage.style.setProperty('--bg-split', `${100 - Number(dom.split.value)}%`); 711}); 712dom.download.addEventListener('click', download); 713dom.compress.addEventListener('click', sendToCompressor); 714dom.reset.addEventListener('click', () => { 715 resetResult(); 716 state.file = null; 717 setPhase('idle'); 718 dom.panel.hidden = true; 719 render(); 720}); 721// The one bit of play this page allows itself: patting the progress bar 722// answers with a pulse and a quip. Download speed is unaffected, as the quips 723// freely admit. 724dom.progress.addEventListener('click', () => { 725 if (state.phase !== 'downloading') return; 726 state.quip = T.patQuips[state.quipIdx % T.patQuips.length]; 727 state.quipIdx += 1; 728 dom.progress.classList.remove('bg-progress--patted'); 729 void dom.progress.offsetWidth; // restart the animation on consecutive pats 730 dom.progress.classList.add('bg-progress--patted'); 731 render();
732 track('bg_progress_pat', { bg_tier: state.tier }); 733}); 734 735dom.cancel.addEventListener('click', cancelDownload); 736dom.useLite.addEventListener('click', () => useTier('fallback')); 737dom.tierFallback.addEventListener('click', () => { if (resolveTier() !== 'fallback') useTier('fallback'); }); 738dom.tierQuality.addEventListener('click', () => { if (resolveTier() !== 'quality') useTier('quality'); }); 739dom.retry.addEventListener('click', () => state.file && handleFile(state.file)); 740dom.retryLite.addEventListener('click', () => useTier('fallback')); 741 742addEventListener('beforeunload', event => { 743 if (state.phase === 'downloading') { event.preventDefault(); event.returnValue = ''; } 744}); 745addEventListener('pagehide', () => { 746 if (worker) { worker.postMessage({ type: 'dispose' }); worker.terminate(); worker = null; } 747}); 748 749// Must run in the window scope; the worker has no access to this API. 750if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); 751 752dom.stage.style.setProperty('--bg-split', '50%'); 753render(); 754window.imgminAnalytics?.track('bg_tool_view', {}); 755ensureWorker().postMessage({ type: 'probe', force: localStorage.getItem(TIER_KEY) }); 756 757if (IS_LOCAL) { 758 window.__bgTestHooks = { 759 state, 760 handoff, 761 openDB, 762 useTier, 763 forceTier(tier, reason) { 764 state.tier = tier; 765 state.note = DOWNGRADE_NOTE[reason] || ''; 766 render(); 767 } 768 }; 769}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.