PageSourceSearch

https://imgmin.pro/bg-remove.js?v=20260923a

js imgmin.pro collected 2026-10-02 01:08:10 UTC 31,524 bytes, 769 lines download raw bytes

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.