// ========================================== // OmniConvert: Image Processing Module // ========================================== const ImageHub = { // Module State files: [], // Array of { id, file, name, srcUrl, srcSize, srcDims, status, out: {blob, url, size, name, fmt, dims}, error } nextId: 1, // Options (bound to UI controls) options: { targetFormat: 'png', // png, jpeg, webp, avif, bmp, ico, pdf targetSizeLimit: false, // binary search compression to KB targetSizeValue: 100, // in KB quality: 0.85, // 0.1 to 1.0 (lossy formats only) maxDimension: 0, // 0 for original, or max side length in px bgWhite: true, // fill transparency with white for jpg/bmp autoDownload: false // immediately trigger individual download }, // Initialize event bindings and drag-n-drop init() { this.bindEvents(); // Setup dropzone using app shell helper App.setupDropzone('imageDropzone', 'imageFileInput', (fileList) => { this.handleFilesAdded(fileList); }); }, // Bind controls to State bindEvents() { const $ = (id) => document.getElementById(id); // Target format selection const fmtButtons = document.querySelectorAll('#targetFormatGrid .fmt-btn'); fmtButtons.forEach(btn => { btn.addEventListener('click', () => { fmtButtons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); this.options.targetFormat = btn.dataset.fmt; this.updateControlVisibility(); }); }); // Target size toggle $('targetSizeToggle').addEventListener('change', (e) => { this.options.targetSizeLimit = e.target.checked; this.updateControlVisibility(); }); // Target size input $('targetSizeKBInput').addEventListener('input', (e) => { let val = parseInt(e.target.value, 10); if (isNaN(val) || val < 5) val = 5; this.options.targetSizeValue = val; }); // Quality slider const qSlider = $('qualitySlider'); const qDisplay = $('qualityValDisplay'); qSlider.addEventListener('input', (e) => { const pct = e.target.value; qDisplay.textContent = pct + '%'; this.options.quality = parseInt(pct, 10) / 100; }); // Max side slider const sSlider = $('maxSizeSlider'); const sDisplay = $('maxSizeValDisplay'); sSlider.addEventListener('input', (e) => { const val = parseInt(e.target.value, 10); sDisplay.textContent = val === 0 ? 'Исходный' : val + 'px'; this.options.maxDimension = val; }); // Other settings $('bgWhiteToggle').addEventListener('change', (e) => this.options.bgWhite = e.target.checked); $('autoDownloadToggle').addEventListener('change', (e) => this.options.autoDownload = e.target.checked); // Queue buttons $('clearAllImagesBtn').addEventListener('click', () => this.clearQueue()); $('processAllImagesBtn').addEventListener('click', () => this.processAll()); $('downloadZipBtn').addEventListener('click', () => this.downloadAllAsZip()); }, // Toggle visible sliders based on active options updateControlVisibility() { const qualityCtrl = document.getElementById('qualityControl'); const targetCtrl = document.getElementById('targetSizeControl'); // Lossy formats support quality slider const isLossy = ['jpeg', 'webp', 'avif', 'pdf'].includes(this.options.targetFormat); // Quality slider is shown if lossy and target size is NOT enabled if (isLossy && !this.options.targetSizeLimit) { qualityCtrl.classList.remove('hidden'); } else { qualityCtrl.classList.add('hidden'); } // Target size input is shown only if target size option is toggled if (this.options.targetSizeLimit) { targetCtrl.classList.remove('hidden'); } else { targetCtrl.classList.add('hidden'); } }, // Handle files input selection or drop async handleFilesAdded(fileList) { let imagesCount = 0; const filesToProcess = Array.from(fileList); for (const file of filesToProcess) { const ext = file.name.split('.').pop().toLowerCase(); if (ext === 'pdf') { try { App.showToast(`Обработка PDF "${file.name}"...`, 'info'); const pages = await this.extractPagesFromPDF(file); for (const pageFile of pages) { this.addSingleFileToQueue(pageFile); imagesCount++; } } catch (err) { console.error(err); App.showToast(`Ошибка разбора PDF "${file.name}": ${err.message}`, 'error'); } } else { const isImg = file.type.startsWith('image/') || ['heic', 'heif', 'tiff', 'tif', 'avif', 'ico', 'bmp', 'svg'].includes(ext); if (!isImg) { App.showToast(`Файл "${file.name}" не является поддерживаемым изображением.`, 'warning'); continue; } this.addSingleFileToQueue(file); imagesCount++; } } if (imagesCount > 0) { App.showToast(`Добавлено файлов: ${imagesCount}`, 'info'); document.getElementById('imageQueueCard').classList.remove('hidden'); this.renderQueue(); } }, addSingleFileToQueue(file) { const id = this.nextId++; const srcUrl = URL.createObjectURL(file); this.files.push({ id, file, name: file.name, srcUrl, srcSize: file.size, srcDims: null, status: 'pending', out: null, error: null }); }, // Parse PDF and render pages as PNG image files offline async extractPagesFromPDF(file) { const pdfjsLib = window.pdfjsLib || window['pdfjs-dist/build/pdf']; if (!pdfjsLib) { throw new Error('Библиотека PDF.js не загружена. Проверьте lib/pdf.min.js'); } pdfjsLib.GlobalWorkerOptions.workerSrc = 'lib/pdf.worker.min.js'; const arrayBuffer = await file.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; const numPages = pdf.numPages; App.showToast(`Извлечение страниц из PDF: ${numPages}...`, 'info'); const pagesList = []; const baseName = file.name.replace(/\.[^.]+$/, ''); for (let pageNum = 1; pageNum <= numPages; pageNum++) { const page = await pdf.getPage(pageNum); // Render at double scale to ensure good image quality/readability const scale = 2.0; const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; const ctx = canvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport: viewport }).promise; const blob = await new Promise((resolve) => { canvas.toBlob((b) => resolve(b), 'image/png'); }); const pageFile = new File([blob], `${baseName}_page_${pageNum}.png`, { type: 'image/png' }); pagesList.push(pageFile); } return pagesList; }, // Clear everything in state clearQueue() { this.files.forEach(f => { if (f.srcUrl) URL.revokeObjectURL(f.srcUrl); if (f.out?.url) URL.revokeObjectURL(f.out.url); }); this.files = []; document.getElementById('imageQueueCard').classList.add('hidden'); document.getElementById('bulkDownloadBar').classList.add('hidden'); this.renderQueue(); App.showToast('Очередь очищена', 'info'); }, // Render HTML queue cards renderQueue() { const container = document.getElementById('imagesListContainer'); const queueCount = document.getElementById('queueCount'); container.innerHTML = ''; queueCount.textContent = this.files.length; if (this.files.length === 0) { return; } this.files.forEach(f => { const card = document.createElement('div'); card.className = 'file-card'; card.id = `img-card-${f.id}`; // Thumbnail check let thumbContent = ''; if (f.status === 'done' && f.out?.url && f.out.fmt !== 'pdf') { thumbContent = `${f.name}`; } else if (f.file.type.startsWith('image/svg') || f.name.endsWith('.svg')) { thumbContent = `🎨`; } else if (f.name.endsWith('.heic') || f.name.endsWith('.heif')) { thumbContent = `🍏`; } else { thumbContent = `${f.name}`; } // Details row let metaDetails = `${App.formatBytes(f.srcSize)}`; if (f.srcDims) { metaDetails += `•${f.srcDims.w}x${f.srcDims.h}px`; } if (f.out) { const delta = f.out.size - f.srcSize; const pct = ((delta / f.srcSize) * 100).toFixed(1); const sign = delta < 0 ? '' : '+'; const cssClass = delta < 0 ? 'positive' : 'negative'; metaDetails += ` → ${App.formatBytes(f.out.size)} • (${sign}${pct}%) `; if (f.out.dims) { metaDetails += `•${f.out.dims.w}x${f.out.dims.h}px`; } } // Status Badge let badgeHtml = ''; if (f.status === 'pending') badgeHtml = `${I18n.t('badge_pending', 'ожидает')}`; if (f.status === 'processing') badgeHtml = ` ${I18n.t('badge_processing', 'сжатие...')}`; if (f.status === 'done') badgeHtml = `${I18n.t('badge_done', '✓ готово')}`; if (f.status === 'error') badgeHtml = `${I18n.t('badge_error', '✗ ошибка')}`; // Actions buttons const isDlDisabled = f.status !== 'done' ? 'disabled' : ''; const isCvDisabled = f.status === 'processing' ? 'disabled' : ''; card.innerHTML = `
${thumbContent}
${f.out?.name || f.name}
${metaDetails}
${badgeHtml} ${f.error ? `${f.error}` : ''}
`; // Event Listeners on buttons card.querySelector(`#dl-btn-${f.id}`).addEventListener('click', () => this.downloadSingle(f)); card.querySelector(`#cv-btn-${f.id}`).addEventListener('click', () => this.processFile(f)); card.querySelector(`#rm-btn-${f.id}`).addEventListener('click', () => this.removeFile(f.id)); container.appendChild(card); }); this.updateBulkDownloadBar(); }, // Remove single file removeFile(id) { const f = this.files.find(x => x.id === id); if (f) { if (f.srcUrl) URL.revokeObjectURL(f.srcUrl); if (f.out?.url) URL.revokeObjectURL(f.out.url); } this.files = this.files.filter(x => x.id !== id); if (this.files.length === 0) { document.getElementById('imageQueueCard').classList.add('hidden'); document.getElementById('bulkDownloadBar').classList.add('hidden'); } this.renderQueue(); }, // Trigger individual file download downloadSingle(f) { if (!f.out) return; const a = document.createElement('a'); a.href = f.out.url; a.download = f.out.name; document.body.appendChild(a); a.click(); a.remove(); }, // Update total savings count and ZIP button visibility updateBulkDownloadBar() { const bar = document.getElementById('bulkDownloadBar'); const text = document.getElementById('bulkSavingsText'); const finished = this.files.filter(f => f.status === 'done'); if (finished.length <= 1) { bar.classList.add('hidden'); return; } const totalIn = finished.reduce((s, f) => s + f.srcSize, 0); const totalOut = finished.reduce((s, f) => s + f.out.size, 0); const delta = totalIn - totalOut; if (delta > 0) { const savedPct = ((delta / totalIn) * 100).toFixed(1); text.textContent = `Готово: Сэкономлено всего ${App.formatBytes(delta)} (-${savedPct}%)`; } else { text.textContent = `Готово: Конвертировано ${finished.length} файлов`; } bar.classList.remove('hidden'); }, // Trigger conversion of ALL items async processAll() { const processBtn = document.getElementById('processAllImagesBtn'); processBtn.disabled = true; for (const f of this.files) { if (f.status !== 'done' && f.status !== 'processing') { await this.processFile(f); } } processBtn.disabled = false; App.showToast('Пакетная обработка завершена', 'success'); }, // core file processing engine async processFile(f) { f.status = 'processing'; f.error = null; this.renderQueue(); let tempObjectUrl = null; let imageSource = null; try { let targetFileBlob = f.file; // 1. Apple HEIC/HEIF Decoding Wrapper const ext = f.name.split('.').pop().toLowerCase(); if (['heic', 'heif'].includes(ext)) { if (typeof window.heic2any !== 'function') { throw new Error('Модуль HEIC-декодера не загружен.'); } // Convert HEIC blob to PNG const pngBlob = await window.heic2any({ blob: f.file, toType: 'image/png' }); targetFileBlob = Array.isArray(pngBlob) ? pngBlob[0] : pngBlob; } // 2. Load into HTML Image representation imageSource = await this.loadImageSource(targetFileBlob); f.srcDims = { w: imageSource.width, h: imageSource.height }; let finalBlob = null; let finalDims = { w: imageSource.width, h: imageSource.height }; // 3. Size constraints calculations & smart compression if (this.options.targetSizeLimit) { // Run Binary Search for target size limit const limitBytes = this.options.targetSizeValue * 1024; const searchResult = await this.compressToTargetSize(imageSource, this.options.targetFormat, limitBytes); finalBlob = searchResult.blob; finalDims = searchResult.dims; } else { // standard manual settings const canvas = this.drawCanvas(imageSource, this.options.maxDimension, this.options.bgWhite); finalDims = { w: canvas.width, h: canvas.height }; finalBlob = await this.canvasToBlobWrapper(canvas, this.options.targetFormat, this.options.quality); } // 4. Output assembly const outUrl = URL.createObjectURL(finalBlob); const rawName = f.name.replace(/\.[^.]+$/, ''); const outExt = this.getFormatExtension(this.options.targetFormat); const outName = `${rawName}_converted.${outExt}`; // Revoke old out if exists if (f.out?.url) URL.revokeObjectURL(f.out.url); f.out = { blob: finalBlob, url: outUrl, size: finalBlob.size, name: outName, fmt: this.options.targetFormat, dims: finalDims }; f.status = 'done'; if (this.options.autoDownload) { this.downloadSingle(f); } } catch (err) { console.error(err); f.status = 'error'; f.error = err.message || 'Ошибка обработки'; } finally { // Clean temporary references if (imageSource && imageSource.source && imageSource.source.close) { imageSource.source.close(); } this.renderQueue(); } }, // Image loader utility trying ImageBitmap first async loadImageSource(blob) { try { const bitmap = await createImageBitmap(blob); return { source: bitmap, width: bitmap.width, height: bitmap.height, type: 'bitmap' }; } catch (e) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { resolve({ source: img, width: img.naturalWidth, height: img.naturalHeight, type: 'img', url }); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Не удалось прочитать структуру изображения')); }; img.src = url; }); } }, // Scale, background, and draw image to Canvas drawCanvas(imgInfo, maxSide, bgWhite) { let w = imgInfo.width; let h = imgInfo.height; // Apply max dimension scaling if (maxSide > 0 && Math.max(w, h) > maxSide) { const ratio = maxSide / Math.max(w, h); w = Math.round(w * ratio); h = Math.round(h * ratio); } // ICO limit dimension if (this.options.targetFormat === 'ico') { const target = Math.min(256, Math.max(w, h)); const ratio = target / Math.max(w, h); w = Math.round(w * ratio); h = Math.round(h * ratio); } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); // Fill background if format requires const needsBg = ['jpeg', 'bmp'].includes(this.options.targetFormat) && bgWhite; if (needsBg) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); } ctx.drawImage(imgInfo.source, 0, 0, w, h); return canvas; }, // Core formatting encoder router async canvasToBlobWrapper(canvas, format, quality) { const mimeTypes = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', avif: 'image/avif' }; if (['png', 'jpeg', 'webp'].includes(format)) { return new Promise((resolve, reject) => { canvas.toBlob(blob => { if (blob) resolve(blob); else reject(new Error(`Браузер не скомпилировал ${format.toUpperCase()}`)); }, mimeTypes[format], quality); }); } if (format === 'avif') { return new Promise((resolve, reject) => { canvas.toBlob(blob => { // If browser doesn't support native AVIF encoding, it will fallback to PNG or return null if (blob && blob.type === 'image/avif') { resolve(blob); } else { // Graceful fallback to high-quality WebP with warning App.showToast('AVIF-кодирование не поддерживается браузером. Выполняю экспорт в WebP.', 'warning'); canvas.toBlob(webpBlob => { resolve(webpBlob); }, 'image/webp', quality); } }, 'image/avif', quality); }); } if (format === 'bmp') { return this.encodeBMP(canvas); } if (format === 'ico') { return this.encodeICO(canvas); } if (format === 'pdf') { return this.encodePDF(canvas); } throw new Error('Неподдерживаемый целевой формат'); }, // Binary search size optimizer async compressToTargetSize(imgInfo, format, limitBytes) { let scale = 1.0; // For BMP, since it is raw bytes, we can calculate size mathematically // size = 54 + rowSize * h if (format === 'bmp') { const getBmpSize = (w, h) => { const rowSize = Math.floor((24 * w + 31) / 32) * 4; return 54 + rowSize * h; }; let w = imgInfo.width; let h = imgInfo.height; // Scale down factor calculation directly if (getBmpSize(w, h) > limitBytes) { // size roughly scales quadratically with scale factor scale = Math.sqrt(limitBytes / getBmpSize(w, h)) * 0.95; } const canvas = this.drawCanvas(imgInfo, Math.max(w, h) * scale, this.options.bgWhite); const blob = await this.encodeBMP(canvas); return { blob, dims: { w: canvas.width, h: canvas.height } }; } // For PDF/PNG (lossless types): Binary search on canvas dimensions scale factor if (format === 'png' || format === 'pdf') { let lo = 0.05, hi = 1.0, bestBlob = null, bestDims = null; for (let i = 0; i < 10; i++) { const currentScale = (lo + hi) / 2; const maxSide = Math.max(imgInfo.width, imgInfo.height) * currentScale; const canvas = this.drawCanvas(imgInfo, maxSide, this.options.bgWhite); const blob = await this.canvasToBlobWrapper(canvas, format, 0.9); // high quality if (blob.size <= limitBytes) { bestBlob = blob; bestDims = { w: canvas.width, h: canvas.height }; lo = currentScale; // Try larger } else { hi = currentScale; // Try smaller } } if (bestBlob) return { blob: bestBlob, dims: bestDims }; // Minimal fail-safe const canvas = this.drawCanvas(imgInfo, Math.max(imgInfo.width, imgInfo.height) * 0.05, this.options.bgWhite); const blob = await this.canvasToBlobWrapper(canvas, format, 0.5); return { blob, dims: { w: canvas.width, h: canvas.height } }; } // For JPEG / WebP / AVIF (lossy formats): // First, binary search on QUALITY with full image resolution let canvas = this.drawCanvas(imgInfo, this.options.maxDimension, this.options.bgWhite); let lo = 0.01, hi = 0.99, bestBlob = null; for (let i = 0; i < 12; i++) { const q = (lo + hi) / 2; const blob = await this.canvasToBlobWrapper(canvas, format, q); if (blob.size <= limitBytes) { bestBlob = blob; lo = q; // Try higher quality } else { hi = q; // Try lower quality } } if (bestBlob) { return { blob: bestBlob, dims: { w: canvas.width, h: canvas.height } }; } // If even quality 0.01 is too large, scale down dimensions sequentially scale = 0.9; while (scale > 0.05) { const side = Math.max(imgInfo.width, imgInfo.height) * scale; canvas = this.drawCanvas(imgInfo, side, this.options.bgWhite); const blob = await this.canvasToBlobWrapper(canvas, format, 0.4); // moderate quality if (blob.size <= limitBytes) { return { blob, dims: { w: canvas.width, h: canvas.height } }; } scale -= 0.1; } // Worst-case escape fallback canvas = this.drawCanvas(imgInfo, Math.max(imgInfo.width, imgInfo.height) * 0.05, this.options.bgWhite); const blob = await this.canvasToBlobWrapper(canvas, format, 0.1); return { blob, dims: { w: canvas.width, h: canvas.height } }; }, // 24-bit uncompressed BMP encoder encodeBMP(canvas) { const w = canvas.width; const h = canvas.height; const ctx = canvas.getContext('2d'); const imgData = ctx.getImageData(0, 0, w, h); const pixels = imgData.data; const rowSize = Math.floor((24 * w + 31) / 32) * 4; const pixelArraySize = rowSize * h; const fileSize = 54 + pixelArraySize; const buf = new ArrayBuffer(fileSize); const view = new DataView(buf); // BMP File Header view.setUint16(0, 0x4D42, true); // Signature 'BM' view.setUint32(2, fileSize, true); // total file size view.setUint32(6, 0, true); // reserved view.setUint32(10, 54, true); // offset to image data // DIB Header view.setUint32(14, 40, true); // DIB header length view.setInt32(18, w, true); // image width view.setInt32(22, h, true); // image height (positive = bottom-to-top) view.setUint16(26, 1, true); // color planes view.setUint16(28, 24, true); // bits per pixel view.setUint32(30, 0, true); // compression: BI_RGB (none) view.setUint32(34, pixelArraySize, true); // byte size of pixel array view.setInt32(38, 2835, true); // x pixels/meter (72 dpi) view.setInt32(42, 2835, true); // y pixels/meter view.setUint32(46, 0, true); // palette colors view.setUint32(50, 0, true); // important colors // Pixel data loop (packed BGR format, flipped vertically, with 4-byte padding) const bytes = new Uint8Array(buf); let dst = 54; for (let y = h - 1; y >= 0; y--) { let src = y * w * 4; for (let x = 0; x < w; x++) { bytes[dst] = pixels[src + 2]; // Blue bytes[dst + 1] = pixels[src + 1]; // Green bytes[dst + 2] = pixels[src]; // Red dst += 3; src += 4; } // row padding to align to multiple of 4 bytes while ((dst - 54) % rowSize !== 0) { bytes[dst++] = 0; } } return new Blob([buf], { type: 'image/bmp' }); }, // ICO Encoder (packages standard PNG within ICO file structure) async encodeICO(canvas) { const pngBlob = await new Promise((resolve, reject) => { canvas.toBlob(b => b ? resolve(b) : reject(new Error('Сбой кодирования PNG для ICO')), 'image/png'); }); const pngBuf = await pngBlob.arrayBuffer(); const pngBytes = new Uint8Array(pngBuf); // ICO size restrictions const w = canvas.width >= 256 ? 0 : canvas.width; const h = canvas.height >= 256 ? 0 : canvas.height; const headerSize = 6 + 16; const totalSize = headerSize + pngBytes.length; const outBytes = new Uint8Array(totalSize); const view = new DataView(outBytes.buffer); // Icon Directory Header view.setUint16(0, 0, true); // reserved view.setUint16(2, 1, true); // image type: 1 = ICO view.setUint16(4, 1, true); // number of images in file (1) // Icon Directory Entry outBytes[6] = w; outBytes[7] = h; outBytes[8] = 0; // color palette size (0 for truecolor) outBytes[9] = 0; // reserved view.setUint16(10, 1, true); // color planes view.setUint16(12, 32, true); // bits per pixel view.setUint32(14, pngBytes.length, true); // size of image bytes view.setUint32(18, headerSize, true); // offset to image bytes // Paste actual PNG stream outBytes.set(pngBytes, headerSize); return new Blob([outBytes.buffer], { type: 'image/x-icon' }); }, // PDF Encoder using jsPDF from local lib async encodePDF(canvas) { if (!window.jspdf || !window.jspdf.jsPDF) { throw new Error('Библиотека jsPDF не загружена. Проверьте lib/jspdf.umd.min.js'); } const { jsPDF } = window.jspdf; // Setup document matching canvas aspect ratio const doc = new jsPDF({ orientation: canvas.width > canvas.height ? 'l' : 'p', unit: 'px', format: [canvas.width, canvas.height] }); const imgData = canvas.toDataURL('image/jpeg', 0.95); doc.addImage(imgData, 'JPEG', 0, 0, canvas.width, canvas.height); const pdfBlob = doc.output('blob'); return new Blob([pdfBlob], { type: 'application/pdf' }); }, // Get matching extension string getFormatExtension(fmt) { const mapping = { png: 'png', jpeg: 'jpg', webp: 'webp', avif: 'avif', bmp: 'bmp', ico: 'ico', pdf: 'pdf' }; return mapping[fmt] || 'bin'; }, // Package processed list to JSZip and trigger download async downloadAllAsZip() { const finished = this.files.filter(f => f.status === 'done'); if (finished.length === 0) { App.showToast('Нет готовых файлов для скачивания', 'warning'); return; } if (!window.JSZip) { App.showToast('Библиотека JSZip не загружена. Проверьте lib/jszip.min.js', 'error'); return; } App.showToast('Сборка архива ZIP...', 'info'); const zip = new window.JSZip(); finished.forEach(f => { zip.file(f.out.name, f.out.blob); }); try { const zipBlob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(zipBlob); const a = document.createElement('a'); a.href = url; a.download = `omniconvert_images_${Date.now()}.zip`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); App.showToast('Архив успешно скачан!', 'success'); } catch (e) { App.showToast('Ошибка создания архива: ' + e.message, 'error'); } } }; // Initialize module document.addEventListener('DOMContentLoaded', () => { ImageHub.init(); });