Download old/Pocket_Image_Converter_v1_4.html from PoopMan333/Video_Tools: direct link, hf CLI and curl.
- Browser
- Download file 62.5 kB
-
https://huggingface.co/PoopMan333/Video_Tools/resolve/main/old/Pocket_Image_Converter_v1_4.html
- Command line
-
hf download hf://PoopMan333/Video_Tools/old/Pocket_Image_Converter_v1_4.html
-
curl -L -o Pocket_Image_Converter_v1_4.html https://huggingface.co/PoopMan333/Video_Tools/resolve/main/old/Pocket_Image_Converter_v1_4.html
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 & more · convert · resize · 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">▢</div>
<h2>Drop images here to convert</h2>
<p>HEIC / HEIF · JPEG / JFIF · PNG · WebP · AVIF · GIF · BMP · TIFF* · ICO · add as many as you like · 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">←</span> <span class="kbd">→</span> move between images ·
<span class="kbd">Space</span> toggle selected ·
<span class="kbd">R</span> rotate · <span class="kbd">Del</span> remove ·
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 · 256 colours</option>
<option value="128">Smaller · 128 colours</option>
<option value="64">Smallest · 64 colours</option>
<option value="32">Tiny · 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">▼</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 & flip <span class="chev">▼</span></h3>
<div class="collapbody shut" id="adjBody">
<div class="toolbtns">
<button id="rotL" title="Rotate left">↶ Left</button>
<button id="rotR" title="Rotate right (R)">Right ↷</button>
</div>
<div class="toolbtns">
<button id="flipH">⇆ Flip H</button>
<button id="flipV">⇅ 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">▼</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">▼</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 · <code>{name}</code> original name · <code>{date}</code> today · <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">—</b></div>
<div><span>Output format</span><b id="eFmt">PNG</b></div>
<div><span>Selected output</span><b id="eDims">—</b></div>
<div><span>Est. size (selected)</span><b id="eBytes">—</b></div>
</div>
<button id="exportBtn" class="primary expBtn">Convert & 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 & HEIC support <span class="chev">▼</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 & all offline: your browser's native decoder → the OS codec via WebCodecs → 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 = "×"; 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 = "×"; 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>
<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}