jpeg 2000 + magnifying glass fix
Browse files- pbc3_sweep.py +20 -6
- server.py +20 -5
- static/app.js +27 -13
- static/index.html +9 -8
- static/sweep.js +7 -7
- static/viewer_magnifier.js +13 -10
pbc3_sweep.py
CHANGED
|
@@ -6,7 +6,7 @@ resolved config live in trial user_attrs so the analyzer can re-aggregate by
|
|
| 6 |
megapixel range. JPEG/AVIF/WEBP baselines are logged once as tagged trials.
|
| 7 |
Start/stop controlled from the web Sweep Runner."""
|
| 8 |
|
| 9 |
-
import glob, io, itertools, os, threading, time
|
| 10 |
|
| 11 |
import numpy as np
|
| 12 |
import optuna
|
|
@@ -33,6 +33,7 @@ IMAGE_EXTS = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp")
|
|
| 33 |
JPEG_QUALITIES = (1, 3, 5, 10, 20, 40, 70, 95)
|
| 34 |
AVIF_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
| 35 |
WEBP_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
|
|
|
| 36 |
JXL_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
| 37 |
DOWNSAMPLE_FACTORS = (1, 1.5, 2, 3, 4, 5, 8, 10, 15, 20, 32, 64, 128)
|
| 38 |
|
|
@@ -176,6 +177,20 @@ def _eval_pbc3(cfg, images, on_image=None):
|
|
| 176 |
return rows
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
def _eval_codec(fmt, q, images, on_image=None):
|
| 180 |
rows = []
|
| 181 |
for im in images:
|
|
@@ -183,13 +198,12 @@ def _eval_codec(fmt, q, images, on_image=None):
|
|
| 183 |
raise InterruptedError("sweep stopped")
|
| 184 |
arr = im["arr"]
|
| 185 |
pixels = arr.shape[0] * arr.shape[1]
|
| 186 |
-
buf = io.BytesIO()
|
| 187 |
t = time.perf_counter()
|
| 188 |
-
Image.fromarray(arr)
|
| 189 |
secs = time.perf_counter() - t
|
| 190 |
-
recon = np.asarray(Image.open(io.BytesIO(
|
| 191 |
rows.append({"name": im["name"], "mp": im["mp"], "seconds": secs,
|
| 192 |
-
"bpp": len(
|
| 193 |
if on_image:
|
| 194 |
on_image()
|
| 195 |
return rows
|
|
@@ -235,7 +249,7 @@ def _add_completed(study, values, attrs):
|
|
| 235 |
|
| 236 |
def ensure_baselines(study, images):
|
| 237 |
have = {t.user_attrs.get("baseline") for t in study.trials}
|
| 238 |
-
for fmt, qs in (("JPEG", JPEG_QUALITIES), ("AVIF", AVIF_QUALITIES),
|
| 239 |
("WEBP", WEBP_QUALITIES), ("JXL", JXL_QUALITIES)):
|
| 240 |
for q in qs:
|
| 241 |
if _STOP.is_set():
|
|
|
|
| 6 |
megapixel range. JPEG/AVIF/WEBP baselines are logged once as tagged trials.
|
| 7 |
Start/stop controlled from the web Sweep Runner."""
|
| 8 |
|
| 9 |
+
import glob, io, itertools, math, os, threading, time
|
| 10 |
|
| 11 |
import numpy as np
|
| 12 |
import optuna
|
|
|
|
| 33 |
JPEG_QUALITIES = (1, 3, 5, 10, 20, 40, 70, 95)
|
| 34 |
AVIF_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
| 35 |
WEBP_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
| 36 |
+
JP2_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
| 37 |
JXL_QUALITIES = (0, 1, 3, 5, 10, 20, 40, 70, 95)
|
| 38 |
DOWNSAMPLE_FACTORS = (1, 1.5, 2, 3, 4, 5, 8, 10, 15, 20, 32, 64, 128)
|
| 39 |
|
|
|
|
| 177 |
return rows
|
| 178 |
|
| 179 |
|
| 180 |
+
def _jp2_rate(q):
|
| 181 |
+
q = max(0.0, min(95.0, float(q))) / 95.0
|
| 182 |
+
return 200.0 ** (1.0 - q)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _encode_codec_bytes(img, fmt, q):
|
| 186 |
+
buf = io.BytesIO()
|
| 187 |
+
if fmt == "JPEG2000":
|
| 188 |
+
img.save(buf, format="JPEG2000", quality_mode="rates", quality_layers=[_jp2_rate(q)])
|
| 189 |
+
else:
|
| 190 |
+
img.save(buf, format=fmt, quality=q)
|
| 191 |
+
return buf.getvalue()
|
| 192 |
+
|
| 193 |
+
|
| 194 |
def _eval_codec(fmt, q, images, on_image=None):
|
| 195 |
rows = []
|
| 196 |
for im in images:
|
|
|
|
| 198 |
raise InterruptedError("sweep stopped")
|
| 199 |
arr = im["arr"]
|
| 200 |
pixels = arr.shape[0] * arr.shape[1]
|
|
|
|
| 201 |
t = time.perf_counter()
|
| 202 |
+
data = _encode_codec_bytes(Image.fromarray(arr), fmt, q)
|
| 203 |
secs = time.perf_counter() - t
|
| 204 |
+
recon = np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))
|
| 205 |
rows.append({"name": im["name"], "mp": im["mp"], "seconds": secs,
|
| 206 |
+
"bpp": len(data) * 8 / pixels, "mse": _mse(arr, recon)})
|
| 207 |
if on_image:
|
| 208 |
on_image()
|
| 209 |
return rows
|
|
|
|
| 249 |
|
| 250 |
def ensure_baselines(study, images):
|
| 251 |
have = {t.user_attrs.get("baseline") for t in study.trials}
|
| 252 |
+
for fmt, qs in (("JPEG", JPEG_QUALITIES), ("JPEG2000", JP2_QUALITIES), ("AVIF", AVIF_QUALITIES),
|
| 253 |
("WEBP", WEBP_QUALITIES), ("JXL", JXL_QUALITIES)):
|
| 254 |
for q in qs:
|
| 255 |
if _STOP.is_set():
|
server.py
CHANGED
|
@@ -430,18 +430,34 @@ BPP_GUIDE = {
|
|
| 430 |
"JPEG": [(1, 0.17), (5, 0.21), (10, 0.29), (20, 0.44), (40, 0.65), (60, 0.85), (80, 1.26), (95, 2.77)],
|
| 431 |
"AVIF": [(0, 0.01), (2, 0.02), (5, 0.04), (10, 0.06), (20, 0.12), (40, 0.30), (60, 0.68), (80, 1.16), (95, 2.69)],
|
| 432 |
"WEBP": [(0, 0.06), (5, 0.13), (10, 0.17), (20, 0.25), (40, 0.40), (60, 0.55), (80, 0.85), (95, 1.80)],
|
|
|
|
| 433 |
"JXL": [(0, 0.02), (2, 0.03), (5, 0.05), (10, 0.08), (20, 0.15), (40, 0.34), (60, 0.70), (80, 1.20), (95, 2.60)],
|
| 434 |
}
|
| 435 |
# Quality samples for the tradeoff baselines (AVIF goes down to 0, JPEG stops at 1).
|
| 436 |
JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 437 |
AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 438 |
WEBP_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
|
|
|
| 439 |
JXL_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 440 |
|
| 441 |
def _q_bounds(fmt):
|
| 442 |
return (1, 95) if fmt == "JPEG" else (0, 95)
|
| 443 |
|
| 444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
def _guess_quality(fmt, target_bpp):
|
| 446 |
"""Inverse-interpolate the guideline table to pick a starting quality for `target_bpp`."""
|
| 447 |
g = BPP_GUIDE[fmt]
|
|
@@ -478,13 +494,12 @@ def _match_codec_gen(img, fmt, target_bpp, pixels, max_iters=12):
|
|
| 478 |
q = int(max(qmin, min(qmax, round(q))))
|
| 479 |
if q in tried:
|
| 480 |
return tried[q], False
|
| 481 |
-
buf = io.BytesIO()
|
| 482 |
try:
|
| 483 |
-
|
| 484 |
except Exception:
|
| 485 |
tried[q] = None
|
| 486 |
return None, False
|
| 487 |
-
r = {"q": q, "bpp": len(
|
| 488 |
tried[q] = r
|
| 489 |
if better(r):
|
| 490 |
best["ref"] = r
|
|
@@ -712,9 +727,9 @@ async def match_codec(image: UploadFile = File(...), codec: str = Form("jpeg"),
|
|
| 712 |
img = src.convert("RGBA") if has_alpha else src.convert("RGB")
|
| 713 |
except Exception as exc:
|
| 714 |
return JSONResponse({"error": f"Could not read image: {exc}"}, status_code=400)
|
| 715 |
-
fmt = {"jpeg": "JPEG", "avif": "AVIF", "webp": "WEBP", "jxl": "JXL"}.get(codec.lower(), "JPEG")
|
| 716 |
pixels = img.size[0] * img.size[1]
|
| 717 |
-
enc_img = img if (has_alpha and fmt in ("WEBP", "AVIF", "JXL")) else img.convert("RGB")
|
| 718 |
|
| 719 |
def gen():
|
| 720 |
best = None
|
|
|
|
| 430 |
"JPEG": [(1, 0.17), (5, 0.21), (10, 0.29), (20, 0.44), (40, 0.65), (60, 0.85), (80, 1.26), (95, 2.77)],
|
| 431 |
"AVIF": [(0, 0.01), (2, 0.02), (5, 0.04), (10, 0.06), (20, 0.12), (40, 0.30), (60, 0.68), (80, 1.16), (95, 2.69)],
|
| 432 |
"WEBP": [(0, 0.06), (5, 0.13), (10, 0.17), (20, 0.25), (40, 0.40), (60, 0.55), (80, 0.85), (95, 1.80)],
|
| 433 |
+
"JPEG2000": [(0, 0.03), (5, 0.05), (10, 0.08), (20, 0.14), (40, 0.30), (60, 0.65), (80, 1.20), (95, 2.50)],
|
| 434 |
"JXL": [(0, 0.02), (2, 0.03), (5, 0.05), (10, 0.08), (20, 0.15), (40, 0.34), (60, 0.70), (80, 1.20), (95, 2.60)],
|
| 435 |
}
|
| 436 |
# Quality samples for the tradeoff baselines (AVIF goes down to 0, JPEG stops at 1).
|
| 437 |
JPEG_QUALITIES = (1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 438 |
AVIF_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 439 |
WEBP_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 440 |
+
JP2_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 441 |
JXL_QUALITIES = (0, 1, 2, 3, 5, 8, 10, 20, 40, 60, 80, 95)
|
| 442 |
|
| 443 |
def _q_bounds(fmt):
|
| 444 |
return (1, 95) if fmt == "JPEG" else (0, 95)
|
| 445 |
|
| 446 |
|
| 447 |
+
def _jp2_rate(q):
|
| 448 |
+
q = max(0.0, min(95.0, float(q))) / 95.0
|
| 449 |
+
return 200.0 ** (1.0 - q)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _encode_codec_bytes(img, fmt, q):
|
| 453 |
+
buf = io.BytesIO()
|
| 454 |
+
if fmt == "JPEG2000":
|
| 455 |
+
img.save(buf, format="JPEG2000", quality_mode="rates", quality_layers=[_jp2_rate(q)])
|
| 456 |
+
else:
|
| 457 |
+
img.save(buf, format=fmt, quality=int(q))
|
| 458 |
+
return buf.getvalue()
|
| 459 |
+
|
| 460 |
+
|
| 461 |
def _guess_quality(fmt, target_bpp):
|
| 462 |
"""Inverse-interpolate the guideline table to pick a starting quality for `target_bpp`."""
|
| 463 |
g = BPP_GUIDE[fmt]
|
|
|
|
| 494 |
q = int(max(qmin, min(qmax, round(q))))
|
| 495 |
if q in tried:
|
| 496 |
return tried[q], False
|
|
|
|
| 497 |
try:
|
| 498 |
+
data = _encode_codec_bytes(img, fmt, q)
|
| 499 |
except Exception:
|
| 500 |
tried[q] = None
|
| 501 |
return None, False
|
| 502 |
+
r = {"q": q, "bpp": len(data) * 8 / pixels, "data": data}
|
| 503 |
tried[q] = r
|
| 504 |
if better(r):
|
| 505 |
best["ref"] = r
|
|
|
|
| 727 |
img = src.convert("RGBA") if has_alpha else src.convert("RGB")
|
| 728 |
except Exception as exc:
|
| 729 |
return JSONResponse({"error": f"Could not read image: {exc}"}, status_code=400)
|
| 730 |
+
fmt = {"jpeg": "JPEG", "jp2": "JPEG2000", "jpeg2000": "JPEG2000", "avif": "AVIF", "webp": "WEBP", "jxl": "JXL"}.get(codec.lower(), "JPEG")
|
| 731 |
pixels = img.size[0] * img.size[1]
|
| 732 |
+
enc_img = img if (has_alpha and fmt in ("WEBP", "AVIF", "JXL", "JPEG2000")) else img.convert("RGB")
|
| 733 |
|
| 734 |
def gen():
|
| 735 |
best = None
|
static/app.js
CHANGED
|
@@ -784,7 +784,8 @@ function renderRegistry() {
|
|
| 784 |
// raw (3 bytes RGB) / bpp.
|
| 785 |
const bppToRate = (bpp) => 24 / bpp;
|
| 786 |
|
| 787 |
-
const CODEC_COLOR = { jpeg: "#7d8cff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc" };
|
|
|
|
| 788 |
const PBC_COLOR = "#ff3b41";
|
| 789 |
|
| 790 |
function card(d) {
|
|
@@ -808,14 +809,19 @@ function card(d) {
|
|
| 808 |
: "";
|
| 809 |
|
| 810 |
// Only show codecs that haven't been generated yet; drop the whole row once both exist.
|
| 811 |
-
const haveJ = !!d.jpeg_image, haveA = !!d.avif_image, haveW = !!d.webp_image, haveX = !!d.jxl_image;
|
| 812 |
-
const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveA && haveW && haveX))
|
| 813 |
-
? `<div class="
|
| 814 |
<span class="k">Compare to:</span>
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
</div>`
|
| 820 |
: "";
|
| 821 |
const haveAnim = !!d.animation_url;
|
|
@@ -850,10 +856,12 @@ function card(d) {
|
|
| 850 |
el.querySelector('[data-act="png"]').onclick = () => savePng(d);
|
| 851 |
el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
|
| 852 |
const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
|
|
|
|
| 853 |
const cmpA = el.querySelector('[data-act="cmp-avif"]');
|
| 854 |
const cmpW = el.querySelector('[data-act="cmp-webp"]');
|
| 855 |
const cmpX = el.querySelector('[data-act="cmp-jxl"]');
|
| 856 |
if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
|
|
|
|
| 857 |
if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
|
| 858 |
if (cmpW) cmpW.onclick = () => compareCodec(d, "webp", cmpW);
|
| 859 |
if (cmpX) cmpX.onclick = () => compareCodec(d, "jxl", cmpX);
|
|
@@ -872,7 +880,7 @@ function card(d) {
|
|
| 872 |
// codec is cached on the entry, so the chip is removed and a repeat click is impossible.
|
| 873 |
async function compareCodec(d, codec, btn) {
|
| 874 |
if (!d.original_image || d[codec + "_image"]) return;
|
| 875 |
-
const label = codec.toUpperCase();
|
| 876 |
btn.disabled = true;
|
| 877 |
btn.textContent = label + "…";
|
| 878 |
const job = createJob(`${label} match`);
|
|
@@ -1031,6 +1039,7 @@ const viewer = document.getElementById("viewer");
|
|
| 1031 |
const viewerStage = document.getElementById("viewer-stage");
|
| 1032 |
const holdBtn = document.getElementById("hold-original");
|
| 1033 |
const holdJpeg = document.getElementById("hold-jpeg");
|
|
|
|
| 1034 |
const holdAvif = document.getElementById("hold-avif");
|
| 1035 |
const holdWebp = document.getElementById("hold-webp");
|
| 1036 |
const holdJxl = document.getElementById("hold-jxl");
|
|
@@ -1040,7 +1049,7 @@ const nextBtn = document.getElementById("viewer-next");
|
|
| 1040 |
const capEl = document.getElementById("viewer-caption");
|
| 1041 |
let viewIndex = -1;
|
| 1042 |
let sideBySide = false;
|
| 1043 |
-
let activeCompare = null; //
|
| 1044 |
let viewerDownTarget = null; // where the last pointerdown started (backdrop-close guard)
|
| 1045 |
|
| 1046 |
document.getElementById("viewer-close").addEventListener("click", closeViewer);
|
|
@@ -1069,6 +1078,7 @@ function step(dir) {
|
|
| 1069 |
function compareSrcOf(d, kind) {
|
| 1070 |
return kind === "original" ? d.original_image
|
| 1071 |
: kind === "jpeg" ? d.jpeg_image
|
|
|
|
| 1072 |
: kind === "avif" ? d.avif_image
|
| 1073 |
: kind === "webp" ? d.webp_image
|
| 1074 |
: kind === "jxl" ? d.jxl_image : null;
|
|
@@ -1082,7 +1092,7 @@ function viewerCaption(d) {
|
|
| 1082 |
// Two-line, colored comparison summary (size + MSE only; no composite quality).
|
| 1083 |
// Reusable pieces of the comparison summary (size + MSE only).
|
| 1084 |
function codecCompareParts(d, codec) {
|
| 1085 |
-
const label = codec.toUpperCase();
|
| 1086 |
const cc = CODEC_COLOR[codec];
|
| 1087 |
const fmtSize = d[codec + "_size_kb"], fmtMse = d[codec + "_mse"], q = d[codec + "_q"];
|
| 1088 |
const pct = (a, b) => Math.abs((a - b) / (b || 1e-9) * 100).toFixed(0);
|
|
@@ -1108,7 +1118,7 @@ function codecCompareCaptionHTML(d, codec) {
|
|
| 1108 |
function updateCaption() {
|
| 1109 |
const d = registry[viewIndex];
|
| 1110 |
if (!d) return;
|
| 1111 |
-
if (sideBySide && (["jpeg", "avif", "webp", "jxl"].includes(activeCompare)))
|
| 1112 |
capEl.innerHTML = `<div>${codecCompareParts(d, activeCompare).sentence}</div>`; // stats moved on top
|
| 1113 |
else
|
| 1114 |
capEl.textContent = viewerCaption(d);
|
|
@@ -1126,7 +1136,7 @@ function renderViewer() {
|
|
| 1126 |
viewerStage.className = "viewer-stage dual " + (colScale > rowScale ? "col" : "row");
|
| 1127 |
|
| 1128 |
let leftCap, rightCap;
|
| 1129 |
-
if (["jpeg", "avif", "webp", "jxl"].includes(activeCompare)) {
|
| 1130 |
const p = codecCompareParts(d, activeCompare);
|
| 1131 |
leftCap = `<figcaption class="cmp-stat">${p.codecStat}</figcaption>`;
|
| 1132 |
rightCap = `<figcaption class="cmp-stat">${p.pbcStat}</figcaption>`;
|
|
@@ -1144,16 +1154,19 @@ function renderViewer() {
|
|
| 1144 |
|
| 1145 |
holdBtn.hidden = !d.original_image;
|
| 1146 |
holdJpeg.hidden = !d.jpeg_image;
|
|
|
|
| 1147 |
holdAvif.hidden = !d.avif_image;
|
| 1148 |
holdWebp.hidden = !d.webp_image;
|
| 1149 |
holdJxl.hidden = !d.jxl_image;
|
| 1150 |
holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
|
| 1151 |
holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
|
|
|
|
| 1152 |
holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
|
| 1153 |
holdWebp.textContent = sideBySide ? "WebP" : "Hold for WebP";
|
| 1154 |
holdJxl.textContent = sideBySide ? "JXL" : "Hold for JXL";
|
| 1155 |
holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
|
| 1156 |
holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
|
|
|
|
| 1157 |
holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
|
| 1158 |
holdWebp.classList.toggle("holding", sideBySide && activeCompare === "webp");
|
| 1159 |
holdJxl.classList.toggle("holding", sideBySide && activeCompare === "jxl");
|
|
@@ -1195,6 +1208,7 @@ function wireHold(btn, kind, getCaptionHTML) {
|
|
| 1195 |
}
|
| 1196 |
wireHold(holdBtn, "original");
|
| 1197 |
wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
|
|
|
|
| 1198 |
wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
|
| 1199 |
wireHold(holdWebp, "webp", d => codecCompareCaptionHTML(d, "webp"));
|
| 1200 |
wireHold(holdJxl, "jxl", d => codecCompareCaptionHTML(d, "jxl"));
|
|
|
|
| 784 |
// raw (3 bytes RGB) / bpp.
|
| 785 |
const bppToRate = (bpp) => 24 / bpp;
|
| 786 |
|
| 787 |
+
const CODEC_COLOR = { jpeg: "#7d8cff", jp2: "#4fa3ff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc" };
|
| 788 |
+
const CODEC_LABEL = { jpeg: "JPEG", jp2: "JPEG2000", webp: "WebP", jxl: "JPEG XL", avif: "AVIF" };
|
| 789 |
const PBC_COLOR = "#ff3b41";
|
| 790 |
|
| 791 |
function card(d) {
|
|
|
|
| 809 |
: "";
|
| 810 |
|
| 811 |
// Only show codecs that haven't been generated yet; drop the whole row once both exist.
|
| 812 |
+
const haveJ = !!d.jpeg_image, haveP = !!d.jp2_image, haveA = !!d.avif_image, haveW = !!d.webp_image, haveX = !!d.jxl_image;
|
| 813 |
+
const compareBlock = (!d.decoded && d.original_image && !(haveJ && haveP && haveA && haveW && haveX))
|
| 814 |
+
? `<div class="compare-codecs">
|
| 815 |
<span class="k">Compare to:</span>
|
| 816 |
+
<div class="compare-codec-row">
|
| 817 |
+
${haveJ ? "" : `<button class="chip" data-act="cmp-jpeg" style="color:#7d8cff;border-color:#7d8cff">JPEG</button>`}
|
| 818 |
+
${haveP ? "" : `<button class="chip" data-act="cmp-jp2" style="color:#4fa3ff;border-color:#4fa3ff">JPEG2000</button>`}
|
| 819 |
+
${haveW ? "" : `<button class="chip" data-act="cmp-webp" style="color:#54c0ff;border-color:#54c0ff">WebP</button>`}
|
| 820 |
+
</div>
|
| 821 |
+
<div class="compare-codec-row">
|
| 822 |
+
${haveX ? "" : `<button class="chip" data-act="cmp-jxl" style="color:#c084fc;border-color:#c084fc">JPEG XL</button>`}
|
| 823 |
+
${haveA ? "" : `<button class="chip" data-act="cmp-avif" style="color:#34d39a;border-color:#34d39a">AVIF</button>`}
|
| 824 |
+
</div>
|
| 825 |
</div>`
|
| 826 |
: "";
|
| 827 |
const haveAnim = !!d.animation_url;
|
|
|
|
| 856 |
el.querySelector('[data-act="png"]').onclick = () => savePng(d);
|
| 857 |
el.querySelector('[data-act="pbc"]').onclick = () => savePbc(d);
|
| 858 |
const cmpJ = el.querySelector('[data-act="cmp-jpeg"]');
|
| 859 |
+
const cmpP = el.querySelector('[data-act="cmp-jp2"]');
|
| 860 |
const cmpA = el.querySelector('[data-act="cmp-avif"]');
|
| 861 |
const cmpW = el.querySelector('[data-act="cmp-webp"]');
|
| 862 |
const cmpX = el.querySelector('[data-act="cmp-jxl"]');
|
| 863 |
if (cmpJ) cmpJ.onclick = () => compareCodec(d, "jpeg", cmpJ);
|
| 864 |
+
if (cmpP) cmpP.onclick = () => compareCodec(d, "jp2", cmpP);
|
| 865 |
if (cmpA) cmpA.onclick = () => compareCodec(d, "avif", cmpA);
|
| 866 |
if (cmpW) cmpW.onclick = () => compareCodec(d, "webp", cmpW);
|
| 867 |
if (cmpX) cmpX.onclick = () => compareCodec(d, "jxl", cmpX);
|
|
|
|
| 880 |
// codec is cached on the entry, so the chip is removed and a repeat click is impossible.
|
| 881 |
async function compareCodec(d, codec, btn) {
|
| 882 |
if (!d.original_image || d[codec + "_image"]) return;
|
| 883 |
+
const label = CODEC_LABEL[codec] || codec.toUpperCase();
|
| 884 |
btn.disabled = true;
|
| 885 |
btn.textContent = label + "…";
|
| 886 |
const job = createJob(`${label} match`);
|
|
|
|
| 1039 |
const viewerStage = document.getElementById("viewer-stage");
|
| 1040 |
const holdBtn = document.getElementById("hold-original");
|
| 1041 |
const holdJpeg = document.getElementById("hold-jpeg");
|
| 1042 |
+
const holdJp2 = document.getElementById("hold-jp2");
|
| 1043 |
const holdAvif = document.getElementById("hold-avif");
|
| 1044 |
const holdWebp = document.getElementById("hold-webp");
|
| 1045 |
const holdJxl = document.getElementById("hold-jxl");
|
|
|
|
| 1049 |
const capEl = document.getElementById("viewer-caption");
|
| 1050 |
let viewIndex = -1;
|
| 1051 |
let sideBySide = false;
|
| 1052 |
+
let activeCompare = null; // side-by-side selected comparison key
|
| 1053 |
let viewerDownTarget = null; // where the last pointerdown started (backdrop-close guard)
|
| 1054 |
|
| 1055 |
document.getElementById("viewer-close").addEventListener("click", closeViewer);
|
|
|
|
| 1078 |
function compareSrcOf(d, kind) {
|
| 1079 |
return kind === "original" ? d.original_image
|
| 1080 |
: kind === "jpeg" ? d.jpeg_image
|
| 1081 |
+
: kind === "jp2" ? d.jp2_image
|
| 1082 |
: kind === "avif" ? d.avif_image
|
| 1083 |
: kind === "webp" ? d.webp_image
|
| 1084 |
: kind === "jxl" ? d.jxl_image : null;
|
|
|
|
| 1092 |
// Two-line, colored comparison summary (size + MSE only; no composite quality).
|
| 1093 |
// Reusable pieces of the comparison summary (size + MSE only).
|
| 1094 |
function codecCompareParts(d, codec) {
|
| 1095 |
+
const label = CODEC_LABEL[codec] || codec.toUpperCase();
|
| 1096 |
const cc = CODEC_COLOR[codec];
|
| 1097 |
const fmtSize = d[codec + "_size_kb"], fmtMse = d[codec + "_mse"], q = d[codec + "_q"];
|
| 1098 |
const pct = (a, b) => Math.abs((a - b) / (b || 1e-9) * 100).toFixed(0);
|
|
|
|
| 1118 |
function updateCaption() {
|
| 1119 |
const d = registry[viewIndex];
|
| 1120 |
if (!d) return;
|
| 1121 |
+
if (sideBySide && (["jpeg", "jp2", "avif", "webp", "jxl"].includes(activeCompare)))
|
| 1122 |
capEl.innerHTML = `<div>${codecCompareParts(d, activeCompare).sentence}</div>`; // stats moved on top
|
| 1123 |
else
|
| 1124 |
capEl.textContent = viewerCaption(d);
|
|
|
|
| 1136 |
viewerStage.className = "viewer-stage dual " + (colScale > rowScale ? "col" : "row");
|
| 1137 |
|
| 1138 |
let leftCap, rightCap;
|
| 1139 |
+
if (["jpeg", "jp2", "avif", "webp", "jxl"].includes(activeCompare)) {
|
| 1140 |
const p = codecCompareParts(d, activeCompare);
|
| 1141 |
leftCap = `<figcaption class="cmp-stat">${p.codecStat}</figcaption>`;
|
| 1142 |
rightCap = `<figcaption class="cmp-stat">${p.pbcStat}</figcaption>`;
|
|
|
|
| 1154 |
|
| 1155 |
holdBtn.hidden = !d.original_image;
|
| 1156 |
holdJpeg.hidden = !d.jpeg_image;
|
| 1157 |
+
holdJp2.hidden = !d.jp2_image;
|
| 1158 |
holdAvif.hidden = !d.avif_image;
|
| 1159 |
holdWebp.hidden = !d.webp_image;
|
| 1160 |
holdJxl.hidden = !d.jxl_image;
|
| 1161 |
holdBtn.textContent = sideBySide ? "Original" : "Hold for original";
|
| 1162 |
holdJpeg.textContent = sideBySide ? "JPEG" : "Hold for JPEG";
|
| 1163 |
+
holdJp2.textContent = sideBySide ? "JPEG2000" : "Hold for JPEG2000";
|
| 1164 |
holdAvif.textContent = sideBySide ? "AVIF" : "Hold for AVIF";
|
| 1165 |
holdWebp.textContent = sideBySide ? "WebP" : "Hold for WebP";
|
| 1166 |
holdJxl.textContent = sideBySide ? "JXL" : "Hold for JXL";
|
| 1167 |
holdBtn.classList.toggle("holding", sideBySide && activeCompare === "original");
|
| 1168 |
holdJpeg.classList.toggle("holding", sideBySide && activeCompare === "jpeg");
|
| 1169 |
+
holdJp2.classList.toggle("holding", sideBySide && activeCompare === "jp2");
|
| 1170 |
holdAvif.classList.toggle("holding", sideBySide && activeCompare === "avif");
|
| 1171 |
holdWebp.classList.toggle("holding", sideBySide && activeCompare === "webp");
|
| 1172 |
holdJxl.classList.toggle("holding", sideBySide && activeCompare === "jxl");
|
|
|
|
| 1208 |
}
|
| 1209 |
wireHold(holdBtn, "original");
|
| 1210 |
wireHold(holdJpeg, "jpeg", d => codecCompareCaptionHTML(d, "jpeg"));
|
| 1211 |
+
wireHold(holdJp2, "jp2", d => codecCompareCaptionHTML(d, "jp2"));
|
| 1212 |
wireHold(holdAvif, "avif", d => codecCompareCaptionHTML(d, "avif"));
|
| 1213 |
wireHold(holdWebp, "webp", d => codecCompareCaptionHTML(d, "webp"));
|
| 1214 |
wireHold(holdJxl, "jxl", d => codecCompareCaptionHTML(d, "jxl"));
|
static/index.html
CHANGED
|
@@ -12,22 +12,22 @@
|
|
| 12 |
<link rel="stylesheet" href="/viewer_magnifier.css?v=20260622b">
|
| 13 |
<style>
|
| 14 |
#hold-jpeg { --hc:#7d8cff; }
|
|
|
|
| 15 |
#hold-webp { --hc:#54c0ff; }
|
| 16 |
#hold-jxl { --hc:#c084fc; }
|
| 17 |
#hold-avif { --hc:#34d39a; }
|
| 18 |
-
#hold-jpeg, #hold-webp, #hold-jxl, #hold-avif { border-color: var(--hc); color: var(--hc); }
|
| 19 |
#hold-jpeg.holding, #hold-webp.holding, #hold-jxl.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
|
| 20 |
-
.
|
| 21 |
-
.
|
| 22 |
-
.foot-row [data-act="cmp-jxl"] { order: 3; }
|
| 23 |
-
.foot-row [data-act="cmp-avif"] { order: 4; }
|
| 24 |
|
| 25 |
.viewer-controls #viewer-sbs { order: 0; }
|
| 26 |
.viewer-controls #hold-original { order: 1; }
|
| 27 |
.viewer-controls #hold-jpeg { order: 2; }
|
| 28 |
-
.viewer-controls #hold-
|
| 29 |
-
.viewer-controls #hold-
|
| 30 |
-
.viewer-controls #hold-
|
|
|
|
| 31 |
|
| 32 |
/* Training Lab */
|
| 33 |
.tr-fields { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; }
|
|
@@ -216,6 +216,7 @@
|
|
| 216 |
<label class="sbs-toggle"><input type="checkbox" id="viewer-sbs"> Side by side</label>
|
| 217 |
<button id="hold-original" class="hold-btn" hidden>Hold for original</button>
|
| 218 |
<button id="hold-jpeg" class="hold-btn" hidden>Hold for JPEG</button>
|
|
|
|
| 219 |
<button id="hold-webp" class="hold-btn" hidden>Hold for WEBP</button>
|
| 220 |
<button id="hold-jxl" class="hold-btn" hidden>Hold for JXL</button>
|
| 221 |
<button id="hold-avif" class="hold-btn" hidden>Hold for AVIF</button>
|
|
|
|
| 12 |
<link rel="stylesheet" href="/viewer_magnifier.css?v=20260622b">
|
| 13 |
<style>
|
| 14 |
#hold-jpeg { --hc:#7d8cff; }
|
| 15 |
+
#hold-jp2 { --hc:#4fa3ff; }
|
| 16 |
#hold-webp { --hc:#54c0ff; }
|
| 17 |
#hold-jxl { --hc:#c084fc; }
|
| 18 |
#hold-avif { --hc:#34d39a; }
|
| 19 |
+
#hold-jpeg, #hold-jp2, #hold-webp, #hold-jxl, #hold-avif { border-color: var(--hc); color: var(--hc); }
|
| 20 |
#hold-jpeg.holding, #hold-webp.holding, #hold-jxl.holding, #hold-avif.holding { background: var(--hc); border-color: var(--hc); color: #fff; }
|
| 21 |
+
.compare-codecs { display:flex; flex-direction:column; gap:7px; margin-top:2px; }
|
| 22 |
+
.compare-codec-row { display:flex; flex-wrap:wrap; gap:8px; }
|
|
|
|
|
|
|
| 23 |
|
| 24 |
.viewer-controls #viewer-sbs { order: 0; }
|
| 25 |
.viewer-controls #hold-original { order: 1; }
|
| 26 |
.viewer-controls #hold-jpeg { order: 2; }
|
| 27 |
+
.viewer-controls #hold-jp2 { order: 3; }
|
| 28 |
+
.viewer-controls #hold-webp { order: 4; }
|
| 29 |
+
.viewer-controls #hold-jxl { order: 5; }
|
| 30 |
+
.viewer-controls #hold-avif { order: 6; }
|
| 31 |
|
| 32 |
/* Training Lab */
|
| 33 |
.tr-fields { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; }
|
|
|
|
| 216 |
<label class="sbs-toggle"><input type="checkbox" id="viewer-sbs"> Side by side</label>
|
| 217 |
<button id="hold-original" class="hold-btn" hidden>Hold for original</button>
|
| 218 |
<button id="hold-jpeg" class="hold-btn" hidden>Hold for JPEG</button>
|
| 219 |
+
<button id="hold-jp2" class="hold-btn" hidden>Hold for JPEG2000</button>
|
| 220 |
<button id="hold-webp" class="hold-btn" hidden>Hold for WEBP</button>
|
| 221 |
<button id="hold-jxl" class="hold-btn" hidden>Hold for JXL</button>
|
| 222 |
<button id="hold-avif" class="hold-btn" hidden>Hold for AVIF</button>
|
static/sweep.js
CHANGED
|
@@ -18,16 +18,16 @@
|
|
| 18 |
study: null, trials: [], filtered: [],
|
| 19 |
selected: null, codec: null, built: false, fsKind: null,
|
| 20 |
sort: { key: "mse", dir: 1 }, // MSE ascending = best first
|
| 21 |
-
show: { pbcAll: true, pbcPareto: true, presets: true, jpeg: false, avif: false, webp: false, jxl: false, downsample: false, png: false },
|
| 22 |
dyn: [], mpMin: null, mpMax: null, logx: true, logy: false, psnr: false,
|
| 23 |
fnum: { mse: NaN, bpp: NaN, sp: NaN }, // numeric metric filters (apply to PBC + codec curves)
|
| 24 |
};
|
| 25 |
|
| 26 |
const COLORS = {
|
| 27 |
gray: "#5c3839", pareto: "#ff3b41", sel: "#ffce2e", base: "#ff280c",
|
| 28 |
-
jpeg: "#7d8cff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc", downsample: "#e6e6e6", png: "#ef19ef",
|
| 29 |
};
|
| 30 |
-
const TOGGLES = [["pbcAll", "PBC All"], ["pbcPareto", "PBC Pareto"], ["presets", "PBC Presets"], ["jpeg", "JPEG"], ["
|
| 31 |
|
| 32 |
// 2D plot defs. efficiency = 1/(bpp·mse) (higher is better).
|
| 33 |
const P2D = {
|
|
@@ -239,8 +239,8 @@
|
|
| 239 |
function buildCodec(all) {
|
| 240 |
const pick = (c) => all.filter((t) => t.codec === c)
|
| 241 |
.map((t) => ({ q: t.q, bpp: t.bpp, mse: t.mse, speed: t.speed, eff: t.eff })).sort((a, b) => a.q - b.q);
|
| 242 |
-
const jpeg = pick("JPEG"), avif = pick("AVIF"), webp = pick("WEBP"), jxl = pick("JXL"), downsample = pick("DOWNSAMPLE"), png = pick("PNG");
|
| 243 |
-
S.codec = (jpeg.length || avif.length || webp.length || jxl.length || downsample.length || png.length) ? { jpeg, avif, webp, jxl, downsample, png } : null;
|
| 244 |
}
|
| 245 |
|
| 246 |
function renderSummary() {
|
|
@@ -420,7 +420,7 @@
|
|
| 420 |
|
| 421 |
function scatter2d(xKey, yKey, usePsnr = false) {
|
| 422 |
const all = S.filtered, traces = [];
|
| 423 |
-
const CODECS = ["jpeg", "
|
| 424 |
const yfn = usePsnr ? mseToPsnr : null;
|
| 425 |
const logy = S.logy && !usePsnr;
|
| 426 |
const plotFloor = (key) => {
|
|
@@ -436,7 +436,7 @@
|
|
| 436 |
if (S.show.pbcAll) traces.push(ptTrace(all, xKey, yKey, { name: "PBC all", marker: { size: 5, color: COLORS.gray, opacity: .55 } }, fx, fy, yfn, logy));
|
| 437 |
if (S.show.pbcPareto) traces.push(ptTrace(all.filter((t) => t.pareto), xKey, yKey, { name: "PBC Pareto", marker: { size: 8, color: COLORS.pareto } }, fx, fy, yfn, logy));
|
| 438 |
|
| 439 |
-
[["jpeg", COLORS.jpeg], ["
|
| 440 |
if (!S.show[k]) return;
|
| 441 |
const pts = codecPoints(k);
|
| 442 |
if (pts && pts.length) traces.push(codecTrace([...pts].sort((a, b) => a[xKey] - b[xKey]), xKey, yKey, k.toUpperCase(), c, fx, fy, yfn, logy));
|
|
|
|
| 18 |
study: null, trials: [], filtered: [],
|
| 19 |
selected: null, codec: null, built: false, fsKind: null,
|
| 20 |
sort: { key: "mse", dir: 1 }, // MSE ascending = best first
|
| 21 |
+
show: { pbcAll: true, pbcPareto: true, presets: true, jpeg: false, jp2: false, avif: false, webp: false, jxl: false, downsample: false, png: false },
|
| 22 |
dyn: [], mpMin: null, mpMax: null, logx: true, logy: false, psnr: false,
|
| 23 |
fnum: { mse: NaN, bpp: NaN, sp: NaN }, // numeric metric filters (apply to PBC + codec curves)
|
| 24 |
};
|
| 25 |
|
| 26 |
const COLORS = {
|
| 27 |
gray: "#5c3839", pareto: "#ff3b41", sel: "#ffce2e", base: "#ff280c",
|
| 28 |
+
jpeg: "#7d8cff", jp2: "#4fa3ff", avif: "#34d39a", webp: "#54c0ff", jxl: "#c084fc", downsample: "#e6e6e6", png: "#ef19ef",
|
| 29 |
};
|
| 30 |
+
const TOGGLES = [["pbcAll", "PBC All"], ["pbcPareto", "PBC Pareto"], ["presets", "PBC Presets"], ["jpeg", "JPEG"], ["jp2", "JPEG2000"], ["webp", "WebP"], ["jxl", "JXL"], ["avif", "AVIF"], ["downsample", "Downsample"], ["png", "PNG"]];
|
| 31 |
|
| 32 |
// 2D plot defs. efficiency = 1/(bpp·mse) (higher is better).
|
| 33 |
const P2D = {
|
|
|
|
| 239 |
function buildCodec(all) {
|
| 240 |
const pick = (c) => all.filter((t) => t.codec === c)
|
| 241 |
.map((t) => ({ q: t.q, bpp: t.bpp, mse: t.mse, speed: t.speed, eff: t.eff })).sort((a, b) => a.q - b.q);
|
| 242 |
+
const jpeg = pick("JPEG"), jp2 = pick("JPEG2000"), avif = pick("AVIF"), webp = pick("WEBP"), jxl = pick("JXL"), downsample = pick("DOWNSAMPLE"), png = pick("PNG");
|
| 243 |
+
S.codec = (jpeg.length || jp2.length || avif.length || webp.length || jxl.length || downsample.length || png.length) ? { jpeg, jp2, avif, webp, jxl, downsample, png } : null;
|
| 244 |
}
|
| 245 |
|
| 246 |
function renderSummary() {
|
|
|
|
| 420 |
|
| 421 |
function scatter2d(xKey, yKey, usePsnr = false) {
|
| 422 |
const all = S.filtered, traces = [];
|
| 423 |
+
const CODECS = ["jpeg", "jp2", "webp", "jxl", "avif", "downsample", "png"];
|
| 424 |
const yfn = usePsnr ? mseToPsnr : null;
|
| 425 |
const logy = S.logy && !usePsnr;
|
| 426 |
const plotFloor = (key) => {
|
|
|
|
| 436 |
if (S.show.pbcAll) traces.push(ptTrace(all, xKey, yKey, { name: "PBC all", marker: { size: 5, color: COLORS.gray, opacity: .55 } }, fx, fy, yfn, logy));
|
| 437 |
if (S.show.pbcPareto) traces.push(ptTrace(all.filter((t) => t.pareto), xKey, yKey, { name: "PBC Pareto", marker: { size: 8, color: COLORS.pareto } }, fx, fy, yfn, logy));
|
| 438 |
|
| 439 |
+
[["jpeg", COLORS.jpeg], ["jp2", COLORS.jp2], ["webp", COLORS.webp], ["jxl", COLORS.jxl], ["avif", COLORS.avif], ["downsample", COLORS.downsample], ["png", COLORS.png]].forEach(([k, c]) => {
|
| 440 |
if (!S.show[k]) return;
|
| 441 |
const pts = codecPoints(k);
|
| 442 |
if (pts && pts.length) traces.push(codecTrace([...pts].sort((a, b) => a[xKey] - b[xKey]), xKey, yKey, k.toUpperCase(), c, fx, fy, yfn, logy));
|
static/viewer_magnifier.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
|
| 7 |
const SCALE = 4;
|
| 8 |
let activePointer = null;
|
|
|
|
| 9 |
|
| 10 |
const clamp = (v) => Math.max(0, Math.min(1, v));
|
| 11 |
|
|
@@ -23,24 +24,24 @@
|
|
| 23 |
return e.target.closest ? e.target.closest(".viewer-img-frame img") : null;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
function setZoom(e) {
|
| 27 |
-
|
| 28 |
-
if (!img) return;
|
| 29 |
|
| 30 |
-
const rect =
|
| 31 |
const x = clamp((e.clientX - rect.left) / rect.width);
|
| 32 |
const y = clamp((e.clientY - rect.top) / rect.height);
|
| 33 |
const origin = `${(x * 100).toFixed(2)}% ${(y * 100).toFixed(2)}%`;
|
| 34 |
|
| 35 |
stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.add("magnifying"));
|
| 36 |
-
stage.querySelectorAll(".viewer-img-frame img").forEach((
|
| 37 |
-
|
| 38 |
-
|
| 39 |
});
|
| 40 |
}
|
| 41 |
|
| 42 |
function clearZoom() {
|
| 43 |
activePointer = null;
|
|
|
|
| 44 |
stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.remove("magnifying"));
|
| 45 |
stage.querySelectorAll(".viewer-img-frame img").forEach((img) => {
|
| 46 |
img.style.transform = "";
|
|
@@ -52,12 +53,14 @@
|
|
| 52 |
wrapImages();
|
| 53 |
|
| 54 |
stage.addEventListener("pointerdown", (e) => {
|
| 55 |
-
|
|
|
|
| 56 |
e.preventDefault();
|
| 57 |
e.stopPropagation();
|
| 58 |
activePointer = e.pointerId;
|
|
|
|
| 59 |
try { stage.setPointerCapture(e.pointerId); } catch (_) {}
|
| 60 |
-
setZoom(e);
|
| 61 |
});
|
| 62 |
|
| 63 |
stage.addEventListener("pointermove", (e) => {
|
|
@@ -71,4 +74,4 @@
|
|
| 71 |
if (activePointer === e.pointerId) clearZoom();
|
| 72 |
});
|
| 73 |
window.addEventListener("blur", clearZoom);
|
| 74 |
-
})();
|
|
|
|
| 6 |
|
| 7 |
const SCALE = 4;
|
| 8 |
let activePointer = null;
|
| 9 |
+
let activeImage = null;
|
| 10 |
|
| 11 |
const clamp = (v) => Math.max(0, Math.min(1, v));
|
| 12 |
|
|
|
|
| 24 |
return e.target.closest ? e.target.closest(".viewer-img-frame img") : null;
|
| 25 |
}
|
| 26 |
|
| 27 |
+
function setZoom(e, sourceImage = activeImage || eventImage(e)) {
|
| 28 |
+
if (!sourceImage) return;
|
|
|
|
| 29 |
|
| 30 |
+
const rect = sourceImage.getBoundingClientRect();
|
| 31 |
const x = clamp((e.clientX - rect.left) / rect.width);
|
| 32 |
const y = clamp((e.clientY - rect.top) / rect.height);
|
| 33 |
const origin = `${(x * 100).toFixed(2)}% ${(y * 100).toFixed(2)}%`;
|
| 34 |
|
| 35 |
stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.add("magnifying"));
|
| 36 |
+
stage.querySelectorAll(".viewer-img-frame img").forEach((img) => {
|
| 37 |
+
img.style.transformOrigin = origin;
|
| 38 |
+
img.style.transform = `scale(${SCALE})`;
|
| 39 |
});
|
| 40 |
}
|
| 41 |
|
| 42 |
function clearZoom() {
|
| 43 |
activePointer = null;
|
| 44 |
+
activeImage = null;
|
| 45 |
stage.querySelectorAll(".viewer-img-frame").forEach((frame) => frame.classList.remove("magnifying"));
|
| 46 |
stage.querySelectorAll(".viewer-img-frame img").forEach((img) => {
|
| 47 |
img.style.transform = "";
|
|
|
|
| 53 |
wrapImages();
|
| 54 |
|
| 55 |
stage.addEventListener("pointerdown", (e) => {
|
| 56 |
+
const img = eventImage(e);
|
| 57 |
+
if (!img) return;
|
| 58 |
e.preventDefault();
|
| 59 |
e.stopPropagation();
|
| 60 |
activePointer = e.pointerId;
|
| 61 |
+
activeImage = img;
|
| 62 |
try { stage.setPointerCapture(e.pointerId); } catch (_) {}
|
| 63 |
+
setZoom(e, img);
|
| 64 |
});
|
| 65 |
|
| 66 |
stage.addEventListener("pointermove", (e) => {
|
|
|
|
| 74 |
if (activePointer === e.pointerId) clearZoom();
|
| 75 |
});
|
| 76 |
window.addEventListener("blur", clearZoom);
|
| 77 |
+
})();
|