Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Shinyy Studio | Asset Compressor</title> | |
| <!-- Modern typography --> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background: radial-gradient(circle at top left, #1a1c2c 0%, #0d0e14 100%); | |
| color: #e2e8f0; | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| .glass { | |
| background: rgba(255, 255, 255, 0.03); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); | |
| } | |
| .glass-card { | |
| background: rgba(255, 255, 255, 0.04); | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .glass-card:hover { | |
| background: rgba(255, 255, 255, 0.08); | |
| border-color: rgba(99, 102, 241, 0.3); | |
| } | |
| .glass-card.active { | |
| background: rgba(99, 102, 241, 0.15); | |
| border-color: rgba(99, 102, 241, 0.5); | |
| box-shadow: 0 0 20px rgba(99, 102, 241, 0.2); | |
| } | |
| /* Tabs */ | |
| .tab-btn { transition: all 0.3s ease; } | |
| .tab-btn.active { | |
| background: rgba(99, 102, 241, 0.2); | |
| color: #fff; | |
| border-bottom: 2px solid #6366f1; | |
| } | |
| .format-btn { transition: all 0.2s ease; } | |
| .format-btn.active { | |
| background: #6366f1; | |
| color: #fff; | |
| font-weight: 600; | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 10px; } | |
| ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 10px; } | |
| ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); } | |
| /* Modern Range Sliders */ | |
| input[type=range] { -webkit-appearance: none; background: transparent; width: 100%; margin: 10px 0; } | |
| input[type=range]:focus { outline: none; } | |
| input[type=range]::-webkit-slider-runnable-track { | |
| width: 100%; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.1); | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; height: 16px; width: 16px; border-radius: 50%; | |
| background: #6366f1; cursor: pointer; margin-top: -5px; transition: transform 0.1s; | |
| box-shadow: 0 0 10px rgba(99, 102, 241, 0.5); | |
| } | |
| input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| input[type=range]:disabled::-webkit-slider-thumb { background: #555; box-shadow: none; } | |
| /* Drag & Drop Global Overlay */ | |
| #drop-overlay { display: none; } | |
| body.dragging #drop-overlay { display: flex; } | |
| .checker-bg { | |
| background-image: | |
| linear-gradient(45deg, rgba(255,255,255,0.03) 25%, transparent 25%), | |
| linear-gradient(-45deg, rgba(255,255,255,0.03) 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.03) 75%), | |
| linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.03) 75%); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0px; | |
| } | |
| .fade-in { animation: fadeIn 0.4s ease-out forwards; } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .pulse-border { | |
| animation: pulse-border 2s infinite; | |
| } | |
| @keyframes pulse-border { | |
| 0%, 100% { border-color: rgba(99, 102, 241, 0.2); } | |
| 50% { border-color: rgba(99, 102, 241, 0.8); box-shadow: 0 0 20px rgba(99,102,241,0.3); } | |
| } | |
| </style> | |
| </head> | |
| <body class="min-h-screen overflow-x-hidden p-2 md:p-6 flex flex-col"> | |
| <!-- Drag & Drop Global Overlay --> | |
| <div id="drop-overlay" class="fixed inset-0 bg-indigo-900/40 backdrop-blur-sm z-[100] items-center justify-center text-3xl font-bold text-white pointer-events-none"> | |
| <div class="glass p-12 rounded-[2rem] text-center border-indigo-500 shadow-2xl pulse-border"> | |
| <svg class="w-16 h-16 mx-auto mb-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg> | |
| Drop assets to import into Studio | |
| </div> | |
| </div> | |
| <!-- VIEW 1: Empty State --> | |
| <div id="empty-state" class="flex-1 flex flex-col items-center justify-center relative z-10 fade-in"> | |
| <div class="text-center mb-10"> | |
| <h1 class="text-4xl font-bold tracking-tight text-white mb-2">Asset Compressor <span class="text-indigo-500 text-lg font-medium ml-2 px-3 py-1 glass rounded-full">Module</span></h1> | |
| <p class="text-slate-400 font-light">Optimize, resize, and fine-tune your generations.</p> | |
| </div> | |
| <div id="main-dropzone" class="glass max-w-2xl w-full p-16 rounded-[2.5rem] cursor-pointer group hover:bg-white/5 transition-all border-dashed border-2 border-white/20 hover:border-indigo-500/50"> | |
| <input type="file" accept="image/*,video/*" multiple class="hidden" id="file-upload-main"> | |
| <label for="file-upload-main" class="cursor-pointer flex flex-col items-center gap-6 w-full h-full"> | |
| <div class="w-20 h-20 rounded-full bg-indigo-500/10 flex items-center justify-center group-hover:scale-110 transition-transform group-hover:bg-indigo-500/20"> | |
| <svg class="w-10 h-10 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4v16m8-8H4"></path></svg> | |
| </div> | |
| <div class="text-center"> | |
| <h3 class="text-2xl font-bold text-white mb-2">Import Media</h3> | |
| <p class="text-slate-400 text-sm">Drag and drop images or videos here, or click to browse.</p> | |
| </div> | |
| </label> | |
| </div> | |
| <div id="importLoadingStatus" class="hidden mt-6 text-indigo-400 font-medium animate-pulse">Checking for transferred assets...</div> | |
| </div> | |
| <!-- VIEW 2: Editor State --> | |
| <div id="editor-state" class="mx-auto w-full max-w-[1600px] flex flex-col overflow-hidden relative z-10 hidden fade-in h-[calc(100vh-3rem)]"> | |
| <!-- Header & Tabs --> | |
| <div class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"> | |
| <h1 class="text-2xl font-bold tracking-tight text-white">Asset <span class="text-indigo-500">Compressor</span></h1> | |
| <div class="flex gap-2 p-1 glass rounded-2xl"> | |
| <button class="tab-btn active px-6 py-2 rounded-xl text-sm font-medium text-slate-400 hover:text-white" data-tab="image" onclick="switchTab('image')">Image Tuning</button> | |
| <button class="tab-btn px-6 py-2 rounded-xl text-sm font-medium text-slate-400 hover:text-white" data-tab="video" onclick="switchTab('video')">Video Tuning</button> | |
| </div> | |
| </div> | |
| <!-- SHARED LAYOUT CONTAINER --> | |
| <div class="glass rounded-[2rem] p-2 flex flex-col md:flex-row flex-1 overflow-hidden w-full gap-2"> | |
| <!-- LEFT SIDEBAR: Inventory --> | |
| <div class="w-full md:w-80 flex flex-col shrink-0 bg-black/20 rounded-3xl overflow-hidden border border-white/5"> | |
| <div class="p-5 border-b border-white/10 flex items-center justify-between"> | |
| <h2 class="font-bold text-white text-lg flex items-center gap-2"> | |
| Inventory <span id="gallery-count" class="bg-indigo-500/20 text-indigo-300 text-xs px-2 py-0.5 rounded-full">0</span> | |
| </h2> | |
| <label for="add-more" class="cursor-pointer text-indigo-400 hover:text-indigo-300 transition-colors"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg> | |
| <input id="add-more" type="file" multiple accept="image/*,video/*" class="hidden"> | |
| </label> | |
| </div> | |
| <div id="gallery-list" class="flex-1 overflow-y-auto p-4 space-y-3"> | |
| <!-- Gallery items injected here via JS --> | |
| </div> | |
| <div class="p-4 border-t border-white/10 bg-black/20"> | |
| <button id="btn-download-all" class="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-emerald-600/20 text-sm"> | |
| Download All Processed | |
| </button> | |
| </div> | |
| </div> | |
| <!-- RIGHT MAIN: Crafting Table --> | |
| <div class="flex-1 flex flex-col overflow-y-auto relative rounded-3xl"> | |
| <!-- Empty Editor Placeholder --> | |
| <div id="editor-empty" class="flex-1 flex flex-col items-center justify-center text-slate-500 hidden"> | |
| <svg class="w-16 h-16 mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg> | |
| Select an item from the inventory | |
| </div> | |
| <div id="editor-content" class="p-6 w-full space-y-6 hidden h-full"> | |
| <!-- Header for Active Item --> | |
| <div class="glass p-5 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> | |
| <div class="min-w-0 flex-1"> | |
| <h1 id="active-filename" class="text-xl font-bold text-white truncate">filename.ext</h1> | |
| <div class="text-sm text-slate-400 mt-1 flex gap-4 font-medium items-center"> | |
| <span id="active-dimensions" class="flex items-center gap-1"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path></svg> 0 x 0</span> | |
| <span id="active-savings" class="text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded-md">Saved 0%</span> | |
| </div> | |
| </div> | |
| <button id="btn-apply-all" class="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2.5 rounded-xl font-medium transition-all text-sm shadow-lg shadow-indigo-500/20 whitespace-nowrap"> | |
| Apply Settings to All | |
| </button> | |
| </div> | |
| <!-- Controls & Preview Grid --> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start h-[calc(100%-100px)]"> | |
| <!-- Controls Column --> | |
| <div class="lg:col-span-4 space-y-6 overflow-y-auto pr-2 pb-10 max-h-full"> | |
| <!-- SECTION 1: Compression --> | |
| <div class="glass-card p-6 rounded-2xl"> | |
| <h2 class="text-sm font-bold text-indigo-400 uppercase tracking-wider mb-5">Output Format</h2> | |
| <div class="space-y-6"> | |
| <!-- Image Formats --> | |
| <div id="image-format-section"> | |
| <div class="flex gap-2 p-1 bg-black/30 rounded-xl" id="format-selectors"> | |
| <button data-format="image/jpeg" class="format-btn active flex-1 py-2 rounded-lg text-sm text-slate-400">JPG</button> | |
| <button data-format="image/webp" class="format-btn flex-1 py-2 rounded-lg text-sm text-slate-400">WEBP</button> | |
| <button data-format="image/png" class="format-btn flex-1 py-2 rounded-lg text-sm text-slate-400">PNG</button> | |
| </div> | |
| </div> | |
| <!-- Video Formats --> | |
| <div id="video-format-section" class="hidden"> | |
| <div class="flex gap-2 p-1 bg-black/30 rounded-xl"> | |
| <button class="format-btn active flex-1 py-2 rounded-lg text-sm cursor-default">WEBM (VP8 Native)</button> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-sm font-medium text-slate-300" id="quality-label">Compression Quality</label> | |
| <span id="val-quality" class="text-sm font-bold text-indigo-400">80%</span> | |
| </div> | |
| <input type="range" id="slider-quality" min="0.1" max="1.0" step="0.01" value="0.8"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-sm font-medium text-slate-300">Resolution Scale</label> | |
| <span id="val-scale" class="text-sm font-bold text-indigo-400">100%</span> | |
| </div> | |
| <input type="range" id="slider-scale" min="0.1" max="1.0" step="0.05" value="1.0"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SECTION 2: Adjustments --> | |
| <div class="glass-card p-6 rounded-2xl"> | |
| <div class="flex items-center justify-between mb-5"> | |
| <h2 class="text-sm font-bold text-indigo-400 uppercase tracking-wider">Color Tuning</h2> | |
| <button id="btn-reset-edits" class="text-xs text-slate-500 hover:text-white transition-colors">Reset</button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div> | |
| <div class="flex justify-between text-sm"> | |
| <label class="font-medium text-slate-300">Brightness</label> | |
| <span id="val-brightness" class="text-slate-400">100%</span> | |
| </div> | |
| <input type="range" id="slider-brightness" min="0" max="200" value="100"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between text-sm"> | |
| <label class="font-medium text-slate-300">Contrast</label> | |
| <span id="val-contrast" class="text-slate-400">100%</span> | |
| </div> | |
| <input type="range" id="slider-contrast" min="0" max="200" value="100"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between text-sm"> | |
| <label class="font-medium text-slate-300">Saturation</label> | |
| <span id="val-saturation" class="text-slate-400">100%</span> | |
| </div> | |
| <input type="range" id="slider-saturation" min="0" max="200" value="100"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between text-sm"> | |
| <label class="font-medium text-slate-300">Hue Rotation</label> | |
| <span id="val-hue" class="text-slate-400">0°</span> | |
| </div> | |
| <input type="range" id="slider-hue" min="0" max="360" value="0"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Info & Action --> | |
| <div class="glass-card p-6 rounded-2xl"> | |
| <div class="grid grid-cols-2 gap-4 mb-6"> | |
| <div class="bg-black/30 rounded-xl p-3 text-center border border-white/5"> | |
| <div class="text-xs text-slate-400 mb-1">Original Size</div> | |
| <div id="stat-original" class="text-lg font-bold text-white">0 MB</div> | |
| </div> | |
| <div class="bg-emerald-500/10 rounded-xl p-3 text-center border border-emerald-500/20"> | |
| <div class="text-xs text-emerald-400 mb-1">New Size</div> | |
| <div id="stat-result" class="text-lg font-bold text-white">0 MB</div> | |
| </div> | |
| </div> | |
| <button id="btn-process-video" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3.5 rounded-xl mb-4 hidden transition-all shadow-lg shadow-indigo-600/20"> | |
| Process & Encode Video | |
| </button> | |
| <button id="btn-download-single" class="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3.5 rounded-xl transition-all shadow-lg shadow-emerald-600/20"> | |
| Download Output | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Preview Column --> | |
| <div class="lg:col-span-8 bg-black/40 border border-white/10 rounded-2xl flex flex-col items-center justify-center relative h-full min-h-[400px] overflow-hidden checker-bg shadow-inner"> | |
| <!-- Processing Overlay --> | |
| <div id="preview-loading" class="absolute inset-0 flex flex-col items-center justify-center z-30 bg-black/60 backdrop-blur-sm hidden"> | |
| <div class="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mb-4"></div> | |
| <div id="loading-text" class="text-xl font-bold text-white mb-4">Encoding...</div> | |
| <div class="w-64 h-2 bg-slate-800 rounded-full overflow-hidden"> | |
| <div id="loading-bar-progress" class="h-full bg-indigo-500 transition-all duration-100" style="width:0%"></div> | |
| </div> | |
| </div> | |
| <!-- Image Preview --> | |
| <img id="preview-image" src="" alt="Preview" class="max-w-full max-h-full object-contain relative z-10 transition-opacity duration-300"> | |
| <!-- Video Preview --> | |
| <video id="preview-video" src="" controls class="max-w-full max-h-full object-contain relative z-10 hidden w-full h-full"></video> | |
| <div class="absolute top-4 left-4 z-20 bg-black/50 backdrop-blur-md text-white border border-white/10 text-xs font-bold px-3 py-1.5 rounded-lg flex items-center gap-2"> | |
| <span class="w-2 h-2 rounded-full bg-emerald-400"></span> Live Preview | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Application Logic --> | |
| <script> | |
| // --- State --- | |
| const state = { | |
| currentTab: 'image', // 'image' or 'video' | |
| images: [], | |
| videos: [], | |
| activeImageId: null, | |
| activeVideoId: null, | |
| imageSettings: { format: 'image/jpeg', quality: 0.8, scale: 1.0 }, | |
| videoSettings: { format: 'video/webm', quality: 2.5, scale: 1.0 }, // quality = bitrate in Mbps | |
| imageEdits: { brightness: 100, contrast: 100, saturation: 100, hue: 0 }, | |
| videoEdits: { brightness: 100, contrast: 100, saturation: 100, hue: 0 } | |
| }; | |
| // --- DOM Elements --- | |
| const els = { | |
| body: document.body, | |
| emptyState: document.getElementById('empty-state'), | |
| editorState: document.getElementById('editor-state'), | |
| fileMain: document.getElementById('file-upload-main'), | |
| fileAdd: document.getElementById('add-more'), | |
| galleryList: document.getElementById('gallery-list'), | |
| galleryCount: document.getElementById('gallery-count'), | |
| editorEmpty: document.getElementById('editor-empty'), | |
| editorContent: document.getElementById('editor-content'), | |
| activeFilename: document.getElementById('active-filename'), | |
| activeDimensions: document.getElementById('active-dimensions'), | |
| activeSavings: document.getElementById('active-savings'), | |
| previewImage: document.getElementById('preview-image'), | |
| previewVideo: document.getElementById('preview-video'), | |
| previewLoading: document.getElementById('preview-loading'), | |
| loadingText: document.getElementById('loading-text'), | |
| loadingProgress: document.getElementById('loading-bar-progress'), | |
| statOriginal: document.getElementById('stat-original'), | |
| statResult: document.getElementById('stat-result'), | |
| formatBtns: document.querySelectorAll('.format-btn'), | |
| imgFormatSection: document.getElementById('image-format-section'), | |
| vidFormatSection: document.getElementById('video-format-section'), | |
| sQuality: document.getElementById('slider-quality'), | |
| sScale: document.getElementById('slider-scale'), | |
| sBrightness: document.getElementById('slider-brightness'), | |
| sContrast: document.getElementById('slider-contrast'), | |
| sSaturation: document.getElementById('slider-saturation'), | |
| sHue: document.getElementById('slider-hue'), | |
| lQuality: document.getElementById('quality-label'), | |
| vQuality: document.getElementById('val-quality'), | |
| vScale: document.getElementById('val-scale'), | |
| vBrightness: document.getElementById('val-brightness'), | |
| vContrast: document.getElementById('val-contrast'), | |
| vSaturation: document.getElementById('val-saturation'), | |
| vHue: document.getElementById('val-hue'), | |
| btnResetEdits: document.getElementById('btn-reset-edits'), | |
| btnApplyAll: document.getElementById('btn-apply-all'), | |
| btnDownloadSingle: document.getElementById('btn-download-single'), | |
| btnDownloadAll: document.getElementById('btn-download-all'), | |
| btnProcessVideo: document.getElementById('btn-process-video'), | |
| importLoadingStatus: document.getElementById('importLoadingStatus') | |
| }; | |
| let debounceTimer = null; | |
| const generateId = () => Math.random().toString(36).substr(2, 9); | |
| const formatBytes = (bytes, decimals = 2) => { | |
| if (bytes === 0) return '0 B'; | |
| const k = 1024; | |
| const dm = decimals < 0 ? 0 : decimals; | |
| const sizes = ['B', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; | |
| }; | |
| // --- Iframe Integration Bridge --- | |
| async function autoLoadTransferredAssets() { | |
| let urlsToLoad = []; | |
| // Try extracting from window.parent (the Swapper) | |
| try { | |
| if (window.parent && window.parent !== window) { | |
| // Method A: Swapper sets currentBatchUrls for multi-swap | |
| if (window.parent.currentBatchUrls && window.parent.currentBatchUrls.length > 0) { | |
| urlsToLoad = [...window.parent.currentBatchUrls]; | |
| } else { | |
| // Method B: Swapper History Single Image Modal | |
| const histImg = window.parent.document.getElementById('histModalResult'); | |
| if (histImg && histImg.src) urlsToLoad.push(histImg.src); | |
| // Method C: Swapper Immediate Single Output Slider | |
| const sliderRes = window.parent.document.getElementById('sliderAfter'); | |
| if (sliderRes) { | |
| const img = sliderRes.querySelector('img'); | |
| if (img && img.src) urlsToLoad.push(img.src); | |
| } | |
| } | |
| } | |
| } catch(e) { | |
| console.log("Not in iframe or cross-origin isolated."); | |
| } | |
| // Method D: Fallback to localStorage queue | |
| try { | |
| const ls = localStorage.getItem('compressor_queue'); | |
| if (ls) { | |
| const parsed = JSON.parse(ls); | |
| if (Array.isArray(parsed)) urlsToLoad = [...new Set([...urlsToLoad, ...parsed])]; | |
| localStorage.removeItem('compressor_queue'); // consume it | |
| } | |
| } catch(e) {} | |
| urlsToLoad = [...new Set(urlsToLoad.filter(url => url.length > 0))]; | |
| if (urlsToLoad.length > 0) { | |
| els.importLoadingStatus.classList.remove('hidden'); | |
| els.importLoadingStatus.innerText = `Importing ${urlsToLoad.length} assets from Studio...`; | |
| const filePromises = urlsToLoad.map(async (url, idx) => { | |
| try { | |
| const res = await fetch(url); | |
| const blob = await res.blob(); | |
| const ext = blob.type.split('/')[1] || 'png'; | |
| return new File([blob], `ShinyyOutput_${idx+1}.${ext}`, { type: blob.type }); | |
| } catch(e) { return null; } | |
| }); | |
| const files = (await Promise.all(filePromises)).filter(Boolean); | |
| if(files.length > 0) { | |
| handleFiles(files); | |
| } | |
| els.importLoadingStatus.classList.add('hidden'); | |
| } | |
| } | |
| // --- Tab Switching --- | |
| window.switchTab = (tabMode) => { | |
| state.currentTab = tabMode; | |
| // Update Tab Buttons UI | |
| document.querySelectorAll('.tab-btn').forEach(btn => { | |
| if(btn.dataset.tab === tabMode) btn.classList.add('active'); | |
| else btn.classList.remove('active'); | |
| }); | |
| // Update UI blocks based on mode | |
| if (tabMode === 'image') { | |
| els.imgFormatSection.classList.remove('hidden'); | |
| els.vidFormatSection.classList.add('hidden'); | |
| els.lQuality.innerText = "Compression Quality"; | |
| els.previewImage.classList.remove('hidden'); | |
| els.previewVideo.classList.add('hidden'); | |
| els.btnProcessVideo.classList.add('hidden'); | |
| els.btnApplyAll.classList.remove('hidden'); | |
| // Set Sliders to Image Settings | |
| els.sQuality.min = "0.1"; els.sQuality.max = "1.0"; els.sQuality.step = "0.01"; | |
| } else { | |
| els.imgFormatSection.classList.add('hidden'); | |
| els.vidFormatSection.classList.remove('hidden'); | |
| els.lQuality.innerText = "Target Bitrate (Mbps)"; | |
| els.previewImage.classList.add('hidden'); | |
| els.previewVideo.classList.remove('hidden'); | |
| els.btnProcessVideo.classList.remove('hidden'); | |
| els.btnApplyAll.classList.add('hidden'); | |
| // Set Sliders to Video Settings | |
| els.sQuality.min = "0.5"; els.sQuality.max = "10.0"; els.sQuality.step = "0.5"; | |
| } | |
| updateUI(); | |
| }; | |
| // --- Core Engines --- | |
| // Image Engine | |
| const processImageCanvas = (item, settings, edits) => { | |
| return new Promise((resolve) => { | |
| const img = new Image(); | |
| img.src = item.originalPreview; | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const newWidth = Math.max(1, Math.floor(item.originalDimensions.width * settings.scale)); | |
| const newHeight = Math.max(1, Math.floor(item.originalDimensions.height * settings.scale)); | |
| canvas.width = newWidth; | |
| canvas.height = newHeight; | |
| ctx.filter = `brightness(${edits.brightness}%) contrast(${edits.contrast}%) saturate(${edits.saturation}%) hue-rotate(${edits.hue}deg)`; | |
| ctx.drawImage(img, 0, 0, newWidth, newHeight); | |
| canvas.toBlob((blob) => { | |
| if(!blob) return resolve(null); | |
| if (item.compressedUrl) URL.revokeObjectURL(item.compressedUrl); | |
| resolve({ | |
| compressedUrl: URL.createObjectURL(blob), | |
| compressedSize: blob.size, | |
| isProcessing: false, | |
| status: 'done' | |
| }); | |
| }, settings.format, parseFloat(settings.quality)); | |
| }; | |
| img.onerror = () => resolve({ isProcessing: false, status: 'error' }); | |
| }); | |
| }; | |
| // Video Engine (MediaRecorder) | |
| const processVideoCanvas = (item, settings, edits) => { | |
| return new Promise((resolve) => { | |
| const video = document.createElement('video'); | |
| video.src = item.originalPreview; | |
| video.muted = true; | |
| video.crossOrigin = 'anonymous'; | |
| video.onloadedmetadata = () => { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = Math.max(1, Math.floor(item.originalDimensions.width * settings.scale)); | |
| canvas.height = Math.max(1, Math.floor(item.originalDimensions.height * settings.scale)); | |
| const stream = canvas.captureStream(30); | |
| const bitrate = parseFloat(settings.quality) * 1000000; | |
| const options = { mimeType: 'video/webm', videoBitsPerSecond: bitrate }; | |
| let recorder; | |
| try { recorder = new MediaRecorder(stream, options); } | |
| catch (e) { recorder = new MediaRecorder(stream); } | |
| const chunks = []; | |
| recorder.ondataavailable = e => { if (e.data && e.data.size > 0) chunks.push(e.data); }; | |
| recorder.onstop = () => { | |
| const blob = new Blob(chunks, { type: 'video/webm' }); | |
| if (item.compressedUrl) URL.revokeObjectURL(item.compressedUrl); | |
| resolve({ | |
| compressedUrl: URL.createObjectURL(blob), | |
| compressedSize: blob.size, | |
| isProcessing: false, | |
| status: 'done' | |
| }); | |
| }; | |
| video.play(); | |
| recorder.start(); | |
| const drawFrame = () => { | |
| if (video.paused || video.ended) return; | |
| ctx.filter = `brightness(${edits.brightness}%) contrast(${edits.contrast}%) saturate(${edits.saturation}%) hue-rotate(${edits.hue}deg)`; | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| const percent = (video.currentTime / video.duration) * 100; | |
| els.loadingProgress.style.width = `${percent}%`; | |
| els.loadingText.innerText = `ENCODING: ${Math.floor(percent)}%`; | |
| requestAnimationFrame(drawFrame); | |
| }; | |
| video.addEventListener('play', () => drawFrame()); | |
| video.addEventListener('ended', () => recorder.stop()); | |
| }; | |
| }); | |
| }; | |
| // --- Processing Triggers --- | |
| const recompressActiveImage = () => { | |
| if (state.currentTab !== 'image') return; | |
| if (!state.activeImageId) return; | |
| const item = state.images.find(i => i.id === state.activeImageId); | |
| if (!item) return; | |
| item.isProcessing = true; | |
| updateUI(); | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(async () => { | |
| const result = await processImageCanvas(item, state.imageSettings, state.imageEdits); | |
| if (result) Object.assign(item, result); | |
| updateUI(); | |
| }, 150); | |
| }; | |
| els.btnProcessVideo.addEventListener('click', async () => { | |
| if (state.currentTab !== 'video' || !state.activeVideoId) return; | |
| const item = state.videos.find(i => i.id === state.activeVideoId); | |
| if (!item) return; | |
| item.isProcessing = true; | |
| els.previewVideo.pause(); | |
| updateUI(); | |
| const result = await processVideoCanvas(item, state.videoSettings, state.videoEdits); | |
| if (result) Object.assign(item, result); | |
| updateUI(); | |
| }); | |
| // --- File Handling --- | |
| const handleFiles = (files) => { | |
| if (!files || files.length === 0) return; | |
| let switchedTab = false; | |
| Array.from(files).forEach(file => { | |
| const isImage = file.type.match('image.*'); | |
| const isVideo = file.type.match('video.*'); | |
| if (!isImage && !isVideo) return; | |
| const id = generateId(); | |
| const objectUrl = URL.createObjectURL(file); | |
| const processFileDimensions = (width, height) => { | |
| const newItem = { | |
| id, file, originalPreview: objectUrl, | |
| originalSize: file.size, | |
| originalDimensions: { width, height }, | |
| compressedUrl: null, compressedSize: 0, | |
| isProcessing: isImage, // Images auto-process | |
| status: 'pending' | |
| }; | |
| if (isImage) { | |
| if (state.images.length === 0) state.activeImageId = id; | |
| state.images.push(newItem); | |
| if (!switchedTab && state.currentTab !== 'image') { | |
| switchTab('image'); switchedTab = true; | |
| } | |
| processImageCanvas(newItem, state.imageSettings, state.imageEdits).then(res => { | |
| Object.assign(newItem, res); | |
| updateUI(); | |
| }); | |
| } else if (isVideo) { | |
| if (state.videos.length === 0) state.activeVideoId = id; | |
| state.videos.push(newItem); | |
| if (!switchedTab && state.currentTab !== 'video') { | |
| switchTab('video'); switchedTab = true; | |
| } | |
| } | |
| updateUI(); | |
| }; | |
| if (isImage) { | |
| const img = new Image(); | |
| img.onload = () => processFileDimensions(img.width, img.height); | |
| img.src = objectUrl; | |
| } else if (isVideo) { | |
| const vid = document.createElement('video'); | |
| vid.onloadedmetadata = () => processFileDimensions(vid.videoWidth, vid.videoHeight); | |
| vid.src = objectUrl; | |
| } | |
| }); | |
| }; | |
| // --- UI Updates --- | |
| const updateUI = () => { | |
| const isImageTab = state.currentTab === 'image'; | |
| const itemsList = isImageTab ? state.images : state.videos; | |
| const activeId = isImageTab ? state.activeImageId : state.activeVideoId; | |
| const settings = isImageTab ? state.imageSettings : state.videoSettings; | |
| const edits = isImageTab ? state.imageEdits : state.videoEdits; | |
| // Global visibility | |
| if (state.images.length === 0 && state.videos.length === 0) { | |
| els.emptyState.classList.remove('hidden'); | |
| els.editorState.classList.add('hidden'); | |
| return; | |
| } else { | |
| els.emptyState.classList.add('hidden'); | |
| els.editorState.classList.remove('hidden'); | |
| } | |
| // Sync Sliders | |
| els.sQuality.value = settings.quality; | |
| els.sScale.value = settings.scale; | |
| els.sBrightness.value = edits.brightness; | |
| els.sContrast.value = edits.contrast; | |
| els.sSaturation.value = edits.saturation; | |
| els.sHue.value = edits.hue; | |
| // Sync Values | |
| els.vQuality.innerText = isImageTab ? `${Math.round(settings.quality * 100)}%` : `${settings.quality} Mbps`; | |
| els.vScale.innerText = `${Math.round(settings.scale * 100)}%`; | |
| els.vBrightness.innerText = `${edits.brightness}%`; | |
| els.vContrast.innerText = `${edits.contrast}%`; | |
| els.vSaturation.innerText = `${edits.saturation}%`; | |
| els.vHue.innerText = `${edits.hue}°`; | |
| els.galleryCount.innerText = itemsList.length; | |
| // Render Sidebar | |
| els.galleryList.innerHTML = ''; | |
| itemsList.forEach(item => { | |
| const isActive = item.id === activeId; | |
| const div = document.createElement('div'); | |
| div.className = `glass-card p-2 rounded-xl cursor-pointer flex gap-3 items-center group ${isActive ? 'active' : ''}`; | |
| div.onclick = () => { | |
| if (isImageTab) state.activeImageId = item.id; else state.activeVideoId = item.id; | |
| updateUI(); | |
| }; | |
| let sizeStr = `<span class="text-slate-400">${formatBytes(item.originalSize)}</span>`; | |
| if (item.status === 'done') { | |
| sizeStr += ` <svg class="w-3 h-3 text-slate-500 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg> <span class="text-emerald-400 font-bold">${formatBytes(item.compressedSize)}</span>`; | |
| } | |
| const thumbHtml = isImageTab | |
| ? `<img src="${item.originalPreview}" class="w-full h-full object-cover">` | |
| : `<video src="${item.originalPreview}#t=0.1" class="w-full h-full object-cover"></video>`; | |
| div.innerHTML = ` | |
| <div class="w-12 h-12 rounded-lg bg-black/50 overflow-hidden relative shrink-0"> | |
| ${thumbHtml} | |
| ${!isImageTab ? `<div class="absolute inset-0 flex justify-center items-center text-white bg-black/30"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M4 4l12 6-12 6z"/></svg></div>` : ''} | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="text-sm font-bold text-white truncate group-hover:text-indigo-300 transition-colors">${item.file.name}</div> | |
| <div class="text-xs mt-0.5 flex items-center gap-1">${sizeStr}</div> | |
| </div> | |
| <div class="flex items-center gap-1 flex-col"> | |
| ${item.isProcessing ? '<div class="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>' : ''} | |
| <button class="btn-rm-single text-slate-500 hover:text-rose-400 p-1 opacity-0 group-hover:opacity-100 transition-all"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg></button> | |
| </div> | |
| `; | |
| div.querySelector('.btn-rm-single').onclick = (e) => { | |
| e.stopPropagation(); | |
| if (isImageTab) { | |
| state.images = state.images.filter(i => i.id !== item.id); | |
| if (state.activeImageId === item.id) state.activeImageId = state.images.length > 0 ? state.images[0].id : null; | |
| } else { | |
| state.videos = state.videos.filter(i => i.id !== item.id); | |
| if (state.activeVideoId === item.id) state.activeVideoId = state.videos.length > 0 ? state.videos[0].id : null; | |
| } | |
| updateUI(); | |
| }; | |
| els.galleryList.appendChild(div); | |
| }); | |
| // Update Editor Workspace | |
| const activeItem = itemsList.find(i => i.id === activeId); | |
| if (!activeItem) { | |
| els.editorEmpty.classList.remove('hidden'); | |
| els.editorContent.classList.add('hidden'); | |
| return; | |
| } | |
| els.editorEmpty.classList.add('hidden'); | |
| els.editorContent.classList.remove('hidden'); | |
| els.activeFilename.innerText = activeItem.file.name; | |
| els.activeDimensions.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path></svg> ${Math.floor(activeItem.originalDimensions.width * settings.scale)} x ${Math.floor(activeItem.originalDimensions.height * settings.scale)}`; | |
| let savings = 0; | |
| if (activeItem.compressedSize > 0) savings = (100 - (activeItem.compressedSize / activeItem.originalSize * 100)).toFixed(1); | |
| els.activeSavings.innerText = `Saved ${savings}%`; | |
| if(savings < 0) { els.activeSavings.className = "text-amber-400 bg-amber-400/10 px-2 py-0.5 rounded-md"; els.activeSavings.innerText = `Larger ${Math.abs(savings)}%`; } | |
| else { els.activeSavings.className = "text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded-md"; } | |
| // Setup Preview | |
| if (isImageTab) { | |
| els.previewImage.src = activeItem.compressedUrl || activeItem.originalPreview; | |
| els.previewImage.style.filter = ''; | |
| if (activeItem.isProcessing) { | |
| els.previewLoading.classList.remove('hidden'); | |
| els.loadingProgress.style.width = `100%`; | |
| els.loadingText.innerText = "Applying Filter..."; | |
| els.previewImage.classList.add('opacity-30'); | |
| els.btnDownloadSingle.disabled = true; | |
| els.btnDownloadSingle.innerText = "Processing..."; | |
| els.btnDownloadSingle.classList.add('opacity-50', 'cursor-not-allowed'); | |
| } else { | |
| els.previewLoading.classList.add('hidden'); | |
| els.previewImage.classList.remove('opacity-30'); | |
| els.btnDownloadSingle.disabled = false; | |
| els.btnDownloadSingle.innerText = "Download Output"; | |
| els.btnDownloadSingle.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| } | |
| } else { | |
| const videoTarget = activeItem.compressedUrl || activeItem.originalPreview; | |
| if(els.previewVideo.getAttribute('data-src') !== videoTarget) { | |
| els.previewVideo.src = videoTarget; | |
| els.previewVideo.setAttribute('data-src', videoTarget); | |
| } | |
| if (!activeItem.compressedUrl) { | |
| els.previewVideo.style.filter = `brightness(${edits.brightness}%) contrast(${edits.contrast}%) saturate(${edits.saturation}%) hue-rotate(${edits.hue}deg)`; | |
| } else { | |
| els.previewVideo.style.filter = ''; | |
| } | |
| if (activeItem.isProcessing) { | |
| els.previewLoading.classList.remove('hidden'); | |
| els.previewVideo.classList.add('opacity-30'); | |
| els.btnProcessVideo.disabled = true; | |
| els.btnDownloadSingle.disabled = true; | |
| els.btnProcessVideo.innerText = "Encoding..."; | |
| els.btnProcessVideo.classList.add('opacity-50', 'cursor-not-allowed'); | |
| } else { | |
| els.previewLoading.classList.add('hidden'); | |
| els.previewVideo.classList.remove('opacity-30'); | |
| els.btnProcessVideo.disabled = false; | |
| els.btnProcessVideo.innerText = "Process & Encode Video"; | |
| els.btnProcessVideo.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| els.btnDownloadSingle.disabled = !activeItem.compressedUrl; | |
| els.btnDownloadSingle.innerText = activeItem.compressedUrl ? "Download Video" : "Requires Encoding"; | |
| if(!activeItem.compressedUrl) els.btnDownloadSingle.classList.add('opacity-50', 'cursor-not-allowed'); | |
| else els.btnDownloadSingle.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| } | |
| } | |
| els.statOriginal.innerText = formatBytes(activeItem.originalSize); | |
| els.statResult.innerText = activeItem.compressedSize > 0 ? formatBytes(activeItem.compressedSize) : 'Pending'; | |
| if (isImageTab && settings.format === 'image/png') els.sQuality.disabled = true; | |
| else els.sQuality.disabled = false; | |
| }; | |
| // --- Event Listeners --- | |
| ['dragenter', 'dragover'].forEach(evt => { | |
| window.addEventListener(evt, e => { e.preventDefault(); els.body.classList.add('dragging'); }); | |
| }); | |
| ['dragleave', 'drop'].forEach(evt => { | |
| window.addEventListener(evt, e => { | |
| e.preventDefault(); | |
| if(evt === 'dragleave' && e.clientX > 0 && e.clientY > 0 && e.clientX < window.innerWidth && e.clientY < window.innerHeight) return; | |
| els.body.classList.remove('dragging'); | |
| }); | |
| }); | |
| window.addEventListener('drop', e => handleFiles(e.dataTransfer.files)); | |
| els.fileMain.addEventListener('change', e => handleFiles(e.target.files)); | |
| els.fileAdd.addEventListener('change', e => handleFiles(e.target.files)); | |
| els.formatBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| if (state.currentTab !== 'image') return; | |
| els.formatBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| state.imageSettings.format = btn.dataset.format; | |
| recompressActiveImage(); | |
| }); | |
| }); | |
| const bindSlider = (sliderEl, stateKey) => { | |
| sliderEl.addEventListener('input', (e) => { | |
| const val = parseFloat(e.target.value); | |
| const isImg = state.currentTab === 'image'; | |
| if (stateKey === 'quality' || stateKey === 'scale') { | |
| if (isImg) state.imageSettings[stateKey] = val; | |
| else state.videoSettings[stateKey] = val; | |
| } else { | |
| if (isImg) state.imageEdits[stateKey] = val; | |
| else state.videoEdits[stateKey] = val; | |
| } | |
| if (isImg) recompressActiveImage(); | |
| else updateUI(); | |
| }); | |
| }; | |
| bindSlider(els.sQuality, 'quality'); | |
| bindSlider(els.sScale, 'scale'); | |
| bindSlider(els.sBrightness, 'brightness'); | |
| bindSlider(els.sContrast, 'contrast'); | |
| bindSlider(els.sSaturation, 'saturation'); | |
| bindSlider(els.sHue, 'hue'); | |
| els.btnResetEdits.addEventListener('click', () => { | |
| const defaultEdits = { brightness: 100, contrast: 100, saturation: 100, hue: 0 }; | |
| if (state.currentTab === 'image') { | |
| state.imageEdits = { ...defaultEdits }; | |
| recompressActiveImage(); | |
| } else { | |
| state.videoEdits = { ...defaultEdits }; | |
| updateUI(); | |
| } | |
| }); | |
| // Apply All (Image Only) | |
| els.btnApplyAll.addEventListener('click', async () => { | |
| if (state.currentTab !== 'image') return; | |
| state.images.forEach(i => { | |
| if (i.id !== state.activeImageId) i.isProcessing = true; | |
| }); | |
| updateUI(); | |
| for (let i = 0; i < state.images.length; i++) { | |
| const item = state.images[i]; | |
| if (item.id === state.activeImageId) continue; | |
| const result = await processImageCanvas(item, state.imageSettings, state.imageEdits); | |
| if (result) Object.assign(item, result); | |
| } | |
| updateUI(); | |
| }); | |
| const downloadFile = (item, isImage) => { | |
| if (!item.compressedUrl) return; | |
| const link = document.createElement('a'); | |
| link.href = item.compressedUrl; | |
| let ext = 'webm'; | |
| if (isImage) { | |
| const mime = state.imageSettings.format; | |
| if(mime === 'image/jpeg') ext = 'jpg'; | |
| else if(mime === 'image/png') ext = 'png'; | |
| else if(mime === 'image/webp') ext = 'webp'; | |
| } | |
| link.download = `ShinyyOptimized_${item.file.name.split('.')[0]}.${ext}`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| els.btnDownloadSingle.addEventListener('click', () => { | |
| const isImg = state.currentTab === 'image'; | |
| const list = isImg ? state.images : state.videos; | |
| const id = isImg ? state.activeImageId : state.activeVideoId; | |
| const activeItem = list.find(i => i.id === id); | |
| if(activeItem && !activeItem.isProcessing && activeItem.compressedUrl) downloadFile(activeItem, isImg); | |
| }); | |
| els.btnDownloadAll.addEventListener('click', async () => { | |
| const isImg = state.currentTab === 'image'; | |
| const itemsList = isImg ? state.images : state.videos; | |
| const itemsToDownload = itemsList.filter(item => !item.isProcessing && item.compressedUrl); | |
| for (let i = 0; i < itemsToDownload.length; i++) { | |
| downloadFile(itemsToDownload[i], isImg); | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| } | |
| }); | |
| // Boot process | |
| window.addEventListener('DOMContentLoaded', () => { | |
| switchTab('image'); | |
| autoLoadTransferredAssets(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |