razy101 commited on
Commit
128021b
·
1 Parent(s): 0e89299

Adding files

Browse files
Files changed (5) hide show
  1. README.md +5 -5
  2. app.js +602 -0
  3. index.html +130 -19
  4. style.css +0 -28
  5. styles.css +235 -0
README.md CHANGED
@@ -1,12 +1,12 @@
1
  ---
2
  title: Remove Background
3
- emoji: 📊
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: static
7
  pinned: false
8
  license: apache-2.0
9
- short_description: Instant background removal locally in your browser 🥥
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Remove Background
3
+ emoji: 🔥
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: static
7
  pinned: false
8
  license: apache-2.0
9
+ short_description: Instant background removal locally in your browser.
10
  ---
11
 
12
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.js ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AutoModel, AutoProcessor, env, RawImage
3
+ } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1/dist/transformers.min.js';
4
+
5
+ env.allowLocalModels = false;
6
+ env.backends.onnx.wasm.proxy = false;
7
+
8
+ document.addEventListener('DOMContentLoaded', () => {
9
+ const $ = id => document.getElementById(id);
10
+
11
+ const html = document.documentElement;
12
+ const themeToggle = $('themeToggle');
13
+ const iconSun = $('iconSun');
14
+ const iconMoon = $('iconMoon');
15
+ const uploadZone = $('uploadZone');
16
+ const fileInput = $('fileInput');
17
+ const panel = $('panel');
18
+ const originalImg = $('originalImg');
19
+ const resultBody = $('resultBody');
20
+ const resultStatus = $('resultStatus');
21
+ const processBtn = $('processBtn');
22
+ const resetBtn = $('resetBtn');
23
+ const progressWrap = $('progressWrap');
24
+ const progressFill = $('progressFill');
25
+ const progressLabel = $('progressLabel');
26
+ const uploadHint = $('uploadHint');
27
+ const messageBanner = $('messageBanner');
28
+ const queueList = $('queueList');
29
+ const historyList = $('historyList');
30
+ const downloadZipBtn = $('downloadZipBtn');
31
+
32
+ const MAX_FILE_SIZE = 20 * 1024 * 1024;
33
+ const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
34
+
35
+ let model = null;
36
+ let processor = null;
37
+ let currentFile = null;
38
+ let currentDataURL = null;
39
+ let filesQueue = [];
40
+ let history = [];
41
+ let currentBatchResults = [];
42
+ let isDark = false;
43
+ let isBusy = false;
44
+
45
+ function applyTheme(dark) {
46
+ isDark = dark;
47
+ html.setAttribute('data-theme', isDark ? 'dark' : 'light');
48
+ iconSun.style.display = isDark ? 'none' : '';
49
+ iconMoon.style.display = isDark ? '' : 'none';
50
+ }
51
+
52
+ function initTheme() {
53
+ const savedTheme = localStorage.getItem('theme');
54
+
55
+ if (savedTheme !== null) {
56
+ applyTheme(savedTheme === 'dark');
57
+ } else {
58
+ const prefersDark = window.matchMedia &&
59
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
60
+ applyTheme(prefersDark);
61
+ }
62
+ }
63
+
64
+ function setMessage(text, type = 'info') {
65
+ if (!text) {
66
+ messageBanner.className = 'message-banner';
67
+ messageBanner.textContent = '';
68
+ return;
69
+ }
70
+
71
+ messageBanner.className = `message-banner visible ${type}`;
72
+ messageBanner.textContent = text;
73
+ }
74
+
75
+ function setProgress(pct, label) {
76
+ progressFill.style.width = pct + '%';
77
+ progressLabel.textContent = label;
78
+ }
79
+
80
+ function resetProgress() {
81
+ setProgress(0, 'Waiting for image...');
82
+ progressWrap.classList.remove('visible');
83
+ }
84
+
85
+ function fileToDataURL(file) {
86
+ return new Promise((resolve, reject) => {
87
+ try {
88
+ const reader = new FileReader();
89
+ reader.onload = (e) => resolve(e.target.result);
90
+ reader.onerror = (err) => reject(err);
91
+ reader.readAsDataURL(file);
92
+ } catch (err) {
93
+ reject(err);
94
+ }
95
+ });
96
+ }
97
+
98
+ function dataURLtoBlob(dataURL) {
99
+ const parts = dataURL.split(',');
100
+ const meta = parts[0] || '';
101
+ const bstr = atob(parts[1] || '');
102
+ const mimeMatch = meta.match(/:(.*?);/);
103
+ const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
104
+ const n = bstr.length;
105
+ const u8 = new Uint8Array(n);
106
+ for (let i = 0; i < n; i++) u8[i] = bstr.charCodeAt(i);
107
+ return new Blob([u8], { type: mime });
108
+ }
109
+
110
+ function imageDataFromDataURL(dataURL) {
111
+ return new Promise((resolve, reject) => {
112
+ const img = new Image();
113
+ img.onload = () => {
114
+ const c = document.createElement('canvas');
115
+ c.width = img.naturalWidth;
116
+ c.height = img.naturalHeight;
117
+ const ctx = c.getContext('2d');
118
+ ctx.drawImage(img, 0, 0);
119
+ resolve({ imageData: ctx.getImageData(0, 0, c.width, c.height), width: c.width, height: c.height });
120
+ };
121
+ img.onerror = reject;
122
+ img.src = dataURL;
123
+ });
124
+ }
125
+
126
+ function setUploadState(state) {
127
+ uploadZone.classList.remove('is-error', 'is-ready', 'is-processing');
128
+ if (state) uploadZone.classList.add(`is-${state}`);
129
+ }
130
+
131
+ function validateFile(file) {
132
+ if (!file) return { valid: false, message: 'No file was selected.' };
133
+
134
+ const extension = (file.name || '').split('.').pop()?.toLowerCase();
135
+ const normalizedType = (file.type || '').toLowerCase();
136
+ const hasSupportedType = ACCEPTED_TYPES.includes(normalizedType) || ['png', 'jpg', 'jpeg', 'webp'].includes(extension);
137
+
138
+ if (!hasSupportedType) {
139
+ return { valid: false, message: 'Please choose a PNG, JPEG, or WebP image.' };
140
+ }
141
+
142
+ if (file.size > MAX_FILE_SIZE) {
143
+ return { valid: false, message: 'The selected file is larger than 20 MB. Please choose a smaller image.' };
144
+ }
145
+
146
+ return { valid: true };
147
+ }
148
+
149
+ function resetResultArea() {
150
+ resultBody.innerHTML = `
151
+ <div class="result-empty">
152
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
153
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
154
+ <circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
155
+ </svg>
156
+ <span>Your result will appear here</span>
157
+ </div>`;
158
+ resultBody.style.flexDirection = '';
159
+ resultBody.classList.remove('has-result');
160
+ resultStatus.style.display = 'none';
161
+ resultStatus.className = 'status-badge processing';
162
+ resultStatus.style.background = '';
163
+ resultStatus.style.color = '';
164
+ }
165
+
166
+ function setBusy(busy) {
167
+ isBusy = busy;
168
+ processBtn.disabled = busy;
169
+ fileInput.disabled = busy;
170
+ uploadZone.classList.toggle('is-processing', busy);
171
+ }
172
+
173
+ themeToggle.addEventListener('click', () => {
174
+ const newIsDark = !isDark;
175
+ applyTheme(newIsDark);
176
+ localStorage.setItem('theme', newIsDark ? 'dark' : 'light');
177
+ });
178
+
179
+ if (window.matchMedia) {
180
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
181
+ if (!localStorage.getItem('theme')) {
182
+ applyTheme(e.matches);
183
+ }
184
+ });
185
+ }
186
+
187
+ uploadZone.addEventListener('dragover', e => {
188
+ e.preventDefault();
189
+ uploadZone.classList.add('drag-over');
190
+ });
191
+ uploadZone.addEventListener('dragleave', () => {
192
+ uploadZone.classList.remove('drag-over');
193
+ });
194
+ uploadZone.addEventListener('drop', e => {
195
+ e.preventDefault();
196
+ uploadZone.classList.remove('drag-over');
197
+ const files = e.dataTransfer.files;
198
+ if (files && files.length) handleFile(files);
199
+ });
200
+ uploadZone.addEventListener('keydown', e => {
201
+ if (e.key === 'Enter' || e.key === ' ') {
202
+ e.preventDefault();
203
+ fileInput.click();
204
+ }
205
+ });
206
+ fileInput.addEventListener('change', () => {
207
+ if (fileInput.files && fileInput.files.length) handleFile(fileInput.files);
208
+ });
209
+
210
+ async function handleFile(fileOrList) {
211
+ const files = fileOrList instanceof FileList || Array.isArray(fileOrList) ? Array.from(fileOrList) : [fileOrList];
212
+ let added = 0;
213
+ for (const file of files) {
214
+ const validation = validateFile(file);
215
+ if (!validation.valid) {
216
+ setUploadState('error');
217
+ setMessage(validation.message, 'error');
218
+ continue;
219
+ }
220
+ try {
221
+ const dataURL = await fileToDataURL(file);
222
+ filesQueue.push({ file, name: file.name, dataURL });
223
+ added++;
224
+ } catch (err) {
225
+ console.error('[Upload] error:', err);
226
+ }
227
+ }
228
+
229
+ if (filesQueue.length > 0) {
230
+ // show first item as preview
231
+ currentFile = filesQueue[0].file;
232
+ currentDataURL = filesQueue[0].dataURL;
233
+ originalImg.src = currentDataURL;
234
+ uploadZone.style.display = 'none';
235
+ panel.classList.add('visible');
236
+ resetResultArea();
237
+ resultStatus.style.display = 'none';
238
+ resetProgress();
239
+ setMessage(`${filesQueue.length} image(s) added to queue. Click Remove Background to process.`, 'success');
240
+ setUploadState('ready');
241
+ renderQueue();
242
+ updateOriginalLabel();
243
+ setBusy(false);
244
+ } else if (added === 0) {
245
+ setMessage('No valid images were added.', 'warning');
246
+ }
247
+ }
248
+
249
+ function renderQueue() {
250
+ if (!queueList) return;
251
+ queueList.innerHTML = '';
252
+ filesQueue.forEach((it, idx) => {
253
+ const el = document.createElement('div');
254
+ el.className = 'item';
255
+ el.innerHTML = `<img src="${it.dataURL}" alt="${it.name}"><div class="meta">${it.name}</div><div class="remove" data-idx="${idx}">✕</div>`;
256
+ queueList.appendChild(el);
257
+ });
258
+ // attach remove handlers
259
+ queueList.querySelectorAll('.remove').forEach(btn => {
260
+ btn.addEventListener('click', (e) => {
261
+ const idx = Number(e.currentTarget.getAttribute('data-idx'));
262
+ filesQueue.splice(idx, 1);
263
+ renderQueue();
264
+ if (filesQueue.length === 0) {
265
+ // restore upload view
266
+ panel.classList.remove('visible');
267
+ uploadZone.style.display = '';
268
+ } else {
269
+ currentFile = filesQueue[0].file;
270
+ currentDataURL = filesQueue[0].dataURL;
271
+ originalImg.src = currentDataURL;
272
+ }
273
+ });
274
+ });
275
+ updateOriginalLabel();
276
+ }
277
+
278
+ function loadHistory() {
279
+ try {
280
+ const raw = localStorage.getItem('rb_history');
281
+ history = raw ? JSON.parse(raw) : [];
282
+ } catch (e) {
283
+ history = [];
284
+ }
285
+ renderHistory();
286
+ }
287
+
288
+ function updateOriginalLabel() {
289
+ const lbl = document.getElementById('originalLabel');
290
+ if (!lbl) return;
291
+ const count = filesQueue.length || (currentFile ? 1 : 0);
292
+ lbl.textContent = count > 1 ? `Original (${count})` : 'Original';
293
+ }
294
+
295
+ function saveHistory() {
296
+ try {
297
+ localStorage.setItem('rb_history', JSON.stringify(history));
298
+ } catch (e) {
299
+ console.warn('Could not save history', e);
300
+ }
301
+ }
302
+
303
+ function addToHistory(item) {
304
+ history.unshift(item);
305
+ // limit history
306
+ if (history.length > 40) history.length = 40;
307
+ saveHistory();
308
+ renderHistory();
309
+ }
310
+
311
+ function renderHistory() {
312
+ if (!historyList) return;
313
+ historyList.innerHTML = '';
314
+ history.forEach((h, idx) => {
315
+ const el = document.createElement('div');
316
+ el.className = 'item';
317
+ el.innerHTML = `<img src="${h.dataURL}" alt="${h.name}"><div class="meta">${h.name}</div>`;
318
+ el.title = 'Download image';
319
+ el.addEventListener('click', () => {
320
+ // download
321
+ const a = document.createElement('a');
322
+ a.href = h.dataURL;
323
+ a.download = h.name || `removed-${idx}.png`;
324
+ a.click();
325
+ });
326
+ historyList.appendChild(el);
327
+ });
328
+ }
329
+
330
+ async function ensureModelReady() {
331
+ if (model && processor) return;
332
+
333
+ setProgress(8, 'Downloading model files...');
334
+ try {
335
+ model = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
336
+ config: { model_type: 'custom' },
337
+ });
338
+ setProgress(36, 'Preparing image processor...');
339
+ processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4', {
340
+ config: {
341
+ do_normalize: true,
342
+ do_pad: false,
343
+ do_rescale: true,
344
+ do_resize: true,
345
+ image_mean: [0.5, 0.5, 0.5],
346
+ image_std: [1, 1, 1],
347
+ resample: 2,
348
+ rescale_factor: 1 / 255,
349
+ size: { width: 1024, height: 1024 },
350
+ },
351
+ });
352
+ } catch (error) {
353
+ throw new Error(`The AI model could not be loaded. Please check your connection and try again. ${error?.message || ''}`.trim());
354
+ }
355
+ }
356
+
357
+ async function runSegmentation(dataURL) {
358
+ await ensureModelReady();
359
+
360
+ setProgress(46, 'Reading image...');
361
+ const image = await RawImage.fromURL(dataURL);
362
+ const origW = image.width;
363
+ const origH = image.height;
364
+
365
+ setProgress(58, 'Preparing mask...');
366
+ const { pixel_values } = await processor(image);
367
+
368
+ setProgress(74, 'Running inference...');
369
+ const { output } = await model({ input: pixel_values });
370
+
371
+ setProgress(86, 'Refining mask...');
372
+ const tensor = output[0];
373
+ const dims = tensor.dims;
374
+ const raw = tensor.data;
375
+
376
+ const tH = dims[dims.length - 2];
377
+ const tW = dims[dims.length - 1];
378
+ const nPx = tH * tW;
379
+ const offset = raw.length - nPx;
380
+
381
+ let dMin = Infinity;
382
+ let dMax = -Infinity;
383
+ for (let i = 0; i < nPx; i++) {
384
+ const v = raw[offset + i];
385
+ if (v < dMin) dMin = v;
386
+ if (v > dMax) dMax = v;
387
+ }
388
+
389
+ const needsSigmoid = dMin < -0.01 || dMax > 1.01;
390
+ const sigmoid = x => 1 / (1 + Math.exp(-x));
391
+ const maskTensor = new Float32Array(nPx);
392
+ for (let i = 0; i < nPx; i++) {
393
+ const v = raw[offset + i];
394
+ maskTensor[i] = needsSigmoid ? sigmoid(v) : v;
395
+ }
396
+
397
+ const maskFull = new Float32Array(origW * origH);
398
+ const scaleX = (tW - 1) / Math.max(origW - 1, 1);
399
+ const scaleY = (tH - 1) / Math.max(origH - 1, 1);
400
+
401
+ for (let y = 0; y < origH; y++) {
402
+ const ty = y * scaleY;
403
+ const ty0 = Math.floor(ty);
404
+ const ty1 = Math.min(ty0 + 1, tH - 1);
405
+ const dy = ty - ty0;
406
+ for (let x = 0; x < origW; x++) {
407
+ const tx = x * scaleX;
408
+ const tx0 = Math.floor(tx);
409
+ const tx1 = Math.min(tx0 + 1, tW - 1);
410
+ const dx = tx - tx0;
411
+ const v00 = maskTensor[ty0 * tW + tx0];
412
+ const v10 = maskTensor[ty0 * tW + tx1];
413
+ const v01 = maskTensor[ty1 * tW + tx0];
414
+ const v11 = maskTensor[ty1 * tW + tx1];
415
+ maskFull[y * origW + x] = v00 * (1 - dx) * (1 - dy) + v10 * dx * (1 - dy) + v01 * (1 - dx) * dy + v11 * dx * dy;
416
+ }
417
+ }
418
+
419
+ let borderSum = 0;
420
+ let borderCount = 0;
421
+ for (let x = 0; x < origW; x++) {
422
+ borderSum += maskFull[x] + maskFull[(origH - 1) * origW + x];
423
+ borderCount += 2;
424
+ }
425
+ for (let y = 1; y < origH - 1; y++) {
426
+ borderSum += maskFull[y * origW] + maskFull[y * origW + origW - 1];
427
+ borderCount += 2;
428
+ }
429
+ const borderMean = borderCount > 0 ? borderSum / borderCount : 0;
430
+ const isInverted = borderMean > 0.5;
431
+ if (isInverted) {
432
+ for (let i = 0; i < maskFull.length; i++) maskFull[i] = 1 - maskFull[i];
433
+ }
434
+
435
+ setProgress(94, 'Compositing image...');
436
+ const { imageData: origImageData } = await imageDataFromDataURL(dataURL);
437
+ const origPx = origImageData.data;
438
+ const canvas = document.createElement('canvas');
439
+ canvas.width = origW;
440
+ canvas.height = origH;
441
+ const ctx = canvas.getContext('2d');
442
+ const out = ctx.createImageData(origW, origH);
443
+
444
+ for (let i = 0; i < origW * origH; i++) {
445
+ out.data[4 * i] = origPx[4 * i];
446
+ out.data[4 * i + 1] = origPx[4 * i + 1];
447
+ out.data[4 * i + 2] = origPx[4 * i + 2];
448
+ out.data[4 * i + 3] = Math.round(maskFull[i] * 255);
449
+ }
450
+ ctx.putImageData(out, 0, 0);
451
+
452
+ return canvas.toDataURL('image/png');
453
+ }
454
+
455
+ resetBtn.addEventListener('click', () => {
456
+ currentFile = null;
457
+ currentDataURL = null;
458
+ fileInput.value = '';
459
+ originalImg.src = '';
460
+ panel.classList.remove('visible');
461
+ uploadZone.style.display = '';
462
+ uploadZone.classList.remove('drag-over', 'is-processing', 'is-ready', 'is-error');
463
+ resetResultArea();
464
+ resultStatus.style.display = 'none';
465
+ resetProgress();
466
+ setMessage('Choose a PNG, JPEG, or WebP image to begin.', 'info');
467
+ setBusy(false);
468
+ uploadHint.textContent = 'Supports PNG, JPEG, and WebP — up to 20 MB.';
469
+ filesQueue = [];
470
+ currentBatchResults = [];
471
+ if (downloadZipBtn) downloadZipBtn.style.display = 'none';
472
+ updateOriginalLabel();
473
+ });
474
+
475
+ processBtn.addEventListener('click', async () => {
476
+ if (isBusy) return;
477
+ if ((!currentDataURL || !currentDataURL.length) && filesQueue.length === 0) return;
478
+
479
+ currentBatchResults = [];
480
+ if (downloadZipBtn) downloadZipBtn.style.display = 'none';
481
+ setBusy(true);
482
+ resultStatus.style.display = 'inline-flex';
483
+ resultStatus.className = 'status-badge processing';
484
+ resultStatus.innerHTML = '<div class="dot pulse"></div>Processing';
485
+ progressWrap.classList.add('visible');
486
+ setMessage('Processing images...', 'info');
487
+
488
+ try {
489
+ if (filesQueue.length > 0) {
490
+ const total = filesQueue.length;
491
+ while (filesQueue.length > 0) {
492
+ const idx = total - filesQueue.length;
493
+ const item = filesQueue[0];
494
+ setProgress(Math.round((idx / total) * 90) + 5, `Processing ${idx + 1}/${total}...`);
495
+ const resultDataURL = await runSegmentation(item.dataURL);
496
+
497
+ // show result for this item
498
+ const resultImg = new Image();
499
+ resultImg.src = resultDataURL;
500
+ resultImg.style.cssText = 'max-width:100%;max-height:320px;object-fit:contain;border-radius:4px;';
501
+ const dlBtn = document.createElement('button');
502
+ dlBtn.className = 'btn btn-secondary';
503
+ dlBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download PNG';
504
+ dlBtn.style.cssText = 'margin-top:12px;width:fit-content;';
505
+ dlBtn.addEventListener('click', () => {
506
+ const a = document.createElement('a');
507
+ a.href = resultDataURL;
508
+ a.download = (item.name ? item.name.replace(/\.[^.]+$/, '') : 'removed') + '-bg.png';
509
+ a.click();
510
+ });
511
+
512
+ resultBody.innerHTML = '';
513
+ resultBody.style.flexDirection = 'column';
514
+ resultBody.classList.add('has-result');
515
+ resultBody.appendChild(resultImg);
516
+ resultBody.appendChild(dlBtn);
517
+
518
+ resultStatus.innerHTML = '<div class="dot"></div>Ready';
519
+ resultStatus.className = 'status-badge ready';
520
+
521
+ // save to history
522
+ addToHistory({ name: (item.name ? item.name.replace(/\.[^.]+$/, '') : 'removed') + '.png', dataURL: resultDataURL, ts: Date.now() });
523
+ currentBatchResults.push({ name: (item.name ? item.name.replace(/\.[^.]+$/, '') : 'removed') + '.png', dataURL: resultDataURL });
524
+
525
+ // remove processed item
526
+ filesQueue.shift();
527
+ renderQueue();
528
+ }
529
+
530
+ if (currentBatchResults.length > 0 && downloadZipBtn) {
531
+ downloadZipBtn.style.display = 'inline-block';
532
+ downloadZipBtn.onclick = async () => {
533
+ try {
534
+ setMessage('Preparing ZIP...', 'info');
535
+ const zip = new JSZip();
536
+ for (const it of currentBatchResults) {
537
+ zip.file(it.name, dataURLtoBlob(it.dataURL));
538
+ }
539
+ const blob = await zip.generateAsync({ type: 'blob' });
540
+ const a = document.createElement('a');
541
+ a.href = URL.createObjectURL(blob);
542
+ a.download = 'removed-images.zip';
543
+ a.click();
544
+ URL.revokeObjectURL(a.href);
545
+ setMessage('ZIP downloaded', 'success');
546
+ } catch (e) {
547
+ console.error('ZIP error', e);
548
+ setMessage('Failed to create ZIP.', 'error');
549
+ }
550
+ };
551
+ }
552
+ setProgress(100, 'All done ✓');
553
+ setMessage('All images processed and saved to history.', 'success');
554
+ } else {
555
+ // single image
556
+ setProgress(5, 'Preparing segmentation...');
557
+ const resultDataURL = await runSegmentation(currentDataURL);
558
+
559
+ const resultImg = new Image();
560
+ resultImg.src = resultDataURL;
561
+ resultImg.style.cssText = 'max-width:100%;max-height:320px;object-fit:contain;border-radius:4px;';
562
+ const dlBtn = document.createElement('button');
563
+ dlBtn.className = 'btn btn-secondary';
564
+ dlBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download PNG';
565
+ dlBtn.style.cssText = 'margin-top:12px;width:fit-content;';
566
+ dlBtn.addEventListener('click', () => {
567
+ const a = document.createElement('a');
568
+ a.href = resultDataURL;
569
+ a.download = (currentFile && currentFile.name ? currentFile.name.replace(/\.[^.]+$/, '') : 'removed') + '-bg.png';
570
+ a.click();
571
+ });
572
+
573
+ resultBody.innerHTML = '';
574
+ resultBody.style.flexDirection = 'column';
575
+ resultBody.classList.add('has-result');
576
+ resultBody.appendChild(resultImg);
577
+ resultBody.appendChild(dlBtn);
578
+
579
+ resultStatus.innerHTML = '<div class="dot"></div>Ready';
580
+ resultStatus.className = 'status-badge ready';
581
+ addToHistory({ name: (currentFile && currentFile.name ? currentFile.name.replace(/\.[^.]+$/, '') : 'removed') + '.png', dataURL: resultDataURL, ts: Date.now() });
582
+ setProgress(100, 'Done ✓');
583
+ setMessage('Background removed successfully. Download your PNG whenever you want.', 'success');
584
+ }
585
+ } catch (error) {
586
+ console.error('[RMBG] error:', error);
587
+ const detail = error?.message || 'Something went wrong while processing the image.';
588
+ setProgress(0, '⚠ ' + detail);
589
+ resultStatus.innerHTML = '<div class="dot"></div>Error';
590
+ resultStatus.className = 'status-badge error';
591
+ setMessage(`Processing failed: ${detail}`, 'error');
592
+ } finally {
593
+ setBusy(false);
594
+ progressWrap.classList.remove('visible');
595
+ }
596
+ });
597
+
598
+ initTheme();
599
+ setMessage('Choose a PNG, JPEG, or WebP image to begin.', 'info');
600
+ uploadHint.textContent = 'Supports PNG, JPEG, and WebP — up to 20 MB.';
601
+ loadHistory();
602
+ });
index.html CHANGED
@@ -1,19 +1,130 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>BG Remove — Background Removal Tool</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,400;0,500;1,400&family=Geist+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="styles.css">
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
12
+ </head>
13
+ <body>
14
+
15
+ <nav>
16
+ <a class="nav-logo" href="#">BG<span>Remove</span></a>
17
+ <div class="nav-right">
18
+ <div class="tag"><div class="dot"></div>Browser-only</div>
19
+ <button class="theme-btn" id="themeToggle" aria-label="Toggle theme">
20
+ <svg id="iconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
21
+ <circle cx="12" cy="12" r="4"/>
22
+ <line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/>
23
+ <line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/>
24
+ <line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
25
+ <line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
26
+ </svg>
27
+ <svg id="iconMoon" style="display:none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
28
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
29
+ </svg>
30
+ </button>
31
+ </div>
32
+ </nav>
33
+
34
+ <main>
35
+ <div class="hero">
36
+ <h1>Remove backgrounds<br><em>instantly.</em></h1>
37
+ <p>Upload a portrait, product shot, or illustration and remove the background in a few seconds — entirely in your browser.</p>
38
+ </div>
39
+
40
+ <div class="upload-zone" id="uploadZone" role="button" tabindex="0">
41
+ <input type="file" id="fileInput" accept="image/png,image/jpeg,image/webp" multiple>
42
+ <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
43
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
44
+ <polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
45
+ </svg>
46
+ <h3>Drop an image here or click to browse</h3>
47
+ <p id="uploadHint">Supports PNG, JPEG, and WebP — up to 20 MB.</p>
48
+ <div class="formats">
49
+ <span class="format-badge">.png</span>
50
+ <span class="format-badge">.jpg</span>
51
+ <span class="format-badge">.webp</span>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="panel" id="panel">
56
+ <div class="img-card">
57
+ <div class="img-card-header">
58
+ <span id="originalLabel">Original</span>
59
+ <div class="status-badge ready"><div class="dot"></div>Loaded</div>
60
+ </div>
61
+ <div class="img-card-body">
62
+ <img id="originalImg" src="" alt="Original image">
63
+ </div>
64
+ </div>
65
+ <div class="img-card">
66
+ <div class="img-card-header">
67
+ <span>Result</span>
68
+ <div class="status-badge processing" id="resultStatus" style="display:none">
69
+ <div class="dot pulse"></div>Processing
70
+ </div>
71
+ </div>
72
+ <div class="img-card-body" id="resultBody">
73
+ <div class="result-empty">
74
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
75
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
76
+ <circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
77
+ </svg>
78
+ <span>Your result will appear here</span>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ <div class="controls-row">
83
+ <button class="btn btn-primary" id="processBtn">
84
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
85
+ <path d="M12 2L2 7l10 5 10-5-10-5z"/>
86
+ <path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
87
+ </svg>
88
+ Remove Background
89
+ </button>
90
+ <div class="progress-wrap" id="progressWrap">
91
+ <div class="progress-bar-track"><div class="progress-bar-fill" id="progressFill"></div></div>
92
+ <span class="progress-label" id="progressLabel">Initializing...</span>
93
+ </div>
94
+ <button class="btn-reset" id="resetBtn">
95
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="display:inline;margin-right:5px">
96
+ <polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
97
+ </svg>
98
+ Reset
99
+ </button>
100
+ </div>
101
+ <button class="btn btn-secondary" id="downloadZipBtn" style="display:none;">Download ZIP</button>
102
+ </div>
103
+
104
+ <section class="queue-history">
105
+ <div class="queue-panel">
106
+ <h4>Queue</h4>
107
+ <div id="queueList" class="list"></div>
108
+ </div>
109
+ <div class="history-panel">
110
+ <h4>History</h4>
111
+ <div id="historyList" class="list"></div>
112
+ </div>
113
+ </section>
114
+
115
+ <div class="message-banner" id="messageBanner" role="status" aria-live="polite"></div>
116
+
117
+ <div class="foot-note">
118
+ <div class="foot-note-icon">
119
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
120
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
121
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
122
+ </svg>
123
+ </div>
124
+ <p><strong>Private by design.</strong> Your images never leave your device. The AI model runs entirely in the browser using WebAssembly — no server, no uploads, no storage.</p>
125
+ </div>
126
+ </main>
127
+
128
+ <script type="module" src="app.js"></script>
129
+ </body>
130
+ </html>
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
styles.css ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #F7F6F3; --surface: #FFFFFF; --surface-2: #F9F9F8;
3
+ --border: #EAEAEA; --border-strong: rgba(0,0,0,0.12);
4
+ --text-primary: #111111; --text-secondary: #787774; --text-tertiary: #AEADA9;
5
+ --accent: #111111; --accent-text: #FFFFFF; --accent-hover: #333333;
6
+ --tag-blue-bg: #E1F3FE; --tag-blue-text: #1F6C9F;
7
+ --tag-green-bg: #EDF3EC; --tag-green-text: #346538;
8
+ --warning-bg: #FFF5E8; --warning-text: #9B5D00;
9
+ --error-bg: #FDECEC; --error-text: #A23A3A;
10
+ --success-bg: #EDF8F0; --success-text: #2D6A3D;
11
+ --shadow: 0 2px 8px rgba(0,0,0,0.04);
12
+ --radius: 10px; --radius-sm: 6px;
13
+ --transition: 200ms cubic-bezier(0.16, 1, 0.3, 1);
14
+ }
15
+ [data-theme="dark"] {
16
+ --bg: #111111; --surface: #1A1A1A; --surface-2: #222222;
17
+ --border: rgba(255,255,255,0.08); --border-strong: rgba(255,255,255,0.14);
18
+ --text-primary: #EFEFEE; --text-secondary: #888784; --text-tertiary: #555452;
19
+ --accent: #EFEFEE; --accent-text: #111111; --accent-hover: #CFCFCC;
20
+ --tag-blue-bg: rgba(31,108,159,0.18); --tag-blue-text: #7CC3F0;
21
+ --tag-green-bg: rgba(52,101,56,0.18); --tag-green-text: #8DC48F;
22
+ --warning-bg: rgba(155,93,0,0.18); --warning-text: #F1C27B;
23
+ --error-bg: rgba(162,58,58,0.18); --error-text: #F4A3A3;
24
+ --success-bg: rgba(45,106,61,0.18); --success-text: #A5D9B0;
25
+ --shadow: 0 2px 12px rgba(0,0,0,0.3);
26
+ }
27
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
28
+ body {
29
+ font-family: 'DM Sans', -apple-system, sans-serif;
30
+ background: var(--bg); color: var(--text-primary);
31
+ min-height: 100vh;
32
+ transition: background var(--transition), color var(--transition);
33
+ -webkit-font-smoothing: antialiased;
34
+ }
35
+ nav {
36
+ position: sticky; top: 0; z-index: 100;
37
+ display: flex; align-items: center; justify-content: space-between;
38
+ padding: 0 clamp(16px, 5vw, 48px); height: 56px;
39
+ background: var(--bg); border-bottom: 1px solid var(--border);
40
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
41
+ transition: background var(--transition), border-color var(--transition);
42
+ }
43
+ .nav-logo {
44
+ font-family: 'Newsreader', Georgia, serif; font-size: 18px; font-weight: 500;
45
+ letter-spacing: -0.03em; color: var(--text-primary); text-decoration: none;
46
+ display: flex; align-items: center; gap: 6px;
47
+ }
48
+ .nav-logo span { font-style: italic; color: var(--text-secondary); }
49
+ .nav-right { display: flex; align-items: center; gap: 12px; }
50
+ .tag {
51
+ display: inline-flex; align-items: center; gap: 5px;
52
+ font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase;
53
+ padding: 3px 10px; border-radius: 9999px;
54
+ background: var(--tag-blue-bg); color: var(--tag-blue-text);
55
+ }
56
+ .theme-btn {
57
+ width: 34px; height: 34px; border: 1px solid var(--border-strong);
58
+ border-radius: var(--radius-sm); background: var(--surface); color: var(--text-secondary);
59
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
60
+ transition: background var(--transition), transform 150ms, box-shadow var(--transition);
61
+ flex-shrink: 0;
62
+ }
63
+ .theme-btn:hover { background: var(--surface-2); box-shadow: var(--shadow); }
64
+ .theme-btn:active { transform: scale(0.95); }
65
+ .theme-btn svg { width: 16px; height: 16px; }
66
+ main {
67
+ max-width: 960px; margin: 0 auto;
68
+ padding: clamp(36px, 8vw, 72px) clamp(16px, 5vw, 32px) 80px;
69
+ }
70
+ .hero {
71
+ margin-bottom: 32px; opacity: 0; transform: translateY(12px);
72
+ animation: fadeUp 600ms cubic-bezier(0.16, 1, 0.3, 1) 100ms forwards;
73
+ }
74
+ .hero h1 {
75
+ font-family: 'Newsreader', Georgia, serif;
76
+ font-size: clamp(32px, 6vw, 54px); font-weight: 400;
77
+ letter-spacing: -0.035em; line-height: 1.1; color: var(--text-primary); margin-bottom: 12px;
78
+ }
79
+ .hero h1 em { font-style: italic; color: var(--text-secondary); }
80
+ .hero p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; max-width: 560px; }
81
+ .upload-zone {
82
+ border: 1.5px dashed var(--border-strong); border-radius: var(--radius);
83
+ background: var(--surface); padding: 56px 32px; text-align: center; cursor: pointer;
84
+ transition: border-color var(--transition), background var(--transition), box-shadow var(--transition), transform var(--transition);
85
+ position: relative; overflow: hidden; opacity: 0; transform: translateY(12px);
86
+ animation: fadeUp 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms forwards;
87
+ }
88
+ .upload-zone:hover, .upload-zone.drag-over, .upload-zone.is-ready {
89
+ border-color: var(--text-secondary); background: var(--surface-2); box-shadow: var(--shadow);
90
+ }
91
+ .upload-zone.is-error {
92
+ border-color: var(--error-text); background: var(--error-bg);
93
+ }
94
+ .upload-zone.is-processing {
95
+ opacity: 0.8; transform: scale(0.99); pointer-events: none;
96
+ }
97
+ .upload-zone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
98
+ .upload-icon { width: 44px; height: 44px; margin: 0 auto 16px; color: var(--text-tertiary); }
99
+ .upload-zone h3 { font-size: 16px; font-weight: 500; color: var(--text-primary); margin-bottom: 6px; }
100
+ .upload-zone p { font-size: 13px; color: var(--text-secondary); }
101
+ .upload-zone .formats { display: flex; gap: 6px; justify-content: center; margin-top: 16px; flex-wrap: wrap; }
102
+ .format-badge {
103
+ font-family: 'Geist Mono', monospace; font-size: 11px; padding: 2px 8px;
104
+ border: 1px solid var(--border); border-radius: 4px;
105
+ background: var(--surface-2); color: var(--text-secondary);
106
+ }
107
+ .panel {
108
+ display: none; gap: 20px; opacity: 0; transform: translateY(12px);
109
+ margin-top: 24px;
110
+ animation: fadeUp 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
111
+ }
112
+ .panel.visible { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; }
113
+ @media (max-width: 620px) { .panel.visible { grid-template-columns: 1fr; } }
114
+ .img-card {
115
+ border: 1px solid var(--border); border-radius: var(--radius);
116
+ background: var(--surface); overflow: hidden; position: relative;
117
+ }
118
+ .img-card-header {
119
+ padding: 14px 18px; border-bottom: 1px solid var(--border);
120
+ display: flex; align-items: center; justify-content: space-between;
121
+ }
122
+ .img-card-header span {
123
+ font-size: 12px; font-weight: 500; letter-spacing: 0.04em;
124
+ text-transform: uppercase; color: var(--text-secondary);
125
+ }
126
+ .img-card-body {
127
+ padding: 16px; min-height: 260px; display: flex;
128
+ align-items: center; justify-content: center;
129
+ background: repeating-conic-gradient(var(--surface-2) 0% 25%, var(--surface) 0% 50%) 0 0 / 16px 16px;
130
+ }
131
+ .img-card-body img { max-width: 100%; max-height: 320px; object-fit: contain; border-radius: 4px; }
132
+ .controls-row { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
133
+ .btn {
134
+ display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px;
135
+ border-radius: var(--radius-sm); font-family: 'DM Sans', sans-serif;
136
+ font-size: 14px; font-weight: 500; cursor: pointer; border: none;
137
+ transition: background var(--transition), transform 150ms, box-shadow var(--transition);
138
+ white-space: nowrap;
139
+ }
140
+ .btn:active { transform: scale(0.97); }
141
+ .btn-primary { background: var(--accent); color: var(--accent-text); }
142
+ .btn-primary:hover { background: var(--accent-hover); }
143
+ .btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
144
+ .btn-secondary { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); }
145
+ .btn-secondary:hover { background: var(--surface-2); box-shadow: var(--shadow); }
146
+ .btn svg { width: 16px; height: 16px; flex-shrink: 0; }
147
+ .btn-reset {
148
+ margin-left: auto; background: transparent; border: none;
149
+ font-size: 13px; color: var(--text-secondary); cursor: pointer;
150
+ padding: 8px 12px; border-radius: var(--radius-sm); font-family: 'DM Sans', sans-serif;
151
+ transition: color var(--transition), background var(--transition);
152
+ }
153
+ .btn-reset:hover { color: var(--text-primary); background: var(--surface); }
154
+ .progress-wrap { display: none; flex-direction: column; gap: 8px; width: 100%; }
155
+ .progress-wrap.visible { display: flex; }
156
+ .progress-bar-track { height: 2px; background: var(--border); border-radius: 2px; overflow: hidden; }
157
+ .progress-bar-fill { height: 100%; background: var(--text-primary); border-radius: 2px; width: 0%; transition: width 300ms ease; }
158
+ .progress-label { font-size: 12px; color: var(--text-secondary); font-family: 'Geist Mono', monospace; }
159
+ .status-badge {
160
+ display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 500;
161
+ letter-spacing: 0.05em; text-transform: uppercase; padding: 3px 10px; border-radius: 9999px;
162
+ }
163
+ .status-badge.ready { background: var(--tag-green-bg); color: var(--tag-green-text); }
164
+ .status-badge.processing { background: var(--tag-blue-bg); color: var(--tag-blue-text); }
165
+ .status-badge.error { background: var(--error-bg); color: var(--error-text); }
166
+ .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
167
+ .dot.pulse { animation: pulse 1.2s ease infinite; }
168
+ .result-empty {
169
+ display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px;
170
+ color: var(--text-tertiary);
171
+ text-align: center;
172
+ }
173
+ .result-empty span { font-size: 13px; }
174
+ .message-banner {
175
+ display: none; margin-top: 16px; padding: 12px 14px; border-radius: 10px;
176
+ font-size: 13px; line-height: 1.5; border: 1px solid transparent;
177
+ }
178
+ .message-banner.visible { display: block; }
179
+ .message-banner.info { background: var(--surface-2); border-color: var(--border); color: var(--text-secondary); }
180
+ .message-banner.warning { background: var(--warning-bg); border-color: rgba(155,93,0,0.2); color: var(--warning-text); }
181
+ .message-banner.error { background: var(--error-bg); border-color: rgba(162,58,58,0.2); color: var(--error-text); }
182
+ .message-banner.success { background: var(--success-bg); border-color: rgba(45,106,61,0.18); color: var(--success-text); }
183
+ .foot-note {
184
+ margin-top: 32px; padding-top: 24px; border-top: 1px solid var(--border);
185
+ display: flex; align-items: flex-start; gap: 12px; opacity: 0; transform: translateY(12px);
186
+ animation: fadeUp 600ms cubic-bezier(0.16, 1, 0.3, 1) 400ms forwards;
187
+ }
188
+ .foot-note-icon {
189
+ width: 28px; height: 28px; border-radius: 6px; background: var(--surface);
190
+ border: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
191
+ color: var(--text-secondary); flex-shrink: 0;
192
+ }
193
+ .foot-note p { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
194
+ .foot-note strong { color: var(--text-primary); font-weight: 500; }
195
+ @keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
196
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
197
+ @media (max-width: 480px) {
198
+ .upload-zone { padding: 40px 20px; }
199
+ .tag { display: none; }
200
+ .controls-row { flex-direction: column; align-items: stretch; }
201
+ .btn-reset { margin-left: 0; }
202
+ }
203
+
204
+ .queue-history {
205
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 20px;
206
+ }
207
+ .queue-panel, .history-panel { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; background: var(--surface); }
208
+ .queue-panel h4, .history-panel h4 { margin: 0 0 8px 0; font-size: 13px; color: var(--text-secondary); }
209
+ .list { display: flex; gap: 8px; flex-wrap: wrap; min-height: 72px; }
210
+ .item { width: 72px; height: 72px; border-radius: 6px; overflow: hidden; background: var(--surface-2); display: flex; align-items: center; justify-content: center; position: relative; }
211
+ .item img { width: 100%; height: 100%; object-fit: cover; }
212
+ .item .meta { position: absolute; left: 4px; bottom: 4px; right: 4px; font-size: 10px; color: var(--text-secondary); text-align: center; }
213
+ .item .remove { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,0.06); border-radius: 4px; padding: 2px 4px; font-size: 11px; cursor: pointer; }
214
+
215
+ @media (max-width: 620px) { .queue-history { grid-template-columns: 1fr; } }
216
+
217
+ /* History thumbnails: show pointer and download icon on hover */
218
+ .history-panel .item { cursor: default; }
219
+ .history-panel .item:hover { cursor: pointer; }
220
+ .history-panel .item::after {
221
+ content: '';
222
+ position: absolute;
223
+ top: 6px;
224
+ right: 6px;
225
+ width: 18px;
226
+ height: 18px;
227
+ background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' y1='15' x2='12' y2='3'/%3E%3C/svg%3E");
228
+ background-size: contain;
229
+ background-repeat: no-repeat;
230
+ opacity: 0;
231
+ transform: translateY(-4px);
232
+ transition: opacity 160ms ease, transform 160ms ease;
233
+ pointer-events: none;
234
+ }
235
+ .history-panel .item:hover::after { opacity: 1; transform: translateY(0); }