EgeEken commited on
Commit
84a77d0
·
verified ·
1 Parent(s): 69fdaec

Upload 4 files

Browse files
Files changed (4) hide show
  1. server.py +14 -6
  2. static/app.js +75 -19
  3. static/index.html +5 -0
  4. static/styles.css +27 -4
server.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import base64
2
  import io
3
  import math
@@ -43,6 +44,11 @@ def _i(v, d):
43
  return int(_f(v, d))
44
 
45
 
 
 
 
 
 
46
  @app.post("/api/compress")
47
  async def compress(
48
  image: UploadFile = File(...),
@@ -60,10 +66,7 @@ async def compress(
60
  decay_cutoff: str = Form(""),
61
  decay_softness: str = Form(""),
62
  decay_progress: str = Form(""),
63
- multlist_mode: str = Form("Stable_Uniform"),
64
- mult_bit_count: str = Form("2"),
65
- mult_min: str = Form("-10"),
66
- mult_max: str = Form("20"),
67
  strokes_per_quadrant: str = Form("100"),
68
  quadrant_warmup_time: str = Form("0.5"),
69
  quadrant_max_bits: str = Form("8"),
@@ -92,10 +95,15 @@ async def compress(
92
  "softness": _f(decay_softness, 0.5),
93
  "progress": _f(decay_progress, 0.5),
94
  }
 
 
 
 
 
95
  kwargs = dict(
96
  stroke_count=_i(stroke_count, -1),
97
  size_range=(_f(size_range_start, 0.3), _f(size_range_end, 0.01)),
98
- mult_list=PBC.generate_multlist(_i(mult_bit_count, 2), _i(mult_min, -10), _i(mult_max, 20), multlist_mode),
99
  start_mode=start_mode,
100
  decay_params=decay,
101
  strokes_per_quadrant=_i(strokes_per_quadrant, 100),
@@ -141,7 +149,7 @@ async def compress(
141
  "mse": round(mse, 1),
142
  "psnr": round(psnr, 2),
143
  "time_seconds": round(elapsed, 2),
144
- "params": {"mode": mode, **{k: (list(v) if isinstance(v, tuple) else v) for k, v in kwargs.items() if k not in ("mult_list", "downsample_alg", "decay_params")}},
145
  })
146
 
147
 
 
1
+ import ast
2
  import base64
3
  import io
4
  import math
 
44
  return int(_f(v, d))
45
 
46
 
47
+ @app.get("/api/multlist")
48
+ def multlist(bit_count: int = 2, min: int = -10, max: int = 20, mode: str = "Stable_Uniform"):
49
+ return {"list": list(PBC.generate_multlist(bit_count, min, max, mode))}
50
+
51
+
52
  @app.post("/api/compress")
53
  async def compress(
54
  image: UploadFile = File(...),
 
66
  decay_cutoff: str = Form(""),
67
  decay_softness: str = Form(""),
68
  decay_progress: str = Form(""),
69
+ mult_list: str = Form(""),
 
 
 
70
  strokes_per_quadrant: str = Form("100"),
71
  quadrant_warmup_time: str = Form("0.5"),
72
  quadrant_max_bits: str = Form("8"),
 
95
  "softness": _f(decay_softness, 0.5),
96
  "progress": _f(decay_progress, 0.5),
97
  }
98
+ try:
99
+ mlist = [int(x) for x in ast.literal_eval(mult_list)]
100
+ assert mlist
101
+ except (ValueError, SyntaxError, AssertionError, TypeError):
102
+ mlist = [-10, 0, 5, 20]
103
  kwargs = dict(
104
  stroke_count=_i(stroke_count, -1),
105
  size_range=(_f(size_range_start, 0.3), _f(size_range_end, 0.01)),
106
+ mult_list=mlist,
107
  start_mode=start_mode,
108
  decay_params=decay,
109
  strokes_per_quadrant=_i(strokes_per_quadrant, 100),
 
149
  "mse": round(mse, 1),
150
  "psnr": round(psnr, 2),
151
  "time_seconds": round(elapsed, 2),
152
+ "params": {"mode": mode, **{k: (list(v) if isinstance(v, tuple) else v) for k, v in kwargs.items() if k not in ("downsample_alg", "decay_params")}},
153
  })
154
 
155
 
static/app.js CHANGED
@@ -18,10 +18,12 @@ const SAMPLES = [
18
  const CANVAS = 256; // internal canvas resolution
19
  const SCALE = CANVAS / 512; // spec defined for a 512 canvas
20
  const RADIUS = 100 * SCALE; // 100px brush radius (clicks only)
21
- const START_SIZE = 40 * SCALE; // 40x40 strokes to start
 
 
 
22
  const STROKES_PER_FRAME = 18;
23
  const CYCLE_MS = 5000;
24
- const CLICK_SHRINK_S = 2; // click strokes 40→1 in 2s
25
  const N = 5; // pairs in the ring (3 visible)
26
 
27
  const SLOTS = [
@@ -33,9 +35,11 @@ const SLOTS = [
33
  ];
34
 
35
  let pairs = [], offset = 0, activePair = null, activeStart = 0, pressStart = 0, srcCursor = 0;
36
- let mouse = { down: false, x: 0, y: 0, target: null };
37
 
38
- const sizeAt = (t, T) => t >= T ? 1 : Math.max(1, Math.round(START_SIZE * Math.exp(-(Math.log(START_SIZE) / T) * t)));
 
 
39
 
40
  function makePair() {
41
  const el = document.createElement("div");
@@ -136,16 +140,16 @@ function paintLoop() {
136
  if (p && p.srcData) {
137
  const clicking = mouse.down && mouse.target === p;
138
  if (clicking) {
139
- // Each press restarts the 40→1 (2s) schedule, painted within a circle at the cursor.
140
- const size = sizeAt((performance.now() - pressStart) / 1000, CLICK_SHRINK_S);
141
  for (let i = 0; i < STROKES_PER_FRAME * 2; i++) {
142
  const a = Math.random() * Math.PI * 2;
143
  const d = Math.random() * Math.max(0, RADIUS - size);
144
  fillStroke(p, mouse.x + Math.cos(a) * d - size / 2, mouse.y + Math.sin(a) * d - size / 2, size);
145
  }
146
  } else {
147
- // Default: global random placement across the whole canvas, 401 over the full pair lifetime.
148
- const size = sizeAt((performance.now() - activeStart) / 1000, CYCLE_MS / 1000);
149
  for (let i = 0; i < STROKES_PER_FRAME; i++) {
150
  fillStroke(p, Math.random() * (CANVAS - size), Math.random() * (CANVAS - size), size);
151
  }
@@ -172,6 +176,7 @@ function initRoster() {
172
  const p = pairs.find(x => x.canvas === cv);
173
  if (p !== activePair) return;
174
  mouse.down = true; mouse.target = p; pressStart = performance.now();
 
175
  moveMouse(e, cv);
176
  });
177
  roster.addEventListener("mousemove", e => {
@@ -229,10 +234,11 @@ const PARAMS = [
229
  { id: "decay_softness", label: "Decay softness", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
230
  { id: "decay_progress", label: "Decay progress", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
231
 
232
- { id: "multlist_mode", label: "Multiplier list gen", group: "Multiplier list", modes: ["Manual"], type: "select", options: ["Stable_Uniform", "Uniform", "Random"], value: "Stable_Uniform" },
233
  { id: "mult_bit_count", label: "Multiplier bit count", group: "Multiplier list", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
234
  { id: "mult_min", label: "Multiplier minimum", group: "Multiplier list", modes: ["Manual"], type: "slider", min: -255, max: 255, step: 1, value: -10 },
235
  { id: "mult_max", label: "Multiplier maximum", group: "Multiplier list", modes: ["Manual"], type: "slider", min: -255, max: 255, step: 1, value: 20 },
 
236
 
237
  { id: "strokes_per_quadrant", label: "Strokes / quadrant", group: "Quadrants", modes: ["Manual"], type: "slider", min: 10, max: 1000, step: 10, value: 100 },
238
  { id: "quadrant_warmup_time", label: "Quadrant warmup", group: "Quadrants", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
@@ -276,13 +282,22 @@ function renderParams() {
276
  paramsBody.querySelectorAll("[data-pid]").forEach(el => {
277
  el.addEventListener("input", () => {
278
  const id = el.dataset.pid;
279
- const v = el.type === "checkbox" ? el.checked : el.value;
280
- paramState[id] = v;
281
- if (el.type === "range" || el.type === "number") {
282
  paramsBody.querySelectorAll(`[data-pid="${id}"]`).forEach(o => { if (o !== el) o.value = el.value; });
283
- }
284
  });
285
  });
 
 
 
 
 
 
 
 
 
 
 
286
  }
287
 
288
  function control(p) {
@@ -291,6 +306,12 @@ function control(p) {
291
  return `<div class="param"><label>${p.label}</label><select data-pid="${p.id}">${p.options.map(o => `<option ${o == v ? "selected" : ""}>${o}</option>`).join("")}</select></div>`;
292
  if (p.type === "check")
293
  return `<div class="param"><label>${p.label}</label><label class="check-row"><input type="checkbox" data-pid="${p.id}" ${v ? "checked" : ""}> enabled</label></div>`;
 
 
 
 
 
 
294
  return `<div class="param ${p.full ? "full" : ""}">
295
  <label>${p.label}${p.hint ? ` <i>${p.hint}</i>` : ""}</label>
296
  <div class="slider-row">
@@ -458,24 +479,59 @@ function escapeParams(p) {
458
  ============================================================ */
459
  const viewer = document.getElementById("viewer");
460
  const viewerStage = document.getElementById("viewer-stage");
 
 
 
 
 
461
  document.getElementById("viewer-close").addEventListener("click", closeViewer);
462
  viewer.addEventListener("click", e => { if (e.target === viewer) closeViewer(); });
463
- window.addEventListener("keydown", e => { if (e.key === "Escape") closeViewer(); });
 
 
 
 
 
 
 
464
 
465
- function openViewer(d) {
 
 
 
 
 
 
 
 
 
466
  const horizontal = d.width >= d.height;
467
  if (d.compare) {
468
  viewerStage.className = `viewer-stage pair ${horizontal ? "vert" : ""}`;
469
  viewerStage.innerHTML = `<img src="${d.original_image}"><img src="${d.reconstructed_image}">`;
470
- document.getElementById("viewer-caption").textContent = "original / compressed";
471
  } else {
472
  viewerStage.className = "viewer-stage";
473
  viewerStage.innerHTML = `<img src="${d.reconstructed_image}">`;
474
- document.getElementById("viewer-caption").textContent = `compressed · ${d.compression_rate}× · ${d.psnr} dB`;
475
  }
476
- viewer.hidden = false;
 
 
 
 
477
  }
478
- function closeViewer() { viewer.hidden = true; }
 
 
 
 
 
 
 
 
 
 
479
 
480
  /* ============================================================
481
  DOWNLOAD / SHARE
 
18
  const CANVAS = 256; // internal canvas resolution
19
  const SCALE = CANVAS / 512; // spec defined for a 512 canvas
20
  const RADIUS = 100 * SCALE; // 100px brush radius (clicks only)
21
+ const GLOBAL_START = 500 * SCALE; // global auto strokes: 500x500
22
+ const GLOBAL_END = 10 * SCALE; // … down to 10x10
23
+ const GLOBAL_T = 5; // … over 5 seconds
24
+ const CLICK_T = 2; // click strokes shrink to 1x1 over 2s
25
  const STROKES_PER_FRAME = 18;
26
  const CYCLE_MS = 5000;
 
27
  const N = 5; // pairs in the ring (3 visible)
28
 
29
  const SLOTS = [
 
35
  ];
36
 
37
  let pairs = [], offset = 0, activePair = null, activeStart = 0, pressStart = 0, srcCursor = 0;
38
+ let mouse = { down: false, x: 0, y: 0, target: null, startSize: GLOBAL_START };
39
 
40
+ // Exponential (fast-then-slow) interpolation from s0 to s1 over T seconds.
41
+ const expSize = (s0, s1, t, T) => Math.max(1, Math.round(s0 * Math.pow(s1 / s0, Math.min(t, T) / T)));
42
+ const globalSizeNow = () => expSize(GLOBAL_START, GLOBAL_END, (performance.now() - activeStart) / 1000, GLOBAL_T);
43
 
44
  function makePair() {
45
  const el = document.createElement("div");
 
140
  if (p && p.srcData) {
141
  const clicking = mouse.down && mouse.target === p;
142
  if (clicking) {
143
+ // Each press shrinks from the current global size down to 1x1 over 2s, within a circle at the cursor.
144
+ const size = expSize(mouse.startSize, 1, (performance.now() - pressStart) / 1000, CLICK_T);
145
  for (let i = 0; i < STROKES_PER_FRAME * 2; i++) {
146
  const a = Math.random() * Math.PI * 2;
147
  const d = Math.random() * Math.max(0, RADIUS - size);
148
  fillStroke(p, mouse.x + Math.cos(a) * d - size / 2, mouse.y + Math.sin(a) * d - size / 2, size);
149
  }
150
  } else {
151
+ // Default: global random placement across the whole canvas, 50010 over the full lifetime.
152
+ const size = globalSizeNow();
153
  for (let i = 0; i < STROKES_PER_FRAME; i++) {
154
  fillStroke(p, Math.random() * (CANVAS - size), Math.random() * (CANVAS - size), size);
155
  }
 
176
  const p = pairs.find(x => x.canvas === cv);
177
  if (p !== activePair) return;
178
  mouse.down = true; mouse.target = p; pressStart = performance.now();
179
+ mouse.startSize = globalSizeNow();
180
  moveMouse(e, cv);
181
  });
182
  roster.addEventListener("mousemove", e => {
 
234
  { id: "decay_softness", label: "Decay softness", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
235
  { id: "decay_progress", label: "Decay progress", group: "Decay", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
236
 
237
+ { id: "multlist_mode", label: "Generator mode", group: "Multiplier list", modes: ["Manual"], type: "select", options: ["Stable_Uniform", "Uniform", "Random"], value: "Stable_Uniform" },
238
  { id: "mult_bit_count", label: "Multiplier bit count", group: "Multiplier list", modes: ["Manual"], type: "slider", min: 1, max: 9, step: 1, value: 2 },
239
  { id: "mult_min", label: "Multiplier minimum", group: "Multiplier list", modes: ["Manual"], type: "slider", min: -255, max: 255, step: 1, value: -10 },
240
  { id: "mult_max", label: "Multiplier maximum", group: "Multiplier list", modes: ["Manual"], type: "slider", min: -255, max: 255, step: 1, value: 20 },
241
+ { id: "mult_list", label: "Multiplier list", hint: "editable", group: "Multiplier list", modes: ["Manual"], type: "text", value: "[-10, 0, 5, 20]", gen: true },
242
 
243
  { id: "strokes_per_quadrant", label: "Strokes / quadrant", group: "Quadrants", modes: ["Manual"], type: "slider", min: 10, max: 1000, step: 10, value: 100 },
244
  { id: "quadrant_warmup_time", label: "Quadrant warmup", group: "Quadrants", modes: ["Manual"], type: "slider", min: 0, max: 1, step: 0.01, value: 0.5 },
 
282
  paramsBody.querySelectorAll("[data-pid]").forEach(el => {
283
  el.addEventListener("input", () => {
284
  const id = el.dataset.pid;
285
+ paramState[id] = el.type === "checkbox" ? el.checked : el.value;
286
+ if (el.type === "range" || el.type === "number")
 
287
  paramsBody.querySelectorAll(`[data-pid="${id}"]`).forEach(o => { if (o !== el) o.value = el.value; });
 
288
  });
289
  });
290
+
291
+ const gen = paramsBody.querySelector("#gen-mult_list");
292
+ if (gen) gen.addEventListener("click", async () => {
293
+ const q = new URLSearchParams({ bit_count: paramState.mult_bit_count, min: paramState.mult_min, max: paramState.mult_max, mode: paramState.multlist_mode });
294
+ try {
295
+ const { list } = await (await fetch("/api/multlist?" + q)).json();
296
+ const s = "[" + list.join(", ") + "]";
297
+ paramState.mult_list = s;
298
+ paramsBody.querySelector('[data-pid="mult_list"]').value = s;
299
+ } catch { toast("Could not generate list"); }
300
+ });
301
  }
302
 
303
  function control(p) {
 
306
  return `<div class="param"><label>${p.label}</label><select data-pid="${p.id}">${p.options.map(o => `<option ${o == v ? "selected" : ""}>${o}</option>`).join("")}</select></div>`;
307
  if (p.type === "check")
308
  return `<div class="param"><label>${p.label}</label><label class="check-row"><input type="checkbox" data-pid="${p.id}" ${v ? "checked" : ""}> enabled</label></div>`;
309
+ if (p.type === "text")
310
+ return `<div class="param full"><label>${p.label}${p.hint ? ` <i>${p.hint}</i>` : ""}</label>
311
+ <div class="slider-row">
312
+ <input type="text" class="num" data-pid="${p.id}" value="${v}" style="flex:1;text-align:left">
313
+ ${p.gen ? `<button type="button" class="gen-btn" id="gen-${p.id}">Generate</button>` : ""}
314
+ </div></div>`;
315
  return `<div class="param ${p.full ? "full" : ""}">
316
  <label>${p.label}${p.hint ? ` <i>${p.hint}</i>` : ""}</label>
317
  <div class="slider-row">
 
479
  ============================================================ */
480
  const viewer = document.getElementById("viewer");
481
  const viewerStage = document.getElementById("viewer-stage");
482
+ const holdBtn = document.getElementById("hold-original");
483
+ const prevBtn = document.getElementById("viewer-prev");
484
+ const nextBtn = document.getElementById("viewer-next");
485
+ let viewIndex = -1;
486
+
487
  document.getElementById("viewer-close").addEventListener("click", closeViewer);
488
  viewer.addEventListener("click", e => { if (e.target === viewer) closeViewer(); });
489
+ prevBtn.addEventListener("click", () => step(-1));
490
+ nextBtn.addEventListener("click", () => step(1));
491
+ window.addEventListener("keydown", e => {
492
+ if (viewer.hidden) return;
493
+ if (e.key === "Escape") closeViewer();
494
+ else if (e.key === "ArrowLeft") step(-1);
495
+ else if (e.key === "ArrowRight") step(1);
496
+ });
497
 
498
+ function openViewer(d) { viewIndex = registry.indexOf(d); renderViewer(); viewer.hidden = false; }
499
+ function closeViewer() { viewer.hidden = true; }
500
+ function step(dir) {
501
+ const i = viewIndex + dir;
502
+ if (i >= 0 && i < registry.length) { viewIndex = i; renderViewer(); }
503
+ }
504
+
505
+ function renderViewer() {
506
+ const d = registry[viewIndex];
507
+ if (!d) return;
508
  const horizontal = d.width >= d.height;
509
  if (d.compare) {
510
  viewerStage.className = `viewer-stage pair ${horizontal ? "vert" : ""}`;
511
  viewerStage.innerHTML = `<img src="${d.original_image}"><img src="${d.reconstructed_image}">`;
512
+ holdBtn.hidden = true;
513
  } else {
514
  viewerStage.className = "viewer-stage";
515
  viewerStage.innerHTML = `<img src="${d.reconstructed_image}">`;
516
+ holdBtn.hidden = false;
517
  }
518
+ holdBtn.classList.remove("holding");
519
+ document.getElementById("viewer-caption").textContent =
520
+ `Compressed ${d.compression_rate}× in ${d.time_seconds}s | MSE: ${d.mse} | PSNR: ${d.psnr} dB`;
521
+ prevBtn.disabled = viewIndex <= 0;
522
+ nextBtn.disabled = viewIndex >= registry.length - 1;
523
  }
524
+
525
+ function holdOriginal(on) {
526
+ const d = registry[viewIndex];
527
+ const img = viewerStage.querySelector("img");
528
+ if (!d || !img) return;
529
+ img.src = on ? d.original_image : d.reconstructed_image;
530
+ holdBtn.classList.toggle("holding", on);
531
+ }
532
+ holdBtn.addEventListener("pointerdown", e => { e.preventDefault(); holdOriginal(true); });
533
+ holdBtn.addEventListener("pointerup", () => holdOriginal(false));
534
+ holdBtn.addEventListener("pointerleave", () => holdBtn.classList.contains("holding") && holdOriginal(false));
535
 
536
  /* ============================================================
537
  DOWNLOAD / SHARE
static/index.html CHANGED
@@ -115,8 +115,13 @@
115
  <!-- FULLSCREEN VIEWER -->
116
  <div id="viewer" class="viewer" hidden>
117
  <button id="viewer-close" class="viewer-close" aria-label="Close">×</button>
 
 
118
  <div id="viewer-stage" class="viewer-stage"></div>
119
  <p id="viewer-caption" class="viewer-caption"></p>
 
 
 
120
  </div>
121
 
122
  <div id="toast" class="toast"></div>
 
115
  <!-- FULLSCREEN VIEWER -->
116
  <div id="viewer" class="viewer" hidden>
117
  <button id="viewer-close" class="viewer-close" aria-label="Close">×</button>
118
+ <button id="viewer-prev" class="viewer-nav prev" aria-label="Previous">‹</button>
119
+ <button id="viewer-next" class="viewer-nav next" aria-label="Next">›</button>
120
  <div id="viewer-stage" class="viewer-stage"></div>
121
  <p id="viewer-caption" class="viewer-caption"></p>
122
+ <div class="viewer-controls">
123
+ <button id="hold-original" class="hold-btn" hidden>Hold for original</button>
124
+ </div>
125
  </div>
126
 
127
  <div id="toast" class="toast"></div>
static/styles.css CHANGED
@@ -155,6 +155,12 @@ em{font-style:normal;color:var(--red);}
155
  .param select:focus,.param .num:focus{border-color:var(--red-dim);}
156
  .slider-row{display:flex;align-items:center;gap:.7rem;}
157
  .slider-row .num{width:96px;flex:none;text-align:right;}
 
 
 
 
 
 
158
  input[type=range]{
159
  -webkit-appearance:none;appearance:none;flex:1;height:4px;border-radius:3px;
160
  background:var(--border2);outline:none;cursor:pointer;
@@ -226,17 +232,34 @@ input[type=range]::-moz-range-thumb{
226
  position:fixed;inset:0;z-index:60;background:rgba(8,8,10,.94);backdrop-filter:blur(6px);
227
  display:flex;flex-direction:column;align-items:center;justify-content:center;padding:3rem;gap:1rem;
228
  }
229
- .viewer-stage{max-width:92vw;max-height:82vh;display:flex;gap:14px;align-items:center;justify-content:center;}
230
  .viewer-stage.vert{flex-direction:column;}
231
- .viewer-stage img{max-width:100%;max-height:82vh;object-fit:contain;border-radius:8px;border:1px solid var(--border2);}
232
  .viewer-stage.pair img{max-width:46vw;}
233
- .viewer-stage.vert.pair img{max-width:80vw;max-height:39vh;}
234
  .viewer-close{
235
  position:absolute;top:22px;right:26px;background:var(--surface2);border:1px solid var(--border2);
236
  color:var(--text);width:40px;height:40px;border-radius:50%;font-size:22px;cursor:pointer;transition:.15s;
237
  }
238
  .viewer-close:hover{color:var(--red);border-color:var(--red-dim);}
239
- .viewer-caption{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--dim);}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
  /* ===== TOAST ===== */
242
  .toast{
 
155
  .param select:focus,.param .num:focus{border-color:var(--red-dim);}
156
  .slider-row{display:flex;align-items:center;gap:.7rem;}
157
  .slider-row .num{width:96px;flex:none;text-align:right;}
158
+ .gen-btn{
159
+ background:var(--surface2);border:1px solid var(--border2);color:var(--muted);
160
+ font-family:inherit;font-size:12.5px;padding:9px 14px;border-radius:7px;cursor:pointer;
161
+ flex:none;white-space:nowrap;transition:.15s;
162
+ }
163
+ .gen-btn:hover{color:var(--text);border-color:var(--red-dim);}
164
  input[type=range]{
165
  -webkit-appearance:none;appearance:none;flex:1;height:4px;border-radius:3px;
166
  background:var(--border2);outline:none;cursor:pointer;
 
232
  position:fixed;inset:0;z-index:60;background:rgba(8,8,10,.94);backdrop-filter:blur(6px);
233
  display:flex;flex-direction:column;align-items:center;justify-content:center;padding:3rem;gap:1rem;
234
  }
235
+ .viewer-stage{max-width:92vw;max-height:78vh;display:flex;gap:14px;align-items:center;justify-content:center;}
236
  .viewer-stage.vert{flex-direction:column;}
237
+ .viewer-stage img{max-width:100%;max-height:78vh;object-fit:contain;border-radius:8px;border:1px solid var(--border2);}
238
  .viewer-stage.pair img{max-width:46vw;}
239
+ .viewer-stage.vert.pair img{max-width:80vw;max-height:38vh;}
240
  .viewer-close{
241
  position:absolute;top:22px;right:26px;background:var(--surface2);border:1px solid var(--border2);
242
  color:var(--text);width:40px;height:40px;border-radius:50%;font-size:22px;cursor:pointer;transition:.15s;
243
  }
244
  .viewer-close:hover{color:var(--red);border-color:var(--red-dim);}
245
+ .viewer-nav{
246
+ position:absolute;top:50%;transform:translateY(-50%);background:var(--surface2);
247
+ border:1px solid var(--border2);color:var(--text);width:46px;height:46px;border-radius:50%;
248
+ font-size:24px;line-height:1;cursor:pointer;transition:.15s;
249
+ }
250
+ .viewer-nav:hover{color:var(--red);border-color:var(--red-dim);}
251
+ .viewer-nav.prev{left:26px;}
252
+ .viewer-nav.next{right:26px;}
253
+ .viewer-nav[disabled]{opacity:.25;cursor:default;}
254
+ .viewer-caption{font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--muted);text-align:center;}
255
+ .viewer-controls{display:flex;gap:.7rem;}
256
+ .hold-btn{
257
+ background:var(--surface2);border:1px solid var(--border2);color:var(--muted);
258
+ font-family:inherit;font-size:13px;padding:9px 18px;border-radius:8px;cursor:pointer;
259
+ user-select:none;transition:.15s;
260
+ }
261
+ .hold-btn:hover{color:var(--text);border-color:var(--red-dim);}
262
+ .hold-btn.holding{background:var(--red);color:#fff;border-color:var(--red);}
263
 
264
  /* ===== TOAST ===== */
265
  .toast{