"use strict";
/* ============================================================
SAMPLE IMAGES ── swap these for your own files in static/samples/
The teaser cycles these first, then pulls random photos from Lorem
Picsum (picsum.photos → Unsplash-sourced, keyless, CORS-enabled).
Set USE_REMOTE=false to only ever loop your local samples.
Anything that fails to load/CORS falls back to a procedural pattern.
============================================================ */
const SAMPLES = [
"/samples/1.jpg",
"/samples/2.jpg",
"/samples/3.jpg",
"/samples/4.jpg",
"/samples/5.jpg",
];
const USE_REMOTE = true;
// const remoteURL = () => `https://picsum.photos/seed/${Math.floor(Math.random() * 1e9)}/${CANVAS}/${CANVAS}`;
const remoteURL = () => `/api/random_photo?seed=${Math.floor(Math.random() * 1e9)}`;
/* ============================================================
LANDING ROSTER ANIMATION
============================================================ */
const CANVAS = 256; // internal canvas resolution
const RADIUS = 50; // click brush radius
const GLOBAL_START = 128; // global auto strokes: 128x128 …
const GLOBAL_END = 4; // … down to 4x4
const GLOBAL_T = 2.5; // … over 2.5s (then stays at GLOBAL_END)
const CLICK_END = 2; // size clicks shrink down to
const CLICK_T = 2; // … over 2 seconds
const STROKES_PER_FRAME = 18;
const CYCLE_MS = 5000;
const N = 5; // pairs in the ring (3 visible)
const BG = [22, 22, 24]; // canvas seed color (#161618)
const SLOTS = [
{ y: 0, op: 1, scale: 1, z: 3 }, // r0 mid · active (on top)
{ y: 185, op: .5, scale: .92, z: 2 }, // r1 bottom · next
{ y: 380, op: 0, scale: .85, z: 1 }, // r2 hidden below (entering)
{ y: -380, op: 0, scale: .85, z: 1 }, // r3 hidden above (exiting)
{ y: -185, op: .5, scale: .92, z: 2 }, // r4 top · prev
];
let pairs = [], offset = 0, activePair = null, activeStart = 0, pressStart = 0, srcCursor = 0;
let firstCycle = true; // hide the "previously seen" top slot until a real one rotates up
let landingActive = true; // only run the teaser while the landing is on screen
let actualAlgo = false; // landing demo runs the real PBC algorithm (streamed) when true
let streamAbort = null; // aborts the in-flight stream when rotating / toggling / leaving
let mouse = { down: false, x: 0, y: 0, target: null, startSize: GLOBAL_START };
const cycleFill = document.getElementById("cycle-fill");
const isLive = () => landingActive && !document.hidden;
// Exponential (fast-then-slow) interpolation from s0 to s1 over T seconds.
const expSize = (s0, s1, t, T) => Math.max(1, Math.round(s0 * Math.pow(s1 / s0, Math.min(t, T) / T)));
const globalSizeNow = () => expSize(GLOBAL_START, GLOBAL_END, (performance.now() - activeStart) / 1000, GLOBAL_T);
function resetBuf(p) {
for (let i = 0; i < p.buf.length; i += 3) { p.buf[i] = BG[0]; p.buf[i + 1] = BG[1]; p.buf[i + 2] = BG[2]; }
}
function clearCanvas(p) {
p.ctx.fillStyle = "#161618";
p.ctx.fillRect(0, 0, CANVAS, CANVAS);
}
function makePair() {
const el = document.createElement("div");
el.className = "pair";
el.innerHTML = `
source
canvas
`;
const cv = el.querySelector("canvas");
const ctx = cv.getContext("2d");
const p = { el, img: el.querySelector("img"), canvas: cv, ctx, srcData: null, buf: new Uint8ClampedArray(CANVAS * CANVAS * 3) };
clearCanvas(p);
resetBuf(p);
return p;
}
function proceduralFallback(p, seed) {
const off = document.createElement("canvas");
off.width = off.height = CANVAS;
const c = off.getContext("2d");
const g = c.createLinearGradient(0, 0, CANVAS, CANVAS);
const h = (seed * 67) % 360;
g.addColorStop(0, `hsl(${h},65%,55%)`);
g.addColorStop(.5, `hsl(${(h + 60) % 360},60%,40%)`);
g.addColorStop(1, `hsl(${(h + 160) % 360},70%,30%)`);
c.fillStyle = g;
c.fillRect(0, 0, CANVAS, CANVAS);
for (let i = 0; i < 18; i++) {
c.fillStyle = `hsla(${(h + i * 30) % 360},70%,${30 + (i % 5) * 10}%,.5)`;
c.beginPath();
c.arc(Math.random() * CANVAS, Math.random() * CANVAS, 20 + Math.random() * 70, 0, 7);
c.fill();
}
p.img.src = off.toDataURL();
p.srcData = c.getImageData(0, 0, CANVAS, CANVAS).data;
if (actualAlgo && isLive() && p === activePair) startStream(p);
}
function loadSource(p) {
const remote = USE_REMOTE && srcCursor >= SAMPLES.length;
const url = remote ? remoteURL() : SAMPLES[srcCursor % SAMPLES.length];
const seed = srcCursor++;
clearCanvas(p);
resetBuf(p);
const im = new Image();
im.crossOrigin = "anonymous";
im.onload = () => {
try {
const off = document.createElement("canvas");
off.width = off.height = CANVAS;
const c = off.getContext("2d");
const s = Math.max(CANVAS / im.width, CANVAS / im.height);
const w = im.width * s, h = im.height * s;
c.drawImage(im, (CANVAS - w) / 2, (CANVAS - h) / 2, w, h);
p.srcData = c.getImageData(0, 0, CANVAS, CANVAS).data; // throws if CORS-tainted
p.img.src = off.toDataURL();
if (actualAlgo && isLive() && p === activePair) startStream(p);
} catch (e) {
proceduralFallback(p, seed); // CORS-tainted or decode issue
}
};
im.onerror = () => proceduralFallback(p, seed);
im.src = url;
}
function rOf(p) { return ((pairs.indexOf(p) - offset) % N + N) % N; }
function layoutAll() {
pairs.forEach(p => {
const r = rOf(p), slot = SLOTS[r];
p.el.style.transform = `translate(-50%, calc(-50% + ${slot.y}px)) scale(${slot.scale})`;
p.el.style.opacity = (firstCycle && r === 4) ? 0 : slot.op;
p.el.style.zIndex = slot.z; // active stays above the faded prev/next tiles
p.el.classList.toggle("active", r === 0);
p.el.querySelector(".canvas-cell .tag").textContent = r === 0 ? "canvas · live" : "canvas";
});
}
function tick() {
if (!isLive()) return; // paused while off the landing page / off-tab
offset++;
firstCycle = false; // the pair now arriving at the top has actually been painted
const recycled = pairs.find(p => rOf(p) === 2);
recycled.el.classList.add("no-anim");
loadSource(recycled);
layoutAll();
requestAnimationFrame(() => requestAnimationFrame(() => recycled.el.classList.remove("no-anim")));
activePair = pairs.find(p => rOf(p) === 0);
activeStart = performance.now();
if (actualAlgo) startStream(activePair);
}
/* ---- Real algorithm: stream PBC frames onto the active canvas ---- */
async function startStream(p) {
if (!actualAlgo || !isLive() || !p || !p.img.src) return;
if (streamAbort) streamAbort.abort();
const ctrl = new AbortController();
streamAbort = ctrl;
clearCanvas(p);
try {
const blob = await (await fetch(p.img.src)).blob();
const fd = new FormData();
fd.append("image", blob, "src.png");
fd.append("downsample_initialize", "true");
const res = await fetch("/api/stream_compress", { method: "POST", body: fd, signal: ctrl.signal });
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
if (!line) continue;
let obj; try { obj = JSON.parse(line); } catch { continue; }
if (obj.image && !ctrl.signal.aborted) drawFrame(p, obj.image, ctrl);
}
}
} catch (e) { /* aborted or network error → ignore */ }
}
function drawFrame(p, dataurl, ctrl) {
const im = new Image();
im.onload = () => { if (!ctrl.signal.aborted) p.ctx.drawImage(im, 0, 0, CANVAS, CANVAS); };
im.src = dataurl;
}
function fillStroke(p, x, y, size) {
x = Math.max(0, Math.min(CANVAS - size, Math.round(x)));
y = Math.max(0, Math.min(CANVAS - size, Math.round(y)));
const src = p.srcData, buf = p.buf;
if (!src) return;
let sR = 0, sG = 0, sB = 0, sq = 0, before = 0;
for (let yy = y; yy < y + size; yy++) {
for (let xx = x; xx < x + size; xx++) {
const si = (yy * CANVAS + xx) * 4, bi = (yy * CANVAS + xx) * 3;
const r = src[si], g = src[si + 1], b = src[si + 2];
const cr = buf[bi], cg = buf[bi + 1], cb = buf[bi + 2];
sR += r; sG += g; sB += b;
sq += r * r + g * g + b * b;
before += (r - cr) * (r - cr) + (g - cg) * (g - cg) + (b - cb) * (b - cb);
}
}
const n = size * size;
// error after filling with the area average == variance of the source area
const after = sq - (sR * sR + sG * sG + sB * sB) / n;
if (after >= before) return; // would not improve → skip
const mr = Math.round(sR / n), mg = Math.round(sG / n), mb = Math.round(sB / n);
for (let yy = y; yy < y + size; yy++) {
for (let xx = x; xx < x + size; xx++) {
const bi = (yy * CANVAS + xx) * 3;
buf[bi] = mr; buf[bi + 1] = mg; buf[bi + 2] = mb;
}
}
p.ctx.fillStyle = `rgb(${mr},${mg},${mb})`;
p.ctx.fillRect(x, y, size, size);
}
function paintLoop() {
const p = activePair;
const live = isLive();
if (live && !actualAlgo && p && p.srcData) {
const clicking = mouse.down && mouse.target === p;
if (clicking) {
const size = expSize(mouse.startSize, CLICK_END, (performance.now() - pressStart) / 1000, CLICK_T);
for (let i = 0; i < STROKES_PER_FRAME * 2; i++) {
const a = Math.random() * Math.PI * 2;
const d = Math.random() * Math.max(0, RADIUS - size);
fillStroke(p, mouse.x + Math.cos(a) * d - size / 2, mouse.y + Math.sin(a) * d - size / 2, size);
}
} else {
const size = globalSizeNow();
for (let i = 0; i < STROKES_PER_FRAME; i++) {
fillStroke(p, Math.random() * (CANVAS - size), Math.random() * (CANVAS - size), size);
}
}
}
if (cycleFill && live) {
const rem = Math.max(0, 1 - (performance.now() - activeStart) / CYCLE_MS);
cycleFill.style.transform = `scaleX(${rem})`;
}
requestAnimationFrame(paintLoop);
}
function initRoster() {
const roster = document.getElementById("roster");
for (let i = 0; i < N; i++) {
const p = makePair();
pairs.push(p);
roster.appendChild(p.el);
loadSource(p);
}
layoutAll();
activePair = pairs.find(p => rOf(p) === 0);
activeStart = performance.now();
roster.addEventListener("mousedown", e => {
if (actualAlgo) return; // no click-painting in real-algorithm mode
const cv = e.target.closest(".canvas-cell canvas");
if (!cv) return;
const p = pairs.find(x => x.canvas === cv);
if (p !== activePair) return;
mouse.down = true; mouse.target = p; pressStart = performance.now();
mouse.startSize = Math.min(globalSizeNow(), RADIUS);
moveMouse(e, cv);
});
roster.addEventListener("mousemove", e => {
if (mouse.down && mouse.target) moveMouse(e, mouse.target.canvas);
});
window.addEventListener("mouseup", () => { mouse.down = false; });
const useReal = document.getElementById("use-real");
const landingRight = document.getElementById("landing-right");
useReal.addEventListener("change", () => {
actualAlgo = useReal.checked;
landingRight.classList.toggle("use-real", actualAlgo);
if (actualAlgo) {
startStream(activePair);
} else {
if (streamAbort) streamAbort.abort();
if (activePair) { clearCanvas(activePair); resetBuf(activePair); } // resume simulation cleanly
}
});
// Pause everything (paint, rotation, streaming) when the tab is hidden.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
if (streamAbort) streamAbort.abort();
} else if (landingActive) {
activeStart = performance.now();
if (actualAlgo) startStream(activePair);
}
});
setInterval(tick, CYCLE_MS);
requestAnimationFrame(paintLoop);
}
function moveMouse(e, cv) {
const rect = cv.getBoundingClientRect();
mouse.x = (e.clientX - rect.left) / rect.width * CANVAS;
mouse.y = (e.clientY - rect.top) / rect.height * CANVAS;
}
/* ============================================================
NAVIGATION
============================================================ */
function enterApp() {
document.getElementById("landing").hidden = true;
document.getElementById("app").hidden = false;
landingActive = false;
if (streamAbort) streamAbort.abort();
gotoView("compress");
}
function exitToLanding() {
document.getElementById("app").hidden = true;
document.getElementById("landing").hidden = false;
landingActive = true;
activeStart = performance.now(); // resume the active pair's size curve
if (actualAlgo) startStream(activePair);
}
document.getElementById("enter-btn").addEventListener("click", enterApp);
document.getElementById("back-home").addEventListener("click", exitToLanding);
document.querySelectorAll(".nav-item").forEach(n => n.addEventListener("click", () => gotoView(n.dataset.view)));
function gotoView(v) {
document.querySelectorAll(".nav-item").forEach(x => x.classList.toggle("active", x.dataset.view === v));
document.getElementById("view-compress").hidden = v !== "compress";
document.getElementById("view-decode").hidden = v !== "decode";
document.getElementById("view-registry").hidden = v !== "registry";
if (v === "registry") hideLastResult();
}
/* ============================================================
PARAMETERS (PBC3.0)
Auto → pick one of the PBC3Config presets.
Semi → the high-impact parameters as sliders/inputs.
Manual → every PBC3Config field.
============================================================ */
const AUTO_CONFIGS = [
{ id: "compression", label: "Compression" },
{ id: "balanced", label: "Balanced" },
{ id: "quality", label: "Quality" },
{ id: "high_quality", label: "High Quality" },
];
const PRESET_LEARNED_Q = { compression: 0.4, balanced: 0.6, quality: 0.8, high_quality: 0.95 };
const PARAMS = [
// ---- Neural network filler (all modes) ----
{ id: "learned_filler_enabled", label: "Neural network filler", hint: "tiny learned policy — faster on heavy presets, adds one quality dial", group: "Neural network filler", modes: ["Semi", "Manual"], type: "check", value: true },
{ id: "learned_filler_q", label: "Quality dial", hint: "learned-filler quality, relative to the preset", group: "Neural network filler", modes: ["Semi", "Manual"], type: "slider", min: 0.1, max: 0.95, step: 0.05, value: 0.35, full: true },
// ---- Search (Semi shows the first four; the rest are Manual-only) ----
{ id: "patch_count", label: "Patch count", hint: "more patches = higher quality, larger files, slower", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 500, step: 1, value: 20, full: true },
{ id: "search_depth", label: "Search depth", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 2000, step: 10, value: 200 },
{ id: "proposal_depth", label: "Proposal depth", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 500, step: 5, value: 50 },
{ id: "exact_depth", label: "Exact depth", group: "Search", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 200, step: 1, value: 10 },
{ id: "top_k", label: "Top-k", group: "Search", modes: ["Manual"], type: "slider", min: 1, max: 200, step: 1, value: 20 },
{ id: "anchor_block_size", label: "Anchor block size", group: "Search", modes: ["Manual"], type: "slider", min: 1, max: 64, step: 1, value: 8 },
{ id: "cell_sizes_per_candidate", label: "Cell sizes / candidate", group: "Search", modes: ["Manual"], type: "slider", min: 1, max: 16, step: 1, value: 3 },
// ---- Quality schedule (Semi + Manual) ----
{ id: "search_q_start", label: "Search q start", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.4 },
{ id: "search_q_end", label: "Search q end", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.1 },
{ id: "q_init", label: "Q init", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.7 },
{ id: "q_start", label: "Q start", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.9 },
{ id: "q_end", label: "Q end", group: "Quality schedule", modes: ["Semi", "Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.9 },
// ---- Downsampling (Semi + Manual; Manual adds the init-layer knobs) ----
{ id: "downsample_rate", label: "Downsample rate", hint: "auto = derive from max pixels", group: "Downsampling", modes: ["Semi", "Manual"], type: "slider", min: 1, max: 16, step: 0.1, value: 2, autoToggle: { value: -1 } },
{ id: "auto_downsample_max_pixels", label: "Auto downsample max pixels", group: "Downsampling", modes: ["Semi", "Manual"], type: "slider", min: 10000, max: 4000000, step: 10000, value: 250000, full: true },
{ id: "warmup_ratio", label: "Warmup ratio", hint: "off = no warmup. Otherwise: fraction of patches applied on the initial low-res canvas before switching up to a higher-res one", group: "Downsampling", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.6, full: true, autoToggle: { value: -1, label: "off" } },
{ id: "warm_downsample_max_pixels", label: "Warm downsample max pixels", hint: "post-warmup canvas size; 'full res' = no limit. Ignored unless it exceeds the initial canvas size", group: "Downsampling", modes: ["Manual"], type: "slider", min: 10000, max: 8000000, step: 10000, value: 750000, full: true, autoToggle: { value: -1, label: "full res" } },
{ id: "auto_downsample_init", label: "Auto downsample init", group: "Downsampling", modes: ["Manual"], type: "check", value: true },
{ id: "init_search_depth", label: "Init search depth", group: "Downsampling", modes: ["Manual"], type: "slider", min: 0, max: 50, step: 1, value: 7 },
{ id: "downsample_init_cell_size", label: "Init cell size", group: "Downsampling", modes: ["Manual"], type: "slider", min: 1, max: 64, step: 1, value: 12 },
{ id: "downsample_palette_bitcount", label: "Init palette bitcount", group: "Downsampling", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 6 },
// ---- Patch & cell sizing (Manual) ----
{ id: "min_patch_size", label: "Min patch size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 1024, step: 1, value: 16 },
{ id: "max_patch_size", label: "Max patch size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 2000, step: 1, value: 400 },
{ id: "min_cell_size", label: "Min cell size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 64, step: 1, value: 1 },
{ id: "max_cell_size", label: "Max cell size", group: "Patch & cell sizing", modes: ["Manual"], type: "slider", min: 1, max: 256, step: 1, value: 64 },
// ---- Color (Manual) ----
{ id: "color_space", label: "Color space", group: "Color", modes: ["Manual"], type: "select", options: ["RGB", "YCbCr"], value: "YCbCr" },
{ id: "channel_cycle", label: "Channel cycle", group: "Color", modes: ["Manual"], type: "select", options: ["Off", "Sum", "Max"], value: "Sum" },
// ---- Palette & bit allocation (Manual) ----
{ id: "patch_palette_bitcount", label: "Patch palette bitcount", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
{ id: "patch_bitcount_mode", label: "Patch bitcount mode", group: "Palette & bits", modes: ["Manual"], type: "select", options: ["constant", "dynamic"], value: "constant" },
{ id: "dynamic_patch_bitcount_min", label: "Dynamic bitcount min", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
{ id: "dynamic_patch_bitcount_max", label: "Dynamic bitcount max", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 3 },
{ id: "palette_mode", label: "Palette mode", group: "Palette & bits", modes: ["Manual"], type: "select", options: ["generated", "explicit", "auto"], value: "generated" },
{ id: "explicit_palette_max_bitcount", label: "Explicit palette max bitcount", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 3 },
{ id: "palette_difference_threshold", label: "Palette diff threshold", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 0, max: 255, step: 1, value: 0 },
{ id: "palette_difference_threshold_mode", label: "Palette diff threshold mode", group: "Palette & bits", modes: ["Manual"], type: "select", options: ["constant", "linear"], value: "constant" },
{ id: "mask_size", label: "Mask size", group: "Palette & bits", modes: ["Manual"], type: "slider", min: 1, max: 1023, step: 1, value: 4 },
{ id: "positive_bias", label: "Positive bias", group: "Palette & bits", modes: ["Manual"], type: "check", value: true },
// ---- Advanced (Manual) ----
{ id: "quality_target_mae", label: "Quality target MAE", hint: "0 = off (stop early once MAE drops below this)", group: "Advanced", modes: ["Manual"], type: "slider", min: 0, max: 50, step: 0.1, value: 0, full: true },
{ id: "use_lzma", label: "Use LZMA", group: "Advanced", modes: ["Manual"], type: "check", value: true },
{ id: "random_seed", label: "Random seed", group: "Advanced", modes: ["Manual"], type: "slider", min: 0, max: 1000000, step: 1, value: 2003 },
{ id: "debug_mode", label: "Debug mode", group: "Advanced", modes: ["Manual"], type: "check", value: false },
{ id: "debug_print", label: "Debug print", group: "Advanced", modes: ["Manual"], type: "check", value: false },
];
const paramState = {};
PARAMS.forEach(p => paramState[p.id] = p.value);
paramState.preset = "quality";
let PRESET_VALUES = {};
fetch("/api/presets").then(r => r.json()).then(d => {
PRESET_VALUES = d;
applyPreset(paramState.preset);
if (paramsBody.innerHTML) renderParams();
}).catch(() => {
if (paramState.preset in PRESET_LEARNED_Q) paramState.learned_filler_q = PRESET_LEARNED_Q[paramState.preset];
});
function applyPreset(name) {
paramState.preset = name;
if (name in PRESET_LEARNED_Q) paramState.learned_filler_q = PRESET_LEARNED_Q[name];
const cfg = PRESET_VALUES[name];
if (!cfg) return;
PARAMS.forEach(p => {
if (p.id.startsWith("learned_filler") || !(p.id in cfg)) return;
paramState[p.id] = (p.type === "check") ? !!cfg[p.id] : cfg[p.id];
if (p.autoToggle) paramState[p.id + "__auto"] = (cfg[p.id] === p.autoToggle.value);
});
}
function markCustom() {
if (paramState.preset === "custom") return;
paramState.preset = "custom";
const sel = paramsBody.querySelector('[data-pid="__preset"]');
if (sel) sel.value = "custom";
}
PARAMS.filter(p => p.autoToggle).forEach(p => paramState[p.id + "__auto"] = true);
let paramMode = "Auto";
const paramsBody = document.getElementById("params-body");
document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.addEventListener("click", () => {
document.querySelectorAll("#param-mode .seg-btn").forEach(x => x.classList.remove("active"));
b.classList.add("active");
paramMode = b.dataset.mode;
if (paramMode === "Auto") {
const preset = (paramState.preset && paramState.preset !== "custom") ? paramState.preset : "quality";
applyPreset(preset);
}
renderParams();
}));
function presetSelectHtml() {
const opts = paramMode === "Auto" ? AUTO_CONFIGS : [...AUTO_CONFIGS, { id: "custom", label: "Custom" }];
return `Preset
Configuration
${opts.map(c => `${c.label} `).join("")}
`;
}
function renderParams() {
const items = PARAMS.filter(p => p.modes.includes(paramMode));
let html = presetSelectHtml();
if (paramMode === "Auto")
html += `Auto — pick a preset; structural parameters are derived from the image. The neural network filler and its quality dial still apply.
`;
let group = null;
html += ``;
items.forEach(p => {
if (p.group !== group) { group = p.group; html += `
${group}
`; }
html += control(p);
});
html += `
`;
if (paramMode !== "Auto")
html += `Changing a parameter switches the preset to “Custom”; pick a preset to reset all parameters.
`;
paramsBody.innerHTML = html;
paramsBody.querySelector('[data-pid="__preset"]').addEventListener("change", e => {
if (e.target.value === "custom") { paramState.preset = "custom"; return; }
applyPreset(e.target.value);
renderParams();
});
paramsBody.querySelectorAll("[data-pid]").forEach(el => {
if (el.dataset.pid === "__preset") return;
el.addEventListener("input", () => {
const id = el.dataset.pid;
paramState[id] = el.type === "checkbox" ? el.checked : el.value;
if (el.type === "range" || el.type === "number")
paramsBody.querySelectorAll(`[data-pid="${id}"]`).forEach(o => { if (o !== el) o.value = el.value; });
if (!id.startsWith("learned_filler")) markCustom();
});
});
paramsBody.querySelectorAll("[data-auto]").forEach(cb => {
const id = cb.dataset.auto;
cb.checked = !!paramState[id + "__auto"];
const sync = () => {
paramState[id + "__auto"] = cb.checked;
paramsBody.querySelectorAll(`[data-pid="${id}"]`).forEach(el => el.disabled = cb.checked);
};
cb.addEventListener("change", () => { sync(); markCustom(); });
sync();
});
}
function control(p) {
const v = paramState[p.id];
const hint = p.hint ? ` ${p.hint} ` : "";
if (p.type === "select")
return `${p.label}${hint} ${p.options.map(o => `${o} `).join("")}
`;
if (p.type === "check")
return `${p.label}${hint} enabled
`;
return ``;
}
/* ---- Sweep Analyzer "Load into Demo" hook -------------------------------
The analyzer still emits PBC2.4-style param dumps; PBC3 sweep integration
is pending. For now this parses the dump and applies any key that matches a
current parameter id, switching to Manual without erroring on the rest. */
function parseOptuna(text) {
const out = {};
text.trim().split(/\r?\n/).forEach(line => {
const m = line.trim().match(/^([A-Za-z_]+)[\s:=]+(.+)$/);
if (m) out[m[1]] = m[2].trim();
});
return out;
}
async function applyOptuna(text) {
const p = parseOptuna(text);
if (!Object.keys(p).length) { toast("Couldn't parse anything"); return; }
for (const [k, v] of Object.entries(p)) if (k in paramState) paramState[k] = v;
paramMode = "Manual";
document.querySelectorAll("#param-mode .seg-btn").forEach(b => b.classList.toggle("active", b.dataset.mode === "Manual"));
renderParams();
toast("Loaded config");
}
renderParams();
/* ============================================================
IMAGE INPUT (compress)
============================================================ */
let currentFile = null;
const fileInput = document.getElementById("file-input");
const dropzone = document.getElementById("dropzone");
const preview = document.getElementById("preview");
const compressBtn = document.getElementById("compress-btn");
dropzone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => fileInput.files[0] && setFile(fileInput.files[0]));
["dragover", "dragenter"].forEach(ev => dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.remove("drag"); }));
dropzone.addEventListener("drop", e => { const f = e.dataTransfer.files[0]; if (f) setFile(f); });
function setFile(f) {
if (!f.type.startsWith("image/")) return toast("Please drop an image file");
currentFile = f;
preview.src = URL.createObjectURL(f);
preview.hidden = false;
document.getElementById("dropzone-empty").hidden = true;
compressBtn.disabled = false;
}
/* ============================================================
PBC INPUT (decode)
============================================================ */
let decodeFile = null;
const decodeInput = document.getElementById("decode-input");
const decodeZone = document.getElementById("decode-zone");
const decodeBtn = document.getElementById("decode-btn");
decodeZone.addEventListener("click", () => decodeInput.click());
decodeInput.addEventListener("change", () => decodeInput.files[0] && setDecodeFile(decodeInput.files[0]));
["dragover", "dragenter"].forEach(ev => decodeZone.addEventListener(ev, e => { e.preventDefault(); decodeZone.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => decodeZone.addEventListener(ev, e => { e.preventDefault(); decodeZone.classList.remove("drag"); }));
decodeZone.addEventListener("drop", e => { const f = e.dataTransfer.files[0]; if (f) setDecodeFile(f); });
function setDecodeFile(f) {
if (!f.name.toLowerCase().endsWith(".pbc")) return toast("Please provide a .pbc file");
decodeFile = f;
document.getElementById("decode-name").textContent = f.name;
decodeBtn.disabled = false;
}
/* ============================================================
GLOBAL JOB QUEUE (compress / decode / codec-match)
Body-level container → visible from any section. Compress runs
sequentially (CPU-bound); decode + codec matches run immediately.
============================================================ */
const orbStatus = document.getElementById("orb-status");
const jobQueueEl = document.createElement("div");
jobQueueEl.className = "job-queue";
document.body.appendChild(jobQueueEl);
const JOB_LABELS = { queued: "Queued", run: "Working…", done: "Done", error: "Failed" };
function renderJob(job) {
const el = document.createElement("div");
el.className = "job queued";
el.innerHTML = `
${job.name} Queued
waiting…
`;
return el;
}
function createJob(name) {
const job = { name, status: "queued" };
job.el = renderJob(job);
jobQueueEl.appendChild(job.el);
return job;
}
function setJobState(job, state, label) {
job.status = state;
job.el.className = "job " + state;
job.el.querySelector(".job-state").textContent = label || JOB_LABELS[state];
}
function setJobMeta(job, html) { job.el.querySelector(".job-meta").innerHTML = html; }
function finishJob(job) {
setTimeout(() => { job.el.classList.add("out"); setTimeout(() => job.el.remove(), 400); },
job.status === "error" ? 6000 : 4000);
}
function fileToDataURL(file) {
return new Promise(r => { const fr = new FileReader(); fr.onload = () => r(fr.result); fr.readAsDataURL(file); });
}
function buildCompressForm(file) {
const fd = new FormData();
const custom = paramState.preset === "custom";
const effectiveMode = custom && paramMode === "Auto" ? "Manual" : paramMode;
fd.append("image", file);
fd.append("mode", effectiveMode);
if (effectiveMode === "Auto") {
fd.append("auto_config", paramState.preset || "quality");
} else {
PARAMS.forEach(p => {
if (p.autoToggle && paramState[p.id + "__auto"]) { fd.append(p.id, p.autoToggle.value); return; }
const v = paramState[p.id];
fd.append(p.id, p.type === "check" ? (v ? "true" : "false") : v);
});
}
return fd;
}
/* ---- Compress: sequential queue ---- */
const compressJobs = [];
let compressRunning = false;
compressBtn.addEventListener("click", () => {
if (!currentFile) return;
const job = createJob(currentFile.name);
job.file = currentFile;
job.fd = buildCompressForm(currentFile);
compressJobs.push(job);
orbStatus.textContent = `queued · ${compressJobs.length} pending`;
runCompressQueue();
});
async function runCompressQueue() {
if (compressRunning) return;
const job = compressJobs.find(j => j.status === "queued");
if (!job) return;
compressRunning = true;
await runCompress(job);
compressRunning = false;
runCompressQueue();
}
async function runCompress(job) {
setJobState(job, "run", "Encoding…");
const t0 = performance.now();
const timer = setInterval(() => setJobMeta(job, `${((performance.now() - t0) / 1000).toFixed(1)}s`), 100);
orbStatus.textContent = "compressing…";
try {
const res = await fetch("/api/compress", { method: "POST", body: job.fd });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const total = (performance.now() - t0) / 1000;
if (job.file && !data.original_image) data.original_image = await fileToDataURL(job.file);
compressJobs.splice(compressJobs.indexOf(job), 1);
setJobState(job, "done", "Done");
let s = `total ${total.toFixed(1)}s · encode ${data.time_seconds.toFixed(2)}s · overhead ${Math.max(0, total - data.time_seconds).toFixed(1)}s`;
if (data.stage_timings) {
const top = Object.entries(data.stage_timings).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k} ${v}s`).join(" · ");
s += `server: ${top} `;
}
setJobMeta(job, s);
addToRegistry(data); showLastResult(data);
orbStatus.textContent = `done · ${data.compression_rate}x`;
} catch (e) {
compressJobs.splice(compressJobs.indexOf(job), 1);
setJobState(job, "error", "Failed");
setJobMeta(job, "failed — see console");
orbStatus.textContent = "failed";
console.error(e);
} finally {
clearInterval(timer);
finishJob(job);
}
}
/* ---- Decode: runs immediately ---- */
decodeBtn.addEventListener("click", () => { if (decodeFile) runDecode(decodeFile); });
async function runDecode(file) {
const job = createJob(file.name);
setJobState(job, "run", "Decoding…");
const t0 = performance.now();
const timer = setInterval(() => setJobMeta(job, `${((performance.now() - t0) / 1000).toFixed(1)}s`), 100);
document.getElementById("decode-status").textContent = "decoding…";
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch("/api/decode", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const total = (performance.now() - t0) / 1000;
setJobState(job, "done", "Done");
setJobMeta(job, `decoded ${data.compression_rate}× in ${total.toFixed(1)}s`);
addToRegistry(data); showLastResult(data);
document.getElementById("decode-status").textContent = `done · ${data.compression_rate}x`;
} catch (e) {
setJobState(job, "error", "Failed");
setJobMeta(job, "failed — invalid .pbc?");
document.getElementById("decode-status").textContent = "failed";
console.error(e);
} finally {
clearInterval(timer);
finishJob(job);
}
}
/* ============================================================
LAST-RESULT FLY-IN
============================================================ */
const lastResult = document.getElementById("last-result");
document.getElementById("lr-close").addEventListener("click", hideLastResult);
document.getElementById("lr-img").addEventListener("click", () => gotoView("registry"));
function showLastResult(d) {
document.getElementById("lr-img").src = d.reconstructed_image;
document.getElementById("lr-rate").textContent = d.compression_rate + "×";
document.getElementById("lr-size").textContent = d.compressed_kb + " KB";
lastResult.hidden = false;
lastResult.classList.remove("out");
void lastResult.offsetWidth;
}
function hideLastResult() {
if (lastResult.hidden) return;
lastResult.classList.add("out");
setTimeout(() => { lastResult.hidden = true; }, 460);
}
/* ============================================================
REGISTRY
============================================================ */
const registry = [];
const grid = document.getElementById("registry-grid");
function addToRegistry(d) {
d.id = Date.now();
d.date = new Date().toLocaleString();
registry.unshift(d);
document.getElementById("reg-count").textContent = registry.length;
renderRegistry();
}
function renderRegistry() {
document.getElementById("registry-empty").hidden = registry.length > 0;
grid.innerHTML = "";
registry.forEach(d => grid.appendChild(card(d)));
}
// PBC reports compression as N× (original_raw / compressed). To keep codec comparisons
// in the same terms, convert a codec's bpp to the equivalent rate: 24 bits-per-pixel
// raw (3 bytes RGB) / bpp.
const bppToRate = (bpp) => 24 / bpp;
const CODEC_COLOR = { jpeg: "#7d8cff", jp2: "#4fa3ff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc" };
const CODEC_LABEL = { jpeg: "JPEG", jp2: "JPEG2000", webp: "WebP", jxl: "JPEG XL", avif: "AVIF" };
const PBC_COLOR = "#ff3b41";
function card(d) {
const el = document.createElement("div");
el.className = "card";
const stats = d.decoded ? `
Dimensions ${d.width}×${d.height}px
Decoded ${d.original_raw_kb} KB
File (.pbc) ${d.compressed_kb} KB
Rate ${d.compression_rate}× · ${d.compression_percent}%
Decode time ${d.time_seconds}s
`
: `
Dimensions ${d.width}×${d.height}px
Original ${d.original_raw_kb} KB
Compressed ${d.compressed_kb} KB
Rate ${d.compression_rate}× · ${d.compression_percent}%
MSE ${d.mse}
Time ${d.time_seconds}s
`;
const badge = d.decoded
? `decoded `
: "";
// Only show codecs that haven't been generated yet; drop the whole row once both exist.
const haveJ = !!d.jpeg_image, haveP = !!d.jp2_image, haveA = !!d.avif_image, haveW = !!d.webp_image, haveX = !!d.jxl_image;
const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveP && haveA && haveW && haveX))
? `
Compare to:
${haveJ ? "" : `JPEG `}
${haveP ? "" : `JPEG2000 `}
${haveW ? "" : `WebP `}
${haveX ? "" : `JPEG XL `}
${haveA ? "" : `AVIF `}
`
: "";
const haveAnim = !!d.animation_url;
const animBlock = d.pbc_base64
? ``
: "";
el.innerHTML = `
${stats}
${d.date}${d.params ? " · " + escapeParams(d.params) : ""}
`;
el.querySelector('[data-act="fs"]').onclick = () => openViewer(d);
el.querySelector(".card-media img").ondblclick = () => openViewer(d);
el.querySelector('[data-act="png"]').onclick = () => savePng(d);
el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
const cmpP = el.querySelector('[data-act="cmp-jp2"]');
const cmpA = el.querySelector('[data-act="cmp-avif"]');
const cmpW = el.querySelector('[data-act="cmp-webp"]');
const cmpX = el.querySelector('[data-act="cmp-jxl"]');
if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
if (cmpP) cmpP.onclick = () => compareCodec(d, "jp2", cmpP);
if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
if (cmpW) cmpW.onclick = () => compareCodec(d, "webp", cmpW);
if (cmpX) cmpX.onclick = () => compareCodec(d, "jxl", cmpX);
const animBtn = el.querySelector('[data-act="anim"]');
if (animBtn) animBtn.onclick = () => createAnimation(d, animBtn);
el.querySelector('[data-act="delete"]').onclick = () => {
registry.splice(registry.indexOf(d), 1);
document.getElementById("reg-count").textContent = registry.length;
renderRegistry();
};
return el;
}
// Encode the registry entry's original image with JPEG/AVIF at PBC3's bpp, then expose
// it as a "Hold for …" / side-by-side option in the fullscreen viewer. Once generated the
// codec is cached on the entry, so the chip is removed and a repeat click is impossible.
async function compareCodec(d, codec, btn) {
if (!d.original_image || d[codec + "_image"]) return;
const label = CODEC_LABEL[codec] || codec.toUpperCase();
btn.disabled = true;
btn.textContent = label + "…";
const job = createJob(`${label} match`);
setJobState(job, "run", `${label} matching…`);
setJobMeta(job, "starting…");
try {
const blob = await (await fetch(d.original_image)).blob();
const target = (d.compressed_kb * 1024 * 8) / (d.width * d.height);
const fd = new FormData();
fd.append("image", blob, "original.png");
fd.append("codec", codec);
fd.append("target_bpp", target);
const res = await fetch("/api/match_codec", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", done = null;
while (true) {
const { value, done: rd } = await reader.read();
if (rd) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
if (!line) continue;
const o = JSON.parse(line);
if (o.done) done = o;
else if (o.error) throw new Error(o.error);
else if (o.q !== undefined) {
setJobState(job, "run", `${label} q${o.q}`);
setJobMeta(job, `bpp ${o.bpp.toFixed(4)} · target ${target.toFixed(4)}`);
}
}
}
if (!done) throw new Error("no result");
d[codec + "_image"] = done.image;
d[codec + "_bpp"] = done.bpp;
d[codec + "_q"] = done.q;
d[codec + "_mse"] = done.mse;
d[codec + "_size_kb"] = done.size_kb;
setJobState(job, "done", `${label} q${done.q}`);
setJobMeta(job, `${done.size_kb} KB · ${done.bpp.toFixed(4)} bpp · MSE ${done.mse}`);
renderRegistry();
if (!viewer.hidden && registry[viewIndex] === d) renderViewer();
} catch (e) {
setJobState(job, "error", `${label} failed`);
setJobMeta(job, "see console");
toast(`${label} comparison failed`);
console.error(e);
btn.disabled = false;
btn.textContent = label;
} finally {
finishJob(job);
}
}
/* ============================================================
ANIMATION (create via /api/animate, view in the animation viewer)
============================================================ */
async function createAnimation(d, btn) {
if (d.animation_url) { openAnimViewer(d); return; }
btn.disabled = true;
btn.textContent = "Rendering…";
const job = createJob("Animation");
setJobState(job, "run", "Rendering animation…");
setJobMeta(job, "decoding frames + encoding video…");
const t0 = performance.now();
const timer = setInterval(() => setJobMeta(job, `${((performance.now() - t0) / 1000).toFixed(1)}s`), 200);
try {
const bin = atob(d.pbc_base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
const fd = new FormData();
fd.append("file", new Blob([arr], { type: "application/octet-stream" }), "image.pbc");
if (d.original_image) {
const ob = await (await fetch(d.original_image)).blob();
fd.append("original", ob, "original.png");
}
const res = await fetch("/api/animate", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
d.animation_blob = blob;
d.animation_type = blob.type;
d.animation_url = URL.createObjectURL(blob);
setJobState(job, "done", "Animation ready");
setJobMeta(job, `${(blob.size / 1048576).toFixed(2)} MB · ${((performance.now() - t0) / 1000).toFixed(1)}s`);
renderRegistry();
openAnimViewer(d);
} catch (e) {
setJobState(job, "error", "Animation failed");
setJobMeta(job, "see console");
toast("Animation failed");
console.error(e);
btn.disabled = false;
btn.textContent = "Create animation";
} finally {
clearInterval(timer);
finishJob(job);
}
}
/* ============================================================
ANIMATION VIEWER
============================================================ */
const animViewer = document.getElementById("anim-viewer");
const animVideo = document.getElementById("anim-video");
const animGif = document.getElementById("anim-gif");
let animBlob = null, animName = "pbc_animation.mp4";
document.getElementById("anim-close").addEventListener("click", closeAnimViewer);
animViewer.addEventListener("click", e => { if (e.target === animViewer) closeAnimViewer(); });
animVideo.addEventListener("click", e => {
e.preventDefault(); // stop the browser's own click-to-toggle
animVideo.paused ? animVideo.play() : animVideo.pause();
});
animVideo.addEventListener("seeking", () => { if (!animVideo.paused) animVideo.pause(); });
document.getElementById("anim-download").addEventListener("click", () => { if (animBlob) download(animBlob, animName); });
window.addEventListener("keydown", e => { if (!animViewer.hidden && e.key === "Escape") closeAnimViewer(); });
function openAnimViewer(d) {
const isGif = d.animation_type === "image/gif";
animGif.hidden = !isGif;
animVideo.hidden = isGif;
animBlob = d.animation_blob;
animName = `pbc_animation_${d.id}.${isGif ? "gif" : "mp4"}`;
if (isGif) {
animGif.src = d.animation_url;
} else {
animVideo.src = d.animation_url;
animVideo.controls = true;
animVideo.play().catch(() => {});
}
animViewer.hidden = false;
}
function closeAnimViewer() {
animViewer.hidden = true;
try { animVideo.pause(); } catch (_) {}
}
function escapeParams(p) {
if (!p || p.mode === "Auto") return p && p.auto_config ? `Auto · ${p.auto_config}` : "Auto";
return Object.entries(p).filter(([k]) => k !== "mode")
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`).join(" · ") || p.mode;
}
/* ============================================================
FULLSCREEN VIEWER
- Hold mode (default): press-and-hold a button to swap the single image.
- Side-by-side mode: the same buttons become toggles that place the chosen
reference next to PBC3. Row vs. stacked is whichever fits the bigger image
given the current window + image aspect ratio.
The caption reserves two lines of height at all times, so swapping in the
comparison text never shifts the image or the buttons.
============================================================ */
const viewer = document.getElementById("viewer");
const viewerStage = document.getElementById("viewer-stage");
const holdBtn = document.getElementById("hold-original");
const holdJpeg = document.getElementById("hold-jpeg");
const holdJp2 = document.getElementById("hold-jp2");
const holdAvif = document.getElementById("hold-avif");
const holdWebp = document.getElementById("hold-webp");
const holdJxl = document.getElementById("hold-jxl");
const sbsToggle = document.getElementById("viewer-sbs");
const prevBtn = document.getElementById("viewer-prev");
const nextBtn = document.getElementById("viewer-next");
const capEl = document.getElementById("viewer-caption");
let viewIndex = -1;
let sideBySide = false;
let activeCompare = null; // side-by-side selected comparison key
let viewerDownTarget = null; // where the last pointerdown started (backdrop-close guard)
document.getElementById("viewer-close").addEventListener("click", closeViewer);
// Only close on a genuine click that both started AND ended on the backdrop — a
// drag that releases over the backdrop (e.g. letting go of a hold button) must not close.
viewer.addEventListener("pointerdown", e => { viewerDownTarget = e.target; });
viewer.addEventListener("click", e => { if (e.target === viewer && viewerDownTarget === viewer) closeViewer(); });
prevBtn.addEventListener("click", () => step(-1));
nextBtn.addEventListener("click", () => step(1));
sbsToggle.addEventListener("change", () => { sideBySide = sbsToggle.checked; activeCompare = null; renderViewer(); });
window.addEventListener("keydown", e => {
if (viewer.hidden) return;
if (e.key === "Escape") closeViewer();
else if (e.key === "ArrowLeft") step(-1);
else if (e.key === "ArrowRight") step(1);
});
window.addEventListener("resize", () => { if (!viewer.hidden) renderViewer(); });
function openViewer(d) { viewIndex = registry.indexOf(d); activeCompare = null; renderViewer(); viewer.hidden = false; }
function closeViewer() { viewer.hidden = true; }
function step(dir) {
const i = viewIndex + dir;
if (i >= 0 && i < registry.length) { viewIndex = i; activeCompare = null; renderViewer(); }
}
function compareSrcOf(d, kind) {
return kind === "original" ? d.original_image
: kind === "jpeg" ? d.jpeg_image
: kind === "jp2" ? d.jp2_image
: kind === "avif" ? d.avif_image
: kind === "webp" ? d.webp_image
: kind === "jxl" ? d.jxl_image : null;
}
function viewerCaption(d) {
if (d.decoded) return `Decoded in ${d.time_seconds}s | ${d.compression_rate}× compression`;
return `Compressed ${d.compression_rate}× in ${d.time_seconds}s | MSE: ${d.mse}`;
}
// Two-line, colored comparison summary (size + MSE only; no composite quality).
// Reusable pieces of the comparison summary (size + MSE only).
function codecCompareParts(d, codec) {
const label = CODEC_LABEL[codec] || codec.toUpperCase();
const cc = CODEC_COLOR[codec];
const fmtSize = d[codec + "_size_kb"], fmtMse = d[codec + "_mse"], q = d[codec + "_q"];
const pct = (a, b) => Math.abs((a - b) / (b || 1e-9) * 100).toFixed(0);
const red = s => `${s} `;
const csp = s => `${s} `;
return {
pbcStat: `${red("PBC")}: ${d.compressed_kb} KB · ${d.mse} MSE`,
codecStat: `${csp(label)}: ${fmtSize} KB · ${fmtMse} MSE`,
sentence: `${csp(label + " (q=" + q + ")")} compressed this image with `
+ `${red((fmtSize / d.compressed_kb).toFixed(1) + "x")} the file size and `
+ `${red((fmtMse / d.mse).toFixed(1) + "x")} the information loss.`,
};
}
// Hold mode: both lines stacked at the bottom (real lines, not
).
function codecCompareCaptionHTML(d, codec) {
const p = codecCompareParts(d, codec);
return `
${p.pbcStat} | ${p.codecStat}
${p.sentence}
`;
}
function updateCaption() {
const d = registry[viewIndex];
if (!d) return;
if (sideBySide && (["jpeg", "jp2", "avif", "webp", "jxl"].includes(activeCompare)))
capEl.innerHTML = `
${codecCompareParts(d, activeCompare).sentence}
`; // stats moved on top
else
capEl.textContent = viewerCaption(d);
}
function renderViewer() {
const d = registry[viewIndex];
if (!d) return;
const compareSrc = sideBySide ? compareSrcOf(d, activeCompare) : null;
if (compareSrc) {
const vw = window.innerWidth, vh = window.innerHeight;
const rowScale = Math.min(0.47 * vw / d.width, 0.72 * vh / d.height);
const colScale = Math.min(0.90 * vw / d.width, 0.35 * vh / d.height);
viewerStage.className = "viewer-stage dual " + (colScale > rowScale ? "col" : "row");
let leftCap, rightCap;
if (["jpeg", "jp2", "avif", "webp", "jxl"].includes(activeCompare)) {
const p = codecCompareParts(d, activeCompare);
leftCap = `
${p.codecStat} `;
rightCap = `
${p.pbcStat} `;
} else {
leftCap = `
Original `;
rightCap = `
PBC3 `;
}
viewerStage.innerHTML =
`
${leftCap} ` +
`
${rightCap} `;
} else {
viewerStage.className = "viewer-stage";
viewerStage.innerHTML = `
`;
}
holdBtn.hidden = !d.original_image;
holdJpeg.hidden = !d.jpeg_image;
holdJp2.hidden = !d.jp2_image;
holdAvif.hidden = !d.avif_image;
holdWebp.hidden = !d.webp_image;
holdJxl.hidden = !d.jxl_image;
holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
holdJp2.textContent = sideBySide ? "JPEG2000" : "Hold for JPEG2000";
holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
holdWebp.textContent = sideBySide ? "WebP" : "Hold for WebP";
holdJxl.textContent = sideBySide ? "JXL" : "Hold for JXL";
holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
holdJp2.classList.toggle("holding", sideBySide && activeCompare === "jp2");
holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
holdWebp.classList.toggle("holding", sideBySide && activeCompare === "webp");
holdJxl.classList.toggle("holding", sideBySide && activeCompare === "jxl");
updateCaption();
prevBtn.disabled = viewIndex <= 0;
nextBtn.disabled = viewIndex >= registry.length - 1;
}
function wireHold(btn, kind, getCaptionHTML) {
const release = () => {
const d = registry[viewIndex];
const img = viewerStage.querySelector("img");
if (d && img) img.src = d.reconstructed_image;
if (d) capEl.textContent = viewerCaption(d);
btn.classList.remove("holding");
};
btn.addEventListener("pointerdown", e => {
e.preventDefault();
const d = registry[viewIndex];
if (!d) return;
if (sideBySide) { // toggle this reference on/off
activeCompare = activeCompare === kind ? null : kind;
renderViewer();
return;
}
// Capture the pointer so the hold survives the cursor drifting off the button
// (e.g. when the caption grows) and we always get the matching pointerup.
try { btn.setPointerCapture(e.pointerId); } catch (_) {}
const img = viewerStage.querySelector("img");
const src = compareSrcOf(d, kind);
if (!img || !src) return;
img.src = src;
if (getCaptionHTML) capEl.innerHTML = getCaptionHTML(d);
btn.classList.add("holding");
});
btn.addEventListener("pointerup", () => { if (!sideBySide) release(); });
btn.addEventListener("pointercancel", () => { if (!sideBySide) release(); });
}
wireHold(holdBtn, "original");
wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
wireHold(holdJp2, "jp2", d => codecCompareCaptionHTML(d, "jp2"));
wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
wireHold(holdWebp, "webp", d => codecCompareCaptionHTML(d, "webp"));
wireHold(holdJxl, "jxl", d => codecCompareCaptionHTML(d, "jxl"));
/* ============================================================
DOWNLOAD
============================================================ */
function savePbc(d) {
const bin = atob(d.pbc_base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
download(new Blob([arr], { type: "application/octet-stream" }), `pbc_${d.id}.pbc`);
}
async function savePng(d) {
const blob = await (await fetch(d.reconstructed_image)).blob();
download(blob, `pbc_${d.id}.png`);
}
function download(blob, name) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/* ============================================================
TOAST
============================================================ */
let toastTimer;
function toast(msg) {
const t = document.getElementById("toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 2600);
}
/* ============================================================
BOOT
============================================================ */
initRoster();