PageSourceSearch

https://svgoptimizer.com/shared/js/compress-converter.js?v=mtzzl1bp

js svgoptimizer.com collected 2026-10-04 13:25:08 UTC 62,903 bytes, 1,946 lines download raw bytes

1(function() {
2  'use strict';
3
4  let svgoOptimize = null;
5  let svgoLoading = null;
6
7  async function loadSvgo() {
8    if (svgoOptimize) return svgoOptimize;
9    if (svgoLoading) return svgoLoading;
10
11    svgoLoading = import('/shared/js/svgo.browser.js')
12      .then(module => {
13        svgoOptimize = module.optimize;
14        return svgoOptimize;
15      });
16
17    return svgoLoading;
18  }
19
20  const CONFIG = window.APP_CONFIG || {
21    maxSize: 50 * 1024 * 1024,
22    maxFiles: 20,
23    inputFormats: ['jpg', 'jpeg', 'png', 'webp', 'gif'],
24    texts: {},
25    errors: {}
26  };
27
28  const fileInput = document.getElementById('fileInput');
29  const dropZone = document.getElementById('dropZone');
30  const dropMessage = document.getElementById('dropMessage');
31  const filesScroll = document.getElementById('filesScroll');
32  const filesContainer = document.getElementById('filesContainer');
33  const prevBtn = document.getElementById('prevBtn');
34  const nextBtn = document.getElementById('nextBtn');
35  const clearBtn = document.getElementById('clearBtn');
36  const convertBtn = document.getElementById('convertBtn');
37  const convertCounter = document.getElementById('convertCounter');
38  const downloadAllBtn = document.getElementById('downloadAllBtn');
39  const downloadCounter = document.getElementById('downloadCounter');
40  const hasSeparateDownloadBtn = !!downloadAllBtn;
41  const fileCardTemplate = document.getElementById('fileCardTemplate');
42
43  const settingsPanel = document.getElementById('comparisonPanel');
44  const settingsFileName = document.getElementById('settingsFileName');
45  const settingsLoader = document.getElementById('settingsLoader');
46  const comparisonCanvas = document.getElementById('comparisonCanvas');
47  const comparisonThumb = document.getElementById('comparisonThumb');
48  const zoomIndicator = document.getElementById('zoomIndicator');
49  const qualityInput = document.getElementById('qualityInput');
50  const qualitySlider = document.getElementById('qualitySlider');
51  const qualityLabel = document.getElementById('qualityLabel');
52  const applyQualityBtn = document.getElementById('applyQualityBtn');
53
54  const SLIDER_CONFIG = {
55    quality: { min: 10, max: 100, default: 80, labelKey: 'quality' },
56    colors: { min: 2, max: 256, default: 256, labelKey: 'colors' },
57    precision: { min: 0, max: 5, default: 3, labelKey: 'precision' }
58  };
59
60  function getSliderType(format) {
61    const fmt = format?.toLowerCase();
62    if (fmt === 'png' || fmt === 'gif') return 'colors';
63    if (fmt === 'svg') return 'precision';
64    return 'quality';
65  }
66
67  function updateSliderForFormat(format, currentValue, maxValue = null) {
68    const type = getSliderType(format);
69    const config = SLIDER_CONFIG[type];
70    const effectiveMax = maxValue != null ? Math.min(maxValue, config.max) : config.max;
71
72    if (qualitySlider) {
73      qualitySlider.min = config.min;
74      qualitySlider.max = effectiveMax;
75    }
76    if (qualityLabel) {
77
78      qualityLabel.textContent = CONFIG.texts[config.labelKey] || config.labelKey;
79    }
80
81    let value = currentValue ?? config.default;
82    value = Math.max(config.min, Math.min(effectiveMax, value));
83
84    if (qualityInput) qualityInput.value = value;
85    if (qualitySlider) qualitySlider.value = value;
86
87    return value;
88  }
89  const originalSizeEl = document.getElementById('originalSize');
90  const compressedSizeEl = document.getElementById('compressedSize');
91  const savingsPercentEl = document.getElementById('savingsPercent');
92
93  const frameCounterEl = document.getElementById('frameCounter');
94  const framePrev10Btn = document.getElementById('framePrev10');
95  const framePrev1Btn = document.getElementById('framePrev1');
96  const frameNext1Btn = document.getElementById('frameNext1');
97  const frameNext10Btn = document.getElementById('frameNext10');
98  const frameInput = document.getElementById('frameInput');
99  const frameTotalLabel = document.getElementById('frameTotalLabel');
100
101  let currentFrameIndex = 0;
102  let currentTotalFrames = 0;
103  let currentAnimatedFileData = null;
104
105  let files = [];
106  window.converterFiles = files;
107  let isConverting = false;
108  let isDownloading = false;
109  let hasDownloaded = false;
110  let selectedFileId = null;
111  let comparisonPanelInstance = null;
112  let autoSelectFirst = true;
113  function getDefaultValue(format) {
114    const type = getSliderType(format);
115    return SLIDER_CONFIG[type].default;
116  }
117  let previewDebounceTimer = null;
118  let isPreviewCompressing = false;
119  let hasQualityChanged = false;
120
121  let worker = null;
122  const pendingThumbnails = new Map();
123  const pendingCompressions = new Map();
124
125  function classifyError(msg) {
126    return window.SharedUtils.classifyError(msg, CONFIG.errors);
127  }
128
129  function isWasmCrashError(errorMessage) {
130    return window.SharedUtils.isWasmCrashError(errorMessage);
131  }
132
133  function recreateWorker() {
134    reportError(new Error('WASM worker crashed, recreating'), { op: 'wasm-recovery' });
135    if (worker) {
136      worker.terminate();
137    }
138    worker = null;
139
140    const allPendingWithReject = [
141      pendingThumbnails,
142      pendingCompressions,
143      pendingFrameReads,
144      pendingAnimatedWebpCompressions,
145      pendingGifCompressions
146    ];
147    const error = new Error('Worker crashed, recreating');
148    allPendingWithReject.forEach(map => {
149      map.forEach(pending => pending.reject(error));
150      map.clear();
151    });
152    pendingFrameCounts.forEach(pending => pending.resolve(1));
153    pendingFrameCounts.clear();
154
155  }
156  const abortedFileIds = new Set();
157  const shimmeredElements = new Set();
158
159  const PREVIEW_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
160
161  function extractQuality(result, fallback) {
162
163    return result.optimalColors || result.compressedColors ||
164           result.detectedColors || fallback;
165  }
166
167  function shimmerHint(element) {
168    window.SharedUtils.shimmerHint(element, shimmeredElements);
169  }
170
171  const pendingFrameCounts = new Map();
172  const pendingFrameReads = new Map();
173  const pendingAnimatedWebpCompressions = new Map();
174  const pendingGifCompressions = new Map();
175
176  function countFramesInWorker(file) {
177    return new Promise(async (resolve) => {
178      initWorker();
179      const id = generateId();
180      const arrayBuffer = await file.arrayBuffer();
181
182      pendingFrameCounts.set(id, { resolve });
183
184      worker.postMessage({
185        type: 'count_frames',
186        id,
187        data: arrayBuffer
188      }, [arrayBuffer]);
189    });
190  }
191
192  function readFrameInWorker(blob, frameIndex, cacheType = 'original', cacheKey = null) {
193    return new Promise(async (resolve, reject) => {
194      initWorker();
195      const id = generateId();
196      const arrayBuffer = await blob.arrayBuffer();
197
198      pendingFrameReads.set(id, { resolve, reject });
199
200      worker.postMessage({
201        type: 'read_frame',
202        id,
203        data: arrayBuffer,
204        frameIndex,
205        cacheType,
206        cacheKey: cacheKey || blob.size.toString()
207      }, [arrayBuffer]);
208    });
209  }
210
211  function compressAnimatedWebpInWorker(file, quality) {
212    return new Promise(async (resolve, reject) => {
213      initWorker();
214      const id = generateId();
215      const arrayBuffer = await file.arrayBuffer();
216
217      pendingAnimatedWebpCompressions.set(id, { resolve, reject });
218
219      worker.postMessage({
220        type: 'compress_animated_webp',
221        id,
222        data: arrayBuffer,
223        quality
224      }, [arrayBuffer]);
225    });
226  }
227
228  function compressGifInWorker(file, quality, autoMode = false, maxQuality = 99, optimizationLevel = 3) {
229    return new Promise(async (resolve, reject) => {
230      initWorker();
231      const id = generateId();
232      const arrayBuffer = await file.arrayBuffer();
233
234      pendingGifCompressions.set(id, { resolve, reject });
235
236      worker.postMessage({
237        type: 'compress_gif',
238        id,
239        data: arrayBuffer,
240        quality,
241        autoMode,
242        maxQuality,
243        optimizationLevel
244      }, [arrayBuffer]);
245    });
246  }
247
248  function initWorker() {
249    if (worker) return;
250
251    worker = new Worker(`/shared/js/compress-worker.js?v=${window.APP_CONFIG.v}`, { type: 'module' });
252
253    worker.onmessage = function(e) {
254      const { type, id, success, data, mimeType, error } = e.data;
255
256      if (type === 'thumbnail_result') {
257        const pending = pendingThumbnails.get(id);
258        if (pending) {
259          pendingThumbnails.delete(id);
260          if (success) {
261            const blob = new Blob([data], { type: mimeType });
262            const url = URL.createObjectURL(blob);
263            pending.resolve({ url, width: e.data.width, height: e.data.height });
264          } else {
265            pending.reject(new Error(error));
266            if (isWasmCrashError(error)) {
267              recreateWorker();
268            }
269          }
270        }
271      }
272
273      if (type === 'frame_count_result') {
274        const pending = pendingFrameCounts.get(id);
275        if (pending) {
276          pendingFrameCounts.delete(id);
277          if (success === false && error) {
278            pending.resolve(1);
279            if (isWasmCrashError(error)) {
280              recreateWorker();
281            }
282          } else {
283            pending.resolve(e.data.frameCount || 1);
284          }
285        }
286      }
287
288      if (type === 'frame_result') {
289        const pending = pendingFrameReads.get(id);
290        if (pending) {
291          pendingFrameReads.delete(id);
292          if (success) {
293
294            const pixelData = new Uint8ClampedArray(e.data.data);
295            pending.resolve({
296              width: e.data.width,
297              height: e.data.height,
298              data: pixelData
299            });
300          } else {
301            pending.reject(new Error(error));
302            if (isWasmCrashError(error)) {
303              recreateWorker();
304            }
305          }
306        }
307      }
308
309      if (type === 'compress_animated_webp_result') {
310        const pending = pendingAnimatedWebpCompressions.get(id);
311        if (pending) {
312          pendingAnimatedWebpCompressions.delete(id);
313          if (success) {
314            pending.resolve({
315              data: new Uint8Array(data),
316              mimeType,
317              originalSize: e.data.originalSize,
318              compressedSize: e.data.compressedSize,
319              detectedQuality: e.data.detectedQuality
320            });
321          } else {
322            pending.reject(new Error(error));
323            if (isWasmCrashError(error)) {
324              recreateWorker();
325            }
326          }
327        }
328      }
329
330      if (type === 'compress_gif_result') {
331        const pending = pendingGifCompressions.get(id);
332        if (pending) {
333          pendingGifCompressions.delete(id);
334          if (success) {
335            pending.resolve({
336              data: new Uint8Array(data),
337              mimeType,
338              originalSize: e.data.originalSize,
339              compressedSize: e.data.compressedSize,
340              detectedColors: e.data.detectedColors,
341              optimalColors: e.data.optimalColors
342            });
343          } else {
344            pending.reject(new Error(error));
345            if (isWasmCrashError(error)) {
346              recreateWorker();
347            }
348          }
349        }
350      }
351
352      if (type === 'compress_result') {
353        const pending = pendingCompressions.get(id);
354        if (pending) {
355          pendingCompressions.delete(id);
356          if (success) {
357            pending.resolve({
358              data: new Uint8Array(data),
359              mimeType,
360              originalSize: e.data.originalSize,
361              compressedSize: e.data.compressedSize,
362              actualCompressedSize: e.data.actualCompressedSize,
363              compressedColors: e.data.compressedColors,
364              originalColors: e.data.originalColors,
365              oxipngRatio: e.data.oxipngRatio,
366              detectedQuality: e.data.detectedQuality,
367              usedOriginal: e.data.usedOriginal,
368              imageWidth: e.data.imageWidth,
369              imageHeight: e.data.imageHeight
370            });
371          } else {
372
373            const err = new Error(error);
374            if (typeof e.data.imageWidth === 'number') {
375              err.imageWidth = e.data.imageWidth;
376              err.imageHeight = e.data.imageHeight;
377            }
378            pending.reject(err);
379            if (isWasmCrashError(error)) {
380              recreateWorker();
381            }
382          }
383        }
384      }
385
386    };
387
388    worker.onerror = function(err) {
389      const errorMsg = err.message || 'Worker error';
390      const allPending = [
391        pendingThumbnails,
392        pendingCompressions,
393        pendingFrameReads,
394        pendingAnimatedWebpCompressions,
395        pendingGifCompressions
396      ];
397      allPending.forEach(map => {
398        map.forEach(pending => pending.reject(new Error(errorMsg)));
399        map.clear();
400      });
401
402      pendingFrameCounts.forEach(pending => pending.resolve(1));
403      pendingFrameCounts.clear();
404      worker = null;
405    };
406  }
407
408  function generateThumbnailInWorker(file, inputFormat) {
409    return new Promise(async (resolve, reject) => {
410      initWorker();
411      const id = generateId();
412      const arrayBuffer = await file.arrayBuffer();
413
414      pendingThumbnails.set(id, { resolve, reject });
415
416      worker.postMessage({
417        type: 'thumbnail',
418        id,
419        data: arrayBuffer,
420        inputFormat
421      }, [arrayBuffer]);
422    });
423  }
424
425  function isSvgSprite(svgContent) {
426    const parser = new DOMParser();
427    const doc = parser.parseFromString(svgContent, 'image/svg+xml');
428    const svg = doc.documentElement;
429
430    for (const child of svg.children) {
431      const tag = child.tagName.toLowerCase();
432      if (tag !== 'defs' && tag !== 'style' && tag !== 'metadata') {
433        return false;
434      }
435    }
436
437    return svg.querySelector('symbol') !== null;
438  }
439
440  function detectSvgPrecision(svgContent) {
441    let maxPrecision = 0;
442    const matches = svgContent.match(/\d+\.\d+/g);
443    if (matches) {
444      for (const match of matches) {
445        const decimals = match.split('.')[1];
446        if (decimals && decimals.length > maxPrecision) {
447          maxPrecision = decimals.length;
448        }
449      }
450    }
451    return maxPrecision;
452  }
453
454  function fixSvgDimensions(svgContent) {
455    return svgContent.replace(/<svg([^>]*)>/i, function(match, attrs) {
456      const hasWidth = /\swidth\s*=/i.test(attrs);
457      const hasHeight = /\sheight\s*=/i.test(attrs);
458
459      if (!hasWidth || !hasHeight) {
460        const viewBoxMatch = attrs.match(/viewBox\s*=\s*["']([^"']+)["']/i);
461        if (viewBoxMatch) {
462          const parts = viewBoxMatch[1].trim().split(/\s+/);
463          if (parts.length === 4) {
464            const vbWidth = parts[2];
465            const vbHeight = parts[3];
466            let newAttrs = attrs;
467            if (!hasWidth) newAttrs += ` width="${vbWidth}"`;
468            if (!hasHeight) newAttrs += ` height="${vbHeight}"`;
469            return '<svg' + newAttrs + '>';
470          }
471        }
472      }
473      return match;
474    });
475  }
476
477  function svgDeclaredEncoding(bytes) {
478    let head = '';
479    const limit = Math.min(bytes.length, 256);
480    for (let i = 0; i < limit; i++) head += String.fromCharCode(bytes[i]);
481    const m = /encoding\s*=\s*["']([\w.:-]+)["']/i.exec(head);
482    return m ? m[1] : null;
483  }
484
485  function decodeSvgBytes(bytes) {
486    if (bytes.length > 1) {
487      if (bytes[0] === 0xFF && bytes[1] === 0xFE) return new TextDecoder('utf-16le').decode(bytes);
488      if (bytes[0] === 0xFE && bytes[1] === 0xFF) return new TextDecoder('utf-16be').decode(bytes);
489    }
490    const label = svgDeclaredEncoding(bytes);
491    if (label) {
492      try {
493        return new TextDecoder(label, { fatal: true }).decode(bytes);
494      } catch (e) {
495      }
496    }
497    return new TextDecoder().decode(bytes);
498  }
499
500  async function readSvgText(file) {
501    let bytes = new Uint8Array(await file.arrayBuffer());
502    if (bytes.length > 2 && bytes[0] === 0x1F && bytes[1] === 0x8B) {
503      if (typeof DecompressionStream !== 'function') throw new Error('Invalid SVG file: compressed SVG is not supported by this browser');
504      try {
505        const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
506        bytes = new Uint8Array(await new Response(stream).arrayBuffer());
507      } catch (e) {
508        throw new Error('Invalid SVG file: broken gzip stream');
509      }
510    }
511    const text = decodeSvgBytes(bytes);
512    if (!/<svg[\s>\/]/i.test(text)) throw new Error('Invalid SVG file');
513    return text;
514  }
515
516  async function isGzipFile(file) {
517    const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
518    return head.length === 2 && head[0] === 0x1F && head[1] === 0x8B;
519  }
520
521  async function gzipBytes(bytes) {
522    const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'));
523    return new Uint8Array(await new Response(stream).arrayBuffer());
524  }
525
526  async function optimizeSvgFile(fileData, precision, multipass) {
527    const svgContent = await readSvgText(fileData.file);
528    const optimized = await compressSvg(svgContent, precision, multipass);
529    if (optimized === null) {
530      const data = new Uint8Array(await fileData.file.arrayBuffer());
531      return { data, compressedSize: data.byteLength, mimeType: 'image/svg+xml', usedOriginal: true };
532    }
533    const text = new TextEncoder().encode(optimized);
534    if (await isGzipFile(fileData.file) && typeof CompressionStream === 'function') {
535      const data = await gzipBytes(text);
536      return { data, compressedSize: data.byteLength, mimeType: 'image/svg+xml', viewData: text, outputExt: 'svgz' };
537    }
538    return { data: text, compressedSize: text.byteLength, mimeType: 'image/svg+xml', outputExt: 'svg' };
539  }
540
541  function outputViewBlob(fileData) {
542    if (fileData.outputBlob === fileData.file && fileData.previewFile) return fileData.previewFile;
543    return fileData.outputView || fileData.outputBlob;
544  }
545
546  function svgResultBlobs(result) {
547    const output = new Blob([result.data], { type: result.mimeType });
548    const view = result.viewData ? new Blob([result.viewData], { type: result.mimeType }) : output;
549    return { output, view };
550  }
551
552  async function compressSvg(svgContent, precision, multipass = true) {
553    try {
554      const optimize = await loadSvgo();
555      const result = optimize(svgContent, {
556        multipass: multipass,
557        floatPrecision: precision,
558        plugins: [
559          {
560            name: 'preset-default',
561            params: {
562              overrides: {
563
564                removeHiddenElems: false,
565                removeUselessDefs: false,
566                cleanupIds: false,
567                cleanupNumericValues: {
568                  floatPrecision: precision
569                },
570                convertPathData: {
571                  floatPrecision: precision
572                },
573                convertTransform: {
574                  floatPrecision: precision
575                }
576              }
577            }
578          }
579        ]
580      });
581      return fixSvgDimensions(result.data);
582    } catch (error) {
583      console.error('SVGO optimization error:', error);
584      reportError(error, { op: 'svgo' });
585      return null;
586    }
587  }
588
589  function compressImageInWorker(file, format, quality, optimizationLevel = 2, autoMode = false, maxQuality = 99) {
590    return new Promise(async (resolve, reject) => {
591      initWorker();
592      const id = generateId();
593      const arrayBuffer = await file.arrayBuffer();
594
595      pendingCompressions.set(id, { resolve, reject });
596
597      worker.postMessage({
598        type: 'compress',
599        id,
600        data: arrayBuffer,
601        format,
602        quality,
603        optimizationLevel,
604        autoMode,
605        maxQuality
606      }, [arrayBuffer]);
607    });
608  }
609
610  async function compressFile(fileData, quality) {
611    const format = fileData.inputFormat.toLowerCase();
612
613    if (!['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'].includes(format)) {
614      return;
615    }
616
617    if (abortedFileIds.has(fileData.id)) {
618      abortedFileIds.delete(fileData.id);
619      return;
620    }
621
622    try {
623      fileData.status = 'converting';
624      updateFileCard(fileData);
625
626      const isAnimated = fileData.frameCount > 1;
627      let actualQuality = quality;
628
629      if (abortedFileIds.has(fileData.id)) {
630        abortedFileIds.delete(fileData.id);
631        return;
632      }
633
634      let result;
635      const useAutoMode = format === 'png' || format === 'gif';
636
637      if (format === 'svg') {
638        result = await optimizeSvgFile(fileData, actualQuality, true);
639      } else if (format === 'gif') {
640        result = await compressGifInWorker(fileData.file, actualQuality, useAutoMode, 99, 3);
641      } else if (format === 'webp' && isAnimated) {
642        result = await compressAnimatedWebpInWorker(fileData.file, actualQuality);
643      } else {
644        result = await compressImageInWorker(fileData.file, format, actualQuality, 3, useAutoMode, 99);
645      }
646
647      if (abortedFileIds.has(fileData.id)) {
648        abortedFileIds.delete(fileData.id);
649        return;
650      }
651
652      if (typeof result.imageWidth === 'number') {
653        fileData.imageWidth = result.imageWidth;
654        fileData.imageHeight = result.imageHeight;
655      }
656
657      if (fileData.compressedUrl) {
658        URL.revokeObjectURL(fileData.compressedUrl);
659      }
660
661      if (result.compressedSize >= fileData.originalSize) {
662        const originalUrl = URL.createObjectURL(fileData.file);
663        const viewBlob = fileData.previewFile || fileData.file;
664        fileData.compressedBlob = viewBlob;
665        fileData.compressedUrl = viewBlob === fileData.file ? originalUrl : URL.createObjectURL(viewBlob);
666        fileData.compressedSize = fileData.originalSize;
667        fileData.outputBlob = fileData.file;
668        fileData.outputUrl = originalUrl;
669        fileData.outputSize = fileData.originalSize;
670        fileData.outputView = null;
671        if (format === 'svg') fileData.outputFormat = fileData.originalExt || 'svg';
672      } else {
673        const { output, view } = svgResultBlobs(result);
674        fileData.compressedBlob = view;
675        fileData.compressedUrl = URL.createObjectURL(view);
676        fileData.compressedSize = result.compressedSize;
677        fileData.outputBlob = output;
678        fileData.outputUrl = view === output ? fileData.compressedUrl : URL.createObjectURL(output);
679        fileData.outputSize = result.compressedSize;
680        fileData.outputView = view === output ? null : view;
681        if (format === 'svg') fileData.outputFormat = result.outputExt || 'svg';
682      }
683      actualQuality = extractQuality(result, actualQuality);
684      fileData.quality = actualQuality;
685      fileData.previewQuality = actualQuality;
686
687      if (result.detectedColors || result.originalColors) {
688        fileData.maxColors = result.detectedColors || result.originalColors;
689        if (qualitySlider && selectedFileId === fileData.id) {
690          qualitySlider.max = fileData.maxColors;
691        }
692      }
693
694      if (result.detectedQuality) {
695        fileData.maxQuality = result.detectedQuality;
696        if (qualitySlider && selectedFileId === fileData.id) {
697          qualitySlider.max = fileData.maxQuality;
698        }
699      }
700
701      if (result.oxipngRatio) {
702        fileData.oxipngRatio = result.oxipngRatio;
703      }
704      fileData.status = 'done';
705      reportSuccess({ op: 'compress', format, inputSize: fileData.originalSize, outputSize: fileData.compressedSize, ...imageDims(fileData) });
706
707      updateFileCard(fileData);
708      updateUI();
709
710      if (!fileData.isSprite && (autoSelectFirst || !selectedFileId)) {
711        autoSelectFirst = false;
712        showComparisonPanel(fileData);
713      }
714
715      if (selectedFileId === fileData.id && comparisonPanelInstance) {
716
717        if (currentAnimatedFileData && currentAnimatedFileData.id === fileData.id) {
718          comparisonPanelInstance.updateStats(fileData.originalSize, fileData.compressedSize, false);
719          reloadCurrentFrame(fileData);
720        } else {
721          comparisonPanelInstance.updateCompressedImage(fileData.compressedUrl, fileData.compressedSize, false);
722        }
723      }
724
725    } catch (error) {
726      console.error('processFile error:', error);
727      reportError(error, { op: 'compress', format, fileSize: fileData.originalSize, ...imageDims(fileData) });
728      if (abortedFileIds.has(fileData.id)) {
729        abortedFileIds.delete(fileData.id);
730        return;
731      }
732      fileData.status = 'error';
733      fileData.errorMessage = error.message || 'Compression failed';
734      updateFileCard(fileData);
735    }
736  }
737
738  async function compressPreviewOnly(fileData, quality) {
739    const format = fileData.inputFormat.toLowerCase();
740    if (!['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'].includes(format)) {
741      return;
742    }
743
744    try {
745      isPreviewCompressing = true;
746      setLoading(true);
747
748      const isAnimated = fileData.frameCount > 1;
749
750      let result;
751      if (format === 'svg') {
752        result = await optimizeSvgFile(fileData, quality, false);
753      } else if (format === 'gif') {
754
755        result = await compressGifInWorker(fileData.file, quality, false, 99, 2);
756      } else if (format === 'webp' && isAnimated) {
757        result = await compressAnimatedWebpInWorker(fileData.file, quality);
758      } else {
759        result = await compressImageInWorker(fileData.file, format, quality, 2);
760      }
761
762      if (fileData.compressedUrl && fileData.compressedUrl !== fileData.outputUrl) {
763        URL.revokeObjectURL(fileData.compressedUrl);
764      }
765
766      const blob = svgResultBlobs(result).view;
767      fileData.compressedBlob = blob;
768      fileData.compressedUrl = URL.createObjectURL(blob);
769      fileData.compressedSize = result.compressedSize;
770
771      const actualQuality = extractQuality(result, quality);
772      fileData.previewQuality = actualQuality;
773
774      if (result.originalColors) {
775        fileData.maxColors = result.originalColors;
776        if (qualitySlider) {
777          qualitySlider.max = result.originalColors;
778        }
779      }
780
781      const estimatedSize = result.usedOriginal
782        ? result.compressedSize
783        : (result.actualCompressedSize || result.compressedSize);
784
785      if (selectedFileId === fileData.id && comparisonPanelInstance) {
786
787        if (currentAnimatedFileData && currentAnimatedFileData.id === fileData.id) {
788          comparisonPanelInstance.updateStats(fileData.originalSize, estimatedSize, true);
789          reloadCurrentFrame(fileData);
790        } else {
791          comparisonPanelInstance.updateCompressedImage(fileData.compressedUrl, estimatedSize, true);
792        }
793      }
794
795    } catch (error) {
796      console.error('compressPreviewOnly error:', error);
797      reportError(error, { op: 'preview', format });
798    } finally {
799      isPreviewCompressing = false;
800      setLoading(false);
801    }
802  }
803
804  async function compressAllFiles() {
805    if (isConverting) return;
806
807    const pendingFiles = files.filter(f => f.status === 'pending');
808    if (pendingFiles.length === 0) return;
809
810    isConverting = true;
811    updateUI();
812
813    for (const fileData of pendingFiles) {
814      await compressFile(fileData, fileData.quality);
815    }
816
817    isConverting = false;
818    updateUI();
819  }
820
821  async function addFiles(newFiles) {
822    const remaining = CONFIG.maxFiles - files.length;
823    if (remaining <= 0) return;
824
825    const filesToAdd = Array.from(newFiles).slice(0, remaining);
826    const needsThumbnail = [];
827    const needsDimensions = [];
828
829    for (const file of filesToAdd) {
830      let ext = getFileExtension(file.name);
831
832      const realFormat = await detectFormat(file);
833      if (realFormat && CONFIG.inputFormats.includes(realFormat)) {
834        ext = realFormat;
835      }
836      if (ext === 'svgz') ext = 'svg';
837
838      if (!CONFIG.inputFormats.includes(ext)) {
839        continue;
840      }
841
842      if (file.size > CONFIG.maxSize) {
843        continue;
844      }
845
846      const canPreview = PREVIEW_FORMATS.includes(ext);
847      const baseName = file.name.replace(/\.[^.]+$/, '');
848
849      const fileData = {
850        id: generateId(),
851        file: file,
852        name: baseName,
853        inputFormat: ext.toUpperCase(),
854        status: 'pending',
855        progress: 0,
856        localThumbUrl: (canPreview && ext !== 'svg') ? URL.createObjectURL(file) : null,
857        previewFile: null,
858        outputView: null,
859        imageWidth: null,
860        imageHeight: null,
861        frameCount: 1,
862        outputFormat: ext,
863        originalExt: getFileExtension(file.name),
864        outputBlob: null,
865        outputUrl: null,
866        outputSize: null,
867        errorMessage: null,
868        isZip: false,
869
870        originalSize: file.size,
871        compressedSize: null,
872        compressedBlob: null,
873        compressedUrl: null,
874        quality: getDefaultValue(ext),
875        oxipngRatio: null,
876        downloaded: false
877      };
878
879      files.push(fileData);
880      renderFileCard(fileData);
881
882      if (!canPreview) {
883        needsThumbnail.push(fileData);
884      } else if (ext !== 'svg') {
885        needsDimensions.push(fileData);
886      }
887
888      if (ext === 'gif' || ext === 'webp' || ext === 'tiff' || ext === 'tif') {
889        fileData.frameCountPromise = countFramesInWorker(file).then(count => {
890          fileData.frameCount = count;
891          return count;
892        });
893      }
894
895      if (ext === 'svg') {
896        readSvgText(file).then(svgContent => {
897          const detectedPrecision = detectSvgPrecision(svgContent);
898          fileData.maxPrecision = Math.min(detectedPrecision, SLIDER_CONFIG.precision.max);
899          fileData.quality = Math.min(fileData.quality, fileData.maxPrecision);
900
901          fileData.isSprite = isSvgSprite(svgContent);
902
903          const fixedSvg = fixSvgDimensions(svgContent);
904          const fixedBlob = new Blob([fixedSvg], { type: 'image/svg+xml' });
905          fileData.previewFile = fixedBlob;
906
907          if (fileData.localThumbUrl) {
908            URL.revokeObjectURL(fileData.localThumbUrl);
909          }
910          fileData.localThumbUrl = URL.createObjectURL(fixedBlob);
911
912          const card = filesContainer.querySelector(`[data-id="${fileData.id}"]`);
913          if (card) {
914            const img = card.querySelector('.file__image');
915            if (img) {
916              img.onload = () => img.classList.remove('file__image_hidden');
917              img.src = fileData.localThumbUrl;
918            }
919          }
920        }).catch(() => {});
921      }
922    }
923
924    updateUI();
925    scrollToEnd();
926
927    if (needsThumbnail.length > 0) {
928      generateThumbnails(needsThumbnail);
929    }
930
931    if (needsDimensions.length > 0) {
932      getDimensions(needsDimensions);
933    }
934
935  }
936
937  async function getDimensions(fileDataList) {
938    for (const fileData of fileDataList) {
939      try {
940        const bitmap = await createImageBitmap(fileData.file);
941        fileData.imageWidth = bitmap.width;
942        fileData.imageHeight = bitmap.height;
943        bitmap.close();
944      } catch (error) {
945        fileData.status = 'error';
946        fileData.errorMessage = 'The source image could not be decoded';
947        updateFileCard(fileData);
948        reportError(error, { op: 'corrupt', fileSize: fileData.file.size, ...imageDims(fileData) });
949      }
950    }
951  }
952
953  async function generateThumbnails(fileDataList) {
954    for (const fileData of fileDataList) {
955      try {
956        const thumbTimeout = new Promise((_, reject) =>
957          setTimeout(() => reject(new Error('The source image could not be decoded')), 5000)
958        );
959        const result = await Promise.race([
960          generateThumbnailInWorker(fileData.file, fileData.inputFormat.toLowerCase()),
961          thumbTimeout
962        ]);
963        if (!result.width || !result.height) {
964          throw new Error('The source image could not be decoded');
965        }
966        if (fileData.localThumbUrl) {
967          URL.revokeObjectURL(fileData.localThumbUrl);
968        }
969        fileData.localThumbUrl = result.url;
970        fileData.imageWidth = result.width;
971        fileData.imageHeight = result.height;
972
973        const card = filesContainer.querySelector(`[data-id="${fileData.id}"]`);
974        if (card) {
975          const img = card.querySelector('.file__image');
976          if (img) {
977            img.src = result.url;
978            img.onload = () => img.classList.remove('file__image_hidden');
979            img.onerror = () => {
980              fileData.status = 'error';
981              fileData.errorMessage = 'The source image could not be decoded';
982              updateFileCard(fileData);
983            };
984          }
985        }
986      } catch (error) {
987        fileData.status = 'error';
988        fileData.errorMessage = error.message || 'Failed to read file';
989        updateFileCard(fileData);
990      }
991    }
992  }
993
994  function removeFile(id) {
995    const index = files.findIndex(f => f.id === id);
996    if (index === -1) return;
997
998    const fileData = files[index];
999
1000    if (fileData.status === 'converting') {
1001      abortedFileIds.add(id);
1002    }
1003
1004    if (previewDebounceTimer) {
1005      clearTimeout(previewDebounceTimer);
1006      previewDebounceTimer = null;
1007    }
1008
1009    if (fileData.localThumbUrl) {
1010      URL.revokeObjectURL(fileData.localThumbUrl);
1011    }
1012    if (fileData.outputUrl) {
1013      URL.revokeObjectURL(fileData.outputUrl);
1014    }
1015
1016    if (fileData.compressedUrl && fileData.compressedUrl !== fileData.outputUrl) {
1017      URL.revokeObjectURL(fileData.compressedUrl);
1018    }
1019
1020    if (selectedFileId === id) {
1021      selectedFileId = null;
1022      hideComparisonPanel();
1023    }
1024
1025    files.splice(index, 1);
1026
1027    const card = filesContainer.querySelector(`[data-id="${id}"]`);
1028    if (card) {
1029      shimmeredElements.delete(card);
1030      card.remove();
1031    }
1032
1033    updateUI();
1034  }
1035
1036  function clearAll() {
1037
1038    files.forEach(f => {
1039      if (f.status === 'converting' || f.status === 'pending') {
1040        abortedFileIds.add(f.id);
1041      }
1042    });
1043    isConverting = false;
1044
1045    if (previewDebounceTimer) {
1046      clearTimeout(previewDebounceTimer);
1047      previewDebounceTimer = null;
1048    }
1049
1050    files.forEach(f => {
1051      if (f.localThumbUrl) URL.revokeObjectURL(f.localThumbUrl);
1052      if (f.outputUrl) URL.revokeObjectURL(f.outputUrl);
1053
1054      if (f.compressedUrl && f.compressedUrl !== f.outputUrl) {
1055        URL.revokeObjectURL(f.compressedUrl);
1056      }
1057    });
1058
1059    files = [];
1060    filesContainer.innerHTML = '';
1061    shimmeredElements.clear();
1062    hideComparisonPanel();
1063    autoSelectFirst = true;
1064    updateUI();
1065  }
1066
1067  function downloadFile(fileData) {
1068    if (!fileData.outputUrl || !fileData.outputBlob) return;
1069
1070    hasDownloaded = true;
1071    fileData.downloaded = true;
1072
1073    const ext = fileData.isZip ? 'zip' : fileData.outputFormat;
1074    const filename = `${fileData.name}.${ext}`;
1075    const a = document.createElement('a');
1076    a.href = fileData.outputUrl;
1077    a.download = filename;
1078    document.body.appendChild(a);
1079    a.click();
1080    document.body.removeChild(a);
1081
1082    updateFileCard(fileData);
1083    updateUI();
1084  }
1085
1086  function downloadAll() {
1087    const doneFiles = files.filter(f => f.status === 'done' && f.outputBlob && !f.downloaded);
1088    if (doneFiles.length === 0 || isDownloading) return;
1089
1090    isDownloading = true;
1091    convertBtn.disabled = true;
1092
1093    let completed = 0;
1094    const total = doneFiles.length;
1095
1096    doneFiles.forEach((fileData, index) => {
1097      setTimeout(() => {
1098        downloadFile(fileData);
1099        completed++;
1100        if (completed >= total) {
1101          isDownloading = false;
1102          updateUI();
1103        }
1104      }, index * 100);
1105    });
1106  }
1107
1108  function showComparisonPanel(fileData) {
1109    selectedFileId = fileData.id;
1110
1111    hasQualityChanged = false;
1112    updateApplyButtonState();
1113
1114    const allCards = filesContainer.querySelectorAll('.file');
1115    allCards.forEach(card => {
1116      card.classList.toggle('file_active', card.dataset.id === fileData.id);
1117    });
1118
1119    if (settingsFileName) {
1120      settingsFileName.textContent = fileData.name + '.' + fileData.inputFormat.toLowerCase();
1121    }
1122
1123    const maxValue = fileData.maxColors ?? fileData.maxQuality ?? fileData.maxPrecision;
1124    updateSliderForFormat(fileData.inputFormat, fileData.quality, maxValue);
1125
1126    if (fileData.compressedUrl && fileData.compressedUrl !== fileData.outputUrl && fileData.compressedBlob !== outputViewBlob(fileData)) {
1127      URL.revokeObjectURL(fileData.compressedUrl);
1128      const viewBlob = outputViewBlob(fileData);
1129      fileData.compressedBlob = viewBlob;
1130      fileData.compressedUrl = viewBlob === fileData.outputBlob ? fileData.outputUrl : URL.createObjectURL(viewBlob);
1131      fileData.compressedSize = fileData.outputSize;
1132      fileData.previewQuality = fileData.quality;
1133    }
1134
1135    hideFrameCounter();
1136
1137    if (comparisonPanelInstance) {
1138      comparisonPanelInstance.show({
1139        originalUrl: fileData.localThumbUrl,
1140        compressedUrl: fileData.outputUrl,
1141        originalSize: fileData.originalSize,
1142        compressedSize: fileData.outputSize,
1143        quality: fileData.quality
1144      });
1145
1146      requestAnimationFrame(() => {
1147        if (window.resizeQualitySlider) window.resizeQualitySlider();
1148      });
1149    }
1150
1151    const format = fileData.inputFormat.toLowerCase();
1152    const isAnimatable = format === 'gif' || format === 'webp';
1153    if (isAnimatable && fileData.frameCount > 1) {
1154      initAnimationReader(fileData);
1155    }
1156  }
1157
1158  function hideComparisonPanel() {
1159    selectedFileId = null;
1160
1161    const allCards = filesContainer.querySelectorAll('.file');
1162    allCards.forEach(card => card.classList.remove('file_active'));
1163
1164    if (comparisonPanelInstance) {
1165      comparisonPanelInstance.hide();
1166    }
1167
1168    hideFrameCounter();
1169    clearFrameCache('all');
1170    currentAnimatedFileData = null;
1171  }
1172
1173  function showFrameCounter(totalFrames) {
1174    if (!frameCounterEl) return;
1175    currentTotalFrames = totalFrames;
1176    currentFrameIndex = 0;
1177
1178    frameCounterEl.classList.remove('settings__frame-counter_hidden');
1179    updateFrameCounterUI();
1180  }
1181
1182  function hideFrameCounter() {
1183    if (!frameCounterEl) return;
1184    frameCounterEl.classList.add('settings__frame-counter_hidden');
1185    currentTotalFrames = 0;
1186    currentFrameIndex = 0;
1187  }
1188
1189  function updateFrameCounterUI() {
1190    if (!frameInput || !frameTotalLabel) return;
1191
1192    frameInput.value = currentFrameIndex + 1;
1193    frameTotalLabel.textContent = currentTotalFrames;
1194
1195    const atStart = currentFrameIndex === 0;
1196    const atEnd = currentFrameIndex >= currentTotalFrames - 1;
1197
1198    if (framePrev10Btn) framePrev10Btn.disabled = atStart || currentTotalFrames <= 10;
1199    if (framePrev1Btn) framePrev1Btn.disabled = atStart;
1200    if (frameNext1Btn) frameNext1Btn.disabled = atEnd;
1201    if (frameNext10Btn) frameNext10Btn.disabled = atEnd || currentTotalFrames <= 10;
1202
1203    const maxDigits = String(currentTotalFrames).length;
1204    const currentDigits = String(currentFrameIndex + 1).length;
1205    frameInput.maxLength = maxDigits;
1206    frameInput.style.width = currentDigits + 'ch';
1207  }
1208
1209  let frameChangeTimer = null;
1210  let frameLoadVersion = 0;
1211  const FRAME_CHANGE_DELAY = 50;
1212
1213  function requestFrameChange(index, force = false) {
1214
1215    if (!currentAnimatedFileData || index < 0 || index >= currentTotalFrames) {
1216
1217      return;
1218    }
1219    if (!force && index === currentFrameIndex) {
1220
1221      return;
1222    }
1223
1224    currentFrameIndex = index;
1225    updateFrameCounterUI();
1226
1227    if (frameChangeTimer) {
1228      clearTimeout(frameChangeTimer);
1229    }
1230    frameChangeTimer = setTimeout(() => {
1231      frameChangeTimer = null;
1232      loadCurrentFrame();
1233    }, FRAME_CHANGE_DELAY);
1234  }
1235
1236  async function loadCurrentFrame() {
1237
1238    if (!currentAnimatedFileData) return;
1239
1240    const version = ++frameLoadVersion;
1241    const index = currentFrameIndex;
1242    const fileData = currentAnimatedFileData;
1243
1244    try {
1245
1246      const loadFrame = async (blob, cacheType, cacheKey) => {
1247        const result = await readFrameInWorker(blob, index, cacheType, cacheKey);
1248
1249        const imageData = new ImageData(result.data, result.width, result.height);
1250        const canvas = document.createElement('canvas');
1251        canvas.width = result.width;
1252        canvas.height = result.height;
1253        canvas.getContext('2d').putImageData(imageData, 0, 0);
1254        return canvas.toDataURL('image/png');
1255      };
1256
1257      const originalKey = fileData.id + '-original';
1258      const compressedKey = fileData.id + '-compressed-q' + (fileData.quality || 80);
1259
1260      const [originalDataUrl, compressedDataUrl] = await Promise.all([
1261        loadFrame(fileData.file, 'original', originalKey),
1262        loadFrame(fileData.compressedBlob || fileData.file, 'compressed', compressedKey)
1263      ]);
1264
1265      if (version === frameLoadVersion && comparisonPanelInstance) {
1266        const originalImg = new Image();
1267        const compressedImg = new Image();
1268
1269        await Promise.all([
1270          new Promise(r => { originalImg.onload = r; originalImg.src = originalDataUrl; }),
1271          new Promise(r => { compressedImg.onload = r; compressedImg.src = compressedDataUrl; })
1272        ]);
1273
1274        if (version === frameLoadVersion) {
1275          comparisonPanelInstance.setImage('original', originalImg);
1276          comparisonPanelInstance.setImage('compressed', compressedImg);
1277        }
1278      }
1279    } catch (error) {
1280      console.error('showComparisonPanel error:', error);
1281    }
1282  }
1283
1284  async function goToFrame(index, force = false) {
1285    requestFrameChange(index, force);
1286  }
1287
1288  function setFrameButtonsDisabled(disabled) {
1289    if (framePrev10Btn) framePrev10Btn.disabled = disabled;
1290    if (framePrev1Btn) framePrev1Btn.disabled = disabled;
1291    if (frameNext1Btn) frameNext1Btn.disabled = disabled;
1292    if (frameNext10Btn) frameNext10Btn.disabled = disabled;
1293    if (frameInput) frameInput.disabled = disabled;
1294  }
1295
1296  function frameStep(delta) {
1297
1298    const newIndex = Math.max(0, Math.min(currentTotalFrames - 1, currentFrameIndex + delta));
1299
1300    goToFrame(newIndex);
1301  }
1302
1303  function handleFrameInputChange(e) {
1304    const val = parseInt(e.target.value, 10);
1305    if (!isNaN(val) && val >= 1 && val <= currentTotalFrames) {
1306      goToFrame(val - 1);
1307    } else {
1308      e.target.value = currentFrameIndex + 1;
1309    }
1310  }
1311
1312  async function initAnimationReader(fileData) {
1313    currentAnimatedFileData = fileData;
1314
1315    try {
1316
1317      if (fileData.frameCount > 1) {
1318        showFrameCounter(fileData.frameCount);
1319
1320        goToFrame(0, true);
1321      }
1322    } catch (error) {
1323
1324      hideFrameCounter();
1325    }
1326  }
1327
1328  function clearFrameCache(cacheType = 'all') {
1329    if (worker) {
1330      worker.postMessage({ type: 'clear_frame_cache', cacheType });
1331    }
1332  }
1333
1334  async function reloadCurrentFrame(fileData) {
1335    if (!currentAnimatedFileData || currentAnimatedFileData.id !== fileData.id) return;
1336    if (!fileData.compressedBlob) return;
1337
1338    currentAnimatedFileData = fileData;
1339
1340    clearFrameCache('compressed');
1341
1342    await goToFrame(currentFrameIndex, true);
1343  }
1344
1345  let isDraggingSlider = false;
1346
1347  function setLoading(loading) {
1348    if (settingsLoader) {
1349      settingsLoader.classList.toggle('loader_hidden', !loading);
1350    }
1351    if (!isDraggingSlider) {
1352      if (qualityInput) qualityInput.disabled = loading;
1353      if (qualitySlider) qualitySlider.disabled = loading;
1354    }
1355
1356    if (applyQualityBtn) applyQualityBtn.disabled = loading || !hasQualityChanged;
1357  }
1358
1359  function updateApplyButtonState() {
1360    if (applyQualityBtn) {
1361      applyQualityBtn.disabled = !hasQualityChanged;
1362    }
1363  }
1364
1365  function handleQualityChange(forcePreview = false) {
1366    if (!selectedFileId) return;
1367
1368    const fileData = files.find(f => f.id === selectedFileId);
1369    if (!fileData || fileData.status !== 'done') return;
1370
1371    const type = getSliderType(fileData.inputFormat);
1372    const config = SLIDER_CONFIG[type];
1373    const quality = parseInt(qualityInput?.value || qualitySlider?.value || config.default, 10);
1374    if (quality < config.min || quality > config.max) return;
1375
1376    hasQualityChanged = quality !== fileData.quality;
1377    updateApplyButtonState();
1378
1379    if (quality === fileData.previewQuality) return;
1380    if (isDraggingSlider && !forcePreview) return;
1381
1382    if (previewDebounceTimer) {
1383      clearTimeout(previewDebounceTimer);
1384    }
1385
1386    previewDebounceTimer = setTimeout(() => {
1387      compressPreviewOnly(fileData, quality);
1388    }, 300);
1389  }
1390
1391  async function handleApplyQuality() {
1392    if (!selectedFileId) return;
1393
1394    const fileData = files.find(f => f.id === selectedFileId);
1395    if (!fileData) return;
1396
1397    const format = fileData.inputFormat.toLowerCase();
1398    const defaultVal = getDefaultValue(format);
1399    const quality = fileData.previewQuality || parseInt(qualityInput?.value || defaultVal, 10);
1400
1401    if (quality === fileData.quality) return;
1402
1403    setLoading(true);
1404
1405    try {
1406      const isAnimated = fileData.frameCount > 1;
1407
1408      let result;
1409      if (format === 'svg') {
1410        result = await optimizeSvgFile(fileData, quality, true);
1411      } else if (format === 'gif') {
1412
1413        result = await compressGifInWorker(fileData.file, quality, false, 99, 3);
1414      } else if (format === 'webp' && isAnimated) {
1415        result = await compressAnimatedWebpInWorker(fileData.file, quality);
1416      } else {
1417        result = await compressImageInWorker(fileData.file, format, quality, 3);
1418      }
1419
1420      if (fileData.outputUrl && fileData.outputUrl !== fileData.compressedUrl) {
1421        URL.revokeObjectURL(fileData.outputUrl);
1422      }
1423      if (fileData.compressedUrl) {
1424        URL.revokeObjectURL(fileData.compressedUrl);
1425      }
1426
1427      const { output, view } = svgResultBlobs(result);
1428      fileData.compressedBlob = view;
1429      fileData.compressedUrl = URL.createObjectURL(view);
1430      fileData.compressedSize = result.compressedSize;
1431      fileData.outputBlob = output;
1432      fileData.outputUrl = view === output ? fileData.compressedUrl : URL.createObjectURL(output);
1433      fileData.outputSize = result.compressedSize;
1434      fileData.outputView = view === output ? null : view;
1435      if (format === 'svg') fileData.outputFormat = result.usedOriginal ? (fileData.originalExt || 'svg') : (result.outputExt || 'svg');
1436
1437      const actualQuality = extractQuality(result, quality);
1438      fileData.quality = actualQuality;
1439      fileData.previewQuality = actualQuality;
1440
1441      if (result.originalColors) {
1442        fileData.maxColors = result.originalColors;
1443        if (qualitySlider) {
1444          qualitySlider.max = result.originalColors;
1445        }
1446      }
1447
1448      if (result.oxipngRatio) {
1449        fileData.oxipngRatio = result.oxipngRatio;
1450      }
1451
1452      if (extractQuality(result, null) && qualityInput && qualitySlider) {
1453        qualityInput.value = actualQuality;
1454        qualitySlider.value = actualQuality;
1455      }
1456
1457      if (comparisonPanelInstance) {
1458
1459        if (currentAnimatedFileData && currentAnimatedFileData.id === fileData.id) {
1460          comparisonPanelInstance.updateStats(fileData.originalSize, fileData.compressedSize, false);
1461          reloadCurrentFrame(fileData);
1462        } else {
1463          comparisonPanelInstance.updateCompressedImage(fileData.compressedUrl, fileData.compressedSize, false);
1464        }
1465      }
1466
1467      fileData.downloaded = false;
1468      updateFileCard(fileData);
1469      updateUI();
1470
1471      hasQualityChanged = false;
1472
1473    } catch (error) {
1474      console.error('handleApplyQuality error:', error);
1475      reportError(error, { op: 'quality-change' });
1476    } finally {
1477      setLoading(false);
1478    }
1479  }
1480
1481  function renderFileCard(fileData) {
1482    const template = fileCardTemplate.content.cloneNode(true);
1483    const card = template.querySelector('.file');
1484
1485    card.dataset.id = fileData.id;
1486    card.querySelector('.file__title').textContent = truncateName(fileData.name + '.' + fileData.inputFormat.toLowerCase());
1487
1488    const img = card.querySelector('.file__image');
1489    if (fileData.localThumbUrl) {
1490      img.src = fileData.localThumbUrl;
1491      img.onload = () => img.classList.remove('file__image_hidden');
1492      img.onerror = () => {
1493        fileData.status = 'error';
1494        fileData.errorMessage = 'The source image could not be decoded';
1495        updateFileCard(fileData);
1496      };
1497    }
1498
1499    const pendingFormatText = card.querySelector('.file__state_pending .file__state-text_format');
1500    if (pendingFormatText) {
1501      pendingFormatText.textContent = fileData.inputFormat;
1502    }
1503
1504    const removeBtn = card.querySelector('.remove-btn');
1505    removeBtn.addEventListener('click', (e) => {
1506      e.stopPropagation();
1507      removeFile(fileData.id);
1508    });
1509
1510    const downloadBtn = card.querySelector('.file-button');
1511    downloadBtn.addEventListener('click', (e) => {
1512      e.stopPropagation();
1513      const currentFile = files.find(f => f.id === fileData.id);
1514      if (currentFile && currentFile.status === 'done') {
1515        downloadFile(currentFile);
1516      }
1517    });
1518
1519    card.addEventListener('click', () => {
1520      const currentFile = files.find(f => f.id === fileData.id);
1521
1522      if (currentFile && currentFile.status === 'done' && !currentFile.isSprite) {
1523        showComparisonPanel(currentFile);
1524      }
1525    });
1526
1527    filesContainer.appendChild(card);
1528    updateScrollButtons();
1529  }
1530
1531  function updateFileCard(fileData) {
1532    const card = filesContainer.querySelector(`[data-id="${fileData.id}"]`);
1533    if (!card) return;
1534
1535    const fileButton = card.querySelector('.file-button');
1536    const progressBar = card.querySelector('.file-button__progress');
1537    const buttonText = card.querySelector('.file-button__text');
1538
1539    const statePending = card.querySelector('.file__state_pending');
1540    const stateConverting = card.querySelector('.file__state_converting');
1541    const stateError = card.querySelector('.file__state_error');
1542    const stateSaving = card.querySelector('.file__state_saving');
1543
1544    [statePending, stateConverting, stateError, stateSaving].forEach(s => {
1545      if (s) s.classList.remove('file__state_visible');
1546    });
1547
1548    switch (fileData.status) {
1549      case 'pending':
1550        statePending.classList.add('file__state_visible');
1551        fileButton.classList.add('file-button_hidden');
1552        fileButton.disabled = true;
1553        progressBar.style.width = '0%';
1554        buttonText.textContent = '';
1555        break;
1556
1557      case 'converting':
1558        stateConverting.classList.add('file__state_visible');
1559        fileButton.classList.remove('file-button_hidden');
1560        fileButton.disabled = true;
1561        progressBar.classList.remove('file-button__progress_pause', 'file-button__progress_hidden');
1562        void progressBar.offsetHeight;
1563        progressBar.style.width = '100%';
1564        buttonText.textContent = '';
1565        break;
1566
1567      case 'done':
1568        stateSaving.classList.add('file__state_visible');
1569
1570        const savingText = stateSaving.querySelector('.file__state-text_saving');
1571        if (savingText && fileData.originalSize && fileData.outputSize) {
1572          const savings = Math.round((1 - fileData.outputSize / fileData.originalSize) * 100);
1573          const savingsText = savings >= 0 ? `-${savings}%` : `+${Math.abs(savings)}%`;
1574          savingText.textContent = fileData.isSprite ? `Sprite ${savingsText}` : savingsText;
1575        }
1576        progressBar.classList.add('file-button__progress_hidden');
1577        if (fileData.downloaded) {
1578          fileButton.classList.add('file-button_hidden');
1579          fileButton.disabled = true;
1580          buttonText.textContent = '';
1581        } else {
1582          fileButton.classList.remove('file-button_hidden');
1583          fileButton.disabled = false;
1584          buttonText.textContent = CONFIG.texts.save || 'SAVE';
1585        }
1586        break;
1587
1588      case 'error':
1589        stateError.classList.add('file__state_visible');
1590        const detail = stateError.querySelector('.file__state-detail');
1591        if (detail) detail.textContent = classifyError(fileData.errorMessage);
1592        fileButton.classList.add('file-button_hidden');
1593        fileButton.disabled = true;
1594        progressBar.style.width = '0%';
1595        buttonText.textContent = '';
1596        updateUI();
1597        break;
1598    }
1599
1600    card.classList.toggle('file_can-setting', fileData.status === 'done' && !fileData.isSprite);
1601    card.classList.toggle('file_sprite', !!fileData.isSprite);
1602  }
1603
1604  function updateUI() {
1605    const hasFiles = files.length > 0;
1606    const pendingCount = files.filter(f => f.status === 'pending').length;
1607    const downloadableCount = files.filter(f => f.status === 'done' && !f.downloaded).length;
1608
1609    clearBtn.disabled = !hasFiles;
1610
1611    if (hasSeparateDownloadBtn) {
1612
1613      if (pendingCount > 0) {
1614        convertBtn.disabled = isConverting;
1615        convertCounter.textContent = pendingCount;
1616        convertCounter.style.display = '';
1617
1618        if (!convertBtn.disabled) {
1619          setTimeout(() => { if (!convertBtn.disabled) shimmerHint(convertBtn); }, 500);
1620        }
1621      } else {
1622        convertBtn.disabled = true;
1623        convertCounter.style.display = 'none';
1624      }
1625
1626      if (downloadableCount > 0) {
1627        downloadAllBtn.disabled = isConverting || isDownloading;
1628        downloadCounter.textContent = downloadableCount;
1629        downloadCounter.style.display = '';
1630      } else {
1631        downloadAllBtn.disabled = true;
1632        downloadCounter.style.display = 'none';
1633      }
1634    } else {
1635
1636      const convertBtnText = convertBtn.querySelector('.button__text');
1637      const convertIconConvert = convertBtn.querySelector('.button__icon_convert');
1638      const convertIconDownload = convertBtn.querySelector('.button__icon_download');
1639
1640      if (pendingCount > 0) {
1641        convertBtn.disabled = isConverting;
1642        convertBtn.classList.add('button_dark');
1643        convertBtn.classList.remove('button_success');
1644        convertBtnText.textContent = CONFIG.texts.convert || 'COMPRESS';
1645        if (convertIconConvert) convertIconConvert.style.display = '';
1646        if (convertIconDownload) convertIconDownload.style.display = 'none';
1647        convertCounter.textContent = pendingCount;
1648        convertCounter.style.display = '';
1649
1650        if (!convertBtn.disabled) {
1651          setTimeout(() => { if (!convertBtn.disabled) shimmerHint(convertBtn); }, 500);
1652        }
1653      } else if (downloadableCount > 0) {
1654        convertBtn.disabled = isDownloading;
1655        convertBtn.classList.remove('button_dark');
1656        convertBtn.classList.add('button_success');
1657        convertBtnText.textContent = CONFIG.texts.download || 'DOWNLOAD';
1658        if (convertIconConvert) convertIconConvert.style.display = 'none';
1659        if (convertIconDownload) convertIconDownload.style.display = '';
1660        convertCounter.textContent = downloadableCount;
1661        convertCounter.style.display = '';
1662      } else {
1663        convertBtn.disabled = true;
1664        convertBtn.classList.add('button_dark');
1665        convertBtn.classList.remove('button_success');
1666        convertBtnText.textContent = CONFIG.texts.convert || 'COMPRESS';
1667        if (convertIconConvert) convertIconConvert.style.display = '';
1668        if (convertIconDownload) convertIconDownload.style.display = 'none';
1669        convertCounter.style.display = 'none';
1670      }
1671    }
1672
1673    dropMessage.classList.toggle('drop-caption_hidden', hasFiles);
1674    updateScrollButtons();
1675  }
1676
1677  function updateScrollButtons() {
1678    if (!filesScroll) return;
1679
1680    const { scrollLeft, scrollWidth, clientWidth } = filesScroll;
1681    const canScrollLeft = scrollLeft > 0;
1682    const canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
1683
1684    prevBtn.disabled = !canScrollLeft;
1685    nextBtn.disabled = !canScrollRight;
1686  }
1687
1688  function scrollToEnd() {
1689    if (!filesScroll) return;
1690    setTimeout(() => {
1691      filesScroll.scrollTo({
1692        left: filesScroll.scrollWidth,
1693        behavior: 'smooth'
1694      });
1695    }, 100);
1696  }
1697
1698  function scrollBy(amount) {
1699    if (!filesScroll) return;
1700
1701    const maxScroll = filesScroll.scrollWidth - filesScroll.clientWidth;
1702
1703    if (amount < 0 && filesScroll.scrollLeft < Math.abs(amount)) {
1704      filesScroll.scrollTo({ left: 0, behavior: 'smooth' });
1705      return;
1706    }
1707
1708    if (amount > 0 && filesScroll.scrollLeft + amount > maxScroll - 10) {
1709      filesScroll.scrollTo({ left: maxScroll, behavior: 'smooth' });
1710      return;
1711    }
1712
1713    filesScroll.scrollBy({
1714      left: amount,
1715      behavior: 'smooth'
1716    });
1717  }
1718
1719  function handleFileSelect(e) {
1720    const selectedFiles = e.target.files;
1721    if (selectedFiles && selectedFiles.length > 0) {
1722      addFiles(selectedFiles);
1723      e.target.value = '';
1724    }
1725  }
1726
1727  function handleDragOver(e) {
1728    e.preventDefault();
1729    e.stopPropagation();
1730    dropZone.classList.add('files__list-outer_dragover');
1731  }
1732
1733  function handleDragLeave(e) {
1734    e.preventDefault();
1735    e.stopPropagation();
1736    dropZone.classList.remove('files__list-outer_dragover');
1737  }
1738
1739  function handleDrop(e) {
1740    e.preventDefault();
1741    e.stopPropagation();
1742    dropZone.classList.remove('files__list-outer_dragover');
1743
1744    const droppedFiles = e.dataTransfer.files;
1745    if (droppedFiles && droppedFiles.length > 0) {
1746      addFiles(droppedFiles);
1747    }
1748  }
1749
1750  function handleCompress() {
1751    const pendingCount = files.filter(f => f.status === 'pending').length;
1752    const convertingCount = files.filter(f =>
1752 f.status === 'converting').length;
1753
1754    if (hasSeparateDownloadBtn) {
1755
1756      if (pendingCount > 0 && convertingCount === 0) {
1757        compressAllFiles();
1758      }
1759    } else {
1760
1761      if (pendingCount > 0 || convertingCount > 0) {
1762        if (pendingCount > 0 && convertingCount === 0) {
1763          compressAllFiles();
1764        }
1765      } else {
1766        downloadAll();
1767      }
1768    }
1769  }
1770
1771  function init() {
1772    if (typeof OffscreenCanvas === 'undefined') {
1773      const msg = dropMessage.querySelector('.drop-caption__text');
1774      if (msg) {
1775        msg.textContent = (CONFIG.errors.browserUnsupported || 'Please update your browser').toUpperCase();
1776        msg.style.color = '#e74c3c';
1777      }
1778      fileInput.disabled = true;
1779      fileInput.parentElement.classList.add('button_disabled');
1780      return;
1781    }
1782
1783    fileInput.addEventListener('change', handleFileSelect);
1784
1785    dropZone.addEventListener('dragover', handleDragOver);
1786    dropZone.addEventListener('dragleave', handleDragLeave);
1787    dropZone.addEventListener('drop', handleDrop);
1788
1789    document.addEventListener('dragover', e => e.preventDefault());
1790    document.addEventListener('drop', e => e.preventDefault());
1791
1792    clearBtn.addEventListener('click', clearAll);
1793    convertBtn.addEventListener('click', handleCompress);
1794    downloadAllBtn.addEventListener('click', downloadAll);
1795
1796    prevBtn.addEventListener('click', () => scrollBy(-300));
1797    nextBtn.addEventListener('click', () => scrollBy(300));
1798    filesScroll.addEventListener('scroll', updateScrollButtons);
1799    window.addEventListener('resize', updateScrollButtons);
1800
1801    if (settingsPanel && comparisonCanvas && comparisonThumb) {
1802      comparisonPanelInstance = window.ComparisonPanel({
1803        container: settingsPanel,
1804        canvas: comparisonCanvas,
1805        thumbElement: comparisonThumb,
1806        originalSizeElement: originalSizeEl,
1807        compressedSizeElement: compressedSizeEl,
1808        savingsElement: savingsPercentEl,
1809        zoomIndicatorElement: zoomIndicator
1810      });
1811    }
1812
1813    if (qualityInput && qualitySlider) {
1814      qualityInput.addEventListener('blur', () => {
1815        const min = parseInt(qualitySlider.min, 10);
1816        const max = parseInt(qualitySlider.max, 10);
1817        let val = parseInt(qualityInput.value, 10);
1818        if (isNaN(val) || val < min) val = min;
1819        if (val > max) val = max;
1820        qualityInput.value = val;
1821        qualitySlider.value = val;
1822        handleQualityChange();
1823      });
1824
1825      qualityInput.addEventListener('keydown', (e) => {
1826        if (e.key === 'Enter') {
1827          e.target.blur();
1828        }
1829      });
1830
1831      qualitySlider.addEventListener('input', () => {
1832        qualityInput.value = qualitySlider.value;
1833        handleQualityChange();
1834      });
1835
1836      const rangeOuter = qualitySlider.parentNode;
1837
1838      function updateSliderFromPointer(e) {
1839        const rect = rangeOuter.getBoundingClientRect();
1840        const isVertical = rect.height > rect.width;
1841        let ratio;
1842        if (isVertical) {
1843          ratio = 1 - (e.clientY - rect.top) / rect.height;
1844        } else {
1845          ratio = (e.clientX - rect.left) / rect.width;
1846        }
1847        ratio = Math.max(0, Math.min(1, ratio));
1848        const min = parseInt(qualitySlider.min, 10);
1849        const max = parseInt(qualitySlider.max, 10);
1850        const value = Math.round(min + ratio * (max - min));
1851        qualitySlider.value = value;
1852        qualityInput.value = value;
1853        handleQualityChange();
1854      }
1855
1856      function onSliderMove(e) {
1857        if (!isDraggingSlider) return;
1858        e.preventDefault();
1859        updateSliderFromPointer(e);
1860      }
1861
1862      function onSliderUp() {
1863        if (!isDraggingSlider) return;
1864        isDraggingSlider = false;
1865        document.removeEventListener('pointermove', onSliderMove);
1866        document.removeEventListener('pointerup', onSliderUp);
1867        document.removeEventListener('pointercancel', onSliderUp);
1868        handleQualityChange(true);
1869      }
1870
1871      rangeOuter.addEventListener('pointerdown', (e) => {
1872        e.preventDefault();
1873        isDraggingSlider = true;
1874        updateSliderFromPointer(e);
1875        document.addEventListener('pointermove', onSliderMove);
1876        document.addEventListener('pointerup', onSliderUp);
1877        document.addEventListener('pointercancel', onSliderUp);
1878      });
1879
1880      const mobileQuery = window.matchMedia('(max-width: 555px)');
1881      window.resizeQualitySlider = function() {
1882        const parent = qualitySlider.parentNode;
1883        if (mobileQuery.matches) {
1884          qualitySlider.style.removeProperty('left');
1885          qualitySlider.style.removeProperty('width');
1886        } else {
1887          qualitySlider.style.left = parent.offsetWidth / 2 + 'px';
1888          qualitySlider.style.width = parent.offsetHeight + 'px';
1889        }
1890      };
1891      window.addEventListener('resize', window.resizeQualitySlider);
1892    }
1893
1894    if (applyQualityBtn) {
1895      applyQualityBtn.addEventListener('click', handleApplyQuality);
1896    }
1897
1898    if (framePrev10Btn) {
1899      framePrev10Btn.addEventListener('click', () => frameStep(-10));
1900    }
1901    if (framePrev1Btn) {
1902      framePrev1Btn.addEventListener('click', () => frameStep(-1));
1903    }
1904    if (frameNext1Btn) {
1905      frameNext1Btn.addEventListener('click', () => frameStep(1));
1906    }
1907    if (frameNext10Btn) {
1908
1909      frameNext10Btn.addEventListener('click', () => {
1910
1911        frameStep(10);
1912      });
1913    }
1914    if (frameInput) {
1915      frameInput.addEventListener('blur', handleFrameInputChange);
1916      frameInput.addEventListener('keydown', (e) => {
1917        if (e.key === 'Enter') {
1918          e.target.blur();
1919        }
1920      });
1921
1922      frameInput.addEventListener('input', (e) => {
1923        const len = e.target.value.length || 1;
1924        e.target.style.width = len + 'ch';
1925      });
1926    }
1927
1928    window.addEventListener('beforeunload', () => {
1929      files.forEach(f => {
1930        if (f.localThumbUrl) URL.revokeObjectURL(f.localThumbUrl);
1931        if (f.outputUrl) URL.revokeObjectURL(f.outputUrl);
1932        if (f.compressedUrl) URL.revokeObjectURL(f.compressedUrl);
1933      });
1934      if (worker) worker.terminate();
1935    });
1936
1937    updateUI();
1938  }
1939
1940  if (document.readyState === 'loading') {
1941    document.addEventListener('DOMContentLoaded', init);
1942  } else {
1943    init();
1944  }
1945
1946})();

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.