Created
October 19, 2025 16:02
-
-
Save iamwrm/4cc3efa26789ebe10782a14cad62c94f to your computer and use it in GitHub Desktop.
image compression v3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Smart Image Compressor</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| background: white; | |
| border-radius: 20px; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | |
| overflow: hidden; | |
| } | |
| .header { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 30px; | |
| text-align: center; | |
| } | |
| .header h1 { | |
| font-size: 2.5em; | |
| margin-bottom: 10px; | |
| } | |
| .header p { | |
| font-size: 1.1em; | |
| opacity: 0.9; | |
| } | |
| .content { | |
| padding: 30px; | |
| } | |
| .upload-section { | |
| border: 3px dashed #667eea; | |
| border-radius: 15px; | |
| padding: 40px; | |
| text-align: center; | |
| background: #f8f9ff; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| margin-bottom: 30px; | |
| } | |
| .upload-section:hover { | |
| background: #f0f2ff; | |
| border-color: #764ba2; | |
| } | |
| .upload-section.drag-over { | |
| background: #e8ebff; | |
| border-color: #764ba2; | |
| transform: scale(1.02); | |
| } | |
| .upload-icon { | |
| font-size: 4em; | |
| margin-bottom: 15px; | |
| } | |
| .controls { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 30px; | |
| } | |
| .control-group { | |
| background: #f8f9ff; | |
| padding: 20px; | |
| border-radius: 10px; | |
| } | |
| .control-group label { | |
| display: block; | |
| font-weight: 600; | |
| margin-bottom: 10px; | |
| color: #333; | |
| } | |
| .control-group select, | |
| .control-group input[type="range"] { | |
| width: 100%; | |
| padding: 10px; | |
| border: 2px solid #ddd; | |
| border-radius: 8px; | |
| font-size: 1em; | |
| } | |
| .control-group select { | |
| cursor: pointer; | |
| background: white; | |
| } | |
| .quality-value { | |
| display: inline-block; | |
| margin-left: 10px; | |
| font-weight: bold; | |
| color: #667eea; | |
| } | |
| .preview-section { | |
| display: none; | |
| margin-top: 30px; | |
| } | |
| .preview-section.active { | |
| display: block; | |
| } | |
| .images-container { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| margin-bottom: 20px; | |
| } | |
| .image-preview { | |
| background: #f8f9ff; | |
| border-radius: 10px; | |
| padding: 20px; | |
| text-align: center; | |
| } | |
| .image-preview h3 { | |
| margin-bottom: 15px; | |
| color: #333; | |
| } | |
| .image-preview img { | |
| max-width: 100%; | |
| max-height: 400px; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| } | |
| .image-preview .info { | |
| margin-top: 10px; | |
| font-size: 0.9em; | |
| color: #666; | |
| } | |
| .stats { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 25px; | |
| border-radius: 15px; | |
| margin-bottom: 20px; | |
| } | |
| .stats h3 { | |
| margin-bottom: 15px; | |
| font-size: 1.5em; | |
| } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 15px; | |
| } | |
| .stat-item { | |
| background: rgba(255, 255, 255, 0.2); | |
| padding: 15px; | |
| border-radius: 10px; | |
| backdrop-filter: blur(10px); | |
| } | |
| .stat-label { | |
| font-size: 0.9em; | |
| opacity: 0.9; | |
| margin-bottom: 5px; | |
| } | |
| .stat-value { | |
| font-size: 1.5em; | |
| font-weight: bold; | |
| } | |
| .detection-info { | |
| background: #f8f9ff; | |
| border-left: 4px solid #667eea; | |
| padding: 15px 20px; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| } | |
| .detection-info strong { | |
| color: #667eea; | |
| } | |
| .button { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| padding: 15px 40px; | |
| font-size: 1.1em; | |
| font-weight: 600; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| display: inline-block; | |
| text-decoration: none; | |
| } | |
| .button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4); | |
| } | |
| .button:active { | |
| transform: translateY(0); | |
| } | |
| .button-secondary { | |
| background: #6c757d; | |
| margin-left: 10px; | |
| } | |
| .button-container { | |
| text-align: center; | |
| margin-top: 20px; | |
| } | |
| .hidden { | |
| display: none; | |
| } | |
| @media (max-width: 768px) { | |
| .images-container { | |
| grid-template-columns: 1fr; | |
| } | |
| .header h1 { | |
| font-size: 1.8em; | |
| } | |
| .controls { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .loader { | |
| display: none; | |
| text-align: center; | |
| padding: 20px; | |
| } | |
| .loader.active { | |
| display: block; | |
| } | |
| .spinner { | |
| border: 4px solid #f3f3f3; | |
| border-top: 4px solid #667eea; | |
| border-radius: 50%; | |
| width: 50px; | |
| height: 50px; | |
| animation: spin 1s linear infinite; | |
| margin: 0 auto 15px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .toast { | |
| position: fixed; | |
| bottom: 30px; | |
| right: 30px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 15px 25px; | |
| border-radius: 10px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); | |
| display: none; | |
| align-items: center; | |
| gap: 10px; | |
| z-index: 1000; | |
| animation: slideIn 0.3s ease; | |
| } | |
| .toast.show { | |
| display: flex; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(400px); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| @keyframes slideOut { | |
| from { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| to { | |
| transform: translateX(400px); | |
| opacity: 0; | |
| } | |
| } | |
| .toast.hiding { | |
| animation: slideOut 0.3s ease; | |
| } | |
| .keyboard-hint { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: rgba(255, 255, 255, 0.95); | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| font-size: 0.9em; | |
| color: #667eea; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| z-index: 999; | |
| } | |
| .keyboard-hint kbd { | |
| background: #667eea; | |
| color: white; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| font-family: monospace; | |
| font-size: 0.85em; | |
| } | |
| @media (max-width: 768px) { | |
| .keyboard-hint { | |
| display: none; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="keyboard-hint"> | |
| ๐ Paste images: <kbd>Ctrl</kbd>+<kbd>V</kbd> or <kbd>โ</kbd>+<kbd>V</kbd> | |
| </div> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>๐ผ๏ธ Smart Image Compressor</h1> | |
| <p>Auto-detects text vs photos and applies optimal compression</p> | |
| </div> | |
| <div class="content"> | |
| <div class="upload-section" id="uploadSection"> | |
| <div class="upload-icon">๐</div> | |
| <h2>Choose an image or drag & drop</h2> | |
| <p style="margin-top: 10px; color: #666;">Supports PNG, JPEG, WebP, BMP, GIF</p> | |
| <p style="margin-top: 10px; color: #667eea; font-weight: 600;">๐ Or press Ctrl+V / Cmd+V to paste from clipboard</p> | |
| <input type="file" id="fileInput" accept="image/*" style="display: none;"> | |
| </div> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label>Output Format</label> | |
| <select id="formatSelect"> | |
| <option value="webp">WebP (Recommended)</option> | |
| <option value="png">PNG (Lossless)</option> | |
| <option value="jpeg">JPEG (Photos)</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label>Compression Mode</label> | |
| <select id="modeSelect"> | |
| <option value="auto">Auto-detect</option> | |
| <option value="text">Text/Diagram</option> | |
| <option value="photo">Photo</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label>Quality (for lossy formats) | |
| <span class="quality-value" id="qualityValue">85</span> | |
| </label> | |
| <input type="range" id="qualitySlider" min="1" max="100" value="85"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Color Levels (for text mode) | |
| <span class="quality-value" id="colorLevelsValue">32</span> | |
| </label> | |
| <input type="range" id="colorLevelsSlider" min="8" max="256" value="32" step="8"> | |
| </div> | |
| </div> | |
| <div class="loader" id="loader"> | |
| <div class="spinner"></div> | |
| <p>Compressing image...</p> | |
| </div> | |
| <div class="preview-section" id="previewSection"> | |
| <div class="detection-info" id="detectionInfo"></div> | |
| <div class="stats" id="stats"></div> | |
| <div class="images-container"> | |
| <div class="image-preview"> | |
| <h3>Original</h3> | |
| <img id="originalImage" alt="Original"> | |
| <div class="info" id="originalInfo"></div> | |
| </div> | |
| <div class="image-preview"> | |
| <h3>Compressed</h3> | |
| <img id="compressedImage" alt="Compressed"> | |
| <div class="info" id="compressedInfo"></div> | |
| </div> | |
| </div> | |
| <div class="button-container"> | |
| <button class="button" id="downloadButton">โฌ๏ธ Download Compressed Image</button> | |
| <button class="button button-secondary" id="resetButton">๐ Compress Another</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="toast" id="toast"> | |
| <span id="toastIcon">โ</span> | |
| <span id="toastMessage"></span> | |
| </div> | |
| <canvas id="canvas" style="display: none;"></canvas> | |
| <script> | |
| // DOM Elements | |
| const uploadSection = document.getElementById('uploadSection'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const formatSelect = document.getElementById('formatSelect'); | |
| const modeSelect = document.getElementById('modeSelect'); | |
| const qualitySlider = document.getElementById('qualitySlider'); | |
| const qualityValue = document.getElementById('qualityValue'); | |
| const colorLevelsSlider = document.getElementById('colorLevelsSlider'); | |
| const colorLevelsValue = document.getElementById('colorLevelsValue'); | |
| const loader = document.getElementById('loader'); | |
| const previewSection = document.getElementById('previewSection'); | |
| const detectionInfo = document.getElementById('detectionInfo'); | |
| const stats = document.getElementById('stats'); | |
| const originalImage = document.getElementById('originalImage'); | |
| const compressedImage = document.getElementById('compressedImage'); | |
| const originalInfo = document.getElementById('originalInfo'); | |
| const compressedInfo = document.getElementById('compressedInfo'); | |
| const downloadButton = document.getElementById('downloadButton'); | |
| const resetButton = document.getElementById('resetButton'); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const toast = document.getElementById('toast'); | |
| const toastIcon = document.getElementById('toastIcon'); | |
| const toastMessage = document.getElementById('toastMessage'); | |
| let currentFile = null; | |
| let compressedBlob = null; | |
| // Toast notification function | |
| function showToast(message, icon = 'โ') { | |
| toastIcon.textContent = icon; | |
| toastMessage.textContent = message; | |
| toast.classList.remove('hiding'); | |
| toast.classList.add('show'); | |
| setTimeout(() => { | |
| toast.classList.add('hiding'); | |
| setTimeout(() => { | |
| toast.classList.remove('show', 'hiding'); | |
| }, 300); | |
| }, 3000); | |
| } | |
| // Event Listeners | |
| uploadSection.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', handleFileSelect); | |
| qualitySlider.addEventListener('input', (e) => { | |
| qualityValue.textContent = e.target.value; | |
| }); | |
| colorLevelsSlider.addEventListener('input', (e) => { | |
| colorLevelsValue.textContent = e.target.value; | |
| }); | |
| downloadButton.addEventListener('click', downloadCompressed); | |
| resetButton.addEventListener('click', reset); | |
| // Drag and Drop | |
| uploadSection.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadSection.classList.add('drag-over'); | |
| }); | |
| uploadSection.addEventListener('dragleave', () => { | |
| uploadSection.classList.remove('drag-over'); | |
| }); | |
| uploadSection.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadSection.classList.remove('drag-over'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0) { | |
| handleFile(files[0]); | |
| } | |
| }); | |
| // Paste from clipboard | |
| document.addEventListener('paste', (e) => { | |
| // Don't paste if already processing | |
| if (loader.classList.contains('active')) { | |
| return; | |
| } | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| for (let i = 0; i < items.length; i++) { | |
| const item = items[i]; | |
| // Check if item is an image | |
| if (item.type.startsWith('image/')) { | |
| e.preventDefault(); | |
| const blob = item.getAsFile(); | |
| if (blob) { | |
| handleFile(blob); | |
| showToast('Image pasted from clipboard!', '๐'); | |
| // Flash the upload section to show paste worked | |
| uploadSection.style.background = '#e8ebff'; | |
| setTimeout(() => { | |
| uploadSection.style.background = '#f8f9ff'; | |
| }, 300); | |
| } | |
| break; | |
| } | |
| } | |
| }); | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| handleFile(file); | |
| } | |
| } | |
| async function handleFile(file) { | |
| if (!file.type.startsWith('image/')) { | |
| alert('Please select an image file'); | |
| return; | |
| } | |
| currentFile = file; | |
| loader.classList.add('active'); | |
| previewSection.classList.remove('active'); | |
| // Load image | |
| const img = new Image(); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| img.src = e.target.result; | |
| }; | |
| img.onload = () => { | |
| compressImage(img, file); | |
| }; | |
| img.onerror = () => { | |
| loader.classList.remove('active'); | |
| showToast('Failed to load image', 'โ'); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function detectImageType(imageData) { | |
| const data = imageData.data; | |
| const colorMap = new Map(); | |
| const totalPixels = data.length / 4; | |
| // Sample pixels (for performance, check every 10th pixel) | |
| for (let i = 0; i < data.length; i += 40) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const color = `${r},${g},${b}`; | |
| colorMap.set(color, (colorMap.get(color) || 0) + 1); | |
| } | |
| const uniqueColors = colorMap.size; | |
| const sampledPixels = totalPixels / 10; | |
| const colorRatio = uniqueColors / sampledPixels; | |
| // Heuristic: text/diagrams have fewer colors | |
| if (uniqueColors < 50 || colorRatio < 0.1) { | |
| return 'text'; | |
| } | |
| return 'photo'; | |
| } | |
| function quantizeColors(imageData, levels = 32) { | |
| const data = imageData.data; | |
| const step = 256 / levels; | |
| // Reduce color depth by quantizing each channel | |
| for (let i = 0; i < data.length; i += 4) { | |
| // Quantize red channel | |
| data[i] = Math.round(data[i] / step) * step; | |
| // Quantize green channel | |
| data[i + 1] = Math.round(data[i + 1] / step) * step; | |
| // Quantize blue channel | |
| data[i + 2] = Math.round(data[i + 2] / step) * step; | |
| // Keep alpha channel unchanged | |
| } | |
| return imageData; | |
| } | |
| async function compressImage(img, file) { | |
| const format = formatSelect.value; | |
| const mode = modeSelect.value; | |
| const quality = parseInt(qualitySlider.value) / 100; | |
| const colorLevels = parseInt(colorLevelsSlider.value); | |
| // Set canvas size | |
| canvas.width = img.width; | |
| canvas.height = img.height; | |
| // Draw image | |
| ctx.drawImage(img, 0, 0); | |
| // Get image data for detection | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| // Detect image type | |
| let detectedType = mode === 'auto' ? detectImageType(imageData) : mode; | |
| // Show detection info | |
| const typeEmoji = detectedType === 'text' ? '๐' : '๐ธ'; | |
| const typeName = detectedType === 'text' ? 'Text/Diagram' : 'Photo'; | |
| const strategy = detectedType === 'text' | |
| ? `Color quantization (${colorLevels} levels - preserves colors with sharp edges)` | |
| : `Quality-based compression (Q=${Math.round(quality * 100)})`; | |
| detectionInfo.innerHTML = ` | |
| <strong>${typeEmoji} Detected: ${typeName}</strong><br> | |
| Strategy: ${strategy} | |
| `; | |
| // Apply compression strategy | |
| if (detectedType === 'text') { | |
| const quantizedImageData = quantizeColors(imageData, colorLevels); | |
| ctx.putImageData(quantizedImageData, 0, 0); | |
| } | |
| // Convert to blob | |
| const mimeType = format === 'jpeg' ? 'image/jpeg' : `image/${format}`; | |
| const compressionQuality = detectedType === 'text' && format === 'jpeg' ? 0.95 : quality; | |
| canvas.toBlob((blob) => { | |
| compressedBlob = blob; | |
| // Display results | |
| displayResults(img, file, blob, detectedType); | |
| loader.classList.remove('active'); | |
| previewSection.classList.add('active'); | |
| }, mimeType, compressionQuality); | |
| } | |
| function displayResults(img, originalFile, compressedBlob, detectedType) { | |
| // Display original image | |
| originalImage.src = URL.createObjectURL(originalFile); | |
| // Show filename or 'Pasted Image' for clipboard images | |
| const fileName = originalFile.name || 'Pasted Image'; | |
| originalInfo.innerHTML = ` | |
| <strong style="display: block; margin-bottom: 5px;">${fileName}</strong> | |
| ${img.width}ร${img.height}px<br> | |
| ${formatBytes(originalFile.size)} | |
| `; | |
| // Display compressed image | |
| compressedImage.src = URL.createObjectURL(compressedBlob); | |
| const compressedFormat = formatSelect.value.toUpperCase(); | |
| compressedInfo.innerHTML = ` | |
| <strong style="display: block; margin-bottom: 5px;">${compressedFormat} Format</strong> | |
| ${img.width}ร${img.height}px<br> | |
| ${formatBytes(compressedBlob.size)} | |
| `; | |
| // Calculate stats | |
| const originalSize = originalFile.size; | |
| const compressedSize = compressedBlob.size; | |
| const savedBytes = originalSize - compressedSize; | |
| const reduction = ((savedBytes / originalSize) * 100).toFixed(1); | |
| // Display stats | |
| stats.innerHTML = ` | |
| <h3>โ Compression Complete!</h3> | |
| <div class="stats-grid"> | |
| <div class="stat-item"> | |
| <div class="stat-label">Original Size</div> | |
| <div class="stat-value">${formatBytes(originalSize)}</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Compressed Size</div> | |
| <div class="stat-value">${formatBytes(compressedSize)}</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Space Saved</div> | |
| <div class="stat-value">${formatBytes(savedBytes)}</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Reduction</div> | |
| <div class="stat-value">${reduction}%</div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function downloadCompressed() { | |
| if (!compressedBlob) return; | |
| const format = formatSelect.value; | |
| const extension = format === 'jpeg' ? 'jpg' : format; | |
| // Generate filename based on original file or timestamp for pasted images | |
| let filename; | |
| if (currentFile && currentFile.name) { | |
| const originalName = currentFile.name.replace(/\.[^/.]+$/, ''); | |
| filename = `${originalName}_compressed.${extension}`; | |
| } else { | |
| filename = `pasted_image_compressed_${Date.now()}.${extension}`; | |
| } | |
| const url = URL.createObjectURL(compressedBlob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| showToast('Download started!', 'โฌ๏ธ'); | |
| } | |
| function reset() { | |
| currentFile = null; | |
| compressedBlob = null; | |
| fileInput.value = ''; | |
| previewSection.classList.remove('active'); | |
| } | |
| function formatBytes(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| // Show welcome message on page load | |
| window.addEventListener('load', () => { | |
| setTimeout(() => { | |
| showToast('Ready! Click, drag, or paste (Ctrl+V) to start', '๐'); | |
| }, 500); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment