Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created June 23, 2026 23:59
Show Gist options
  • Select an option

  • Save EncodeTheCode/425145f5694ac1d02cdf5e5a555bb81e to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/425145f5694ac1d02cdf5e5a555bb81e to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Local Image Format Converter</title>
<style>
:root {
--bg: #0f172a;
--panel: rgba(15, 23, 42, 0.88);
--panel-2: rgba(30, 41, 59, 0.92);
--text: #e2e8f0;
--muted: #94a3b8;
--border: rgba(148, 163, 184, 0.18);
--accent: #60a5fa;
--accent-2: #34d399;
--danger: #fb7185;
--warn: #fbbf24;
--upload: rgba(96, 165, 250, 0.18);
--converted: rgba(52, 211, 153, 0.18);
--uploaded-border: rgba(96, 165, 250, 0.55);
--converted-border: rgba(52, 211, 153, 0.55);
--drop-glow: rgba(59, 130, 246, 0.42);
--shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
--radius: 20px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.20), transparent 28%),
radial-gradient(circle at top right, rgba(52, 211, 153, 0.15), transparent 28%),
linear-gradient(180deg, #020617 0%, #0f172a 45%, #111827 100%);
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.hero {
display: grid;
grid-template-columns: 1.35fr 0.9fr;
gap: 18px;
margin-bottom: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.hero-main {
padding: 24px;
}
h1 {
margin: 0 0 8px;
font-size: clamp(28px, 3vw, 44px);
line-height: 1.05;
letter-spacing: -0.03em;
}
.subtitle {
margin: 0;
color: var(--muted);
max-width: 62ch;
line-height: 1.6;
font-size: 15px;
}
.hero-meta {
display: grid;
gap: 12px;
padding: 18px;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
padding: 9px 12px;
border-radius: 999px;
color: var(--text);
font-size: 13px;
white-space: nowrap;
}
.controls {
display: grid;
grid-template-columns: 1.6fr 1fr 1fr auto;
gap: 12px;
align-items: end;
margin: 18px 0;
}
.control {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 16px;
padding: 14px;
}
.control label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
}
.control input[type="text"],
.control input[type="color"],
.control select {
width: 100%;
border: 1px solid rgba(148,163,184,0.24);
background: rgba(2, 6, 23, 0.35);
color: var(--text);
border-radius: 12px;
padding: 11px 12px;
outline: none;
}
.control input[type="color"] {
min-height: 44px;
padding: 6px;
cursor: pointer;
}
.btn {
appearance: none;
border: 0;
border-radius: 14px;
padding: 12px 16px;
color: white;
background: linear-gradient(135deg, #2563eb, #0ea5e9);
font-weight: 700;
cursor: pointer;
transition: transform .16s ease, opacity .16s ease, box-shadow .16s ease;
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.22);
min-height: 46px;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.secondary {
background: linear-gradient(135deg, #334155, #475569);
box-shadow: none;
}
.dropzone {
border: 1.5px dashed rgba(96, 165, 250, 0.45);
border-radius: 24px;
background: rgba(15, 23, 42, 0.66);
padding: 26px;
min-height: 160px;
display: grid;
place-items: center;
text-align: center;
transition: transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease;
position: relative;
overflow: hidden;
}
.dropzone.dragover {
border-color: rgba(125, 211, 252, 0.95);
background: rgba(59, 130, 246, 0.18);
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.15), 0 0 0 12px rgba(59, 130, 246, 0.08), 0 18px 45px rgba(15, 23, 42, 0.6);
transform: translateY(-1px);
}
.dropzone strong {
display: block;
font-size: 18px;
margin-bottom: 8px;
}
.dropzone p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.queue-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin: 18px 4px 12px;
}
.queue-head h2 {
margin: 0;
font-size: 18px;
}
.queue-head .stats {
color: var(--muted);
font-size: 14px;
}
.queue {
display: grid;
gap: 12px;
}
.item {
border-radius: 20px;
border: 1px solid var(--border);
background: rgba(15, 23, 42, 0.74);
overflow: hidden;
display: grid;
grid-template-columns: 94px 1fr;
min-height: 114px;
box-shadow: var(--shadow);
}
.item.uploaded { background: linear-gradient(180deg, rgba(30,41,59,0.95), rgba(15,23,42,0.9)); }
.item.converted { background: linear-gradient(180deg, rgba(4,47,46,0.95), rgba(15,23,42,0.9)); }
.item.error { border-color: rgba(251,113,133,0.45); }
.thumb {
width: 94px;
height: 114px;
background: rgba(2, 6, 23, 0.6);
display: grid;
place-items: center;
border-right: 1px solid var(--border);
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.meta {
padding: 14px 14px 12px;
display: grid;
gap: 10px;
}
.topline {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.name {
font-weight: 700;
word-break: break-word;
margin-bottom: 4px;
}
.subinfo {
color: var(--muted);
font-size: 13px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.01em;
}
.badge.uploaded { background: var(--upload); color: #93c5fd; border: 1px solid var(--uploaded-border); }
.badge.converted { background: var(--converted); color: #86efac; border: 1px solid var(--converted-border); }
.badge.pending { background: rgba(250,204,21,0.12); color: #fde68a; border: 1px solid rgba(250,204,21,0.35); }
.badge.error { background: rgba(251,113,133,0.12); color: #fda4af; border: 1px solid rgba(251,113,133,0.35); }
.progress-wrap {
display: grid;
gap: 8px;
}
.progress-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--muted);
gap: 10px;
}
.progress {
width: 100%;
height: 12px;
border-radius: 999px;
background: rgba(148,163,184,0.18);
overflow: hidden;
border: 1px solid rgba(148,163,184,0.14);
}
.progress > div {
height: 100%;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #34d399);
transition: width .16s linear;
}
.item.uploaded .progress > div { background: linear-gradient(90deg, #60a5fa, #93c5fd); }
.item.converted .progress > div { background: linear-gradient(90deg, #34d399, #86efac); }
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tiny {
min-height: 38px;
padding: 9px 12px;
font-size: 13px;
border-radius: 12px;
}
.linkbtn {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.empty {
color: var(--muted);
padding: 14px 2px 4px;
font-size: 14px;
}
.footer-note {
margin-top: 16px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
@media (max-width: 980px) {
.hero, .controls { grid-template-columns: 1fr; }
.item { grid-template-columns: 1fr; }
.thumb { width: 100%; height: 180px; border-right: 0; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<div class="app">
<section class="hero">
<div class="card hero-main">
<h1>Local JPG ↔ PNG Converter</h1>
<p class="subtitle">
Drop images into the queue, convert them locally in your browser, preserve PNG transparency on PNG output,
and flatten transparency naturally for JPG output using a configurable background color.
</p>
<div class="pill-row">
<div class="pill">Drag & drop upload</div>
<div class="pill">Local queue processing</div>
<div class="pill">Per-file progress bars</div>
<div class="pill">Download converted files</div>
<div class="pill">PNG transparency support</div>
</div>
</div>
<div class="card hero-meta">
<div style="padding:4px 4px 0;">
<div style="font-weight:700;font-size:16px;margin-bottom:6px;">How it works</div>
<div style="color:var(--muted);line-height:1.65;font-size:14px;">
Files stay in the browser. The tool reads each image, renders it to canvas, converts it to the selected format,
and creates a downloadable local result.
</div>
</div>
</div>
</section>
<section class="controls card" style="padding:0; background: transparent; border: 0; box-shadow: none;">
<div class="control">
<label for="fileInput">Add images</label>
<input id="fileInput" type="file" accept="image/png,image/jpeg" multiple />
</div>
<div class="control">
<label for="defaultFormat">Default output format</label>
<select id="defaultFormat">
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
<div class="control">
<label for="jpgBg">JPG background for transparency</label>
<input id="jpgBg" type="color" value="#ffffff" />
</div>
<button class="btn" id="clearBtn" type="button">Clear queue</button>
</section>
<section id="dropzone" class="dropzone card" aria-label="Drop image files here">
<div>
<strong>Drop JPG or PNG files here</strong>
<p>Or choose files with the picker above. Multiple files are supported. The box lights up when you drag files over it.</p>
</div>
</section>
<div class="queue-head">
<h2>Conversion queue</h2>
<div class="stats" id="stats">0 files waiting</div>
</div>
<section id="queue" class="queue"></section>
<div id="empty" class="empty">No files in the queue yet.</div>
<p class="footer-note">
JPG export flattens any transparency against the chosen background color because JPG does not support alpha.
PNG export keeps transparency intact. Processing is sequential to keep memory use lower and the UI responsive.
</p>
</div>
<script>
(() => {
'use strict';
const fileInput = document.getElementById('fileInput');
const defaultFormat = document.getElementById('defaultFormat');
const jpgBg = document.getElementById('jpgBg');
const dropzone = document.getElementById('dropzone');
const queueEl = document.getElementById('queue');
const emptyEl = document.getElementById('empty');
const statsEl = document.getElementById('stats');
const clearBtn = document.getElementById('clearBtn');
/** @type {{id:string,file:File,el:HTMLElement,status:string,progress:number,outputFormat:'png'|'jpg',downloadUrl:string|null,outputName:string|null,width:number|null,height:number|null}[]} */
const queue = [];
let processing = false;
const humanSize = (bytes) => {
if (!bytes && bytes !== 0) return '—';
const units = ['B','KB','MB','GB'];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
const extFor = (fmt) => fmt === 'jpg' ? 'jpg' : 'png';
const mimeFor = (fmt) => fmt === 'jpg' ? 'image/jpeg' : 'image/png';
const setStats = () => {
const total = queue.length;
const active = queue.filter(x => x.status === 'queued' || x.status === 'reading' || x.status === 'converting').length;
const done = queue.filter(x => x.status === 'done').length;
statsEl.textContent = `${total} file${total === 1 ? '' : 's'} total • ${active} active • ${done} converted`;
emptyEl.style.display = total ? 'none' : 'block';
};
const createItem = (file) => {
const id = crypto.randomUUID();
const outputFormat = defaultFormat.value;
const item = {
id,
file,
status: 'queued',
progress: 0,
outputFormat,
downloadUrl: null,
outputName: null,
width: null,
height: null,
el: null
};
const wrapper = document.createElement('article');
wrapper.className = 'item';
wrapper.dataset.id = id;
wrapper.innerHTML = `
<div class="thumb"><div style="font-size:12px;color:var(--muted);padding:10px;text-align:center;">Preview</div></div>
<div class="meta">
<div class="topline">
<div>
<div class="name"></div>
<div class="subinfo"></div>
</div>
<span class="badge pending">Queued</span>
</div>
<div class="progress-wrap">
<div class="progress-row"><span class="phase">Waiting</span><span class="pct">0%</span></div>
<div class="progress"><div></div></div>
</div>
<div class="actions">
<button class="btn secondary tiny remove-btn" type="button">Remove</button>
<a class="btn tiny linkbtn download-btn" href="#" download style="pointer-events:none;opacity:.55;">Download</a>
</div>
</div>
`;
const nameEl = wrapper.querySelector('.name');
const infoEl = wrapper.querySelector('.subinfo');
const badgeEl = wrapper.querySelector('.badge');
const phaseEl = wrapper.querySelector('.phase');
const pctEl = wrapper.querySelector('.pct');
const barEl = wrapper.querySelector('.progress > div');
const thumbEl = wrapper.querySelector('.thumb');
const removeBtn = wrapper.querySelector('.remove-btn');
const downloadBtn = wrapper.querySelector('.download-btn');
nameEl.textContent = file.name;
infoEl.textContent = `${file.type || 'image'} • ${humanSize(file.size)} • output: ${outputFormat.toUpperCase()}`;
removeBtn.addEventListener('click', () => {
removeItem(id);
});
downloadBtn.addEventListener('click', (e) => {
const found = queue.find(x => x.id === id);
if (!found || !found.downloadUrl) e.preventDefault();
});
item.el = wrapper;
item._ui = { badgeEl, phaseEl, pctEl, barEl, thumbEl, infoEl, downloadBtn };
queue.push(item);
queueEl.prepend(wrapper);
setStats();
loadPreview(item).catch(err => {
setError(item, err?.message || 'Could not preview this image.');
});
};
const setError = (item, message) => {
item.status = 'error';
const { badgeEl, phaseEl, pctEl, barEl } = item._ui;
item.el.classList.add('error');
badgeEl.textContent = 'Error';
badgeEl.className = 'badge error';
phaseEl.textContent = message;
pctEl.textContent = '—';
barEl.style.width = '100%';
barEl.style.background = 'linear-gradient(90deg, #fb7185, #fda4af)';
setStats();
};
const setProgress = (item, pct, phase, statusClass) => {
item.progress = pct;
const { badgeEl, phaseEl, pctEl, barEl } = item._ui;
item.el.classList.remove('uploaded', 'converted', 'error');
if (statusClass) item.el.classList.add(statusClass);
if (statusClass === 'uploaded') {
badgeEl.textContent = 'Uploaded';
badgeEl.className = 'badge uploaded';
} else if (statusClass === 'converted') {
badgeEl.textContent = 'Converted';
badgeEl.className = 'badge converted';
} else {
badgeEl.textContent = 'Processing';
badgeEl.className = 'badge pending';
}
phaseEl.textContent = phase;
pctEl.textContent = `${Math.round(pct)}%`;
barEl.style.width = `${Math.max(0, Math.min(100, pct))}%`;
};
const loadPreview = async (item) => {
const file = item.file;
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.alt = file.name;
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
item._ui.thumbEl.innerHTML = '';
item._ui.thumbEl.appendChild(img);
};
const removeItem = (id) => {
const idx = queue.findIndex(x => x.id === id);
if (idx === -1) return;
const item = queue[idx];
if (item.downloadUrl) URL.revokeObjectURL(item.downloadUrl);
item.el.remove();
queue.splice(idx, 1);
setStats();
if (!processing) processNext();
};
const clearQueue = () => {
while (queue.length) {
const item = queue.pop();
if (item.downloadUrl) URL.revokeObjectURL(item.downloadUrl);
item.el.remove();
}
setStats();
};
const readImage = async (file) => {
if ('createImageBitmap' in window) {
return await createImageBitmap(file);
}
return await new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Image decode failed.'));
};
img.src = url;
});
};
const canvasToBlob = (canvas, mime, quality) => new Promise((resolve) => {
if (canvas.toBlob) {
canvas.toBlob(resolve, mime, quality);
} else {
const dataUrl = canvas.toDataURL(mime, quality);
fetch(dataUrl).then(r => r.blob()).then(resolve);
}
});
const convertItem = async (item) => {
const file = item.file;
const isJpgInput = /jpeg|jpg/i.test(file.type) || /\.(jpe?g)$/i.test(file.name);
const isPngInput = /png/i.test(file.type) || /\.png$/i.test(file.name);
const outputFormat = defaultFormat.value;
item.outputFormat = outputFormat;
item._ui.infoEl.textContent = `${file.type || 'image'} • ${humanSize(file.size)} • output: ${outputFormat.toUpperCase()}`;
setProgress(item, 2, 'Reading file', '');
item.status = 'reading';
const bitmap = await readImage(file);
const width = bitmap.width || bitmap.naturalWidth;
const height = bitmap.height || bitmap.naturalHeight;
item.width = width;
item.height = height;
setProgress(item, 18, 'Preparing canvas', 'uploaded');
item.status = 'converting';
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false });
const flattenToJpg = outputFormat === 'jpg';
if (flattenToJpg) {
ctx.fillStyle = jpgBg.value;
ctx.fillRect(0, 0, width, height);
} else {
ctx.clearRect(0, 0, width, height);
}
setProgress(item, 55, 'Rendering image', 'uploaded');
ctx.drawImage(bitmap, 0, 0);
if (bitmap.close) bitmap.close();
setProgress(item, 78, 'Encoding output', 'uploaded');
const quality = flattenToJpg ? 0.92 : undefined;
const blob = await canvasToBlob(canvas, mimeFor(outputFormat), quality);
if (!blob) throw new Error('Conversion failed.');
const ext = extFor(outputFormat);
const base = file.name.replace(/\.[^.]+$/, '');
const outputName = `${base}.${ext}`;
const downloadUrl = URL.createObjectURL(blob);
item.downloadUrl = downloadUrl;
item.outputName = outputName;
const a = item._ui.downloadBtn;
a.href = downloadUrl;
a.download = outputName;
a.style.pointerEvents = 'auto';
a.style.opacity = '1';
a.textContent = 'Download';
setProgress(item, 100, flattenToJpg ? 'Finished JPG export' : 'Finished PNG export', 'converted');
item.status = 'done';
item.el.classList.add('converted');
item.el.classList.remove('uploaded');
setStats();
};
const processNext = async () => {
if (processing) return;
const next = queue.find(x => x.status === 'queued');
if (!next) {
setStats();
return;
}
processing = true;
try {
setProgress(next, 1, 'Queued for processing', '');
await new Promise(requestAnimationFrame);
await convertItem(next);
} catch (err) {
setError(next, err?.message || 'Conversion failed.');
} finally {
processing = false;
processNext();
}
};
const addFiles = (files) => {
const list = Array.from(files || []).filter(file => /image\/(png|jpeg)/i.test(file.type) || /\.(png|jpe?g)$/i.test(file.name));
if (!list.length) return;
for (const file of list) createItem(file);
processNext();
};
fileInput.addEventListener('change', (e) => {
addFiles(e.target.files);
fileInput.value = '';
});
['dragenter', 'dragover'].forEach(evt => {
dropzone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evt => {
dropzone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
if (evt === 'drop') dropzone.classList.remove('dragover');
});
});
dropzone.addEventListener('drop', (e) => {
const files = e.dataTransfer?.files;
if (files && files.length) addFiles(files);
});
dropzone.addEventListener('click', () => fileInput.click());
clearBtn.addEventListener('click', clearQueue);
defaultFormat.addEventListener('change', () => {
// Apply the current default to future queued files.
});
setStats();
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment