Video_Tools / old /Pocket_Image_Converter_v1_4.html
PoopMan333's picture
Rename Pocket_Image_Converter_v1_4.html to old/Pocket_Image_Converter_v1_4.html
3d6bb5b verified
Raw History Blame Contribute Delete
62.5 kB
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pocket Image Converter</title> <style> :root{ --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d; --txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6; --ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018; --r:10px; } *{box-sizing:border-box} html,body{margin:0;min-height:100%} body{ background:var(--bg); color:var(--txt); font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; } .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px} header{display:flex;align-items:center;gap:12px;margin-bottom:14px} header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px} header .sub{color:var(--dim);font-size:12.5px} header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px} header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums; border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)} header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8} .spacer{flex:1} button{ font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line); padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s; } button:hover:not(:disabled){background:#242d39;border-color:#3a4553} button:disabled{opacity:.4;cursor:not-allowed} button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px} button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} button.primary:hover:not(:disabled){background:#6bcfff} button.ghost{background:transparent} button.danger{color:#ffb4ae;border-color:#5a2b2b} button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a} .small{padding:5px 10px;font-size:12.5px} /* ---------- drop zone ---------- */ #drop{ border:2px dashed var(--line);border-radius:14px;padding:40px 24px;text-align:center;background:var(--panel); cursor:pointer;transition:.15s;margin-bottom:16px; } #drop:hover,#drop.over{border-color:var(--accent);background:#12212b} #drop .big{font-size:38px;line-height:1;margin-bottom:12px;opacity:.65} #drop h2{margin:0 0 6px;font-size:17px;font-weight:600} #drop p{margin:0;color:var(--dim);font-size:12.5px} #drop .row2{display:flex;gap:8px;justify-content:center;margin-top:14px;flex-wrap:wrap} /* ---------- layout ---------- */ .grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start} @media(max-width:940px){.grid{grid-template-columns:1fr}} .card{background:#11161d;border:1px solid var(--line);border-radius:var(--r);padding:14px} .card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600} .panelbox{background:#0d1219;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px} .panelbox:last-child{margin-bottom:0} h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none; margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26; border-bottom:1px solid var(--line);color:var(--txt)} h3.collap:hover{background:#1c232d} h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px} h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s} h3.collap.shut .chev{transform:rotate(-90deg)} .collapbody.shut{display:none} /* ---------- view tabs ---------- */ .viewtabs{display:flex;gap:5px;margin-bottom:12px} .viewtabs button{padding:6px 14px;font-size:12.5px;border-radius:8px} .viewtabs button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} /* ---------- queue ---------- */ .q-head{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:wrap} .q-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600} .qlist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);max-height:420px;overflow-y:auto} .qrow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px} .qrow:first-child{border-top:none} .qrow:hover{background:var(--panel2)} .qrow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)} .qrow input[type=checkbox]{width:16px;height:16px;flex:none;accent-color:var(--accent);cursor:pointer} .qthumb{width:52px;height:40px;border-radius:4px;border:1px solid var(--line);background:#07090c;flex:none;object-fit:cover;display:block} .qthumb.ph{display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:16px} .qmeta{flex:1 1 90px;min-width:60px;overflow:hidden} .qname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .qsub{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;margin-top:1px} .qstate{font-size:11px;font-variant-numeric:tabular-nums;flex:none;width:74px;text-align:right} .qstate.done{color:var(--ok)} .qstate.err{color:#ffb4ae} .qstate.work{color:var(--accent)} .qx{padding:2px 8px;font-size:13px;border-radius:6px;flex:none} .empty{color:var(--dim);font-size:12.5px;text-align:center;padding:24px 10px} /* ---------- grid view ---------- */ .imgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));gap:10px} .gcard{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);cursor:pointer;transition:.12s} .gcard:hover{border-color:#3a4553} .gcard.on{box-shadow:0 0 0 2px var(--accent);border-color:var(--accent)} .gcard img{width:100%;height:104px;object-fit:cover;display:block;background:#07090c} .gcard .gph{width:100%;height:104px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:20px;background:#07090c} .gcard .gname{font-size:11px;padding:5px 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--txt)} .gcard .gchk{position:absolute;top:6px;left:6px;width:18px;height:18px;accent-color:var(--accent);cursor:pointer;z-index:2} .gcard .gx{position:absolute;top:4px;right:4px;padding:1px 7px;font-size:12px;line-height:1.5;border-radius:6px; background:rgba(0,0,0,.55);color:var(--dim);z-index:2} .gcard .gx:hover{color:#ffb4ae;border-color:#5a2b2b;background:#341c1c} .gcard .gstate{position:absolute;bottom:24px;right:5px;font-size:10px;padding:1px 6px;border-radius:5px;background:rgba(0,0,0,.6);font-variant-numeric:tabular-nums} .gcard .gstate.done{color:#9ae6a8} .gcard .gstate.err{color:#ffb4ae} .gcard .gstate.work{color:var(--accent)} /* ---------- preview stage ---------- */ .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center; position:relative;border:1px solid var(--line);min-height:240px;margin-bottom:14px} .stage canvas{max-width:100%;max-height:52vh;width:auto;height:auto;display:block;background:#000} .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px); padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px} .ba-right{left:auto;right:10px;color:#bfe9ff} /* ---------- settings ---------- */ .row{display:flex;align-items:center;gap:8px;margin-bottom:10px} .row label{flex:0 0 92px;color:var(--dim);font-size:12.5px} .row:last-child{margin-bottom:0} input[type=text],input[type=number],select{ font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line); border-radius:7px;padding:6px 8px;width:100%;min-width:0; } input[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2); border:1px solid var(--line);border-radius:7px;cursor:pointer} input:focus,select:focus{outline:none;border-color:var(--accent)} input[type=range]{width:100%;accent-color:var(--accent)} .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px} .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px;flex:1 1 auto} .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} .hint{color:var(--dim);font-size:11.5px;margin:-2px 0 10px;line-height:1.45} .chk{display:flex;align-items:center;gap:7px;color:var(--txt);font-size:12.5px;margin-bottom:10px;cursor:pointer} .chk input{width:auto;flex:none;accent-color:var(--accent)} .chk .sub{color:var(--dim);font-size:11px} .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px} .est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px} .est span{color:var(--dim)} .est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right} .expBtn{width:100%;padding:11px} .stopBtn{width:100%;padding:9px;margin-top:8px;display:none} .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none} .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s} .status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px;line-height:1.4} .status.err{color:#ffb4ae} .status.ok{color:#9ae6a8} .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4} .toolbtns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px} .toolbtns button{flex:1 1 auto;padding:6px 8px;font-size:12.5px} .keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.7} .kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px; border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace} code{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 4px;font-size:11.5px} @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} </style> </head> <body> <div class="wrap"> <header> <h1>Pocket Image Converter</h1> <span class="sub">HEIC &amp; more &middot; convert &middot; resize &middot; batch</span> <span class="spacer"></span> <span class="verwrap"><span class="ver">v1.4</span><span class="by">by C_Nugget</span></span> </header> <div id="drop"> <div class="big">&#9634;</div> <h2>Drop images here to convert</h2> <p>HEIC / HEIF &middot; JPEG / JFIF &middot; PNG &middot; WebP &middot; AVIF &middot; GIF &middot; BMP &middot; TIFF* &middot; ICO &nbsp;&middot;&nbsp; add as many as you like &nbsp;&middot;&nbsp; nothing is uploaded, it all stays on this computer</p> <div class="row2"> <button id="pickFiles" class="small">Choose files</button> <button id="pickFolder" class="small ghost">Choose a folder</button> </div> <input type="file" id="fileInput" accept="image/*,.heic,.heif,.heics,.jfif,.pjpeg,.pjp,.avif,.tif,.tiff,.bmp,.ico" multiple hidden> <input type="file" id="folderInput" webkitdirectory directory multiple hidden> </div> <div class="grid"> <!-- LEFT: preview + queue --> <div> <div class="viewtabs" id="viewTabs"> <button data-v="preview" class="on">Preview</button> <button data-v="grid">Grid</button> </div> <div id="previewView"> <div class="stage"> <canvas id="preview"></canvas> <div class="badge" id="srcBadge">No image selected</div> <div class="badge ba-right" id="outBadge"></div> </div> </div> <div id="gridView" style="display:none"> <div class="imgrid" id="imgrid"></div> </div> <div class="q-head"> <h3>Queue</h3> <span class="hint" id="qCount" style="margin:0">0 images</span> <span class="spacer"></span> <button class="small ghost" id="selAll">Select all</button> <button class="small ghost" id="selNone">None</button> <button class="small ghost" id="clearDone">Clear finished</button> <button class="small ghost" id="clearAll">Remove all</button> </div> <div id="listView"> <div class="qlist" id="qList"><div class="empty">No images yet. Drop files above and they will appear here in order.</div></div> </div> <div class="keys"> <span class="kbd">&larr;</span> <span class="kbd">&rarr;</span> move between images &nbsp;&middot;&nbsp; <span class="kbd">Space</span> toggle selected &nbsp;&middot;&nbsp; <span class="kbd">R</span> rotate &nbsp;&middot;&nbsp; <span class="kbd">Del</span> remove &nbsp;&middot;&nbsp; only <b style="color:var(--txt)">ticked</b> images are converted; edits apply to every image. </div> </div> <!-- RIGHT: settings --> <div class="card"> <!-- Output format --> <div class="panelbox"> <h3>Output format</h3> <div class="seg" id="fmtSeg"> <button data-fmt="image/png" class="on">PNG</button> <button data-fmt="image/jpeg">JPEG</button> <button data-fmt="image/webp">WebP</button> <button data-fmt="image/avif">AVIF</button> <button data-fmt="application/pdf">PDF</button> </div> <div class="row" id="qualityRow"> <label>Quality</label> <input type="range" id="quality" min="1" max="100" value="82"> <b class="hint" id="qualityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">82</b> </div> <label class="chk" id="targetChk"><input type="checkbox" id="targetOn"> Target file size <span class="sub">(auto-tunes quality)</span></label> <div class="row" id="targetRow" style="display:none"> <label>Max size</label> <input type="number" id="targetSize" min="0.05" step="0.1" value="2" style="flex:1"> <select id="targetUnit" style="flex:0 0 78px"><option value="1048576" selected>MB</option><option value="1024">KB</option></select> </div> <!-- PNG size reduction --> <div id="pngOpts" style="display:none"> <div class="row"> <label>PNG size</label> <select id="pngColors"> <option value="0" selected>Full colour (lossless)</option> <option value="256">Smaller &middot; 256 colours</option> <option value="128">Smaller &middot; 128 colours</option> <option value="64">Smallest &middot; 64 colours</option> <option value="32">Tiny &middot; 32 colours</option> </select> </div> <label class="chk" id="ditherChk" style="display:none"><input type="checkbox" id="pngDither" checked> Dither <span class="sub">(smoother gradients)</span></label> </div> <div class="hint" id="fmtHint">PNG is lossless — great for screenshots and graphics. The quality slider only affects JPEG, WebP and AVIF.</div> </div> <!-- Resize --> <div class="panelbox"> <h3 class="collap shut" data-target="resizeBody">Resize <span class="chev">&#9660;</span></h3> <div class="collapbody shut" id="resizeBody"> <div class="seg" id="resizeMode"> <button data-m="none" class="on">Original</button> <button data-m="pct">Percent</button> <button data-m="px">Pixels</button> <button data-m="max">Max side</button> </div> <div class="row" id="pctRow" style="display:none"><label>Scale</label><input type="number" id="pct" min="1" max="400" step="1" value="100"><span class="hint" style="margin:0">%</span></div> <div id="pxRows" style="display:none"> <div class="row"><label>Width</label><input type="number" id="pxW" min="1" step="1"></div> <div class="row"><label>Height</label><input type="number" id="pxH" min="1" step="1"></div> <label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label> </div> <div class="row" id="maxRow" style="display:none"> <label>Longest side</label> <select id="maxPreset"> <option value="640">640 px</option> <option value="1024">1024 px</option> <option value="1600" selected>1600 px</option> <option value="1920">1920 px</option> <option value="2560">2560 px</option> <option value="4096">4096 px</option> </select> </div> <div class="hint">Images are only ever scaled down to fit — never blown up past their real pixels unless you use Percent above 100.</div> </div> </div> <!-- Adjust --> <div class="panelbox"> <h3 class="collap shut" data-target="adjBody">Rotate &amp; flip <span class="chev">&#9660;</span></h3> <div class="collapbody shut" id="adjBody"> <div class="toolbtns"> <button id="rotL" title="Rotate left">&#8630; Left</button> <button id="rotR" title="Rotate right (R)">Right &#8631;</button> </div> <div class="toolbtns"> <button id="flipH">&#8646; Flip H</button> <button id="flipV">&#8645; Flip V</button> <button id="resetAdj" class="ghost">Reset</button> </div> <label class="chk"><input type="checkbox" id="autoExif" checked> Auto-rotate from EXIF <span class="sub">(fixes sideways iPhone photos)</span></label> <div class="hint">Rotate / flip applies to every image in the queue.</div> </div> </div> <!-- Watermark --> <div class="panelbox"> <h3 class="collap shut" data-target="wmBody">Watermark <span class="chev">&#9660;</span></h3> <div class="collapbody shut" id="wmBody"> <div class="row"><label>Text</label><input type="text" id="wmText" placeholder="Leave blank for none"></div> <div class="row"> <label>Position</label> <select id="wmPos"> <option value="br" selected>Bottom right</option> <option value="bl">Bottom left</option> <option value="tr">Top right</option> <option value="tl">Top left</option> <option value="c">Centre</option> </select> </div> <div class="row"> <label>Size</label> <select id="wmSize"><option value="0.03">Small</option><option value="0.05" selected>Medium</option><option value="0.08">Large</option></select> <input type="color" id="wmColor" value="#ffffff" title="Text colour"> </div> <div class="row"><label>Opacity</label><input type="range" id="wmOpacity" min="10" max="100" value="70"><b class="hint" id="wmOpacityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">70%</b></div> </div> </div> <!-- Naming --> <div class="panelbox"> <h3 class="collap shut" data-target="nameBody">File names <span class="chev">&#9660;</span></h3> <div class="collapbody shut" id="nameBody"> <div class="seg" id="nameMode"> <button data-m="keep" class="on">Keep original</button> <button data-m="tpl">Template</button> </div> <div id="tplRows" style="display:none"> <div class="row"><label>Prefix</label><input type="text" id="prefix" placeholder="e.g. web_"></div> <div class="row"><label>Base</label><input type="text" id="tpl" value="IMG_{n}"></div> <div class="row"><label>Suffix</label><input type="text" id="suffix" placeholder="e.g. _v2"></div> <div class="hint">Tokens: <code>{n}</code> number &middot; <code>{name}</code> original name &middot; <code>{date}</code> today &middot; <code>{w}</code>/<code>{h}</code> output size.</div> </div> <div class="row"><label>Start #</label><input type="number" id="startN" min="0" step="1" value="1"></div> </div> </div> <!-- Export --> <div class="panelbox"> <h3>Export</h3> <div class="est"> <div><span>Selected</span><b id="eCount">&mdash;</b></div> <div><span>Output format</span><b id="eFmt">PNG</b></div> <div><span>Selected output</span><b id="eDims">&mdash;</b></div> <div><span>Est. size (selected)</span><b id="eBytes">&mdash;</b></div> </div> <button id="exportBtn" class="primary expBtn">Convert &amp; save</button> <button id="stopBtn" class="danger stopBtn">Stop</button> <div class="bar" id="bar"><i id="barI"></i></div> <div class="status" id="status"></div> <div class="note" id="note">Batches of 2+ save into a folder you pick (Chrome/Edge), otherwise they download one by one.</div> </div> </div> </div> <div class="panelbox" id="offlineNote" style="margin-top:14px;background:#11161d"> <h3 class="collap shut" data-target="offlineBody">Offline &amp; HEIC support <span class="chev">&#9660;</span></h3> <div class="collapbody shut" id="offlineBody" style="color:var(--dim);font-size:12px;line-height:1.6"> <p style="margin:0 0 8px">This file is <b style="color:var(--txt)">fully self-contained</b> — no internet needed, nothing is uploaded. All the common formats (<b style="color:var(--txt)">JPEG / JFIF, PNG, WebP, AVIF, GIF, BMP</b>) are decoded and converted entirely by your browser.</p> <p style="margin:0 0 8px"><b style="color:var(--txt)">HEIC</b> is the one format browsers can't decode on their own. This tool tries, in order &amp; all offline: your browser's native decoder &rarr; the OS codec via WebCodecs &rarr; a local <code>heic2any.min.js</code>. It works with no setup on:</p> <ul style="margin:0 0 8px;padding-left:18px"> <li><b style="color:var(--txt)">Safari</b> (Mac/iPhone/iPad) — decodes HEIC natively, works instantly.</li> <li><b style="color:var(--txt)">Chrome / Edge on Windows</b> — install the free <i>HEVC Video Extensions</i> from the Microsoft Store once, then HEIC decodes offline via the OS.</li> <li><b style="color:var(--txt)">Chrome / Edge on macOS</b> — uses the system codec, usually works out of the box.</li> </ul> <p style="margin:0"><b style="color:var(--txt)">Guaranteed-offline HEIC on any browser:</b> download <code>heic2any.min.js</code> once (from <code>github.com/alexcorvi/heic2any</code>, the <code>/dist</code> folder) and save it <b style="color:var(--txt)">in the same folder as this HTML file</b>. The app auto-detects it and then decodes HEIC locally forever, with zero network.</p> </div> </div> <div class="keys" style="margin-top:8px"> AVIF <i>output</i> depends on your browser (Chrome/Edge support it; Safari/Firefox may not). A few spec items (original file dates, Live Photo extraction, subfolder mirroring) need a desktop app. </div> </div> <canvas id="work" style="display:none"></canvas> <script> "use strict"; (function(){ /* ======================= helpers ======================= */ const $ = id => document.getElementById(id); const clamp = (v,a,b) => Math.min(b, Math.max(a, v)); function human(bytes){ if(!isFinite(bytes) || bytes <= 0) return "\u2014"; if(bytes < 1024) return bytes + " B"; if(bytes < 1024*1024) return Math.round(bytes/1024) + " KB"; if(bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(bytes < 10*1024*1024 ? 2 : 1) + " MB"; return (bytes/1024/1024/1024).toFixed(2) + " GB"; } function baseName(n){ return String(n||"image").replace(/\.[^.]+$/, "") || "image"; } function safeName(s, fallback){ return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,90) || fallback; } function extFor(mime){ return ({"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/avif":"avif","application/pdf":"pdf"})[mime] || "img"; } function isLossy(mime){ return mime==="image/jpeg" || mime==="image/webp" || mime==="image/avif"; } function say(el, msg, kind){ el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function todayStr(){ const d = new Date(); const p = n => String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()); } function wait(ms){ return new Promise(r => setTimeout(r, ms)); } /* format support probe */ async function supportsMime(mime){ try{ const c = document.createElement("canvas"); c.width = c.height = 4; const blob = await new Promise(r => c.toBlob(r, mime, 0.8)); return !!(blob && blob.type === mime); }catch(e){ return false; } } /* ======================= state ======================= */ const items = []; // {id,file,name,selected,bitmap,w,h,status,outName,outSize,thumbUrl,err} let sel = -1, uid = 0, exporting = false, cancel = false, view = "preview"; let outFmt = "image/png"; const support = {}; /* ======================= collapsibles ======================= */ document.querySelectorAll("h3.collap").forEach(h => { h.addEventListener("click", () => { h.classList.toggle("shut"); const b = $(h.dataset.target); if(b) b.classList.toggle("shut"); }); }); /* ======================= loading files ======================= */ const drop = $("drop"); $("pickFiles").addEventListener("click", e => { e.stopPropagation(); $("fileInput").click(); }); $("pickFolder").addEventListener("click", e => { e.stopPropagation(); $("folderInput").click(); }); drop.addEventListener("click", () => $("fileInput").click()); $("fileInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; }); $("folderInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; }); ["dragenter","dragover"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add("over"); })); ["dragleave","drop"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove("over"); })); window.addEventListener("dragover", e => e.preventDefault()); window.addEventListener("drop", e => { e.preventDefault(); drop.classList.remove("over"); if(e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); }); function looksImage(f){ return (f.type && f.type.startsWith("image/")) || /\.(heic|heif|heics|jpe?g|jfif|pjpeg|pjp|png|webp|gif|bmp|tiff?|avif|ico)$/i.test(f.name); } async function addFiles(fileList){ const files = Array.from(fileList).filter(looksImage); if(!files.length){ say($("status"), "Those files are not images. Add HEIC, JPEG, PNG, WebP, GIF or BMP.", "err"); return; } say($("status"), "Reading " + files.length + (files.length === 1 ? " image\u2026" : " images\u2026")); const firstNew = items.length; for(const f of files){ items.push({ id: ++uid, file: f, name: f.name, selected: true, status: "queued", bitmap: null, w: 0, h: 0, outName: "", outSize: 0, thumbUrl: "", err: "" }); } render(); for(let i = firstNew; i < items.length; i++){ if(!items[i].bitmap && items[i].status !== "error") await decode(items[i]); render(); } if(sel < 0 && items.length){ sel = 0; drawPreview(); } say($("status"), items.length + " image" + (items.length===1?"":"s") + " ready.", "ok"); updateEstimate(); } const isHeic = f => /\.(heic|heif)$/i.test(f.name) || f.type === "image/heic" || f.type === "image/heif" || f.type === "image/heic-sequence"; /* ---- HEIC engine (optional, offline-first) ---- */ let heicPromise = null, heicSource = ""; function loadScript(src){ return new Promise((res, rej) => { const s = document.createElement("script"); s.src = src; s.async = true; s.onload = () => res(true); s.onerror = () => rej(new Error("failed " + src)); document.head.appendChild(s); }); } function ensureHeic(){ if(typeof heic2any !== "undefined") return Promise.resolve(true); if(heicPromise) return heicPromise; const sources = [ { url: "heic2any.min.js", local: true }, { url: "libheif/heic2any.min.js", local: true }, { url: "https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js", local: false }, { url: "https://unpkg.com/heic2any@0.0.4/dist/heic2any.min.js", local: false } ]; heicPromise = (async () => { for(const s of sources){ try{ await loadScript(s.url); if(typeof heic2any !== "undefined"){ heicSource = s.local ? "local file" : "CDN"; return true; } } catch(e){ /* try next */ } } heicPromise = null; throw new Error("no HEIC engine available offline"); })(); return heicPromise; } async function decodeViaWebCodecs(blob){ if(typeof ImageDecoder === "undefined") return null; try{ const type = blob.type && blob.type !== "" ? blob.type : "image/heic"; if(ImageDecoder.isTypeSupported){ const ok = await ImageDecoder.isTypeSupported(type).catch(() => false); if(!ok) return null; } const buf = await blob.arrayBuffer(); const dec = new ImageDecoder({ data: buf, type }); const { image } = await dec.decode(); const bmp = await createImageBitmap(image); try{ image.close && image.close(); }catch(_){} return bmp; }catch(e){ return null; } } async function decodeNative(blob){ const useExif = $("autoExif").checked; try{ return await createImageBitmap(blob, useExif ? { imageOrientation: "from-image" } : {}); }catch(e){ try{ return await loadViaImg(blob); } catch(e2){ return null; } } } async function decode(it){ it.status = "decoding"; render(); try{ let bmp = await decodeNative(it.file); if(!bmp && isHeic(it.file)) bmp = await decodeViaWebCodecs(it.file); if(!bmp && isHeic(it.file)){ try{ await ensureHeic(); let out; try{ out = await heic2any({ blob: it.file, toType: "image/jpeg", quality: 0.92 }); } catch(e){ if(/already browser readable/i.test(e && e.message || "")){ bmp = await decodeNative(it.file); } else { out = await heic2any({ blob: it.file, toType: "image/png" }); } } if(!bmp && out){ bmp = await decodeNative(Array.isArray(out) ? out[0] : out); } }catch(engineErr){} } if(!bmp) throw new Error(isHeic(it.file) ? "HEIC needs a codec this browser lacks — see the offline HEIC note, or open in Safari" : "this browser can't read that format"); it.bitmap = bmp; it.w = bmp.width; it.h = bmp.height; it.status = "ready"; it.thumbUrl = makeThumb(bmp); }catch(e){ it.status = "error"; it.err = e.message || "could not decode"; } } function loadViaImg(blob){ return new Promise((res, rej) => { const url = URL.createObjectURL(blob), img = new Image(); img.onload = () => { res(img); setTimeout(()=>URL.revokeObjectURL(url), 5000); }; img.onerror = () => { URL.revokeObjectURL(url); rej(new Error("decode failed")); }; img.src = url; }); } function makeThumb(bmp){ const c = document.createElement("canvas"); const w = 160, h = 120; c.width = w; c.height = h; const x = c.getContext("2d"); x.fillStyle = "#07090c"; x.fillRect(0,0,w,h); const sc = Math.max(w/bmp.width, h/bmp.height); const dw = bmp.width*sc, dh = bmp.height*sc; x.drawImage(bmp, (w-dw)/2, (h-dh)/2, dw, dh); return c.toDataURL("image/jpeg", 0.72); } /* ======================= transforms ======================= */ let rot = 0, flipH = false, flipV = false; $("rotL").addEventListener("click", () => { rot = (rot + 270) % 360; drawPreview(); updateEstimate(); }); $("rotR").addEventListener("click", () => { rot = (rot + 90) % 360; drawPreview(); updateEstimate(); }); $("flipH").addEventListener("click", () => { flipH = !flipH; drawPreview(); }); $("flipV").addEventListener("click", () => { flipV = !flipV; drawPreview(); }); $("resetAdj").addEventListener("click", () => { rot = 0; flipH = flipV = false; drawPreview(); updateEstimate(); }); $("autoExif").addEventListener("change", async () => { for(const it of items){ if(it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} it.bitmap = null; it.status = "queued"; } } render(); for(const it of items) if(it.status !== "error") { await decode(it); render(); } drawPreview(); }); function targetSize(sw, sh){ const mode = document.querySelector("#resizeMode button.on").dataset.m; let w = sw, h = sh; if(mode === "pct"){ const p = (+$("pct").value||100)/100; w = sw*p; h = sh*p; } else if(mode === "px"){ const pw = +$("pxW").value||0, ph = +$("pxH").value||0; if(pw && ph){ w = pw; h = ph; } else if(pw){ w = pw; h = sh*pw/sw; } else if(ph){ h = ph; w = sw*ph/sh; } } else if(mode === "max"){ const m = +$("maxPreset").value||1600; const s = Math.min(1, m/Math.max(sw, sh)); w = sw*s; h = sh*s; } return { w: Math.max(1, Math.round(w)), h: Math.max(1, Math.round(h)) }; } function renderToCanvas(bmp){ const swapped = (rot === 90 || rot === 270); const bw = swapped ? bmp.height : bmp.width; const bh = swapped ? bmp.width : bmp.height; const t = targetSize(bw, bh); const cv = $("work"); cv.width = t.w; cv.height = t.h; const g = cv.getContext("2d"); g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high"; const reducedPng = (outFmt === "image/png" && (+$("pngColors").value||0) > 0); if(outFmt === "image/jpeg" || outFmt === "application/pdf" || reducedPng){ g.fillStyle = "#fff"; g.fillRect(0,0,t.w,t.h); } g.save(); g.translate(t.w/2, t.h/2); g.rotate(rot * Math.PI/180); g.scale(flipH ? -1 : 1, flipV ? -1 : 1); const drawW = swapped ? t.h : t.w; const drawH = swapped ? t.w : t.h; g.drawImage(bmp, -drawW/2, -drawH/2, drawW, drawH); g.restore(); drawWatermark(g, t.w, t.h); return cv; } function drawWatermark(g, W, H){ const text = ($("wmText").value || "").trim(); if(!text) return; const size = Math.max(11, Math.round(Math.min(W,H) * (parseFloat($("wmSize").value)||0.05))); g.font = "600 " + size + "px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif"; g.textBaseline = "alphabetic"; const pad = Math.round(size*0.6); const w = g.measureText(text).width; const pos = $("wmPos").value; let x = W - w - pad, y = H - pad; if(pos === "bl"){ x = pad; } else if(pos === "tr"){ y = size + pad; } else if(pos === "tl"){ x = pad; y = size + pad; } else if(pos === "c"){ x = (W - w)/2; y = (H + size)/2; } g.globalAlpha = (+$("wmOpacity").value||70)/100; g.fillStyle = "rgba(0,0,0,.45)"; g.fillText(text, x+Math.max(1,size*0.04), y+Math.max(1,size*0.04)); g.fillStyle = $("wmColor").value || "#ffffff"; g.fillText(text, x, y); g.globalAlpha = 1; } /* ======================= preview ======================= */ function drawPreview(){ const cv = $("preview"), g = cv.getContext("2d"); const it = items[sel]; if(!it || !it.bitmap){ cv.width = 640; cv.height = 360; g.fillStyle = "#000"; g.fillRect(0,0,cv.width,cv.height); $("srcBadge").textContent = it ? (it.status === "error" ? "Could not decode this file" : "Decoding\u2026") : "No image selected"; $("outBadge").textContent = ""; return; } const out = renderToCanvas(it.bitmap); cv.width = out.width; cv.height = out.height; g.drawImage(out, 0, 0); $("srcBadge").textContent = it.name + " \u00b7 " + it.w + "\u00d7" + it.h + (isHeic(it.file) ? " \u00b7 HEIC" : ""); $("outBadge").textContent = out.width + "\u00d7" + out.height + " \u00b7 " + fmtLabel(outFmt); } function fmtLabel(m){ return ({"image/png":"PNG","image/jpeg":"JPEG","image/webp":"WebP","image/avif":"AVIF","application/pdf":"PDF"})[m] || m; } /* ======================= view toggle ======================= */ $("viewTabs").addEventListener("click", e => { const b = e.target.closest("button"); if(!b) return; view = b.dataset.v; document.querySelectorAll("#viewTabs button").forEach(x => x.classList.toggle("on", x === b)); $("previewView").style.display = view === "preview" ? "block" : "none"; $("gridView").style.display = view === "grid" ? "block" : "none"; $("listView").style.display = view === "grid" ? "none" : "block"; if(view === "grid") renderGrid(); else drawPreview(); }); /* ======================= selection helpers ======================= */ function selectableCount(){ return items.filter(it => it.status !== "error").length; } function selectedReady(){ return items.filter(it => it.selected && it.status !== "error" && it.bitmap); } $("selAll").addEventListener("click", () => { items.forEach(it => { if(it.status !== "error") it.selected = true; }); render(); }); $("selNone").addEventListener("click", () => { items.forEach(it => it.selected = false); render(); }); /* ======================= queue rendering ======================= */ function render(){ $("qCount").textContent = items.length + (items.length===1?" image":" images") + (items.length ? " \u00b7 " + selectedReady().length + " ticked" : ""); renderList(); if(view === "grid") renderGrid(); updateEstimate(); } function renderList(){ const list = $("qList"); if(!items.length){ list.innerHTML = '<div class="empty">No images yet. Drop files above and they will appear here in order.</div>'; drawPreview(); return; } list.textContent = ""; items.forEach((it, i) => { const row = document.createElement("div"); row.className = "qrow" + (i === sel ? " on" : ""); row.addEventListener("click", () => { sel = i; drawPreview(); render(); }); const chk = document.createElement("input"); chk.type = "checkbox"; chk.checked = !!it.selected; chk.title = "Include in conversion"; chk.disabled = it.status === "error"; chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); }); row.appendChild(chk); if(it.thumbUrl){ const img = document.createElement("img"); img.className = "qthumb"; img.src = it.thumbUrl; row.appendChild(img); } else { const ph = document.createElement("div"); ph.className = "qthumb ph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; row.appendChild(ph); } const meta = document.createElement("div"); meta.className = "qmeta"; const nm = document.createElement("div"); nm.className = "qname"; nm.textContent = it.name; nm.title = it.name; const sub = document.createElement("div"); sub.className = "qsub"; sub.textContent = it.status === "error" ? ("error: " + it.err) : it.w ? (it.w + "\u00d7" + it.h + " \u00b7 " + human(it.file.size)) : "\u2026"; meta.appendChild(nm); meta.appendChild(sub); row.appendChild(meta); const st = document.createElement("div"); st.className = "qstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : (it.status==="decoding"||it.status==="working") ? "work" : ""); st.textContent = it.status === "done" ? human(it.outSize) : it.status === "error" ? "failed" : it.status === "working" ? "\u2026" : it.status === "decoding" ? "reading" : "ready"; row.appendChild(st); const x = document.createElement("button"); x.className = "qx danger ghost"; x.innerHTML = "&times;"; x.title = "Remove"; x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); }); row.appendChild(x); list.appendChild(row); }); } function renderGrid(){ const g = $("imgrid"); if(!items.length){ g.innerHTML = '<div class="empty">No images yet.</div>'; return; } g.textContent = ""; items.forEach((it, i) => { const card = document.createElement("div"); card.className = "gcard" + (i === sel ? " on" : ""); card.addEventListener("click", () => { sel = i; if(view==="preview") drawPreview(); render(); }); const chk = document.createElement("input"); chk.type = "checkbox"; chk.className = "gchk"; chk.checked = !!it.selected; chk.disabled = it.status === "error"; chk.title = "Include in conversion"; chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); }); card.appendChild(chk); const x = document.createElement("button"); x.className = "gx"; x.innerHTML = "&times;"; x.title = "Remove"; x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); }); card.appendChild(x); if(it.thumbUrl){ const img = document.createElement("img"); img.src = it.thumbUrl; card.appendChild(img); } else { const ph = document.createElement("div"); ph.className = "gph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; card.appendChild(ph); } if(it.status !== "queued" && it.status !== "ready"){ const st = document.createElement("div"); st.className = "gstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : "work"); st.textContent = it.status === "done" ? human(it.outSize) : it.status === "error" ? "failed" : it.status === "decoding" ? "reading" : "\u2026"; card.appendChild(st); } const nm = document.createElement("div"); nm.className = "gname"; nm.textContent = it.name; nm.title = it.name; card.appendChild(nm); g.appendChild(card); }); } function removeAt(i){ if(exporting) return; const it = items[i]; if(it && it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} } items.splice(i, 1); if(sel >= items.length) sel = items.length - 1; render(); if(view==="preview") drawPreview(); } $("clearAll").addEventListener("click", () => { if(exporting) return; items.forEach(it => it.bitmap && it.bitmap.close && it.bitmap.close()); items.length = 0; sel = -1; render(); drawPreview(); say($("status"),""); }); $("clearDone").addEventListener("click", () => { if(exporting) return; for(let i = items.length-1; i>=0; i--) if(items[i].status === "done") removeAt(i); }); /* ======================= settings wiring ======================= */ function syncFmtUI(){ const lossy = isLossy(outFmt); $("qualityRow").style.display = lossy ? "flex" : "none"; $("targetChk").style.display = lossy ? "flex" : "none"; $("targetRow").style.display = (lossy && $("targetOn").checked) ? "flex" : "none"; $("pngOpts").style.display = outFmt === "image/png" ? "block" : "none"; $("ditherChk").style.display = (outFmt === "image/png" && (+$("pngColors").value||0) > 0) ? "flex" : "none"; let hint = ""; if(outFmt === "image/png"){ hint = (+$("pngColors").value||0) > 0 ? "Reduced-colour PNG — far smaller files, still lossless within the palette. Best for graphics/screenshots; photos may posterise a little." : "PNG is lossless — great for screenshots and graphics. For big photos, pick a colour count below to shrink, or use JPEG/WebP."; } else if(outFmt === "image/jpeg") hint = "JPEG is best for photos. No transparency — transparent areas become white."; else if(outFmt === "image/webp") hint = "WebP gives smaller files than JPEG at similar quality, with transparency support."; else if(outFmt === "image/avif") hint = support["image/avif"] === false ? "\u26a0 Your browser can\u2019t encode AVIF — try Chrome or Edge, or pick WebP." : "AVIF is the smallest for photos, but slower to encode. Chrome/Edge only."; else if(outFmt === "application/pdf") hint = "Each ticked image becomes one PDF page (embedded as JPEG) in a single document."; $("fmtHint").textContent = hint; } $("fmtSeg").addEventListener("click", async e => { const b = e.target.closest("button"); if(!b || b.disabled) return; document.querySelectorAll("#fmtSeg button").forEach(x => x.classList.toggle("on", x === b)); outFmt = b.dataset.fmt; syncFmtUI(); drawPreview(); updateEstimate(); }); $("quality").addEventListener("input", () => { $("qualityL").textContent = $("quality").value; updateEstimate(); }); $("targetOn").addEventListener("change", () => { $("targetRow").style.display = ($("targetOn").checked && isLossy(outFmt)) ? "flex" : "none"; updateEstimate(); }); ["targetSize","targetUnit"].forEach(id => $(id).addEventListener("input", updateEstimate)); $("pngColors").addEventListener("change", () => { syncFmtUI(); updateEstimate(); }); $("pngDither").addEventListener("change", updateEstimate); $("resizeMode").addEventListener("click", e => { const b = e.target.closest("button"); if(!b) return; document.querySelectorAll("#resizeMode button").forEach(x => x.classList.toggle("on", x === b)); const m = b.dataset.m; $("pctRow").style.display = m === "pct" ? "flex" : "none"; $("pxRows").style.display = m === "px" ? "block" : "none"; $("maxRow").style.display = m === "max" ? "flex" : "none"; if(m === "px" && items[sel]){ const it = items[sel]; $("pxW").value = it.w; $("pxH").value = it.h; } drawPreview(); updateEstimate(); }); ["pct","maxPreset"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); })); $("pxW").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxH").value = Math.round((+$("pxW").value||1) * it.h / it.w); } drawPreview(); updateEstimate(); }); $("pxH").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxW").value = Math.round((+$("pxH").value||1) * it.w / it.h); } drawPreview(); updateEstimate(); }); ["wmText","wmPos","wmSize","wmColor"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); })); $("wmOpacity").addEventListener("input", () => { $("wmOpacityL").textContent = $("wmOpacity").value + "%"; drawPreview(); }); $("nameMode").addEventListener("click", e => { const b = e.target.closest("button"); if(!b) return; document.querySelectorAll("#nameMode button").forEach(x => x.classList.toggle("on", x === b)); $("tplRows").style.display = b.dataset.m === "tpl" ? "block" : "none"; }); /* ======================= estimate ======================= */ let estTimer = 0; function updateEstimate(){ const selN = selectedReady().length; $("eCount").textContent = items.length ? (selN + " of " + items.length) : "\u2014"; $("eFmt").textContent = fmtLabel(outFmt) + (outFmt==="image/png" && (+$("pngColors").value||0)>0 ? " \u00b7 " + $("pngColors").value + "c" : ""); $("exportBtn").disabled = exporting || selN === 0; const it = items[sel]; if(!it || !it.bitmap){ $("eDims").textContent = "\u2014"; $("eBytes").textContent = "\u2014"; return; } const out = renderToCanvas(it.bitmap); $("eDims").textContent = out.width + "\u00d7" + out.height; clearTimeout(estTimer); estTimer = setTimeout(async () => { try{ const blob = await encodeCanvas(out, false); $("eBytes").textContent = blob ? human(blob.size) : "\u2014"; }catch(e){ $("eBytes").textContent = "\u2014"; } }, 250); } /* ======================= encoding ======================= */ function canvasToBlob(cv, mime, q){ return new Promise(res => cv.toBlob(res, mime, q)); } async function encodeCanvas(cv, doTarget){ if(outFmt === "application/pdf") return makePdf([cv]); if(outFmt === "image/png"){ const colors = +$("pngColors").value || 0; if(colors > 0) return encodeReducedPNG(cv, colors, $("pngDither").checked); return canvasToBlob(cv, "image/png"); } const q = (+$("quality").value||82)/100; if(doTarget && $("targetOn").checked){ const unit = +$("targetUnit").value, limit = (+$("targetSize").value||2) * unit; return encodeToTarget(cv, limit); } return canvasToBlob(cv, outFmt, q); } async function encodeToTarget(cv, limit){ let lo = 0.05, hi = 0.98, best = null; let blob = await canvasToBlob(cv, outFmt, hi); if(blob && blob.size <= limit) return blob; for(let i = 0; i < 8; i++){ const mid = (lo + hi) / 2; blob = await canvasToBlob(cv, outFmt, mid); if(!blob){ break; } if(blob.size <= limit){ best = blob; lo = mid; } else { hi = mid; } } return best || await canvasToBlob(cv, outFmt, lo); } /* ---------- reduced-colour PNG encoder (median cut + zlib via CompressionStream) ---------- */ async function deflateZlib(u8){ if(typeof CompressionStream === "undefined") return null; // caller falls back to normal PNG const cs = new CompressionStream("deflate"); // zlib wrapper (RFC1950) — what PNG needs const w = cs.writable.getWriter(); w.write(u8); w.close(); const buf = await new Response(cs.readable).arrayBuffer(); return new Uint8Array(buf); } const _crcTable = (() => { const t = new Uint32Array(256); for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c = c&1 ? 0xEDB88320^(c>>>1) : c>>>1; t[n]=c>>>0; } return t; })(); function crc32(buf){ let c=0xFFFFFFFF; for(let i=0;i<buf.length;i++) c=_crcTable[(c^buf[i])&255]^(c>>>8); return (c^0xFFFFFFFF)>>>0; } function u32be(n){ return new Uint8Array([(n>>>24)&255,(n>>>16)&255,(n>>>8)&255,n&255]); } function pngChunk(type, data){ const t = new Uint8Array(4); for(let i=0;i<4;i++) t[i]=type.charCodeAt(i); const body = new Uint8Array(t.length + data.length); body.set(t,0); body.set(data,t.length); const out = new Uint8Array(4 + body.length + 4); out.set(u32be(data.length),0); out.set(body,4); out.set(u32be(crc32(body)), 4+body.length); return out; } /* median-cut palette from RGBA (5-bit histogram) */ const _hist = new Int32Array(32768), _rs = new Int32Array(32768), _gs = new Int32Array(32768), _bs = new Int32Array(32768); const _binArr = new Int32Array(32768); function buildPalette(data, maxColors){ _hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0); for(let i=0;i<data.length;i+=4){ const r=data[i],g=data[i+1],b=data[i+2]; const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3); _hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b; } let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _binArr[m++]=k; const stat=(s,e)=>{ let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0; for(let i=s;i<e;i++){ const k=_binArr[i], r=(k>>10)&31,g=(k>>5)&31,b=k&31; if(r<rmin)rmin=r; if(r>rmax)rmax=r; if(g<gmin)gmin=g; if(g>gmax)gmax=g; if(b<bmin)bmin=b; if(b>bmax)bmax=b; } const dr=rmax-rmin,dg=gmax-gmin,db=bmax-bmin; let chan=0,ext=dr; if(dg>ext){chan=1;ext=dg;} if(db>ext){chan=2;ext=db;} return {s,e,chan,ext}; }; let boxes=[stat(0,m)]; while(boxes.length<maxColors){ let bi=-1,best=0; for(let i=0;i<boxes.length;i++){ const B=boxes[i]; if(B.e-B.s<2) continue; if(B.ext>best){best=B.ext;bi=i;} } if(bi<0) break; const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0; const sub=Array.from(_binArr.subarray(B.s,B.e)); sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31))); _binArr.set(sub,B.s); let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_binArr[i]]; let acc=0,mid=B.s+1; for(let i=B.s;i<B.e;i++){ acc+=_hist[_binArr[i]]; if(acc*2>=total){ mid=i+1; break; } } if(mid<=B.s) mid=B.s+1; if(mid>=B.e) mid=B.e-1; boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e)); } const pal=new Uint8Array(768); let count=0; for(let i=0;i<boxes.length;i++){ const B=boxes[i]; let c=0,R=0,G=0,Bl=0; for(let x=B.s;x<B.e;x++){ const k=_binArr[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; } if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); count++; } } return { pal, count: Math.max(1,count) }; } const _near = new Int16Array(32768); function mapToPalette(data, w, h, pal, palCount, dither){ const idx=new Uint8Array(w*h); _near.fill(-1); const nearest=(r,g,b)=>{ const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3); const hit=_near[key]; if(hit>=0) return hit; let bd=Infinity,bi=0; for(let i=0;i<palCount;i++){ const dr=r-pal[i*3],dg=g-pal[i*3+1],db=b-pal[i*3+2]; const d=dr*dr*3+dg*dg*6+db*db; if(d<bd){bd=d;bi=i;} } _near[key]=bi; return bi; }; if(!dither){ for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]); return idx; } const buf=new Float32Array(w*h*3); for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; } const cl=v=>v<0?0:(v>255?255:v); for(let y=0;y<h;y++) for(let x=0;x<w;x++){ const q=y*w+x,o=q*3; const r=cl(buf[o]),g=cl(buf[o+1]),b=cl(buf[o+2]); const i=nearest(r|0,g|0,b|0); idx[q]=i; const er=(r-pal[i*3])*0.75,eg=(g-pal[i*3+1])*0.75,eb=(b-pal[i*3+2])*0.75; if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; } if(y+1<h){ if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; } { const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; } if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; } } } return idx; } async function encodeReducedPNG(cv, colors, dither){ const ctx = cv.getContext("2d"); const { data, width:w, height:h } = ctx.getImageData(0,0,cv.width,cv.height); const { pal, count } = buildPalette(data, colors); const idx = mapToPalette(data, w, h, pal, count, dither); const raw = new Uint8Array(h*(w+1)); for(let y=0;y<h;y++){ raw[y*(w+1)] = 0; raw.set(idx.subarray(y*w, y*w+w), y*(w+1)+1); } const idat = await deflateZlib(raw); if(!idat) return canvasToBlob(cv, "image/png"); // no CompressionStream → normal PNG const sig = new Uint8Array([137,80,78,71,13,10,26,10]); const ihdr = new Uint8Array(13); ihdr.set(u32be(w),0); ihdr.set(u32be(h),4); ihdr[8]=8; ihdr[9]=3; ihdr[10]=0; ihdr[11]=0; ihdr[12]=0; const plte = pal.subarray(0, count*3); const chunks = [ sig, pngChunk("IHDR",ihdr), pngChunk("PLTE",plte), pngChunk("IDAT",idat), pngChunk("IEND", new Uint8Array(0)) ]; let total=0; chunks.forEach(c=>total+=c.length); const out=new Uint8Array(total); let off=0; chunks.forEach(c=>{ out.set(c,off); off+=c.length; }); return new Blob([out], { type:"image/png" }); } /* ---------- PDF: one page per image, JPEG embedded (DCTDecode) ---------- */ async function makePdf(canvases){ const q = clamp((+$("quality").value||82)/100, 0.4, 0.95); const bytes = []; const push = s => { for(let i=0;i<s.length;i++) bytes.push(s.charCodeAt(i) & 255); }; const pushBytes = arr => { for(let i=0;i<arr.length;i++) bytes.push(arr[i]); }; const imgDatas = []; for(const cv of canvases){ const b = await canvasToBlob(cv, "image/jpeg", q); imgDatas.push(new Uint8Array(await b.arrayBuffer())); } const N = canvases.length; const offsets = []; const start = () => offsets.push(bytes.length); push("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); // header (this was the bug — never written before) start(); push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); const kids = []; for(let i=0;i<N;i++) kids.push((3 + i*3) + " 0 R"); start(); push("2 0 obj\n<< /Type /Pages /Count " + N + " /Kids [ " + kids.join(" ") + " ] >>\nendobj\n"); for(let i=0;i<N;i++){ const cv = canvases[i], w = cv.width, h = cv.height; const pageObj = 3 + i*3, contentObj = pageObj + 1, imgObj = pageObj + 2; start(); push(pageObj + " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + w + " " + h + "] " + "/Resources << /XObject << /Im0 " + imgObj + " 0 R >> >> /Contents " + contentObj + " 0 R >>\nendobj\n"); const content = "q\n" + w + " 0 0 " + h + " 0 0 cm\n/Im0 Do\nQ\n"; start(); push(contentObj + " 0 obj\n<< /Length " + content.length + " >>\nstream\n" + content + "endstream\nendobj\n"); const data = imgDatas[i]; start(); push(imgObj + " 0 obj\n<< /Type /XObject /Subtype /Image /Width " + w + " /Height " + h + " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + data.length + " >>\nstream\n"); pushBytes(data); push("\nendstream\nendobj\n"); } const xrefPos = bytes.length, total = 2 + N*3; push("xref\n0 " + (total+1) + "\n"); push("0000000000 65535 f \n"); for(const off of offsets){ push(String(off).padStart(10,"0") + " 00000 n \n"); } push("trailer\n<< /Size " + (total+1) + " /Root 1 0 R >>\nstartxref\n" + xrefPos + "\n%%EOF"); return new Blob([new Uint8Array(bytes)], { type: "application/pdf" }); } /* ======================= naming ======================= */ function outNameFor(it, i, w, h){ const mode = document.querySelector("#nameMode button.on").dataset.m; const ext = extFor(outFmt); const num = (+$("startN").value||1) + i; if(mode === "keep") return safeName(baseName(it.name), "image") + "." + ext; const tpl = ($("tpl").value || "IMG_{n}") .replace(/\{n\}/g, String(num).padStart(3,"0")) .replace(/\{name\}/g, baseName(it.name)) .replace(/\{date\}/g, todayStr()) .replace(/\{w\}/g, w).replace(/\{h\}/g, h); return safeName(($("prefix").value||"") + tpl + ($("suffix").value||""), "image") + "." + ext; } /* ======================= save helpers ======================= */ function download(blob, name){ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 12000); } /* ======================= export ======================= */ $("stopBtn").addEventListener("click", () => { cancel = true; say($("status"), "Stopping\u2026"); }); $("exportBtn").addEventListener("click", async () => { if(exporting) return; const ready = selectedReady(); if(!ready.length){ say($("status"), "Tick at least one readable image first.", "err"); return; } if(outFmt === "image/avif" && support["image/avif"] === false){ say($("status"), "This browser can\u2019t encode AVIF. Pick WebP or JPEG, or use Chrome/Edge.", "err"); return; } if(outFmt === "application/pdf") return exportPdf(ready); exporting = true; cancel = false; lock(true); $("bar").style.display = "block"; $("barI").style.width = "0%"; say($("status"), "Converting\u2026"); let dirHandle = null; if(ready.length > 1 && window.showDirectoryPicker){ try{ dirHandle = await window.showDirectoryPicker({ id:"pocket-img", mode:"readwrite", startIn:"pictures" }); } catch(e){ if(e && e.name === "AbortError"){ exporting=false; lock(false); $("bar").style.display="none"; say($("status"),"Export cancelled \u2014 no folder chosen."); return; } dirHandle = null; } } let done = 0, failed = 0, i = 0; for(const it of ready){ if(cancel) break; it.status = "working"; render(); try{ const cv = renderToCanvas(it.bitmap); const blob = await encodeCanvas(cv, true); if(!blob || !blob.size) throw new Error("empty output"); const name = outNameFor(it, i, cv.width, cv.height); it.outName = name; it.outSize = blob.size; if(dirHandle){ const fh = await dirHandle.getFileHandle(name, { create: true }); const ws = await fh.createWritable(); await ws.write(blob); await ws.close(); }else{ download(blob, name); if(ready.length > 1) await wait(300); } it.status = "done"; done++; }catch(e){ it.status = "error"; it.err = e.message || "convert failed"; failed++; } i++; $("barI").style.width = ((done+failed)/ready.length*100).toFixed(1) + "%"; render(); await wait(0); } exporting = false; lock(false); $("stopBtn").style.display = "none"; $("bar").style.display = "none"; const where = dirHandle ? " to \u201c" + dirHandle.name + "\u201d" : (ready.length>1 ? " to your Downloads folder" : ""); say($("status"), cancel ? ("Stopped \u2014 " + done + " saved.") : ("Done \u2014 " + done + " saved" + where + (failed ? " \u00b7 " + failed + " failed" : "") + "."), failed ? "err" : "ok"); }); async function exportPdf(ready){ exporting = true; cancel = false; lock(true); $("bar").style.display = "block"; $("barI").style.width = "0%"; say($("status"), "Building PDF\u2026"); try{ const canvases = []; for(let k=0;k<ready.length;k++){ if(cancel) break; const cv = renderToCanvas(ready[k].bitmap); const c2 = document.createElement("canvas"); c2.width = cv.width; c2.height = cv.height; c2.getContext("2d").drawImage(cv, 0, 0); canvases.push(c2); $("barI").style.width = ((k+1)/ready.length*80).toFixed(1) + "%"; say($("status"), "Rendering page " + (k+1) + " of " + ready.length + "\u2026"); await wait(0); } if(cancel){ say($("status"), "Stopped."); } else{ $("barI").style.width = "92%"; const blob = await makePdf(canvases); const name = safeName(($("prefix").value||"") + "images_" + todayStr(), "images") + ".pdf"; download(blob, name); ready.forEach(it => { it.status = "done"; it.outSize = Math.round(blob.size/ready.length); }); say($("status"), "Saved " + name + " (" + human(blob.size) + ", " + ready.length + " page" + (ready.length===1?"":"s") + ").", "ok"); } }catch(e){ say($("status"), e.message || "PDF failed.", "err"); } finally{ exporting = false; lock(false); $("stopBtn").style.display = "none"; $("bar").style.display = "none"; render(); } } function lock(on){ $("exportBtn").disabled = on; $("stopBtn").style.display = on ? "block" : "none"; ["clearAll","clearDone","pickFiles","pickFolder","selAll","selNone"].forEach(id => $(id).disabled = on); } /* ======================= keyboard ======================= */ document.addEventListener("keydown", e => { const tag = (e.target.tagName||"").toLowerCase(); if(tag === "input" || tag === "select" || tag === "textarea") return; if(e.metaKey || e.ctrlKey || e.altKey) return; if(exporting) return; if(e.key === "ArrowLeft"){ e.preventDefault(); if(sel > 0){ sel--; drawPreview(); render(); } } else if(e.key === "ArrowRight"){ e.preventDefault(); if(sel < items.length-1){ sel++; drawPreview(); render(); } } else if(e.key === " "){ e.preventDefault(); const it=items[sel]; if(it && it.status!=="error"){ it.selected=!it.selected; render(); } } else if(e.key === "r" || e.key === "R"){ e.preventDefault(); rot = (rot+90)%360; drawPreview(); updateEstimate(); } else if(e.key === "Delete" || e.key === "Backspace"){ if(sel>=0){ e.preventDefault(); removeAt(sel); } } }); /* ======================= startup ======================= */ (async function init(){ support["image/webp"] = await supportsMime("image/webp"); support["image/avif"] = await supportsMime("image/avif"); if(!support["image/avif"]){ const b = document.querySelector('#fmtSeg button[data-fmt="image/avif"]'); if(b){ b.disabled = true; b.title = "Not supported by this browser"; b.style.opacity = ".4"; b.style.cursor = "not-allowed"; } } syncFmtUI(); drawPreview(); render(); })(); })(); </script> </body> </html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pocket Image Converter</title>
<style>
:root{
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
--txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
--ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018;
--r:10px;
}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%}
body{
background:var(--bg); color:var(--txt);
font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
header .sub{color:var(--dim);font-size:12.5px}
header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
.spacer{flex:1}
button{
font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
}
button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
button:disabled{opacity:.4;cursor:not-allowed}
button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
button.primary:hover:not(:disabled){background:#6bcfff}
button.ghost{background:transparent}
button.danger{color:#ffb4ae;border-color:#5a2b2b}
button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
.small{padding:5px 10px;font-size:12.5px}
/* ---------- drop zone ---------- */
#drop{
border:2px dashed var(--line);border-radius:14px;padding:40px 24px;text-align:center;background:var(--panel);
cursor:pointer;transition:.15s;margin-bottom:16px;
}
#drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
#drop .big{font-size:38px;line-height:1;margin-bottom:12px;opacity:.65}
#drop h2{margin:0 0 6px;font-size:17px;font-weight:600}
#drop p{margin:0;color:var(--dim);font-size:12.5px}
#drop .row2{display:flex;gap:8px;justify-content:center;margin-top:14px;flex-wrap:wrap}
/* ---------- layout ---------- */
.grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
@media(max-width:940px){.grid{grid-template-columns:1fr}}
.card{background:#11161d;border:1px solid var(--line);border-radius:var(--r);padding:14px}
.card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
.panelbox{background:#0d1219;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px}
.panelbox:last-child{margin-bottom:0}
h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;
margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26;
border-bottom:1px solid var(--line);color:var(--txt)}
h3.collap:hover{background:#1c232d}
h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px}
h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s}
h3.collap.shut .chev{transform:rotate(-90deg)}
.collapbody.shut{display:none}
/* ---------- view tabs ---------- */
.viewtabs{display:flex;gap:5px;margin-bottom:12px}
.viewtabs button{padding:6px 14px;font-size:12.5px;border-radius:8px}
.viewtabs button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
/* ---------- queue ---------- */
.q-head{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:wrap}
.q-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
.qlist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);max-height:420px;overflow-y:auto}
.qrow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
.qrow:first-child{border-top:none}
.qrow:hover{background:var(--panel2)}
.qrow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
.qrow input[type=checkbox]{width:16px;height:16px;flex:none;accent-color:var(--accent);cursor:pointer}
.qthumb{width:52px;height:40px;border-radius:4px;border:1px solid var(--line);background:#07090c;flex:none;object-fit:cover;display:block}
.qthumb.ph{display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:16px}
.qmeta{flex:1 1 90px;min-width:60px;overflow:hidden}
.qname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.qsub{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;margin-top:1px}
.qstate{font-size:11px;font-variant-numeric:tabular-nums;flex:none;width:74px;text-align:right}
.qstate.done{color:var(--ok)} .qstate.err{color:#ffb4ae} .qstate.work{color:var(--accent)}
.qx{padding:2px 8px;font-size:13px;border-radius:6px;flex:none}
.empty{color:var(--dim);font-size:12.5px;text-align:center;padding:24px 10px}
/* ---------- grid view ---------- */
.imgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));gap:10px}
.gcard{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);cursor:pointer;transition:.12s}
.gcard:hover{border-color:#3a4553}
.gcard.on{box-shadow:0 0 0 2px var(--accent);border-color:var(--accent)}
.gcard img{width:100%;height:104px;object-fit:cover;display:block;background:#07090c}
.gcard .gph{width:100%;height:104px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:20px;background:#07090c}
.gcard .gname{font-size:11px;padding:5px 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--txt)}
.gcard .gchk{position:absolute;top:6px;left:6px;width:18px;height:18px;accent-color:var(--accent);cursor:pointer;z-index:2}
.gcard .gx{position:absolute;top:4px;right:4px;padding:1px 7px;font-size:12px;line-height:1.5;border-radius:6px;
background:rgba(0,0,0,.55);color:var(--dim);z-index:2}