@kitlangton/knobs 0.4.0-next.15 → 0.4.0-next.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +46 -14
  2. package/dist/dev/{chunk-v2g91mrm.js → chunk-2g0znytq.js} +1 -0
  3. package/dist/dev/{chunk-bejz4qcg.js → chunk-3vag4n3q.js} +47 -10
  4. package/dist/dev/{chunk-49hkdggk.js → chunk-5cv3g7ca.js} +395 -104
  5. package/dist/dev/{chunk-902v1jgd.js → chunk-xzm0zawh.js} +6 -6
  6. package/dist/dev/index.js +9 -8
  7. package/dist/index.js +7 -5
  8. package/dist/panel.js +549 -121
  9. package/dist/review-react.js +1341 -880
  10. package/dist/review-solid.js +1341 -880
  11. package/dist/review.js +1711 -1248
  12. package/dist/themes.js +8 -0
  13. package/dist/types/core/schema.d.ts +11 -2
  14. package/dist/types/core/store.d.ts +29 -1
  15. package/dist/types/index.d.ts +1 -1
  16. package/dist/types/panel/active.d.ts +53 -0
  17. package/dist/types/panel/description-ids.d.ts +6 -0
  18. package/dist/types/panel/folds.d.ts +7 -4
  19. package/dist/types/panel/gpu/color.d.ts +2 -0
  20. package/dist/types/panel/gpu/renderer.d.ts +8 -2
  21. package/dist/types/panel/index.d.ts +1 -0
  22. package/dist/types/panel/page-hints.d.ts +3 -2
  23. package/dist/types/panel/page.d.ts +2 -15
  24. package/dist/types/panel/position.d.ts +23 -0
  25. package/dist/types/panel/summary.d.ts +22 -0
  26. package/dist/types/tokens/schema.d.ts +9 -0
  27. package/dist/types/ui/compare-css.d.ts +1 -1
  28. package/dist/types/ui/compare-frames.d.ts +64 -2
  29. package/dist/types/ui/compare-model.d.ts +23 -0
  30. package/dist/types/ui/compare.d.ts +6 -4
  31. package/dist/types/ui/player-css.d.ts +1 -1
  32. package/dist/types/ui/player.d.ts +6 -0
  33. package/dist/types/ui/react.d.ts +1 -1
  34. package/dist/types/ui/solid.d.ts +1 -1
  35. package/dist/types/ui/tooltip-model.d.ts +35 -0
  36. package/dist/types/ui/tooltip.d.ts +1 -1
  37. package/dist/types/ui/walkthrough-model.d.ts +4 -2
  38. package/dist/ui-react.js +2015 -1507
  39. package/dist/ui-solid.js +2014 -1506
  40. package/dist/ui.js +2013 -1505
  41. package/package.json +1 -1
package/dist/ui.js CHANGED
@@ -647,7 +647,6 @@ var compareCss = `
647
647
  :where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){background:color-mix(in srgb,var(--_ku-on-media) 16%,transparent);color:var(--_ku-on-media);transition-duration:calc(var(--_k-hover-in) * 1ms)}
648
648
  .knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--_k-fg-3);pointer-events:none}
649
649
  .knobs-cmp-note:empty{display:none}
650
- .knobs-cmp-strip-host{flex:none;height:60px;min-height:0}
651
650
  .knobs-cmp-timeline-host{flex:none;min-height:0}
652
651
  /* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */
653
652
  .knobs-ui.knobs-cmp-strip{--_ku-cmp-diff:var(--k-compare-diff,var(--_k-ok));position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;
@@ -667,6 +666,9 @@ var playerCss = `
667
666
  .knobs-player-media{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:contain;
668
667
  opacity:0;filter:blur(4px);transition:opacity calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default),filter calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default)}
669
668
  .knobs-player[data-ready] .knobs-player-media{opacity:1;filter:none}
669
+ /* Mid-scrub, the cached frame under the hand, over the video until it lands. */
670
+ .knobs-player-preview{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}
671
+ .knobs-player-preview[hidden]{display:none}
670
672
  .knobs-player-line{flex:none;min-height:0}
671
673
  /* The timeline's own stylesheet is look.ts's; what the player's row and every timeline share
672
674
  about the hand is set here, after it. No hand cursor anywhere (the arrow, or a resize where it
@@ -1448,6 +1450,14 @@ var init_schema = __esm(() => {
1448
1450
  doc: "Panel and card radius, concentric with controls.",
1449
1451
  deprecated: ["--k-panel-radius", "--ku-panel-radius"]
1450
1452
  },
1453
+ {
1454
+ name: "radius-frame",
1455
+ tier: "semantic",
1456
+ type: "length",
1457
+ group: "Shape",
1458
+ source: css("calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))"),
1459
+ doc: "A tuning panel's outer corners: its cards' radius plus its padding and border, so the cards, the preset strip and the focus lens inside run concentric. Squares when --k-radius is 0."
1460
+ },
1451
1461
  {
1452
1462
  name: "round",
1453
1463
  tier: "semantic",
@@ -2566,7 +2576,16 @@ var init_glide = __esm(() => {
2566
2576
  "border-subtle",
2567
2577
  "bg-page"
2568
2578
  ];
2569
- LENGTH_TOKENS = ["pad", "inset", "row", "gap", "radius", "radius-panel", "font-size"];
2579
+ LENGTH_TOKENS = [
2580
+ "pad",
2581
+ "inset",
2582
+ "row",
2583
+ "gap",
2584
+ "radius",
2585
+ "radius-panel",
2586
+ "radius-frame",
2587
+ "font-size"
2588
+ ];
2570
2589
  NUMBER_TOKENS = ["weight-label", "blur", "glow", "glass", "glass-tone"];
2571
2590
  ANIMATED = [...COLOR_TOKENS, ...LENGTH_TOKENS, ...NUMBER_TOKENS, "width"];
2572
2591
  TOKEN_TRANSITIONS = ANIMATED.map((t) => `--kt-${t} var(--k-token-t)`).join(",");
@@ -3565,12 +3584,24 @@ function createRegistry(doc) {
3565
3584
  return;
3566
3585
  if (e.metaKey || e.ctrlKey || e.altKey || e.key !== PAGE_HINT_KEY)
3567
3586
  return;
3568
- if (isTyping(e.target) || !wide())
3587
+ if (isTyping(e.target) || !wide(e.target))
3569
3588
  return;
3570
3589
  if (registry.start())
3571
3590
  e.preventDefault();
3572
3591
  };
3573
- const wide = () => listening && (sources.length > 1 || [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel")));
3592
+ const hostHints = () => [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel"));
3593
+ const wide = (target) => {
3594
+ if (!listening)
3595
+ return false;
3596
+ if (sources.some((s) => s.listen && s.rank >= 2) || hostHints())
3597
+ return true;
3598
+ if (sources.length < 2)
3599
+ return false;
3600
+ if (target === undefined)
3601
+ return true;
3602
+ const piece = target?.closest?.(".knobs-ui,.knobs-pane-body");
3603
+ return Boolean(piece?.classList.contains("knobs-ui"));
3604
+ };
3574
3605
  const relisten = () => {
3575
3606
  const next = sources.some((s) => s.listen);
3576
3607
  if (next === listening)
@@ -6716,6 +6747,31 @@ function seeked(s, current, fps) {
6716
6747
  return { state: { seeking: true, pending: null }, apply: s.pending, settled: false };
6717
6748
  return { state: SEEK_IDLE, apply: null, settled: true };
6718
6749
  }
6750
+ function releaseSeek(current, t, fps, duration) {
6751
+ const at = seekTime(t, fps, duration);
6752
+ return sameFrame(current, at, fps) ? null : at;
6753
+ }
6754
+ function stallGuard(timers, onStall, ms = SEEK_STALL_MS) {
6755
+ let id = 0;
6756
+ const clear = () => {
6757
+ if (id)
6758
+ timers.clearTimeout(id);
6759
+ id = 0;
6760
+ };
6761
+ return {
6762
+ arm() {
6763
+ clear();
6764
+ id = timers.setTimeout(() => {
6765
+ id = 0;
6766
+ onStall();
6767
+ }, ms);
6768
+ },
6769
+ clear,
6770
+ get armed() {
6771
+ return id !== 0;
6772
+ }
6773
+ };
6774
+ }
6719
6775
  function letterbox(media, box) {
6720
6776
  if (media.w <= 0 || media.h <= 0 || box.w <= 0 || box.h <= 0)
6721
6777
  return box;
@@ -6836,7 +6892,7 @@ function stripPeaks(diffs, o = {}) {
6836
6892
  });
6837
6893
  return out;
6838
6894
  }
6839
- var ALL_MODES, MODE_KEYS, modeKey = (mode) => MODE_KEYS[mode].toUpperCase(), VIDEO, frameOf = (t, fps) => Math.max(0, Math.floor(t * fps + 0.0001)), lastFrame = (duration, fps) => Math.max(0, Math.ceil(duration * fps - 0.0001) - 1), seekTime = (t, fps, duration) => frameMidpoint(frameOf(t, fps), fps, duration), sameFrame = (a, b, fps) => frameOf(a, fps) === frameOf(b, fps), SEEK_IDLE, FUSE, fuseCorners = (r, k) => {
6895
+ var ALL_MODES, MODE_KEYS, modeKey = (mode) => MODE_KEYS[mode].toUpperCase(), VIDEO, frameOf = (t, fps) => Math.max(0, Math.floor(t * fps + 0.0001)), lastFrame = (duration, fps) => Math.max(0, Math.ceil(duration * fps - 0.0001) - 1), seekTime = (t, fps, duration) => frameMidpoint(frameOf(t, fps), fps, duration), sameFrame = (a, b, fps) => frameOf(a, fps) === frameOf(b, fps), SEEK_IDLE, SEEK_STALL_MS = 2500, FUSE, fuseCorners = (r, k) => {
6840
6896
  const inner = r * Math.min(1, Math.max(0, k));
6841
6897
  return { before: { left: r, right: inner }, after: { left: inner, right: r } };
6842
6898
  }, labelFold = (fold, mark, blur = 3) => {
@@ -7083,6 +7139,25 @@ var init_compare_mp4 = __esm(() => {
7083
7139
  });
7084
7140
 
7085
7141
  // src/ui/compare-frames.ts
7142
+ function scrubCaching(option, win) {
7143
+ if (option === false)
7144
+ return false;
7145
+ if (option === true)
7146
+ return true;
7147
+ const net = win?.navigator?.connection;
7148
+ if (net?.saveData)
7149
+ return false;
7150
+ return !win?.matchMedia?.("(prefers-reduced-data: reduce)").matches;
7151
+ }
7152
+ function proxyStride(bytes, count, total, budget) {
7153
+ if (count <= 0 || total <= 0)
7154
+ return 1;
7155
+ const each = bytes / count;
7156
+ let stride = 1;
7157
+ while (total / stride * each > budget && stride < total)
7158
+ stride *= 2;
7159
+ return stride;
7160
+ }
7086
7161
  function gridPoints(total, count = GRID) {
7087
7162
  if (total <= 0)
7088
7163
  return [];
@@ -7113,6 +7188,24 @@ function nearestKey(keys, frame, within = Infinity) {
7113
7188
  }
7114
7189
  return best !== null && bestD <= within ? best : null;
7115
7190
  }
7191
+ function claim(core) {
7192
+ if (!round2) {
7193
+ const r = new Set;
7194
+ round2 = r;
7195
+ queueMicrotask(() => {
7196
+ for (const c of actives)
7197
+ if (!r.has(c)) {
7198
+ c.active = false;
7199
+ c.dropFull();
7200
+ }
7201
+ actives = r;
7202
+ round2 = null;
7203
+ });
7204
+ }
7205
+ round2.add(core);
7206
+ actives.add(core);
7207
+ core.active = true;
7208
+ }
7116
7209
  function pump(win) {
7117
7210
  if (running)
7118
7211
  return;
@@ -7120,12 +7213,14 @@ function pump(win) {
7120
7213
  (async () => {
7121
7214
  try {
7122
7215
  for (;; ) {
7123
- const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active));
7216
+ const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active) || a.ran - b.ran);
7124
7217
  let unit = null;
7125
7218
  for (const c of order) {
7126
7219
  unit = c.work();
7127
- if (unit)
7220
+ if (unit) {
7221
+ c.ran = ++turns;
7128
7222
  break;
7223
+ }
7129
7224
  }
7130
7225
  if (!unit)
7131
7226
  break;
@@ -7155,12 +7250,99 @@ async function toBitmap(doc, source, w, h, cap) {
7155
7250
  return createImageBitmap(c);
7156
7251
  }
7157
7252
  }
7253
+ function holdEncoders() {
7254
+ encoderUsers++;
7255
+ }
7256
+ function dropEncoders() {
7257
+ encoderUsers = Math.max(0, encoderUsers - 1);
7258
+ if (encoderUsers > 0)
7259
+ return;
7260
+ endEncoders("released");
7261
+ encoders = undefined;
7262
+ }
7263
+ function encodersOf(host) {
7264
+ if (encoders !== undefined)
7265
+ return encoders;
7266
+ encoders = null;
7267
+ const W = host.Worker;
7268
+ if (!W || !host.OffscreenCanvas)
7269
+ return null;
7270
+ const workers = [];
7271
+ let url = null;
7272
+ try {
7273
+ url = host.URL.createObjectURL(new host.Blob([ENCODER], { type: "text/javascript" }));
7274
+ for (let i = 0;i < ENCODERS; i++)
7275
+ workers.push(new W(url));
7276
+ } catch {
7277
+ for (const w of workers)
7278
+ w.terminate();
7279
+ return null;
7280
+ } finally {
7281
+ if (url)
7282
+ host.URL.revokeObjectURL(url);
7283
+ }
7284
+ const pool = { workers, waiting: new Map, next: 0 };
7285
+ for (const w of workers) {
7286
+ w.onmessage = (e) => {
7287
+ const job = pool.waiting.get(e.data.id);
7288
+ pool.waiting.delete(e.data.id);
7289
+ if (e.data.blob)
7290
+ job?.done(e.data.blob);
7291
+ else
7292
+ job?.fail(new Error(e.data.error));
7293
+ };
7294
+ w.onerror = () => {
7295
+ if (encoders !== pool)
7296
+ return;
7297
+ endEncoders("encoder");
7298
+ encoders = null;
7299
+ };
7300
+ }
7301
+ encoders = pool;
7302
+ return pool;
7303
+ }
7304
+ function encodeOn(pool, bitmap, quality) {
7305
+ const id = pool.next++;
7306
+ const worker = pool.workers[id % pool.workers.length];
7307
+ return new Promise((done, fail) => {
7308
+ pool.waiting.set(id, { done, fail });
7309
+ try {
7310
+ worker.postMessage({ id, bitmap, quality }, [bitmap]);
7311
+ } catch (err) {
7312
+ pool.waiting.delete(id);
7313
+ bitmap.close();
7314
+ fail(err);
7315
+ }
7316
+ });
7317
+ }
7318
+ async function toProxy(doc, source, w, h) {
7319
+ const bm = await toBitmap(doc, source, w, h, PROXY.size);
7320
+ const pool = encodersOf(doc.defaultView);
7321
+ if (pool)
7322
+ return encodeOn(pool, bm, PROXY.quality);
7323
+ try {
7324
+ const Off = doc.defaultView?.OffscreenCanvas;
7325
+ if (Off) {
7326
+ const c = new Off(bm.width, bm.height);
7327
+ c.getContext("2d").drawImage(bm, 0, 0);
7328
+ return await c.convertToBlob({ type: "image/jpeg", quality: PROXY.quality });
7329
+ }
7330
+ const c = doc.createElement("canvas");
7331
+ c.width = bm.width;
7332
+ c.height = bm.height;
7333
+ c.getContext("2d").drawImage(bm, 0, 0);
7334
+ return await new Promise((done, fail) => c.toBlob((b) => b ? done(b) : fail(new Error("toBlob")), "image/jpeg", PROXY.quality));
7335
+ } finally {
7336
+ bm.close();
7337
+ }
7338
+ }
7158
7339
  function acquireFrames(doc, src, fps) {
7159
7340
  const key = `${fps}|${src}`;
7160
7341
  let core = cores.get(key);
7161
7342
  if (!core) {
7162
7343
  core = makeCore(doc, key, src, fps);
7163
7344
  cores.set(key, core);
7345
+ holdEncoders();
7164
7346
  }
7165
7347
  core.refs++;
7166
7348
  return core.cache;
@@ -7181,6 +7363,71 @@ function makeCore(doc, key, src, fps) {
7181
7363
  let generation = 0;
7182
7364
  let tick = 0;
7183
7365
  let notifyRaf = 0;
7366
+ const proxies = new Map;
7367
+ let proxyBytes = 0;
7368
+ let stride = 1;
7369
+ const decoded = new Map;
7370
+ const decoding = new Set;
7371
+ let aim = null;
7372
+ let lastAsked = null;
7373
+ const storeProxy = (frame, blob) => {
7374
+ if (disposed || frame % stride || proxies.has(frame))
7375
+ return;
7376
+ proxies.set(frame, blob);
7377
+ proxyBytes += blob.size;
7378
+ const next = proxyStride(proxyBytes, proxies.size, total, PROXY.budget);
7379
+ if (next <= stride)
7380
+ return;
7381
+ stride = next;
7382
+ for (const [f, b] of proxies)
7383
+ if (f % stride) {
7384
+ proxies.delete(f);
7385
+ proxyBytes -= b.size;
7386
+ }
7387
+ };
7388
+ const proxyNear = (frame) => {
7389
+ const at = Math.round(frame / stride) * stride;
7390
+ let best = null;
7391
+ for (const f of [at, at - stride, at + stride])
7392
+ if (proxies.has(f) && (best === null || Math.abs(f - frame) < Math.abs(best - frame)))
7393
+ best = f;
7394
+ return best;
7395
+ };
7396
+ const proxyTodo = () => {
7397
+ const out = [];
7398
+ for (let f = 0;f < total; f += stride)
7399
+ if (!proxies.has(f))
7400
+ out.push(f);
7401
+ return out;
7402
+ };
7403
+ const decodeProxy = (frame) => {
7404
+ if (frame === null || decoded.has(frame) || decoding.has(frame) || disposed)
7405
+ return;
7406
+ const blob = proxies.get(frame);
7407
+ if (!blob)
7408
+ return;
7409
+ decoding.add(frame);
7410
+ createImageBitmap(blob).then((bitmap) => {
7411
+ decoding.delete(frame);
7412
+ if (disposed)
7413
+ return bitmap.close();
7414
+ decoded.set(frame, { bitmap, used: ++tick });
7415
+ if (decoded.size > PROXY.decoded) {
7416
+ const [oldest] = [...decoded].sort((a, b) => a[1].used - b[1].used);
7417
+ if (oldest && oldest[0] !== frame) {
7418
+ oldest[1].bitmap.close();
7419
+ decoded.delete(oldest[0]);
7420
+ }
7421
+ }
7422
+ for (const f of listeners)
7423
+ f();
7424
+ }, () => decoding.delete(frame));
7425
+ };
7426
+ const dropDecoded = () => {
7427
+ for (const d of decoded.values())
7428
+ d.bitmap.close();
7429
+ decoded.clear();
7430
+ };
7184
7431
  const notify = () => {
7185
7432
  if (notifyRaf)
7186
7433
  return;
@@ -7311,18 +7558,50 @@ function makeCore(doc, key, src, fps) {
7311
7558
  return;
7312
7559
  const forFocus = todo[0].kind === "focus";
7313
7560
  const gen = generation;
7314
- const stale = () => forFocus && gen !== generation;
7561
+ await decodeRun(t, data, run, forFocus ? () => gen !== generation : () => false, (vf, f) => {
7562
+ const want = plan.get(f);
7563
+ if (!want)
7564
+ return null;
7565
+ plan.delete(f);
7566
+ return toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind));
7567
+ });
7568
+ };
7569
+ const proxyUnit = async () => {
7570
+ const todo = proxyTodo();
7571
+ if (!todo.length || !track || !buf)
7572
+ return;
7573
+ const runs = decodeRuns(track.samples, new Set(todo), (pts) => frameOf(pts, fps));
7574
+ const at = aim ?? focusAt ?? 0;
7575
+ let run = runs[0];
7576
+ let best = Number.POSITIVE_INFINITY;
7577
+ for (const r of runs) {
7578
+ const d = Math.min(...r.frames.map((f) => Math.abs(f - at)));
7579
+ if (d < best) {
7580
+ best = d;
7581
+ run = r;
7582
+ }
7583
+ }
7584
+ if (!run)
7585
+ return;
7586
+ const gen = generation;
7587
+ let made = 0;
7588
+ await decodeRun(track, buf, run, () => gen !== generation || made >= PROXY.turn, (vf, f) => {
7589
+ if (f % stride || proxies.has(f) || f >= total)
7590
+ return null;
7591
+ made++;
7592
+ return toProxy(doc, vf, vf.displayWidth, vf.displayHeight).then((b) => storeProxy(f, b));
7593
+ });
7594
+ };
7595
+ const decodeRun = async (t, data, run, stale, take) => {
7315
7596
  let pending = 0;
7316
7597
  let failed = false;
7317
7598
  const decoder = new VideoDecoder({
7318
7599
  output: (vf) => {
7319
- const f = frameOf(vf.timestamp / 1e6, fps);
7320
- const want = plan.get(f);
7321
- if (!want || stale() || disposed)
7600
+ const work = stale() || disposed ? null : take(vf, frameOf(vf.timestamp / 1e6, fps));
7601
+ if (!work)
7322
7602
  return vf.close();
7323
- plan.delete(f);
7324
7603
  pending++;
7325
- toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind)).catch(() => {}).finally(() => {
7604
+ work.catch(() => {}).finally(() => {
7326
7605
  vf.close();
7327
7606
  pending--;
7328
7607
  });
@@ -7436,6 +7715,25 @@ function makeCore(doc, key, src, fps) {
7436
7715
  return points.some((p) => p.frame + p.tolerance >= f && !covered(p));
7437
7716
  }, 6000, false);
7438
7717
  };
7718
+ const videoProxyUnit = async () => {
7719
+ const v = sampler;
7720
+ const todo = proxyTodo();
7721
+ if (!v || !todo.length || !v.videoWidth)
7722
+ return;
7723
+ const at = aim ?? focusAt ?? 0;
7724
+ const first = todo.find((f) => f >= at) ?? todo[0];
7725
+ const grab = async (f) => {
7726
+ if (f % stride || proxies.has(f) || f >= total)
7727
+ return;
7728
+ storeProxy(f, await toProxy(doc, v, v.videoWidth, v.videoHeight));
7729
+ };
7730
+ await seekTo(v, (first + 0.5) / fps);
7731
+ await grab(frameOf(v.currentTime, fps));
7732
+ await playThrough(v, 1, async (f) => {
7733
+ await grab(f);
7734
+ return proxyTodo().length > 0;
7735
+ }, 4000, true);
7736
+ };
7439
7737
  const cache = {
7440
7738
  src,
7441
7739
  get backend() {
@@ -7444,29 +7742,56 @@ function makeCore(doc, key, src, fps) {
7444
7742
  get size() {
7445
7743
  return entries.size;
7446
7744
  },
7447
- nearest(frame, within = Infinity) {
7745
+ get proxies() {
7746
+ return { count: proxies.size, stride, bytes: proxyBytes };
7747
+ },
7748
+ nearest(frame, within = Infinity, decode = false) {
7448
7749
  const k = nearestKey(entries.keys(), frame, within);
7750
+ const kd = nearestKey(decoded.keys(), frame, within);
7751
+ const dk = k === null ? Number.POSITIVE_INFINITY : Math.abs(k - frame);
7752
+ const dd = kd === null ? Number.POSITIVE_INFINITY : Math.abs(kd - frame);
7753
+ if (decode) {
7754
+ const p = proxyNear(frame);
7755
+ if (p !== null && Math.abs(p - frame) < Math.min(dk, dd))
7756
+ decodeProxy(p);
7757
+ const ahead = aheadOf(frame, lastAsked, total);
7758
+ if (ahead !== null)
7759
+ decodeProxy(proxyNear(ahead));
7760
+ lastAsked = frame;
7761
+ }
7762
+ if (kd !== null && (dd < dk || dd === dk && !entries.get(k)?.full)) {
7763
+ const d = decoded.get(kd);
7764
+ d.used = ++tick;
7765
+ return { frame: kd, bitmap: d.bitmap, full: false };
7766
+ }
7449
7767
  if (k === null)
7450
7768
  return null;
7451
7769
  const e = entries.get(k);
7452
7770
  e.used = ++tick;
7453
7771
  return { frame: k, bitmap: e.bitmap, full: e.full };
7454
7772
  },
7773
+ covers(frame) {
7774
+ if (entries.has(frame) || decoded.has(frame))
7775
+ return true;
7776
+ const p = proxyNear(frame);
7777
+ return p !== null && Math.abs(p - frame) <= stride / 2;
7778
+ },
7455
7779
  focus(frame) {
7456
- if (active !== core) {
7457
- active?.dropFull();
7458
- if (active)
7459
- active.active = false;
7460
- active = core;
7461
- core.active = true;
7462
- }
7780
+ claim(core);
7463
7781
  const moved = focusAt === null || Math.abs(frame - focusAt) > FOCUS_RADIUS;
7464
7782
  focusAt = frame;
7783
+ aim = null;
7465
7784
  if (moved)
7466
7785
  generation++;
7467
7786
  start();
7468
7787
  pump(win);
7469
7788
  },
7789
+ follow(frame) {
7790
+ claim(core);
7791
+ aim = frame;
7792
+ start();
7793
+ pump(win);
7794
+ },
7470
7795
  want(points) {
7471
7796
  wants = points;
7472
7797
  if (core.shown > 0)
@@ -7504,12 +7829,15 @@ function makeCore(doc, key, src, fps) {
7504
7829
  refs: 0,
7505
7830
  shown: 0,
7506
7831
  active: false,
7832
+ ran: 0,
7507
7833
  work() {
7508
7834
  if (disposed || backend === "pending")
7509
7835
  return null;
7510
- if (!missing().length)
7511
- return null;
7512
- return backend === "webcodecs" ? decodeUnit() : videoUnit();
7836
+ if (missing().length)
7837
+ return backend === "webcodecs" ? decodeUnit() : videoUnit();
7838
+ if (proxyTodo().length)
7839
+ return backend === "webcodecs" ? proxyUnit() : videoProxyUnit();
7840
+ return null;
7513
7841
  },
7514
7842
  dropFull() {
7515
7843
  for (const [f, e] of entries)
@@ -7518,8 +7846,13 @@ function makeCore(doc, key, src, fps) {
7518
7846
  entries.delete(f);
7519
7847
  }
7520
7848
  focusAt = null;
7849
+ aim = null;
7850
+ lastAsked = null;
7851
+ dropDecoded();
7521
7852
  },
7522
7853
  dispose() {
7854
+ if (disposed)
7855
+ return;
7523
7856
  disposed = true;
7524
7857
  generation++;
7525
7858
  if (notifyRaf)
@@ -7527,6 +7860,8 @@ function makeCore(doc, key, src, fps) {
7527
7860
  for (const e of entries.values())
7528
7861
  e.bitmap.close();
7529
7862
  entries.clear();
7863
+ dropDecoded();
7864
+ proxies.clear();
7530
7865
  listeners.clear();
7531
7866
  if (sampler) {
7532
7867
  sampler.removeAttribute("src");
@@ -7534,24 +7869,45 @@ function makeCore(doc, key, src, fps) {
7534
7869
  }
7535
7870
  buf = null;
7536
7871
  track = null;
7537
- if (active === core)
7538
- active = null;
7872
+ actives.delete(core);
7873
+ round2?.delete(core);
7539
7874
  cores.delete(key);
7875
+ dropEncoders();
7540
7876
  }
7541
7877
  };
7542
7878
  return core;
7543
7879
  }
7544
- var FOCUS_RADIUS = 6, GRID = 48, SEEN = 12, WANTED = 48, PREVIEW = 384, FULL = 1280, cores, active = null, running = false, idle = (win) => new Promise((done) => {
7880
+ var FOCUS_RADIUS = 6, GRID = 48, SEEN = 12, WANTED = 48, PREVIEW = 384, FULL = 1280, PROXY, aheadOf = (frame, last, total) => last === null || last === frame ? null : Math.min(Math.max(0, total - 1), Math.max(0, frame + (frame - last))), cores, actives, round2 = null, running = false, turns = 0, idle = (win) => new Promise((done) => {
7545
7881
  const ric = win.requestIdleCallback;
7546
7882
  if (ric)
7547
7883
  ric(() => done(), { timeout: 60 });
7548
7884
  else
7549
7885
  win.setTimeout(done, 4);
7550
- }), sleep = (win, ms) => new Promise((done) => win.setTimeout(done, ms));
7886
+ }), sleep = (win, ms) => new Promise((done) => win.setTimeout(done, ms)), ENCODER = `onmessage=async(e)=>{const{id,bitmap,quality}=e.data;try{const c=new OffscreenCanvas(bitmap.width,bitmap.height);c.getContext("2d").drawImage(bitmap,0,0);bitmap.close();postMessage({id,blob:await c.convertToBlob({type:"image/jpeg",quality})})}catch(err){postMessage({id,error:String(err)})}}`, ENCODERS = 2, encoders, encoderUsers = 0, endEncoders = (why) => {
7887
+ const pool = encoders;
7888
+ if (!pool)
7889
+ return;
7890
+ for (const w of pool.workers) {
7891
+ w.onmessage = null;
7892
+ w.onerror = null;
7893
+ w.terminate();
7894
+ }
7895
+ for (const j of pool.waiting.values())
7896
+ j.fail(new Error(why));
7897
+ pool.waiting.clear();
7898
+ };
7551
7899
  var init_compare_frames = __esm(() => {
7552
7900
  init_compare_model();
7553
7901
  init_compare_mp4();
7902
+ PROXY = {
7903
+ size: 960,
7904
+ quality: 0.8,
7905
+ budget: 48 * 2 ** 20,
7906
+ decoded: 8,
7907
+ turn: 90
7908
+ };
7554
7909
  cores = new Map;
7910
+ actives = new Set;
7555
7911
  });
7556
7912
 
7557
7913
  // src/ui/compare-gpu.ts
@@ -7817,1046 +8173,695 @@ var init_compare_gpu = __esm(() => {
7817
8173
  lostListeners = new Set;
7818
8174
  });
7819
8175
 
7820
- // src/ui/compare-strip.ts
7821
- function compareStrip(container, options) {
8176
+ // src/ui/compare.ts
8177
+ function compare(container, options) {
7822
8178
  const doc = container.ownerDocument;
7823
8179
  const win = doc.defaultView;
7824
- const timeline = options.timeline;
7825
- const fps = () => options.fps ?? timeline.fps;
7826
- const align = options.align ?? "track";
7827
- const threshold = options.threshold ?? 4 / 255;
7828
- const root = doc.createElement("div");
7829
- root.className = "knobs-cmp-strip";
7830
- root.setAttribute("role", "img");
7831
- root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
8180
+ const caching = scrubCaching(options.scrubCache, win);
8181
+ const look = {
8182
+ ...options.preset ? { preset: options.preset } : {},
8183
+ ...options.theme ? { theme: options.theme } : {},
8184
+ ...options.appearance ? { appearance: options.appearance } : {}
8185
+ };
8186
+ const offered = compareModes(options.modes, options.mode);
8187
+ const modeList = offered.modes;
8188
+ let mode = offered.mode;
8189
+ let wipe = Math.min(1, Math.max(0, options.wipe ?? 0.5));
8190
+ let loupeOn = options.loupe ?? false;
8191
+ let threshold = options.threshold ?? 4 / 255;
8192
+ let fpsOpt = options.fps;
8193
+ const bound = typeof options.timeline === "object" ? options.timeline : null;
8194
+ const fps = () => fpsOpt ?? bound?.fps ?? 30;
8195
+ const auto = container.clientHeight < 1;
8196
+ const el = (tag, cls) => {
8197
+ const n = doc.createElement(tag);
8198
+ n.className = cls;
8199
+ return n;
8200
+ };
8201
+ const root = el("div", "knobs-compare");
8202
+ root.tabIndex = 0;
8203
+ root.setAttribute("role", "group");
8204
+ root.setAttribute("aria-roledescription", "compare");
8205
+ root.setAttribute("aria-label", options.label ?? "Compare");
8206
+ root.setAttribute("aria-keyshortcuts", `Space ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight , . ${modeList.map((m, i) => `${i + 1} ${modeKey(m)}`).join(" ")} Z L 0 = -`);
7832
8207
  applyLook2(root, options);
7833
- const canvas = doc.createElement("canvas");
8208
+ root.dataset.mode = mode;
8209
+ if (auto)
8210
+ root.dataset.auto = "";
8211
+ const bar = el("div", "knobs-cmp-bar");
8212
+ const modes = el("div", "knobs-cmp-modes");
8213
+ modes.setAttribute("role", "radiogroup");
8214
+ modes.setAttribute("aria-label", "Mode");
8215
+ modes.hidden = modeList.length < 2;
8216
+ const modeBtns = modeList.map((m, i) => {
8217
+ const b = el("button", "knobs-cmp-mode");
8218
+ b.type = "button";
8219
+ b.tabIndex = -1;
8220
+ b.textContent = MODE_LABEL[m].short;
8221
+ b.title = `${MODE_LABEL[m].long} · ${i + 1} or ${modeKey(m)}`;
8222
+ b.setAttribute("role", "radio");
8223
+ b.setAttribute("aria-keyshortcuts", `${i + 1} ${modeKey(m)}`);
8224
+ b.addEventListener("click", () => {
8225
+ setMode(m);
8226
+ root.focus({ preventScroll: true });
8227
+ });
8228
+ modes.append(b);
8229
+ return b;
8230
+ });
8231
+ const toggleBtn = (name, label, title) => {
8232
+ const b = el("button", "knobs-cmp-toggle");
8233
+ b.type = "button";
8234
+ b.tabIndex = -1;
8235
+ b.innerHTML = icon(name);
8236
+ b.setAttribute("aria-label", label);
8237
+ b.title = title;
8238
+ return b;
8239
+ };
8240
+ const loupeBtn = toggleBtn("magnifyingGlass", "Loupe", "Loupe · hold Z");
8241
+ loupeBtn.addEventListener("click", () => {
8242
+ setLoupe(!loupeOn);
8243
+ root.focus({ preventScroll: true });
8244
+ });
8245
+ const loopBtn = toggleBtn("repeat", "Loop", "Loop · L (drag its ends on the timeline)");
8246
+ loopBtn.setAttribute("aria-keyshortcuts", "L");
8247
+ loopBtn.addEventListener("click", () => {
8248
+ api.setLoop(!loopNow());
8249
+ root.focus({ preventScroll: true });
8250
+ });
8251
+ bar.append(modes, el("span", "knobs-cmp-fill"), loopBtn, loupeBtn);
8252
+ const stage = el("div", "knobs-cmp-stage");
8253
+ const canvas = el("canvas", "knobs-cmp-canvas");
7834
8254
  canvas.setAttribute("aria-hidden", "true");
7835
- root.append(canvas);
7836
- container.append(root);
7837
- const diffs = new Map;
7838
- const pairDiffs = new Map;
7839
- let disposed = false;
7840
- let near = false;
7841
- let onScreen = false;
7842
- const makeSide = (source) => ({
7843
- source,
7844
- kind: mediaKind(source.src, source.kind),
7845
- frames: null,
7846
- still: null,
7847
- seen: false
8255
+ const split = el("div", "knobs-cmp-split");
8256
+ split.setAttribute("aria-hidden", "true");
8257
+ const zoomPill = el("div", "knobs-cmp-zoom");
8258
+ const zoomPct = el("span", "knobs-cmp-zoom-pct");
8259
+ const zoomReset = el("button", "knobs-cmp-zoom-reset");
8260
+ zoomReset.type = "button";
8261
+ zoomReset.tabIndex = -1;
8262
+ zoomReset.innerHTML = icon("arrowCounterClockwise", { small: true });
8263
+ zoomReset.setAttribute("aria-label", "Reset zoom");
8264
+ zoomReset.title = "Fit · 0 or double-click";
8265
+ zoomReset.addEventListener("click", () => {
8266
+ api.setZoom(null);
8267
+ root.focus({ preventScroll: true });
7848
8268
  });
7849
- let sides = [makeSide(options.before), makeSide(options.after)];
7850
- const offs = new Map;
7851
- const load = (s) => {
7852
- if (s.frames || s.still)
7853
- return;
7854
- if (s.kind === "video") {
7855
- s.frames = acquireFrames(doc, s.source.src, fps());
7856
- offs.set(s, s.frames.listen(schedule));
7857
- if (onScreen) {
7858
- s.frames.visible(true);
7859
- s.seen = true;
7860
- }
7861
- } else {
7862
- const img = doc.createElement("img");
7863
- img.crossOrigin = "anonymous";
7864
- img.decoding = "async";
7865
- img.addEventListener("load", () => {
7866
- pairDiffs.clear();
7867
- schedule();
7868
- });
7869
- img.src = s.source.src;
7870
- s.still = img;
8269
+ zoomPill.append(zoomPct, zoomReset);
8270
+ const labels = [el("div", "knobs-cmp-label"), el("div", "knobs-cmp-label")];
8271
+ labels[0].dataset.side = "before";
8272
+ labels[1].dataset.side = "after";
8273
+ const setLabel = (index, text) => {
8274
+ const l = labels[index];
8275
+ if (!l.firstElementChild) {
8276
+ const mark = el("span", "knobs-cmp-mark");
8277
+ mark.innerHTML = icon(index === 0 ? "x" : "check", { small: true });
8278
+ l.append(mark, el("span", "knobs-cmp-name"));
7871
8279
  }
8280
+ const name = l.lastElementChild;
8281
+ if (name.textContent !== text)
8282
+ name.textContent = text;
7872
8283
  };
7873
- const unload = (s) => {
7874
- offs.get(s)?.();
7875
- offs.delete(s);
7876
- if (s.frames) {
7877
- if (s.seen)
7878
- s.frames.visible(false);
7879
- s.frames.release();
7880
- }
7881
- s.frames = null;
7882
- s.seen = false;
7883
- if (s.still)
7884
- s.still.removeAttribute("src");
7885
- s.still = null;
8284
+ const seam = el("div", "knobs-cmp-seam");
8285
+ seam.setAttribute("aria-hidden", "true");
8286
+ const handle = el("div", "knobs-cmp-handle");
8287
+ handle.setAttribute("role", "slider");
8288
+ handle.setAttribute("aria-label", "Wipe");
8289
+ handle.setAttribute("aria-valuemin", "0");
8290
+ handle.setAttribute("aria-valuemax", "100");
8291
+ const grip = el("span", "knobs-cmp-grip");
8292
+ grip.setAttribute("aria-hidden", "true");
8293
+ handle.append(grip);
8294
+ const note = el("div", "knobs-cmp-note");
8295
+ stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
8296
+ root.append(bar, stage);
8297
+ let tl = bound;
8298
+ let ownTimeline = false;
8299
+ if (!tl && options.timeline === true) {
8300
+ const host = el("div", "knobs-cmp-timeline-host");
8301
+ root.append(host);
8302
+ tl = timeline(host, {
8303
+ ...look,
8304
+ variant: "line",
8305
+ duration: 1,
8306
+ fps: fps(),
8307
+ arrows: { step: 0.5, shift: 2 },
8308
+ label: "Compare time",
8309
+ ...options.hints === false ? { hints: false } : {}
8310
+ });
8311
+ ownTimeline = true;
8312
+ }
8313
+ container.append(root);
8314
+ const sides = [null, null];
8315
+ let duration = 0;
8316
+ let renderer = "dom";
8317
+ let pending = false;
8318
+ let gpu = null;
8319
+ const lay = new Spring(mode === "side" ? 0 : 1, LAYOUT.omega, LAYOUT.zeta, 0.0001);
8320
+ const fade = new Spring(1, FADE2.omega, FADE2.zeta, 0.001);
8321
+ let fromMode = mode;
8322
+ let toMode = mode;
8323
+ const wipeLine = new Spring(wipe, WIPE_CATCH.omega, WIPE_CATCH.zeta, 0.0001);
8324
+ let wipeCatching = false;
8325
+ const gripAt = new Spring(0.5, LAYOUT.omega, LAYOUT.zeta, 0.0001);
8326
+ let loupeHeld = false;
8327
+ let pointer = null;
8328
+ let drag = null;
8329
+ let tap = null;
8330
+ const touches = new Map;
8331
+ let pinch = null;
8332
+ const zu = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
8333
+ const zv = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
8334
+ const zw = new Spring(1, ZOOM.omega, ZOOM.zeta, 0.00001);
8335
+ let viewSent = "0.0000,0.0000,1.0000|";
8336
+ let ownLoop = null;
8337
+ let scrubbing = false;
8338
+ let stageW = 1;
8339
+ let stageH = 1;
8340
+ let stageRect = null;
8341
+ let accent = [0.49, 0.81, 0.61];
8342
+ let labelW = [0, 0];
8343
+ let frameRadius = 0;
8344
+ let dirty = true;
8345
+ let disposed = false;
8346
+ let near = options.lazy === false;
8347
+ let onScreen = options.lazy === false;
8348
+ let releaseTimer = 0;
8349
+ let ownTime = 0;
8350
+ let ownPlaying = false;
8351
+ const videoOf = (s) => s && s.kind === "video" && s.loaded ? s.el : null;
8352
+ const master = () => videoOf(sides[1]) ?? videoOf(sides[0]);
8353
+ const follower = () => {
8354
+ const m = master();
8355
+ const v = videoOf(sides[0]);
8356
+ return m && v && v !== m ? v : null;
7886
8357
  };
7887
- let w = 1;
7888
- let h = 1;
7889
- let geo = { x: 0, w: 1 };
7890
- const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
7891
- const aspectOf = (s) => {
7892
- if (s.still?.naturalWidth)
7893
- return s.still.naturalWidth / s.still.naturalHeight;
7894
- const c = s.frames?.nearest(0);
7895
- return c ? c.bitmap.width / c.bitmap.height : 0;
8358
+ const now = () => tl ? tl.time : ownTime;
8359
+ const isPlaying = () => tl ? tl.playing : ownPlaying;
8360
+ const labelOf = (index) => sides[index]?.source.label ?? (index === 0 ? options.before.label : options.after.label) ?? (index === 0 ? "Before" : "After");
8361
+ const sizeOf = (s) => {
8362
+ if (s.size.w > 0)
8363
+ return s.size;
8364
+ const other = sides[s.index === 0 ? 1 : 0];
8365
+ if (other && other.size.w > 0)
8366
+ return other.size;
8367
+ return { w: options.aspect ?? 16 / 9, h: 1 };
7896
8368
  };
7897
- const columns = () => stripColumns(timeline.view, geo.w, {
7898
- duration: timeline.duration,
7899
- fps: fps(),
7900
- minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
7901
- });
7902
- const measure = () => {
7903
- const r = root.getBoundingClientRect();
7904
- w = Math.max(1, r.width);
7905
- h = Math.max(1, r.height);
7906
- geo = { x: 0, w };
7907
- if (align === "track") {
7908
- const track = timeline.element.querySelector(".knobs-tl-track");
7909
- const t = track?.getBoundingClientRect();
7910
- if (t && t.width > 0)
7911
- geo = { x: t.left - r.left, w: t.width };
7912
- }
7913
- readColors();
7914
- schedule();
8369
+ const wantOf = (s) => {
8370
+ const v = s.el;
8371
+ const f = frameOf(now(), fps());
8372
+ return Number.isFinite(v.duration) ? Math.min(f, lastFrame(v.duration, fps())) : f;
7915
8373
  };
7916
- const ask = (cols) => {
7917
- const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
7918
- for (const s of sides)
7919
- s.frames?.want(points);
8374
+ const emitFrame = (s, frame, source) => root.dispatchEvent(new CustomEvent("knobs-compare-frame", { detail: { side: s.index, frame, source } }));
8375
+ const setCached = (s, on) => {
8376
+ if (renderer === "dom")
8377
+ s.preview.style.display = on ? "" : "none";
7920
8378
  };
7921
- const picOf = (s, c) => {
7922
- if (s.still?.naturalWidth)
7923
- return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
7924
- const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
7925
- return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
8379
+ const showVideo = (s) => {
8380
+ const v = s.el;
8381
+ if (renderer === "gpu") {
8382
+ if (!gpu)
8383
+ return;
8384
+ if (!gpu.upload(s.index, v, v.videoWidth, v.videoHeight))
8385
+ return miss(s);
8386
+ s.misses = 0;
8387
+ }
8388
+ const f = frameOf(v.currentTime, fps());
8389
+ s.shown = { frame: f, cached: false };
8390
+ setCached(s, false);
8391
+ emitFrame(s, f, "video");
8392
+ requestDraw();
7926
8393
  };
7927
- const probe = doc.createElement("canvas");
7928
- probe.width = DIFF.w;
7929
- probe.height = DIFF.h;
7930
- const probeCtx = probe.getContext("2d", { willReadFrequently: true });
7931
- const differ = (a, b) => {
7932
- if (!probeCtx)
8394
+ const showCached = (s, c) => {
8395
+ if (s.shown?.cached && s.shown.frame === c.frame)
7933
8396
  return;
7934
- try {
7935
- probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
7936
- const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
7937
- probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
7938
- const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
7939
- return meanAbsDiff(pa, pb);
7940
- } catch {
8397
+ if (renderer === "gpu") {
8398
+ if (!gpu || !gpu.upload(2 + s.index, c.bitmap, c.bitmap.width, c.bitmap.height))
8399
+ return;
8400
+ } else if (renderer === "dom" && !pending) {
8401
+ const p = s.preview;
8402
+ if (p.width !== c.bitmap.width || p.height !== c.bitmap.height) {
8403
+ p.width = c.bitmap.width;
8404
+ p.height = c.bitmap.height;
8405
+ }
8406
+ p.getContext("2d")?.drawImage(c.bitmap, 0, 0);
8407
+ } else
7941
8408
  return;
7942
- }
7943
- };
7944
- let colors = { surface: "", accent: "", diff: "", wash: "" };
7945
- const rgba = (raw, fallback) => {
7946
- const [r, g, b, a] = parseCssColor(raw, fallback);
7947
- return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
8409
+ s.shown = { frame: c.frame, cached: true };
8410
+ setCached(s, true);
8411
+ emitFrame(s, c.frame, "cache");
8412
+ requestDraw();
7948
8413
  };
7949
- const readColors = () => {
7950
- const cs = win.getComputedStyle(root);
7951
- colors = {
7952
- surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
7953
- accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
7954
- diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
7955
- wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
7956
- };
8414
+ const miss = (s) => {
8415
+ if (s.failed || ++s.misses < 3)
8416
+ return;
8417
+ gpu?.dispose();
8418
+ gpu = null;
8419
+ setRenderer("dom");
7957
8420
  };
7958
- let hover = null;
7959
- let raf = 0;
7960
- let asked = "";
7961
- function schedule() {
7962
- if (!raf && !disposed)
7963
- raf = win.requestAnimationFrame(draw);
7964
- }
7965
- const draw = () => {
7966
- raf = 0;
7967
- if (!near)
8421
+ const pushFrame = (s) => {
8422
+ if (!s.loaded || disposed)
7968
8423
  return;
7969
- const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
7970
- const cw = Math.round(w * dpr);
7971
- const ch = Math.round(h * dpr);
7972
- if (canvas.width !== cw || canvas.height !== ch) {
7973
- canvas.width = cw;
7974
- canvas.height = ch;
8424
+ if (s.kind === "image") {
8425
+ const img = s.el;
8426
+ if (!(img.complete && img.naturalWidth))
8427
+ return;
8428
+ if (renderer === "gpu" && gpu) {
8429
+ if (!gpu.upload(s.index, img, img.naturalWidth, img.naturalHeight))
8430
+ return miss(s);
8431
+ }
8432
+ s.shown = { frame: 0, cached: false };
8433
+ return requestDraw();
7975
8434
  }
7976
- const ctx = canvas.getContext("2d");
7977
- if (!ctx)
8435
+ const v = s.el;
8436
+ if (v.readyState < 2)
8437
+ return;
8438
+ if (isPlaying() || !s.frames)
8439
+ return showVideo(s);
8440
+ const want = wantOf(s);
8441
+ const at = frameOf(v.currentTime, fps());
8442
+ if (at === want) {
8443
+ showVideo(s);
8444
+ s.frames.keep(at, v, v.videoWidth, v.videoHeight);
7978
8445
  return;
7979
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
7980
- ctx.clearRect(0, 0, w, h);
7981
- const cols = columns();
7982
- const key = cols.map((c) => c.frame).join(",");
7983
- if (key !== asked) {
7984
- asked = key;
7985
- ask(cols);
7986
8446
  }
7987
- const rh = rowH();
7988
- const radius = 3;
7989
- let budget = DIFFS_PER_DRAW;
7990
- let more = false;
7991
- const ds = cols.map((c) => {
7992
- const a = picOf(sides[0], c);
7993
- const b = picOf(sides[1], c);
7994
- if (!a || !b)
7995
- return;
7996
- const k = `${a.key}|${b.key}`;
7997
- if (!pairDiffs.has(k)) {
7998
- if (budget-- <= 0) {
7999
- more = true;
8000
- return;
8001
- }
8002
- pairDiffs.set(k, differ(a, b));
8447
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
8448
+ if (c && Math.abs(c.frame - want) < Math.abs(at - want))
8449
+ showCached(s, c);
8450
+ else
8451
+ showVideo(s);
8452
+ };
8453
+ const refreshCached = (s) => {
8454
+ if (!s.loaded || !s.frames || isPlaying())
8455
+ return;
8456
+ const want = wantOf(s);
8457
+ if (s.shown && !s.shown.cached && s.shown.frame === want)
8458
+ return;
8459
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
8460
+ if (!c)
8461
+ return;
8462
+ const was = s.shown ? Math.abs(s.shown.frame - want) : Number.POSITIVE_INFINITY;
8463
+ if (Math.abs(c.frame - want) < was)
8464
+ showCached(s, c);
8465
+ };
8466
+ const giveUp = (s) => {
8467
+ s.seek = SEEK_IDLE;
8468
+ if (!s.loaded || disposed)
8469
+ return;
8470
+ if (s.el.readyState >= 2)
8471
+ return showVideo(s);
8472
+ s.shown = null;
8473
+ setCached(s, false);
8474
+ requestDraw();
8475
+ };
8476
+ const makeSide = (index, source) => {
8477
+ const kind = mediaKind(source.src, source.kind);
8478
+ const media = el(kind === "video" ? "video" : "img", "knobs-cmp-media");
8479
+ media.dataset.side = index === 0 ? "before" : "after";
8480
+ media.crossOrigin = "anonymous";
8481
+ media.setAttribute("aria-hidden", "true");
8482
+ const preview = el("canvas", "knobs-cmp-media knobs-cmp-preview");
8483
+ preview.dataset.side = media.dataset.side;
8484
+ preview.style.display = "none";
8485
+ preview.setAttribute("aria-hidden", "true");
8486
+ const offs = [];
8487
+ const on = (type, f) => {
8488
+ media.addEventListener(type, f);
8489
+ offs.push(() => media.removeEventListener(type, f));
8490
+ };
8491
+ const side = {
8492
+ index,
8493
+ source,
8494
+ kind,
8495
+ el: media,
8496
+ preview,
8497
+ size: { w: 0, h: 0 },
8498
+ seek: SEEK_IDLE,
8499
+ loaded: false,
8500
+ frames: null,
8501
+ unlisten: null,
8502
+ seen: false,
8503
+ shown: null,
8504
+ frameAt: 0,
8505
+ misses: 0,
8506
+ failed: false,
8507
+ stall: stallGuard(win, () => giveUp(side)),
8508
+ stop: () => {
8509
+ side.stall.clear();
8510
+ for (const f of offs)
8511
+ f();
8003
8512
  }
8004
- const d = pairDiffs.get(k);
8005
- if (d !== undefined)
8006
- diffs.set(c.frame, d);
8007
- return d;
8008
- });
8009
- const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
8010
- const peaks = new Set(stripPeaks(ds, { threshold }));
8011
- cols.forEach((c, i) => {
8012
- const x = geo.x + c.x + GAP / 2;
8013
- const cwid = Math.max(1, c.w - GAP);
8014
- if (x + cwid < 0 || x > w)
8513
+ };
8514
+ const fail = () => {
8515
+ if (!side.loaded)
8015
8516
  return;
8016
- for (const row of [0, 1]) {
8017
- const y = row * (rh + GAP);
8018
- const pic = picOf(sides[row], c);
8019
- ctx.save();
8020
- ctx.beginPath();
8021
- ctx.roundRect(x, y, cwid, rh, radius);
8022
- ctx.fillStyle = colors.surface;
8023
- ctx.fill();
8024
- if (pic) {
8025
- ctx.clip();
8026
- const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
8027
- ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
8028
- }
8029
- ctx.restore();
8030
- }
8031
- if (hover === i) {
8032
- ctx.fillStyle = colors.wash;
8033
- ctx.beginPath();
8034
- ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
8035
- ctx.fill();
8036
- }
8037
- const d = ds[i];
8038
- if (d !== undefined && d > 0) {
8039
- const bh = Math.max(1, Math.min(1, d / max) * BAR);
8040
- ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
8041
- ctx.fillStyle = colors.diff;
8042
- ctx.beginPath();
8043
- ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
8044
- ctx.fill();
8045
- ctx.globalAlpha = 1;
8517
+ side.failed = true;
8518
+ note.textContent = `Couldn't load ${labelOf(index)}`;
8519
+ side.stall.clear();
8520
+ side.seek = SEEK_IDLE;
8521
+ side.shown = null;
8522
+ setCached(side, false);
8523
+ };
8524
+ if (media instanceof HTMLVideoElement) {
8525
+ const v = media;
8526
+ v.muted = true;
8527
+ v.defaultMuted = true;
8528
+ v.playsInline = true;
8529
+ v.preload = "auto";
8530
+ v.setAttribute("playsinline", "");
8531
+ v.setAttribute("muted", "");
8532
+ on("loadedmetadata", () => {
8533
+ side.size = { w: v.videoWidth, h: v.videoHeight };
8534
+ measureDuration();
8535
+ if (auto)
8536
+ measure();
8537
+ if (isPlaying())
8538
+ jumpAll(now());
8539
+ else
8540
+ showFrame(now(), false);
8541
+ requestDraw();
8542
+ });
8543
+ on("resize", () => {
8544
+ side.size = { w: v.videoWidth, h: v.videoHeight };
8545
+ requestDraw();
8546
+ });
8547
+ on("loadeddata", () => pushFrame(side));
8548
+ on("seeked", () => {
8549
+ const step = seeked(side.seek, v.currentTime, fps());
8550
+ side.seek = step.state;
8551
+ if (step.apply !== null)
8552
+ seekVideo(side, v, step.apply);
8553
+ else
8554
+ side.stall.clear();
8555
+ pushFrame(side);
8556
+ });
8557
+ on("error", fail);
8558
+ if (v.requestVideoFrameCallback) {
8559
+ let id = 0;
8560
+ const frame = () => {
8561
+ side.frameAt = performance.now();
8562
+ pushFrame(side);
8563
+ id = v.requestVideoFrameCallback(frame);
8564
+ };
8565
+ id = v.requestVideoFrameCallback(frame);
8566
+ offs.push(() => v.cancelVideoFrameCallback?.(id));
8046
8567
  }
8047
- });
8048
- const v = timeline.view;
8049
- const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
8050
- if (px >= -1 && px <= w + 1) {
8051
- ctx.fillStyle = colors.accent;
8052
- ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
8568
+ } else {
8569
+ on("load", () => {
8570
+ side.size = { w: media.naturalWidth, h: media.naturalHeight };
8571
+ if (auto)
8572
+ measure();
8573
+ pushFrame(side);
8574
+ requestDraw();
8575
+ });
8576
+ on("error", fail);
8577
+ media.decoding = "async";
8053
8578
  }
8054
- if (more)
8055
- schedule();
8056
- };
8057
- const timeAt = (clientX) => {
8058
- const r = root.getBoundingClientRect();
8059
- const v = timeline.view;
8060
- const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
8061
- return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
8062
- };
8063
- const columnAt = (clientX) => {
8064
- const x = clientX - root.getBoundingClientRect().left - geo.x;
8065
- const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
8066
- return i < 0 ? null : i;
8579
+ return side;
8067
8580
  };
8068
- let dragging = false;
8069
- const pick = (clientX) => {
8070
- const t = timeAt(clientX);
8071
- timeline.seek(t);
8072
- options.onPick?.(t);
8581
+ const loadSide = (s) => {
8582
+ if (s.loaded || disposed)
8583
+ return;
8584
+ s.loaded = true;
8585
+ s.failed = false;
8586
+ s.el.src = s.source.src;
8587
+ if (s.kind === "video" && caching) {
8588
+ s.frames = acquireFrames(doc, s.source.src, fps());
8589
+ s.unlisten = s.frames.listen(() => refreshCached(s));
8590
+ if (onScreen) {
8591
+ s.frames.visible(true);
8592
+ s.seen = true;
8593
+ }
8594
+ }
8073
8595
  };
8074
- root.addEventListener("pointerdown", (e) => {
8075
- if (e.button !== 0)
8596
+ const unloadSide = (s) => {
8597
+ if (!s.loaded)
8076
8598
  return;
8077
- dragging = true;
8078
- root.setPointerCapture(e.pointerId);
8079
- const host = root.closest(".knobs-compare");
8080
- if (host)
8081
- host.focus({ preventScroll: true });
8082
- else
8083
- timeline.focus();
8084
- if (timeline.playing)
8085
- timeline.pause();
8086
- pick(e.clientX);
8087
- e.preventDefault();
8088
- });
8089
- root.addEventListener("pointermove", (e) => {
8090
- if (dragging)
8091
- pick(e.clientX);
8092
- const i = columnAt(e.clientX);
8093
- if (i !== hover) {
8094
- hover = i;
8095
- schedule();
8599
+ s.loaded = false;
8600
+ s.el.removeAttribute("src");
8601
+ if (s.el instanceof HTMLVideoElement)
8602
+ s.el.load();
8603
+ s.seek = SEEK_IDLE;
8604
+ s.stall.clear();
8605
+ s.shown = null;
8606
+ setCached(s, false);
8607
+ s.unlisten?.();
8608
+ s.unlisten = null;
8609
+ if (s.frames) {
8610
+ if (s.seen)
8611
+ s.frames.visible(false);
8612
+ s.seen = false;
8613
+ s.frames.release();
8614
+ s.frames = null;
8096
8615
  }
8097
- });
8098
- const end = () => {
8099
- dragging = false;
8616
+ gpu?.clear(s.index);
8100
8617
  };
8101
- root.addEventListener("pointerup", end);
8102
- root.addEventListener("pointercancel", end);
8103
- root.addEventListener("lostpointercapture", end);
8104
- root.addEventListener("pointerleave", () => {
8105
- if (hover === null)
8618
+ const measureDuration = () => {
8619
+ const next = Math.max(0, ...sides.map((s) => {
8620
+ const v = videoOf(s);
8621
+ return v && Number.isFinite(v.duration) ? v.duration : 0;
8622
+ }));
8623
+ if (next <= 0 || next === duration)
8106
8624
  return;
8107
- hover = null;
8108
- schedule();
8109
- });
8110
- const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
8111
- const ro = new win.ResizeObserver(measure);
8112
- ro.observe(root);
8113
- const track = timeline.element.querySelector(".knobs-tl-track");
8114
- if (track)
8115
- ro.observe(track);
8116
- root.addEventListener("knobs-restyle", measure);
8117
- const setOnScreen = (on) => {
8118
- onScreen = on;
8625
+ duration = next;
8626
+ if (ownTimeline && tl)
8627
+ tl.update({ duration, fps: fps() });
8628
+ };
8629
+ const setRenderer = (next) => {
8630
+ pending = false;
8631
+ renderer = next;
8632
+ root.dataset.renderer = next;
8633
+ loupeBtn.hidden = next !== "gpu";
8634
+ canvas.style.display = next === "gpu" ? "" : "none";
8119
8635
  for (const s of sides) {
8120
- if (!s.frames || s.seen === on)
8121
- continue;
8122
- s.seen = on;
8123
- s.frames.visible(on);
8636
+ const st = s.el.style;
8637
+ st.clipPath = "";
8638
+ st.maskImage = "";
8639
+ st.setProperty("-webkit-mask-image", "");
8640
+ st.mixBlendMode = "";
8641
+ st.opacity = "";
8642
+ s.shown = null;
8643
+ setCached(s, false);
8124
8644
  }
8645
+ for (const s of sides)
8646
+ pushFrame(s);
8647
+ requestDraw();
8125
8648
  };
8126
- const setNear = (on) => {
8127
- near = on;
8128
- if (on) {
8129
- for (const s of sides)
8130
- load(s);
8131
- schedule();
8132
- } else
8133
- for (const s of sides)
8134
- unload(s);
8649
+ const startGpu = async (retry) => {
8650
+ const g = await createCompareGpu(canvas, () => {
8651
+ gpu = null;
8652
+ if (disposed)
8653
+ return;
8654
+ if (retry)
8655
+ win.setTimeout(() => void startGpu(false), 150);
8656
+ else
8657
+ setRenderer("dom");
8658
+ });
8659
+ if (disposed)
8660
+ return g?.dispose();
8661
+ if (!g)
8662
+ return setRenderer("dom");
8663
+ gpu = g;
8664
+ setRenderer("gpu");
8135
8665
  };
8136
- const IO = win.IntersectionObserver;
8137
- const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
8138
- const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
8139
- measure();
8140
- if (nearIo && screenIo) {
8141
- nearIo.observe(root);
8142
- screenIo.observe(root);
8143
- } else {
8144
- setNear(true);
8145
- setOnScreen(true);
8146
- }
8147
- return {
8148
- element: root,
8149
- diffs,
8150
- update(next) {
8151
- const was = sides;
8152
- sides = [
8153
- next.before ? makeSide(next.before) : was[0],
8154
- next.after ? makeSide(next.after) : was[1]
8155
- ];
8156
- for (const s of was)
8157
- if (!sides.includes(s))
8158
- unload(s);
8159
- diffs.clear();
8160
- pairDiffs.clear();
8161
- asked = "";
8162
- if (near)
8163
- for (const s of sides)
8164
- load(s);
8165
- if (onScreen)
8166
- setOnScreen(true);
8167
- schedule();
8168
- },
8169
- dispose() {
8170
- disposed = true;
8171
- if (raf)
8172
- win.cancelAnimationFrame(raf);
8173
- unlisten();
8174
- ro.disconnect();
8175
- nearIo?.disconnect();
8176
- screenIo?.disconnect();
8177
- for (const s of sides)
8178
- unload(s);
8179
- root.remove();
8666
+ const syncModeUi = () => {
8667
+ root.dataset.mode = mode;
8668
+ modeList.forEach((m, i) => {
8669
+ modeBtns[i].setAttribute("aria-checked", String(m === mode));
8670
+ });
8671
+ handle.tabIndex = mode === "wipe" ? 0 : -1;
8672
+ modePill?.update();
8673
+ };
8674
+ const setMode = (next, emit = true) => {
8675
+ if (next === mode || !modeList.includes(next))
8676
+ return;
8677
+ mode = next;
8678
+ const reduced = reducedMotion(win);
8679
+ tune(fade, reduced ? FADE_REDUCED : FADE2);
8680
+ if (fade.x >= 0.5) {
8681
+ fromMode = toMode;
8682
+ fade.snap(0);
8180
8683
  }
8684
+ toMode = next;
8685
+ fade.set(1);
8686
+ if (reduced)
8687
+ lay.snap(next === "side" ? 0 : 1);
8688
+ else
8689
+ lay.set(next === "side" ? 0 : 1);
8690
+ syncModeUi();
8691
+ if (emit)
8692
+ options.onMode?.(next);
8693
+ requestDraw();
8181
8694
  };
8182
- }
8183
- var BAR = 7, GAP = 2, DIFF, DIFFS_PER_DRAW = 6;
8184
- var init_compare_strip = __esm(() => {
8185
- init_compare_frames();
8186
- init_compare_model();
8187
- init_look();
8188
- init_rgba();
8189
- init_timeline_model();
8190
- DIFF = { w: 32, h: 18 };
8191
- });
8192
-
8193
- // src/ui/compare.ts
8194
- function compare(container, options) {
8195
- const doc = container.ownerDocument;
8196
- const win = doc.defaultView;
8197
- const look = {
8198
- ...options.preset ? { preset: options.preset } : {},
8199
- ...options.theme ? { theme: options.theme } : {},
8200
- ...options.appearance ? { appearance: options.appearance } : {}
8695
+ const setWipe = (next, emit = true, jump = false) => {
8696
+ const w = Math.min(1, Math.max(0, next));
8697
+ if (w === wipe && !jump)
8698
+ return;
8699
+ wipe = w;
8700
+ const minGap = 2 / Math.max(1, wipeRect(layoutNow().L.overlay).w);
8701
+ const head = moveHead({ x: wipeLine.x, target: wipeLine.target }, w, jump && !reducedMotion(win) ? "jump" : "follow", wipeCatching, minGap);
8702
+ wipeCatching = head.catching;
8703
+ if (head.catching) {
8704
+ wipeLine.x = head.x;
8705
+ wipeLine.target = head.target;
8706
+ } else
8707
+ wipeLine.snap(head.x);
8708
+ if (emit)
8709
+ options.onWipe?.(w);
8710
+ if (head.catching)
8711
+ requestDraw();
8712
+ else
8713
+ drawNow();
8201
8714
  };
8202
- const offered = compareModes(options.modes, options.mode);
8203
- const modeList = offered.modes;
8204
- let mode = offered.mode;
8205
- let wipe = Math.min(1, Math.max(0, options.wipe ?? 0.5));
8206
- let loupeOn = options.loupe ?? false;
8207
- let threshold = options.threshold ?? 4 / 255;
8208
- let fpsOpt = options.fps;
8209
- const bound = typeof options.timeline === "object" ? options.timeline : null;
8210
- const fps = () => fpsOpt ?? bound?.fps ?? 30;
8211
- const auto = container.clientHeight < 1;
8212
- const el = (tag, cls) => {
8213
- const n = doc.createElement(tag);
8214
- n.className = cls;
8215
- return n;
8715
+ const setLoupe = (on) => {
8716
+ loupeOn = on;
8717
+ loupeBtn.setAttribute("aria-pressed", String(on));
8718
+ requestDraw();
8216
8719
  };
8217
- const root = el("div", "knobs-compare");
8218
- root.tabIndex = 0;
8219
- root.setAttribute("role", "group");
8220
- root.setAttribute("aria-roledescription", "compare");
8221
- root.setAttribute("aria-label", options.label ?? "Compare");
8222
- root.setAttribute("aria-keyshortcuts", `Space ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight , . ${modeList.map((m, i) => `${i + 1} ${modeKey(m)}`).join(" ")} Z L 0 = -`);
8223
- applyLook2(root, options);
8224
- root.dataset.mode = mode;
8225
- if (auto)
8226
- root.dataset.auto = "";
8227
- const bar = el("div", "knobs-cmp-bar");
8228
- const modes = el("div", "knobs-cmp-modes");
8229
- modes.setAttribute("role", "radiogroup");
8230
- modes.setAttribute("aria-label", "Mode");
8231
- modes.hidden = modeList.length < 2;
8232
- const modeBtns = modeList.map((m, i) => {
8233
- const b = el("button", "knobs-cmp-mode");
8234
- b.type = "button";
8235
- b.tabIndex = -1;
8236
- b.textContent = MODE_LABEL[m].short;
8237
- b.title = `${MODE_LABEL[m].long} · ${i + 1} or ${modeKey(m)}`;
8238
- b.setAttribute("role", "radio");
8239
- b.setAttribute("aria-keyshortcuts", `${i + 1} ${modeKey(m)}`);
8240
- b.addEventListener("click", () => {
8241
- setMode(m);
8242
- root.focus({ preventScroll: true });
8243
- });
8244
- modes.append(b);
8245
- return b;
8246
- });
8247
- const toggleBtn = (name, label, title) => {
8248
- const b = el("button", "knobs-cmp-toggle");
8249
- b.type = "button";
8250
- b.tabIndex = -1;
8251
- b.innerHTML = icon(name);
8252
- b.setAttribute("aria-label", label);
8253
- b.title = title;
8254
- return b;
8720
+ const clampTime = (v, t) => Math.min(Math.max(0, t), Math.max(0, (Number.isFinite(v.duration) ? v.duration : t) - 0.001));
8721
+ const seekVideo = (s, v, t) => {
8722
+ const c = s.frames?.nearest(wantOf(s));
8723
+ const far = !c || Math.abs(c.frame - wantOf(s)) > FAST_SEEK_FRAMES;
8724
+ if (scrubbing && far && v.fastSeek)
8725
+ v.fastSeek(t);
8726
+ else
8727
+ v.currentTime = t;
8728
+ s.stall.arm();
8255
8729
  };
8256
- const loupeBtn = toggleBtn("magnifyingGlass", "Loupe", "Loupe · hold Z");
8257
- loupeBtn.addEventListener("click", () => {
8258
- setLoupe(!loupeOn);
8259
- root.focus({ preventScroll: true });
8260
- });
8261
- const loopBtn = toggleBtn("repeat", "Loop", "Loop · L (drag its ends on the timeline)");
8262
- loopBtn.setAttribute("aria-keyshortcuts", "L");
8263
- loopBtn.addEventListener("click", () => {
8264
- api.setLoop(!loopNow());
8265
- root.focus({ preventScroll: true });
8266
- });
8267
- bar.append(modes, el("span", "knobs-cmp-fill"), loopBtn, loupeBtn);
8268
- const stage = el("div", "knobs-cmp-stage");
8269
- const canvas = el("canvas", "knobs-cmp-canvas");
8270
- canvas.setAttribute("aria-hidden", "true");
8271
- const split = el("div", "knobs-cmp-split");
8272
- split.setAttribute("aria-hidden", "true");
8273
- const zoomPill = el("div", "knobs-cmp-zoom");
8274
- const zoomPct = el("span", "knobs-cmp-zoom-pct");
8275
- const zoomReset = el("button", "knobs-cmp-zoom-reset");
8276
- zoomReset.type = "button";
8277
- zoomReset.tabIndex = -1;
8278
- zoomReset.innerHTML = icon("arrowCounterClockwise", { small: true });
8279
- zoomReset.setAttribute("aria-label", "Reset zoom");
8280
- zoomReset.title = "Fit · 0 or double-click";
8281
- zoomReset.addEventListener("click", () => {
8282
- api.setZoom(null);
8283
- root.focus({ preventScroll: true });
8284
- });
8285
- zoomPill.append(zoomPct, zoomReset);
8286
- const labels = [el("div", "knobs-cmp-label"), el("div", "knobs-cmp-label")];
8287
- labels[0].dataset.side = "before";
8288
- labels[1].dataset.side = "after";
8289
- const setLabel = (index, text) => {
8290
- const l = labels[index];
8291
- if (!l.firstElementChild) {
8292
- const mark = el("span", "knobs-cmp-mark");
8293
- mark.innerHTML = icon(index === 0 ? "x" : "check", { small: true });
8294
- l.append(mark, el("span", "knobs-cmp-name"));
8730
+ const showFrame = (t, used) => {
8731
+ for (const s of sides) {
8732
+ const v = videoOf(s);
8733
+ if (!v || v.readyState < 1)
8734
+ continue;
8735
+ if (!v.paused) {
8736
+ v.pause();
8737
+ v.playbackRate = 1;
8738
+ }
8739
+ const want = Math.min(frameOf(t, fps()), lastFrame(v.duration, fps()));
8740
+ if (used) {
8741
+ if (scrubbing)
8742
+ s.frames?.follow(want);
8743
+ else
8744
+ s.frames?.focus(want);
8745
+ }
8746
+ if (!(scrubbing && s.frames?.covers(want))) {
8747
+ const at = frameMidpoint(want, fps(), v.duration);
8748
+ const step = requestSeek(s.seek, at, v.currentTime, fps());
8749
+ s.seek = step.state;
8750
+ if (step.apply !== null)
8751
+ seekVideo(s, v, step.apply);
8752
+ else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
8753
+ pushFrame(s);
8754
+ }
8755
+ refreshCached(s);
8295
8756
  }
8296
- const name = l.lastElementChild;
8297
- if (name.textContent !== text)
8298
- name.textContent = text;
8299
- };
8300
- const seam = el("div", "knobs-cmp-seam");
8301
- seam.setAttribute("aria-hidden", "true");
8302
- const handle = el("div", "knobs-cmp-handle");
8303
- handle.setAttribute("role", "slider");
8304
- handle.setAttribute("aria-label", "Wipe");
8305
- handle.setAttribute("aria-valuemin", "0");
8306
- handle.setAttribute("aria-valuemax", "100");
8307
- const grip = el("span", "knobs-cmp-grip");
8308
- grip.setAttribute("aria-hidden", "true");
8309
- handle.append(grip);
8310
- const note = el("div", "knobs-cmp-note");
8311
- stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
8312
- root.append(bar, stage);
8313
- const wantsStrip = options.strip === true;
8314
- let tl = bound;
8315
- let ownTimeline = false;
8316
- let stripHost = null;
8317
- if (wantsStrip) {
8318
- stripHost = el("div", "knobs-cmp-strip-host");
8319
- root.append(stripHost);
8320
- }
8321
- if (!tl && (options.timeline === true || wantsStrip)) {
8322
- const host = el("div", "knobs-cmp-timeline-host");
8323
- root.append(host);
8324
- tl = timeline(host, {
8325
- ...look,
8326
- variant: "line",
8327
- duration: 1,
8328
- fps: fps(),
8329
- arrows: { step: 0.5, shift: 2 },
8330
- label: "Compare time",
8331
- ...options.hints === false ? { hints: false } : {}
8332
- });
8333
- ownTimeline = true;
8334
- }
8335
- container.append(root);
8336
- const sides = [null, null];
8337
- let duration = 0;
8338
- let renderer = "dom";
8339
- let pending = false;
8340
- let gpu = null;
8341
- const lay = new Spring(mode === "side" ? 0 : 1, LAYOUT.omega, LAYOUT.zeta, 0.0001);
8342
- const fade = new Spring(1, FADE2.omega, FADE2.zeta, 0.001);
8343
- let fromMode = mode;
8344
- let toMode = mode;
8345
- const wipeLine = new Spring(wipe, WIPE_CATCH.omega, WIPE_CATCH.zeta, 0.0001);
8346
- let wipeCatching = false;
8347
- const gripAt = new Spring(0.5, LAYOUT.omega, LAYOUT.zeta, 0.0001);
8348
- let loupeHeld = false;
8349
- let pointer = null;
8350
- let drag = null;
8351
- let tap = null;
8352
- const touches = new Map;
8353
- let pinch = null;
8354
- const zu = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
8355
- const zv = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
8356
- const zw = new Spring(1, ZOOM.omega, ZOOM.zeta, 0.00001);
8357
- let viewSent = "0.0000,0.0000,1.0000|";
8358
- let ownLoop = null;
8359
- let scrubbing = false;
8360
- let stageW = 1;
8361
- let stageH = 1;
8362
- let stageRect = null;
8363
- let accent = [0.49, 0.81, 0.61];
8364
- let labelW = [0, 0];
8365
- let frameRadius = 0;
8366
- let dirty = true;
8367
- let disposed = false;
8368
- let near = options.lazy === false;
8369
- let onScreen = options.lazy === false;
8370
- let releaseTimer = 0;
8371
- let ownTime = 0;
8372
- let ownPlaying = false;
8373
- const videoOf = (s) => s && s.kind === "video" && s.loaded ? s.el : null;
8374
- const master = () => videoOf(sides[1]) ?? videoOf(sides[0]);
8375
- const follower = () => {
8376
- const m = master();
8377
- const v = videoOf(sides[0]);
8378
- return m && v && v !== m ? v : null;
8379
- };
8380
- const now = () => tl ? tl.time : ownTime;
8381
- const isPlaying = () => tl ? tl.playing : ownPlaying;
8382
- const labelOf = (index) => sides[index]?.source.label ?? (index === 0 ? options.before.label : options.after.label) ?? (index === 0 ? "Before" : "After");
8383
- const sizeOf = (s) => {
8384
- if (s.size.w > 0)
8385
- return s.size;
8386
- const other = sides[s.index === 0 ? 1 : 0];
8387
- if (other && other.size.w > 0)
8388
- return other.size;
8389
- return { w: options.aspect ?? 16 / 9, h: 1 };
8390
- };
8391
- const wantOf = (s) => {
8392
- const v = s.el;
8393
- const f = frameOf(now(), fps());
8394
- return Number.isFinite(v.duration) ? Math.min(f, lastFrame(v.duration, fps())) : f;
8757
+ requestDraw();
8395
8758
  };
8396
- const emitFrame = (s, frame, source) => root.dispatchEvent(new CustomEvent("knobs-compare-frame", { detail: { side: s.index, frame, source } }));
8397
- const setCached = (s, on) => {
8398
- if (renderer === "dom")
8399
- s.preview.style.display = on ? "" : "none";
8759
+ const jumpAll = (t) => {
8760
+ for (const s of sides) {
8761
+ const v = videoOf(s);
8762
+ if (!v || v.readyState < 1)
8763
+ continue;
8764
+ s.seek = SEEK_IDLE;
8765
+ v.currentTime = clampTime(v, t);
8766
+ if (v.paused)
8767
+ v.play().catch(() => {});
8768
+ }
8400
8769
  };
8401
- const showVideo = (s) => {
8402
- const v = s.el;
8403
- if (renderer === "gpu") {
8404
- if (!gpu)
8405
- return;
8406
- if (!gpu.upload(s.index, v, v.videoWidth, v.videoHeight))
8407
- return miss(s);
8408
- s.misses = 0;
8770
+ const startVideos = () => {
8771
+ const t = now();
8772
+ for (const s of sides) {
8773
+ const v = videoOf(s);
8774
+ if (!v)
8775
+ continue;
8776
+ s.seek = SEEK_IDLE;
8777
+ v.playbackRate = 1;
8778
+ if (v.readyState >= 1 && Math.abs(v.currentTime - t) > 1.5 / fps())
8779
+ v.currentTime = clampTime(v, t);
8780
+ v.play().catch(() => {});
8409
8781
  }
8410
- const f = frameOf(v.currentTime, fps());
8411
- s.shown = { frame: f, cached: false };
8412
- setCached(s, false);
8413
- emitFrame(s, f, "video");
8414
- requestDraw();
8782
+ loop.wake();
8415
8783
  };
8416
- const showCached = (s, c) => {
8417
- if (s.shown?.cached && s.shown.frame === c.frame)
8418
- return;
8419
- if (renderer === "gpu") {
8420
- if (!gpu || !gpu.upload(2 + s.index, c.bitmap, c.bitmap.width, c.bitmap.height))
8421
- return;
8422
- } else if (renderer === "dom" && !pending) {
8423
- const p = s.preview;
8424
- if (p.width !== c.bitmap.width || p.height !== c.bitmap.height) {
8425
- p.width = c.bitmap.width;
8426
- p.height = c.bitmap.height;
8784
+ const pauseVideos = () => {
8785
+ for (const s of sides) {
8786
+ const v = videoOf(s);
8787
+ if (v) {
8788
+ v.pause();
8789
+ v.playbackRate = 1;
8427
8790
  }
8428
- p.getContext("2d")?.drawImage(c.bitmap, 0, 0);
8429
- } else
8430
- return;
8431
- s.shown = { frame: c.frame, cached: true };
8432
- setCached(s, true);
8433
- emitFrame(s, c.frame, "cache");
8434
- requestDraw();
8791
+ }
8435
8792
  };
8436
- const miss = (s) => {
8437
- if (s.failed || ++s.misses < 3)
8438
- return;
8439
- gpu?.dispose();
8440
- gpu = null;
8441
- setRenderer("dom");
8793
+ const stopVideos = () => {
8794
+ pauseVideos();
8795
+ showFrame(now(), false);
8442
8796
  };
8443
- const pushFrame = (s) => {
8444
- if (!s.loaded || disposed)
8445
- return;
8446
- if (s.kind === "image") {
8447
- const img = s.el;
8448
- if (!(img.complete && img.naturalWidth))
8449
- return;
8450
- if (renderer === "gpu" && gpu) {
8451
- if (!gpu.upload(s.index, img, img.naturalWidth, img.naturalHeight))
8452
- return miss(s);
8453
- }
8454
- s.shown = { frame: 0, cached: false };
8455
- return requestDraw();
8456
- }
8457
- const v = s.el;
8458
- if (v.readyState < 2)
8459
- return;
8460
- if (isPlaying() || !s.frames)
8461
- return showVideo(s);
8462
- const want = wantOf(s);
8463
- const at = frameOf(v.currentTime, fps());
8464
- if (at === want) {
8465
- showVideo(s);
8466
- s.frames.keep(at, v, v.videoWidth, v.videoHeight);
8467
- return;
8468
- }
8469
- const c = s.frames.nearest(want);
8470
- if (c && Math.abs(c.frame - want) < Math.abs(at - want))
8471
- showCached(s, c);
8797
+ const clock = () => {
8798
+ const m = master();
8799
+ if (!m || !near)
8800
+ return Number.NaN;
8801
+ if (m.readyState < 1 || m.seeking)
8802
+ return tl.time;
8803
+ return m.ended ? Number.POSITIVE_INFINITY : m.currentTime;
8804
+ };
8805
+ const ownSeek = (t) => {
8806
+ ownTime = Math.min(Math.max(0, t), duration);
8807
+ if (ownPlaying)
8808
+ jumpAll(ownTime);
8472
8809
  else
8473
- showVideo(s);
8810
+ showFrame(ownTime, true);
8474
8811
  };
8475
- const refreshCached = (s) => {
8476
- if (!s.loaded || !s.frames || isPlaying())
8477
- return;
8478
- const want = wantOf(s);
8479
- if (s.shown && !s.shown.cached && s.shown.frame === want)
8480
- return;
8481
- const c = s.frames.nearest(want);
8482
- if (!c)
8812
+ const ownPlay = () => {
8813
+ if (ownPlaying || !master())
8483
8814
  return;
8484
- const was = s.shown ? Math.abs(s.shown.frame - want) : Number.POSITIVE_INFINITY;
8485
- if (Math.abs(c.frame - want) < was)
8486
- showCached(s, c);
8815
+ if (ownLoop && (ownTime < ownLoop.start || ownTime >= ownLoop.end - 0.5 / fps()))
8816
+ ownTime = ownLoop.start;
8817
+ else if (!ownLoop && ownTime >= duration - 0.5 / fps())
8818
+ ownTime = 0;
8819
+ ownPlaying = true;
8820
+ startVideos();
8487
8821
  };
8488
- const makeSide = (index, source) => {
8489
- const kind = mediaKind(source.src, source.kind);
8490
- const media = el(kind === "video" ? "video" : "img", "knobs-cmp-media");
8491
- media.dataset.side = index === 0 ? "before" : "after";
8492
- media.crossOrigin = "anonymous";
8493
- media.setAttribute("aria-hidden", "true");
8494
- const preview = el("canvas", "knobs-cmp-media knobs-cmp-preview");
8495
- preview.dataset.side = media.dataset.side;
8496
- preview.style.display = "none";
8497
- preview.setAttribute("aria-hidden", "true");
8498
- const offs = [];
8499
- const on = (type, f) => {
8500
- media.addEventListener(type, f);
8501
- offs.push(() => media.removeEventListener(type, f));
8502
- };
8503
- const side = {
8504
- index,
8505
- source,
8506
- kind,
8507
- el: media,
8508
- preview,
8509
- size: { w: 0, h: 0 },
8510
- seek: SEEK_IDLE,
8511
- loaded: false,
8512
- frames: null,
8513
- unlisten: null,
8514
- seen: false,
8515
- shown: null,
8516
- frameAt: 0,
8517
- misses: 0,
8518
- failed: false,
8519
- stop: () => {
8520
- for (const f of offs)
8521
- f();
8522
- }
8523
- };
8524
- const fail = () => {
8525
- if (!side.loaded)
8526
- return;
8527
- side.failed = true;
8528
- note.textContent = `Couldn't load ${labelOf(index)}`;
8529
- };
8530
- if (media instanceof HTMLVideoElement) {
8531
- const v = media;
8532
- v.muted = true;
8533
- v.defaultMuted = true;
8534
- v.playsInline = true;
8535
- v.preload = "auto";
8536
- v.setAttribute("playsinline", "");
8537
- v.setAttribute("muted", "");
8538
- on("loadedmetadata", () => {
8539
- side.size = { w: v.videoWidth, h: v.videoHeight };
8540
- measureDuration();
8541
- if (auto)
8542
- measure();
8543
- if (isPlaying())
8544
- jumpAll(now());
8545
- else
8546
- showFrame(now(), false);
8547
- requestDraw();
8548
- });
8549
- on("resize", () => {
8550
- side.size = { w: v.videoWidth, h: v.videoHeight };
8551
- requestDraw();
8552
- });
8553
- on("loadeddata", () => pushFrame(side));
8554
- on("seeked", () => {
8555
- const step = seeked(side.seek, v.currentTime, fps());
8556
- side.seek = step.state;
8557
- if (step.apply !== null)
8558
- seekVideo(side, v, step.apply);
8559
- pushFrame(side);
8560
- });
8561
- on("error", fail);
8562
- if (v.requestVideoFrameCallback) {
8563
- let id = 0;
8564
- const frame = () => {
8565
- side.frameAt = performance.now();
8566
- pushFrame(side);
8567
- id = v.requestVideoFrameCallback(frame);
8568
- };
8569
- id = v.requestVideoFrameCallback(frame);
8570
- offs.push(() => v.cancelVideoFrameCallback?.(id));
8571
- }
8572
- } else {
8573
- on("load", () => {
8574
- side.size = { w: media.naturalWidth, h: media.naturalHeight };
8575
- if (auto)
8576
- measure();
8577
- pushFrame(side);
8578
- requestDraw();
8579
- });
8580
- on("error", fail);
8581
- media.decoding = "async";
8582
- }
8583
- return side;
8584
- };
8585
- const loadSide = (s) => {
8586
- if (s.loaded || disposed)
8587
- return;
8588
- s.loaded = true;
8589
- s.failed = false;
8590
- s.el.src = s.source.src;
8591
- if (s.kind === "video") {
8592
- s.frames = acquireFrames(doc, s.source.src, fps());
8593
- s.unlisten = s.frames.listen(() => refreshCached(s));
8594
- if (onScreen) {
8595
- s.frames.visible(true);
8596
- s.seen = true;
8597
- }
8598
- }
8599
- };
8600
- const unloadSide = (s) => {
8601
- if (!s.loaded)
8822
+ const ownPause = () => {
8823
+ if (!ownPlaying)
8602
8824
  return;
8603
- s.loaded = false;
8604
- s.el.removeAttribute("src");
8605
- if (s.el instanceof HTMLVideoElement)
8606
- s.el.load();
8607
- s.seek = SEEK_IDLE;
8608
- s.shown = null;
8609
- setCached(s, false);
8610
- s.unlisten?.();
8611
- s.unlisten = null;
8612
- if (s.frames) {
8613
- if (s.seen)
8614
- s.frames.visible(false);
8615
- s.seen = false;
8616
- s.frames.release();
8617
- s.frames = null;
8618
- }
8619
- gpu?.clear(s.index);
8825
+ ownPlaying = false;
8826
+ ownTime = Math.min(duration, frameOf(ownTime, fps()) / fps());
8827
+ stopVideos();
8620
8828
  };
8621
- const measureDuration = () => {
8622
- const next = Math.max(0, ...sides.map((s) => {
8623
- const v = videoOf(s);
8624
- return v && Number.isFinite(v.duration) ? v.duration : 0;
8625
- }));
8626
- if (next <= 0 || next === duration)
8627
- return;
8628
- duration = next;
8629
- if (ownTimeline && tl)
8630
- tl.update({ duration, fps: fps() });
8829
+ const zoomNow = () => ({ u: zu.x, v: zv.x, w: zw.x, h: zw.x });
8830
+ const zoomed = () => zw.target < 0.999;
8831
+ const layoutNow = () => {
8832
+ const box = { x: 0, y: 0, w: stageW, h: stageH };
8833
+ const L = compareLayout(box, sizeOf(sides[0]), sizeOf(sides[1]));
8834
+ const k = lay.x;
8835
+ const before = lerpRect(L.side.before, L.overlay.before, k);
8836
+ const after = lerpRect(L.side.after, L.overlay.after, k);
8837
+ const z = zoomNow();
8838
+ return { L, before, after, zBefore: zoomedRect(before, z), zAfter: zoomedRect(after, z) };
8631
8839
  };
8632
- const setRenderer = (next) => {
8633
- pending = false;
8634
- renderer = next;
8635
- root.dataset.renderer = next;
8636
- loupeBtn.hidden = next !== "gpu";
8637
- canvas.style.display = next === "gpu" ? "" : "none";
8638
- for (const s of sides) {
8639
- const st = s.el.style;
8640
- st.clipPath = "";
8641
- st.maskImage = "";
8642
- st.setProperty("-webkit-mask-image", "");
8643
- st.mixBlendMode = "";
8644
- st.opacity = "";
8645
- s.shown = null;
8646
- setCached(s, false);
8647
- }
8648
- for (const s of sides)
8649
- pushFrame(s);
8650
- requestDraw();
8840
+ const wipeRect = (r) => {
8841
+ const x = Math.min(r.before.x, r.after.x);
8842
+ const y = Math.min(r.before.y, r.after.y);
8843
+ const x1 = Math.max(r.before.x + r.before.w, r.after.x + r.after.w);
8844
+ const y1 = Math.max(r.before.y + r.before.h, r.after.y + r.after.h);
8845
+ return { x, y, w: x1 - x, h: y1 - y };
8651
8846
  };
8652
- const startGpu = async (retry) => {
8653
- const g = await createCompareGpu(canvas, () => {
8654
- gpu = null;
8655
- if (disposed)
8656
- return;
8657
- if (retry)
8658
- win.setTimeout(() => void startGpu(false), 150);
8659
- else
8660
- setRenderer("dom");
8661
- });
8662
- if (disposed)
8663
- return g?.dispose();
8664
- if (!g)
8665
- return setRenderer("dom");
8666
- gpu = g;
8667
- setRenderer("gpu");
8847
+ const baseAt = (p) => {
8848
+ const r = layoutNow();
8849
+ const inside = (b) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
8850
+ return inside(r.after) || !inside(r.before) ? r.after : r.before;
8668
8851
  };
8669
- const syncModeUi = () => {
8670
- root.dataset.mode = mode;
8671
- modeList.forEach((m, i) => {
8672
- modeBtns[i].setAttribute("aria-checked", String(m === mode));
8673
- });
8674
- handle.tabIndex = mode === "wipe" ? 0 : -1;
8675
- modePill?.update();
8852
+ const loupeShown = () => renderer === "gpu" && (loupeOn || loupeHeld) && pointer !== null;
8853
+ const loopNow = () => {
8854
+ if (!tl)
8855
+ return ownLoop;
8856
+ const r = tl.ranges.find((x) => x.loop);
8857
+ return r ? { start: r.start, end: r.end } : null;
8676
8858
  };
8677
- const setMode = (next, emit = true) => {
8678
- if (next === mode || !modeList.includes(next))
8679
- return;
8680
- mode = next;
8681
- const reduced = reducedMotion(win);
8682
- tune(fade, reduced ? FADE_REDUCED : FADE2);
8683
- if (fade.x >= 0.5) {
8684
- fromMode = toMode;
8685
- fade.snap(0);
8686
- }
8687
- toMode = next;
8688
- fade.set(1);
8689
- if (reduced)
8690
- lay.snap(next === "side" ? 0 : 1);
8691
- else
8692
- lay.set(next === "side" ? 0 : 1);
8693
- syncModeUi();
8694
- if (emit)
8695
- options.onMode?.(next);
8859
+ const syncLoopUi = () => {
8860
+ loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
8696
8861
  requestDraw();
8697
8862
  };
8698
- const setWipe = (next, emit = true, jump = false) => {
8699
- const w = Math.min(1, Math.max(0, next));
8700
- if (w === wipe && !jump)
8701
- return;
8702
- wipe = w;
8703
- const minGap = 2 / Math.max(1, wipeRect(layoutNow().L.overlay).w);
8704
- const head = moveHead({ x: wipeLine.x, target: wipeLine.target }, w, jump && !reducedMotion(win) ? "jump" : "follow", wipeCatching, minGap);
8705
- wipeCatching = head.catching;
8706
- if (head.catching) {
8707
- wipeLine.x = head.x;
8708
- wipeLine.target = head.target;
8709
- } else
8710
- wipeLine.snap(head.x);
8711
- if (emit)
8712
- options.onWipe?.(w);
8713
- if (head.catching)
8714
- requestDraw();
8715
- else
8716
- drawNow();
8717
- };
8718
- const setLoupe = (on) => {
8719
- loupeOn = on;
8720
- loupeBtn.setAttribute("aria-pressed", String(on));
8721
- requestDraw();
8722
- };
8723
- const clampTime = (v, t) => Math.min(Math.max(0, t), Math.max(0, (Number.isFinite(v.duration) ? v.duration : t) - 0.001));
8724
- const seekVideo = (s, v, t) => {
8725
- const c = s.frames?.nearest(wantOf(s));
8726
- const far = !c || Math.abs(c.frame - wantOf(s)) > FAST_SEEK_FRAMES;
8727
- if (scrubbing && far && v.fastSeek)
8728
- v.fastSeek(t);
8729
- else
8730
- v.currentTime = t;
8731
- };
8732
- const showFrame = (t, used) => {
8733
- for (const s of sides) {
8734
- const v = videoOf(s);
8735
- if (!v || v.readyState < 1)
8736
- continue;
8737
- if (!v.paused) {
8738
- v.pause();
8739
- v.playbackRate = 1;
8740
- }
8741
- const want = Math.min(frameOf(t, fps()), lastFrame(v.duration, fps()));
8742
- const step = requestSeek(s.seek, frameMidpoint(want, fps(), v.duration), v.currentTime, fps());
8743
- s.seek = step.state;
8744
- if (step.apply !== null)
8745
- seekVideo(s, v, step.apply);
8746
- else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
8747
- pushFrame(s);
8748
- if (used)
8749
- s.frames?.focus(want);
8750
- refreshCached(s);
8751
- }
8752
- requestDraw();
8753
- };
8754
- const jumpAll = (t) => {
8755
- for (const s of sides) {
8756
- const v = videoOf(s);
8757
- if (!v || v.readyState < 1)
8758
- continue;
8759
- s.seek = SEEK_IDLE;
8760
- v.currentTime = clampTime(v, t);
8761
- if (v.paused)
8762
- v.play().catch(() => {});
8763
- }
8764
- };
8765
- const startVideos = () => {
8766
- const t = now();
8767
- for (const s of sides) {
8768
- const v = videoOf(s);
8769
- if (!v)
8770
- continue;
8771
- s.seek = SEEK_IDLE;
8772
- v.playbackRate = 1;
8773
- if (v.readyState >= 1 && Math.abs(v.currentTime - t) > 1.5 / fps())
8774
- v.currentTime = clampTime(v, t);
8775
- v.play().catch(() => {});
8776
- }
8777
- loop.wake();
8778
- };
8779
- const pauseVideos = () => {
8780
- for (const s of sides) {
8781
- const v = videoOf(s);
8782
- if (v) {
8783
- v.pause();
8784
- v.playbackRate = 1;
8785
- }
8786
- }
8787
- };
8788
- const stopVideos = () => {
8789
- pauseVideos();
8790
- showFrame(now(), false);
8791
- };
8792
- const clock = () => {
8793
- const m = master();
8794
- if (!m || !near)
8795
- return Number.NaN;
8796
- if (m.readyState < 1 || m.seeking)
8797
- return tl.time;
8798
- return m.ended ? Number.POSITIVE_INFINITY : m.currentTime;
8799
- };
8800
- const ownSeek = (t) => {
8801
- ownTime = Math.min(Math.max(0, t), duration);
8802
- if (ownPlaying)
8803
- jumpAll(ownTime);
8804
- else
8805
- showFrame(ownTime, true);
8806
- };
8807
- const ownPlay = () => {
8808
- if (ownPlaying || !master())
8809
- return;
8810
- if (ownLoop && (ownTime < ownLoop.start || ownTime >= ownLoop.end - 0.5 / fps()))
8811
- ownTime = ownLoop.start;
8812
- else if (!ownLoop && ownTime >= duration - 0.5 / fps())
8813
- ownTime = 0;
8814
- ownPlaying = true;
8815
- startVideos();
8816
- };
8817
- const ownPause = () => {
8818
- if (!ownPlaying)
8819
- return;
8820
- ownPlaying = false;
8821
- ownTime = Math.min(duration, frameOf(ownTime, fps()) / fps());
8822
- stopVideos();
8823
- };
8824
- const zoomNow = () => ({ u: zu.x, v: zv.x, w: zw.x, h: zw.x });
8825
- const zoomed = () => zw.target < 0.999;
8826
- const layoutNow = () => {
8827
- const box = { x: 0, y: 0, w: stageW, h: stageH };
8828
- const L = compareLayout(box, sizeOf(sides[0]), sizeOf(sides[1]));
8829
- const k = lay.x;
8830
- const before = lerpRect(L.side.before, L.overlay.before, k);
8831
- const after = lerpRect(L.side.after, L.overlay.after, k);
8832
- const z = zoomNow();
8833
- return { L, before, after, zBefore: zoomedRect(before, z), zAfter: zoomedRect(after, z) };
8834
- };
8835
- const wipeRect = (r) => {
8836
- const x = Math.min(r.before.x, r.after.x);
8837
- const y = Math.min(r.before.y, r.after.y);
8838
- const x1 = Math.max(r.before.x + r.before.w, r.after.x + r.after.w);
8839
- const y1 = Math.max(r.before.y + r.before.h, r.after.y + r.after.h);
8840
- return { x, y, w: x1 - x, h: y1 - y };
8841
- };
8842
- const baseAt = (p) => {
8843
- const r = layoutNow();
8844
- const inside = (b) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
8845
- return inside(r.after) || !inside(r.before) ? r.after : r.before;
8846
- };
8847
- const loupeShown = () => renderer === "gpu" && (loupeOn || loupeHeld) && pointer !== null;
8848
- const loopNow = () => {
8849
- if (!tl)
8850
- return ownLoop;
8851
- const r = tl.ranges.find((x) => x.loop);
8852
- return r ? { start: r.start, end: r.end } : null;
8853
- };
8854
- const syncLoopUi = () => {
8855
- loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
8856
- requestDraw();
8857
- };
8858
- const sendView = () => {
8859
- if (!options.onView)
8863
+ const sendView = () => {
8864
+ if (!options.onView)
8860
8865
  return;
8861
8866
  const z = zoomNow();
8862
8867
  const l = loopNow();
@@ -9377,7 +9382,7 @@ function compare(container, options) {
9377
9382
  holdLoupe(true);
9378
9383
  return;
9379
9384
  case "l":
9380
- if (e.altKey || e.shiftKey)
9385
+ if (e.altKey || e.shiftKey || loopBtn.hidden)
9381
9386
  return;
9382
9387
  claim();
9383
9388
  return api.setLoop(!loopNow());
@@ -9400,6 +9405,9 @@ function compare(container, options) {
9400
9405
  return glideZoom(ZOOM_FIT);
9401
9406
  }
9402
9407
  };
9408
+ const syncLoopButton = () => {
9409
+ loopBtn.hidden = !sides.some((s) => s.kind === "video");
9410
+ };
9403
9411
  const replace = (index, source) => {
9404
9412
  const old = sides[index];
9405
9413
  old.stop();
@@ -9408,6 +9416,7 @@ function compare(container, options) {
9408
9416
  old.el.replaceWith(s.el);
9409
9417
  old.preview.replaceWith(s.preview);
9410
9418
  sides[index] = s;
9419
+ syncLoopButton();
9411
9420
  note.textContent = "";
9412
9421
  setLabel(index, labelOf(index));
9413
9422
  if (near)
@@ -9422,6 +9431,7 @@ function compare(container, options) {
9422
9431
  stage.insertBefore(sides[0].preview, labels[0]);
9423
9432
  setLabel(0, labelOf(0));
9424
9433
  setLabel(1, labelOf(1));
9434
+ syncLoopButton();
9425
9435
  loupeBtn.setAttribute("aria-pressed", String(loupeOn));
9426
9436
  loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
9427
9437
  const modePill = modeList.length > 1 ? selectionPill(modes, {
@@ -9500,7 +9510,7 @@ function compare(container, options) {
9500
9510
  label: MODE_LABEL[m].long,
9501
9511
  activate: () => setMode(m)
9502
9512
  })) : [],
9503
- { el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) },
9513
+ ...loopBtn.hidden ? [] : [{ el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) }],
9504
9514
  ...loupeBtn.hidden ? [] : [{ el: loupeBtn, label: "Loupe", activate: () => setLoupe(!loupeOn) }],
9505
9515
  ...zoomed() ? [{ el: zoomReset, label: "Reset zoom", activate: () => api.setZoom(null) }] : [],
9506
9516
  ...mode === "wipe" ? [
@@ -9511,14 +9521,6 @@ function compare(container, options) {
9511
9521
  }
9512
9522
  ] : []
9513
9523
  ], { listen: false, rank: 1 });
9514
- const strip = wantsStrip && stripHost && tl ? compareStrip(stripHost, {
9515
- ...look,
9516
- before: options.before,
9517
- after: options.after,
9518
- timeline: tl,
9519
- ...fpsOpt ? { fps: fpsOpt } : {},
9520
- threshold
9521
- }) : null;
9522
9524
  const api = {
9523
9525
  element: root,
9524
9526
  get mode() {
@@ -9544,7 +9546,6 @@ function compare(container, options) {
9544
9546
  return renderer;
9545
9547
  },
9546
9548
  timeline: tl,
9547
- strip,
9548
9549
  get zoom() {
9549
9550
  return clampZoom(zoomNow());
9550
9551
  },
@@ -9566,8 +9567,6 @@ function compare(container, options) {
9566
9567
  replace(1, after);
9567
9568
  else if (next.after)
9568
9569
  setLabel(1, next.after.label ?? "After");
9569
- if (before || after)
9570
- strip?.update({ ...before ? { before } : {}, ...after ? { after } : {} });
9571
9570
  if (next.before || next.after)
9572
9571
  readColors();
9573
9572
  if (next.mode !== undefined)
@@ -9634,7 +9633,6 @@ function compare(container, options) {
9634
9633
  if (ownTimeline)
9635
9634
  tl.dispose();
9636
9635
  }
9637
- strip?.dispose();
9638
9636
  for (const s of sides) {
9639
9637
  s.stop();
9640
9638
  unloadSide(s);
@@ -9658,7 +9656,6 @@ var init_compare = __esm(() => {
9658
9656
  init_compare_frames();
9659
9657
  init_compare_gpu();
9660
9658
  init_compare_model();
9661
- init_compare_strip();
9662
9659
  init_icons();
9663
9660
  init_look();
9664
9661
  init_rgba();
@@ -9716,7 +9713,10 @@ function player(container, options) {
9716
9713
  track.src = options.captions;
9717
9714
  v.append(track);
9718
9715
  }
9719
- frame.append(v);
9716
+ const preview = el("canvas", "knobs-player-preview");
9717
+ preview.setAttribute("aria-hidden", "true");
9718
+ preview.hidden = true;
9719
+ frame.append(v, preview);
9720
9720
  const host = el("div", "knobs-player-line");
9721
9721
  root.append(frame, host);
9722
9722
  container.append(root);
@@ -9746,6 +9746,57 @@ function player(container, options) {
9746
9746
  else
9747
9747
  v.currentTime = t;
9748
9748
  };
9749
+ const fps = options.fps ?? 30;
9750
+ let frames = null;
9751
+ let unlistenFrames = null;
9752
+ let scrubbing = false;
9753
+ let previewing = null;
9754
+ const want = () => frameOf(tl.time, fps);
9755
+ const emitFrame = (frame, source) => root.dispatchEvent(new CustomEvent("knobs-player-frame", { detail: { frame, source } }));
9756
+ const drawPreview = (c) => {
9757
+ if (preview.width !== c.bitmap.width || preview.height !== c.bitmap.height) {
9758
+ preview.width = c.bitmap.width;
9759
+ preview.height = c.bitmap.height;
9760
+ }
9761
+ preview.getContext("2d")?.drawImage(c.bitmap, 0, 0);
9762
+ preview.hidden = false;
9763
+ previewing = c.frame;
9764
+ emitFrame(c.frame, "cache");
9765
+ };
9766
+ const hidePreview = () => {
9767
+ if (previewing === null)
9768
+ return;
9769
+ previewing = null;
9770
+ preview.hidden = true;
9771
+ };
9772
+ const showNearest = () => {
9773
+ if (!frames || !scrubbing)
9774
+ return;
9775
+ const w = want();
9776
+ const c = frames.nearest(w, Number.POSITIVE_INFINITY, true);
9777
+ if (!c || c.frame === previewing)
9778
+ return;
9779
+ const shown = previewing ?? frameOf(v.currentTime, fps);
9780
+ if (Math.abs(c.frame - w) < Math.abs(shown - w))
9781
+ drawPreview(c);
9782
+ };
9783
+ const caching = scrubCaching(options.scrubCache, doc.defaultView);
9784
+ const startFrames = () => {
9785
+ if (frames || !caching)
9786
+ return;
9787
+ frames = acquireFrames(doc, options.src, fps);
9788
+ frames.visible(true);
9789
+ unlistenFrames = frames.listen(showNearest);
9790
+ };
9791
+ const stall = stallGuard(doc.defaultView, () => {
9792
+ pending = null;
9793
+ hidePreview();
9794
+ });
9795
+ const onError = () => {
9796
+ stall.clear();
9797
+ pending = null;
9798
+ hidePreview();
9799
+ };
9749
9800
  let reported = 0;
9750
9801
  const onSeeked = () => {
9751
9802
  if (pending !== null) {
@@ -9754,6 +9805,11 @@ function player(container, options) {
9754
9805
  v.currentTime = t;
9755
9806
  return;
9756
9807
  }
9808
+ stall.clear();
9809
+ const at = frameOf(v.currentTime, fps);
9810
+ emitFrame(at, "video");
9811
+ if (!scrubbing || previewing !== null && Math.abs(at - want()) <= Math.abs(previewing - want()))
9812
+ hidePreview();
9757
9813
  if (!v.paused || tl.playing || Math.abs(v.currentTime - reported) < 0.1)
9758
9814
  return;
9759
9815
  reported = v.currentTime;
@@ -9769,6 +9825,7 @@ function player(container, options) {
9769
9825
  v.addEventListener("loadedmetadata", onMeta);
9770
9826
  v.addEventListener("loadeddata", onFrame);
9771
9827
  v.addEventListener("seeked", onSeeked);
9828
+ v.addEventListener("error", onError);
9772
9829
  const unlisten = tl.listen({
9773
9830
  onSeek(t, source) {
9774
9831
  if (source === "play") {
@@ -9776,14 +9833,24 @@ function player(container, options) {
9776
9833
  v.currentTime = t;
9777
9834
  return;
9778
9835
  }
9779
- if (Math.abs(v.currentTime - t) < 1 / (options.fps ?? 30))
9836
+ if (scrubbing && frames) {
9837
+ frames.follow(want());
9838
+ showNearest();
9839
+ if (frames.covers(want()))
9840
+ return;
9841
+ }
9842
+ if (sameFrame(v.currentTime, t, fps)) {
9843
+ if (!scrubbing)
9844
+ hidePreview();
9780
9845
  return;
9846
+ }
9781
9847
  seekVideo(t);
9782
9848
  },
9783
9849
  onPlay(on) {
9784
- if (on)
9850
+ if (on) {
9851
+ hidePreview();
9785
9852
  v.play().catch(() => tl.pause());
9786
- else {
9853
+ } else {
9787
9854
  v.pause();
9788
9855
  reported = tl.time;
9789
9856
  }
@@ -9791,8 +9858,22 @@ function player(container, options) {
9791
9858
  options.onPlay?.(on, tl.time);
9792
9859
  },
9793
9860
  onScrub(on) {
9794
- if (on)
9861
+ scrubbing = on;
9862
+ if (on) {
9795
9863
  v.pause();
9864
+ startFrames();
9865
+ return;
9866
+ }
9867
+ if (v.readyState < 1)
9868
+ return hidePreview();
9869
+ const at = releaseSeek(v.currentTime, tl.time, fps, v.duration);
9870
+ if (at === null) {
9871
+ pending = null;
9872
+ if (!v.seeking)
9873
+ return hidePreview();
9874
+ } else
9875
+ seekVideo(at);
9876
+ stall.arm();
9796
9877
  }
9797
9878
  });
9798
9879
  tl.drive(() => {
@@ -9807,6 +9888,7 @@ function player(container, options) {
9807
9888
  tl.toggle();
9808
9889
  };
9809
9890
  frame.addEventListener("click", onClick);
9891
+ root.addEventListener("pointerenter", startFrames);
9810
9892
  v.src = options.src;
9811
9893
  const api = {
9812
9894
  element: root,
@@ -9833,9 +9915,18 @@ function player(container, options) {
9833
9915
  tl.dispose();
9834
9916
  v.pause();
9835
9917
  frame.removeEventListener("click", onClick);
9918
+ root.removeEventListener("pointerenter", startFrames);
9919
+ unlistenFrames?.();
9920
+ if (frames) {
9921
+ frames.visible(false);
9922
+ frames.release();
9923
+ frames = null;
9924
+ }
9836
9925
  v.removeEventListener("loadedmetadata", onMeta);
9837
9926
  v.removeEventListener("loadeddata", onFrame);
9838
9927
  v.removeEventListener("seeked", onSeeked);
9928
+ v.removeEventListener("error", onError);
9929
+ stall.clear();
9839
9930
  v.removeAttribute("src");
9840
9931
  v.load();
9841
9932
  root.remove();
@@ -9844,6 +9935,8 @@ function player(container, options) {
9844
9935
  return api;
9845
9936
  }
9846
9937
  var init_player = __esm(() => {
9938
+ init_compare_frames();
9939
+ init_compare_model();
9847
9940
  init_look();
9848
9941
  init_timeline();
9849
9942
  });
@@ -15687,8 +15780,66 @@ init_selection_pill();
15687
15780
  init_keys();
15688
15781
  init_layer();
15689
15782
  init_overlay_model();
15690
- var tips = new WeakMap;
15783
+
15784
+ // src/ui/tooltip-model.ts
15691
15785
  var HANDOFF_MS = 400;
15786
+ var TIP_DELAY_MS = 500;
15787
+ function tipSchedule(clock, view) {
15788
+ let shown = null;
15789
+ let pending = null;
15790
+ let timer;
15791
+ let hiddenAt = -Infinity;
15792
+ const cancel = () => {
15793
+ if (pending)
15794
+ clock.clear(timer);
15795
+ pending = null;
15796
+ };
15797
+ const hide = (now = false) => {
15798
+ cancel();
15799
+ if (!shown)
15800
+ return;
15801
+ shown = null;
15802
+ hiddenAt = clock.now();
15803
+ view.hide(now);
15804
+ };
15805
+ return {
15806
+ get shown() {
15807
+ return shown;
15808
+ },
15809
+ get pending() {
15810
+ return pending;
15811
+ },
15812
+ want(tip, options = {}) {
15813
+ if (tip.el === shown?.el || tip.el === pending?.el)
15814
+ return;
15815
+ cancel();
15816
+ const handoff = shown !== null || clock.now() - hiddenAt < HANDOFF_MS;
15817
+ if (handoff || options.now) {
15818
+ shown = tip;
15819
+ view.show(tip, handoff);
15820
+ return;
15821
+ }
15822
+ pending = tip;
15823
+ timer = clock.set(() => {
15824
+ pending = null;
15825
+ if (!view.connected(tip))
15826
+ return;
15827
+ shown = tip;
15828
+ view.show(tip, false);
15829
+ }, options.delay ?? TIP_DELAY_MS);
15830
+ },
15831
+ hide,
15832
+ leave(within) {
15833
+ if (pending && within(pending))
15834
+ cancel();
15835
+ if (shown && within(shown))
15836
+ hide();
15837
+ }
15838
+ };
15839
+ }
15840
+
15841
+ // src/ui/tooltip.ts
15842
+ var tips = new WeakMap;
15692
15843
  var controllers = new WeakMap;
15693
15844
  function tipOf(start) {
15694
15845
  let node = start instanceof Element ? start : null;
@@ -15722,9 +15873,6 @@ function controllerOf(doc) {
15722
15873
  let text;
15723
15874
  let keys;
15724
15875
  let target = null;
15725
- let pending = null;
15726
- let timer = 0;
15727
- let hiddenAt = -Infinity;
15728
15876
  let quiet = null;
15729
15877
  let keyboard = false;
15730
15878
  const build = (look) => {
@@ -15769,41 +15917,32 @@ function controllerOf(doc) {
15769
15917
  else
15770
15918
  motion.show();
15771
15919
  };
15772
- const hide = (now = false) => {
15773
- win.clearTimeout(timer);
15774
- pending = null;
15775
- if (!target)
15776
- return;
15777
- target.removeAttribute("aria-describedby");
15778
- target = null;
15779
- hiddenAt = performance.now();
15780
- if (now)
15781
- motion?.snap(0);
15782
- else
15783
- motion?.hide();
15784
- };
15785
- const want = (found) => {
15920
+ const schedule = tipSchedule({
15921
+ set: (run, ms) => win.setTimeout(run, ms),
15922
+ clear: (handle) => win.clearTimeout(handle),
15923
+ now: () => performance.now()
15924
+ }, {
15925
+ show: ({ el, tip }, snap) => show(el, tip, snap),
15926
+ hide: (now) => {
15927
+ target?.removeAttribute("aria-describedby");
15928
+ target = null;
15929
+ if (now)
15930
+ motion?.snap(0);
15931
+ else
15932
+ motion?.hide();
15933
+ },
15934
+ connected: ({ el }) => el.isConnected
15935
+ });
15936
+ const hide = (now = false) => schedule.hide(now);
15937
+ const want = (found, now = false) => {
15786
15938
  if (!found)
15787
15939
  return hide();
15788
- if (found.el === target || found.el === pending?.el)
15789
- return;
15790
15940
  if (found.el === quiet)
15791
15941
  return;
15792
- win.clearTimeout(timer);
15793
- const handoff = target !== null || performance.now() - hiddenAt < HANDOFF_MS;
15794
- if (handoff) {
15795
- pending = null;
15796
- show(found.el, found.tip, true);
15797
- return;
15798
- }
15799
- if (target)
15800
- hide();
15801
- pending = found;
15802
- timer = win.setTimeout(() => {
15803
- pending = null;
15804
- if (found.el.isConnected)
15805
- show(found.el, found.tip, false);
15806
- }, found.tip.opts.delay ?? 500);
15942
+ schedule.want(found, {
15943
+ now,
15944
+ ...found.tip.opts.delay !== undefined ? { delay: found.tip.opts.delay } : {}
15945
+ });
15807
15946
  };
15808
15947
  doc.addEventListener("pointerover", (e) => {
15809
15948
  if (e.pointerType === "touch")
@@ -15837,11 +15976,10 @@ function controllerOf(doc) {
15837
15976
  doc.addEventListener("focusin", (e) => {
15838
15977
  const found = tipOf(e.target);
15839
15978
  if (found && keyboard && e.target.matches(":focus-visible"))
15840
- want(found);
15979
+ want(found, true);
15841
15980
  });
15842
15981
  doc.addEventListener("focusout", (e) => {
15843
- if (target && target.contains(e.target))
15844
- hide();
15982
+ schedule.leave(({ el }) => el.contains(e.target));
15845
15983
  });
15846
15984
  doc.addEventListener("scroll", () => target && hide(true), true);
15847
15985
  const c = { hide };
@@ -16134,458 +16272,832 @@ function split(container, options) {
16134
16272
  };
16135
16273
  }
16136
16274
  };
16137
- measure();
16138
- const rail = (id) => {
16139
- const v = views.get(id);
16140
- return !row && v.head ? headerSize(v) : railPx;
16141
- };
16142
- const fixed = () => {
16143
- const out = {};
16144
- for (const [id, s] of springs)
16145
- out[id] = s.x;
16146
- return out;
16147
- };
16148
- const target = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail });
16149
- const current = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail, fixed: fixed() });
16150
- const SIDES = { row: ["right", "left"], column: ["bottom", "top"] };
16151
- const sides = (v) => {
16152
- const [a, b] = SIDES[direction];
16153
- return v.edge === "start" ? [a, b] : [b, a];
16154
- };
16155
- const renderFold = (v, size) => {
16156
- const id = v.spec.id;
16157
- const isCollapsed = collapsed[id];
16158
- const folding = isCollapsed || v.folding;
16159
- const r = rail(id);
16160
- const open = folding ? Math.max(v.open, r + 1) : size;
16161
- const p = folding ? foldProgress(size, open, r) : 0;
16162
- v.faces = foldFaces(v.faces, p);
16163
- const m = marginOf(v);
16164
- const settled = !springs.has(id);
16165
- const content = isCollapsed && settled ? 1 : v.faces.content;
16166
- put(v.rail ? v.inner : v.body, "will-change", v.folding ? "opacity, filter" : "");
16167
- if (!v.rail) {
16168
- put(v.inner, "height", folding ? `${open - m * 2}px` : "");
16169
- put(v.inner, "bottom", folding ? "auto" : "");
16170
- fade(v.body, content, CONTENT_BLUR);
16171
- if (v.toggleIcon)
16172
- put(v.toggleIcon, "transform", p ? `rotate(${(-90 * p).toFixed(2)}deg)` : "");
16173
- hide(v.body, isCollapsed && settled);
16275
+ measure();
16276
+ const rail = (id) => {
16277
+ const v = views.get(id);
16278
+ return !row && v.head ? headerSize(v) : railPx;
16279
+ };
16280
+ const fixed = () => {
16281
+ const out = {};
16282
+ for (const [id, s] of springs)
16283
+ out[id] = s.x;
16284
+ return out;
16285
+ };
16286
+ const target = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail });
16287
+ const current = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail, fixed: fixed() });
16288
+ const SIDES = { row: ["right", "left"], column: ["bottom", "top"] };
16289
+ const sides = (v) => {
16290
+ const [a, b] = SIDES[direction];
16291
+ return v.edge === "start" ? [a, b] : [b, a];
16292
+ };
16293
+ const renderFold = (v, size) => {
16294
+ const id = v.spec.id;
16295
+ const isCollapsed = collapsed[id];
16296
+ const folding = isCollapsed || v.folding;
16297
+ const r = rail(id);
16298
+ const open = folding ? Math.max(v.open, r + 1) : size;
16299
+ const p = folding ? foldProgress(size, open, r) : 0;
16300
+ v.faces = foldFaces(v.faces, p);
16301
+ const m = marginOf(v);
16302
+ const settled = !springs.has(id);
16303
+ const content = isCollapsed && settled ? 1 : v.faces.content;
16304
+ put(v.rail ? v.inner : v.body, "will-change", v.folding ? "opacity, filter" : "");
16305
+ if (!v.rail) {
16306
+ put(v.inner, "height", folding ? `${open - m * 2}px` : "");
16307
+ put(v.inner, "bottom", folding ? "auto" : "");
16308
+ fade(v.body, content, CONTENT_BLUR);
16309
+ if (v.toggleIcon)
16310
+ put(v.toggleIcon, "transform", p ? `rotate(${(-90 * p).toFixed(2)}deg)` : "");
16311
+ hide(v.body, isCollapsed && settled);
16312
+ return;
16313
+ }
16314
+ const [side, far] = sides(v);
16315
+ put(v.inner, row ? "width" : "height", folding ? `${open - m * 2}px` : "");
16316
+ put(v.inner, side, folding ? "0" : "");
16317
+ put(v.inner, far, folding ? "auto" : "");
16318
+ fade(v.inner, content, CONTENT_BLUR);
16319
+ hide(v.clip, isCollapsed && settled);
16320
+ const morph = row && Boolean(v.toggleIcon) && !v.el.hasAttribute("data-nested");
16321
+ v.el.toggleAttribute("data-morph", morph);
16322
+ const label = v.railLabel;
16323
+ const travel = morph && style.title === "travel" && Boolean(v.titleEl) && label.textContent === v.titleEl.textContent;
16324
+ v.el.toggleAttribute("data-travel", travel && folding);
16325
+ if (v.countEl)
16326
+ put(v.countEl, "opacity", travel && folding && p > 0 ? Math.max(0, 1 - p / COUNT_CLEAR).toFixed(3) : "");
16327
+ const k = 1 - p;
16328
+ if (travel && !folding) {
16329
+ put(label, "opacity", "0");
16330
+ put(label, "filter", "");
16331
+ put(label, "transform", "");
16332
+ put(label, "color", "");
16333
+ } else if (travel) {
16334
+ const t = titleTravel(v.places, open, r, v.edge, p);
16335
+ put(label, "opacity", "");
16336
+ put(label, "filter", "");
16337
+ put(label, "transform", k === 0 ? "" : `translate(${t.x.toFixed(2)}px,${t.y.toFixed(2)}px) rotate(${t.turn.toFixed(2)}deg)`);
16338
+ put(label, "color", k === 0 ? "" : `color-mix(in srgb,var(--kv-fg) ${(k * 100).toFixed(1)}%,var(--kv-fg-2))`);
16339
+ } else {
16340
+ put(label, "transform", "");
16341
+ put(label, "color", "");
16342
+ }
16343
+ if (morph) {
16344
+ put(v.rail, "opacity", "1");
16345
+ if (!travel)
16346
+ fade(label, v.faces.rail, LABEL_BLUR);
16347
+ const c = v.chev;
16348
+ const dx = c.chevEnd - c.iconEnd + (v.edge === "end" ? open - r : 0);
16349
+ const move = `translate(${(dx * k).toFixed(2)}px,${(c.dy * k).toFixed(2)}px)`;
16350
+ const size = `scale(${(1 + (c.scale - 1) * k).toFixed(4)})`;
16351
+ if (style.chevron === "turn")
16352
+ put(v.turn, "transform", k === 0 ? "" : `${move} rotate(${chevronTurn(p, v.edge).toFixed(2)}deg) ${size}`);
16353
+ else {
16354
+ const turn = chevronFlip(v.flip.x, v.edge);
16355
+ const over = v.flip.resting ? turn === 0 ? "" : "scaleX(-1)" : `perspective(${FLIP_DEPTH}px) rotateY(${turn.toFixed(2)}deg)`;
16356
+ put(v.turn, "transform", k === 0 && !over ? "" : `${move} ${size} ${over}`);
16357
+ }
16358
+ } else {
16359
+ const a = v.faces.rail;
16360
+ put(v.rail, "opacity", a >= 1 ? "1" : a.toFixed(3));
16361
+ put(label, "opacity", "");
16362
+ put(label, "filter", a >= 1 || a <= 0 ? "" : `blur(${(LABEL_BLUR * (1 - a)).toFixed(2)}px)`);
16363
+ put(v.turn, "transform", "");
16364
+ }
16365
+ };
16366
+ let lastReport = "";
16367
+ const render = (shown) => {
16368
+ for (const spec of specs) {
16369
+ const v = views.get(spec.id);
16370
+ const size = Math.max(0, shown[spec.id] ?? 0);
16371
+ v.shown = size;
16372
+ put(v.el, "flex-basis", `${size}px`);
16373
+ put(v.el, row ? "width" : "height", `${size}px`);
16374
+ v.el.toggleAttribute("data-collapsed", collapsed[spec.id]);
16375
+ v.el.toggleAttribute("data-settled", !springs.has(spec.id));
16376
+ if (spec.collapsible)
16377
+ renderFold(v, size);
16378
+ }
16379
+ dividers.forEach((d, i) => {
16380
+ const a = specs[i];
16381
+ const b = specs[i + 1];
16382
+ const now = String(Math.round(views.get(a.id).shown));
16383
+ if (d.getAttribute("aria-valuenow") !== now)
16384
+ d.setAttribute("aria-valuenow", now);
16385
+ if (!d.hasAttribute("aria-controls"))
16386
+ d.setAttribute("aria-controls", `${a.id} ${b.id}`);
16387
+ });
16388
+ const layout = snapshot();
16389
+ const report = JSON.stringify(layout);
16390
+ if (report !== lastReport) {
16391
+ lastReport = report;
16392
+ options.onLayout?.(layout);
16393
+ for (const v of views.values())
16394
+ v.el.dispatchEvent(new Event("knobs-pane-resize"));
16395
+ notifyLayout(doc);
16396
+ }
16397
+ };
16398
+ const snapshot = () => {
16399
+ const s = {};
16400
+ for (const [id, v] of views)
16401
+ s[id] = Math.round(v.shown * 10) / 10;
16402
+ return { sizes: s, collapsed: { ...collapsed } };
16403
+ };
16404
+ const persist = () => {
16405
+ if (!store || !key)
16406
+ return;
16407
+ try {
16408
+ store.setItem(key, serializeLayout({ sizes, collapsed }));
16409
+ } catch {}
16410
+ };
16411
+ const rememberOpen = (shown) => {
16412
+ for (const spec of specs)
16413
+ if (!collapsed[spec.id] && !springs.has(spec.id))
16414
+ views.get(spec.id).open = shown[spec.id];
16415
+ };
16416
+ const loop = frameLoop(win, (dt) => {
16417
+ for (const [id, s] of springs) {
16418
+ s.step(dt);
16419
+ if (s.resting) {
16420
+ springs.delete(id);
16421
+ views.get(id).folding = false;
16422
+ }
16423
+ }
16424
+ let flipping = false;
16425
+ for (const v of views.values()) {
16426
+ v.flip.step(dt);
16427
+ if (!v.flip.resting)
16428
+ flipping = true;
16429
+ }
16430
+ const shown = current();
16431
+ if (springs.size === 0)
16432
+ rememberOpen(shown);
16433
+ render(shown);
16434
+ return springs.size > 0 || flipping;
16435
+ });
16436
+ const animate = (motion = SETTLE) => {
16437
+ const to = target();
16438
+ for (const v of views.values())
16439
+ if (v.folding && !collapsed[v.spec.id])
16440
+ v.open = to[v.spec.id];
16441
+ if (reducedMotion(win)) {
16442
+ springs.clear();
16443
+ const arriving = [...views.values()].filter((v) => v.folding).map((v) => collapsed[v.spec.id] ? v.rail : v.rail ? v.inner : v.body);
16444
+ for (const v of views.values()) {
16445
+ v.folding = false;
16446
+ v.flip.snap(collapsed[v.spec.id] ? 1 : 0);
16447
+ }
16448
+ rememberOpen(to);
16449
+ render(to);
16450
+ for (const el of arriving)
16451
+ el?.animate([{ opacity: 0 }, { opacity: 1 }], {
16452
+ duration: 160,
16453
+ easing: "cubic-bezier(.2,.8,.2,1)"
16454
+ });
16455
+ return;
16456
+ }
16457
+ for (const spec of specs) {
16458
+ const v = views.get(spec.id);
16459
+ const goal = to[spec.id];
16460
+ const s = springs.get(spec.id);
16461
+ if (s) {
16462
+ s.omega = motion.omega;
16463
+ s.zeta = motion.zeta;
16464
+ s.set(goal);
16465
+ } else if (Math.abs(goal - v.shown) > 0.5) {
16466
+ springs.set(spec.id, new Spring(v.shown, motion.omega, motion.zeta, 0.2).set(goal));
16467
+ } else
16468
+ v.folding = false;
16469
+ }
16470
+ loop.wake();
16471
+ };
16472
+ const fold = (id, value) => {
16473
+ const v = views.get(id);
16474
+ if (value && !v.folding)
16475
+ v.open = v.shown;
16476
+ collapsed[id] = value;
16477
+ v.folding = true;
16478
+ v.flip.set(value ? 1 : 0);
16479
+ };
16480
+ const release = (id) => {
16481
+ springs.delete(id);
16482
+ views.get(id).folding = false;
16483
+ };
16484
+ function setCollapsed(id, value, save = true) {
16485
+ const spec = specs.find((p) => p.id === id);
16486
+ if (!spec?.collapsible || collapsed[id] === value)
16487
+ return;
16488
+ fold(id, value);
16489
+ if (save)
16490
+ persist();
16491
+ animate(COLLAPSE);
16492
+ }
16493
+ const setSizes = (next) => {
16494
+ for (const [id, s] of Object.entries(next))
16495
+ sizes[id] = s;
16496
+ };
16497
+ function bindDivider(d, index) {
16498
+ const a = specs[index];
16499
+ const b = specs[index + 1];
16500
+ let start;
16501
+ let from = 0;
16502
+ let pointer = -1;
16503
+ d.addEventListener("pointerdown", (e) => {
16504
+ if (e.button !== 0)
16505
+ return;
16506
+ e.preventDefault();
16507
+ d.focus({ preventScroll: true });
16508
+ const shown = current();
16509
+ for (const id of [a.id, b.id])
16510
+ if (!collapsed[id])
16511
+ release(id);
16512
+ start = { ...shown };
16513
+ for (const id of [a.id, b.id])
16514
+ if (collapsed[id])
16515
+ start[id] = rail(id);
16516
+ from = row ? e.clientX : e.clientY;
16517
+ pointer = e.pointerId;
16518
+ d.setPointerCapture(e.pointerId);
16519
+ d.dataset.active = "";
16520
+ doc.documentElement.dataset.knobsResizing = direction;
16521
+ });
16522
+ d.addEventListener("pointermove", (e) => {
16523
+ if (!start || e.pointerId !== pointer)
16524
+ return;
16525
+ const delta = (row ? e.clientX : e.clientY) - from;
16526
+ const result = drag(specs, start, index, delta, collapsed);
16527
+ if (result.collapse) {
16528
+ const { id, collapsed: now } = result.collapse;
16529
+ if (!now) {
16530
+ for (const [k, s] of Object.entries(result.sizes))
16531
+ if (k === id)
16532
+ sizes[k] = s;
16533
+ }
16534
+ setCollapsed(id, now, false);
16535
+ start = { ...target() };
16536
+ if (now)
16537
+ start[id] = rail(id);
16538
+ from = row ? e.clientX : e.clientY;
16539
+ return;
16540
+ }
16541
+ if (collapsed[a.id] || collapsed[b.id])
16542
+ return;
16543
+ setSizes(result.sizes);
16544
+ for (const id of [a.id, b.id])
16545
+ release(id);
16546
+ const shown = current();
16547
+ render(shown);
16548
+ rememberOpen(shown);
16549
+ });
16550
+ const end = (e) => {
16551
+ if (!start || e.pointerId !== pointer)
16552
+ return;
16553
+ start = undefined;
16554
+ delete d.dataset.active;
16555
+ delete doc.documentElement.dataset.knobsResizing;
16556
+ for (const spec of specs)
16557
+ if (flex.has(spec.id) && !collapsed[spec.id])
16558
+ sizes[spec.id] = views.get(spec.id).shown;
16559
+ persist();
16560
+ };
16561
+ d.addEventListener("pointerup", end);
16562
+ d.addEventListener("pointercancel", end);
16563
+ d.addEventListener("lostpointercapture", end);
16564
+ d.addEventListener("dblclick", (e) => {
16565
+ e.preventDefault();
16566
+ reset(a.id, b.id);
16567
+ });
16568
+ d.addEventListener("keydown", (e) => {
16569
+ if (e.metaKey || e.ctrlKey || e.altKey)
16570
+ return;
16571
+ const back = row ? "ArrowLeft" : "ArrowUp";
16572
+ const fwd = row ? "ArrowRight" : "ArrowDown";
16573
+ const step = e.shiftKey ? 64 : 16;
16574
+ let handled = true;
16575
+ if (e.key === back || e.key === fwd) {
16576
+ const goal = target();
16577
+ const shut = [a.id, b.id].find((id) => collapsed[id]);
16578
+ if (shut)
16579
+ setCollapsed(shut, false);
16580
+ else {
16581
+ setSizes(nudge(specs, goal, index, e.key === fwd ? step : -step));
16582
+ persist();
16583
+ animate();
16584
+ }
16585
+ } else if (e.key === "Home" || e.key === "End") {
16586
+ const goal = target();
16587
+ setSizes(nudge(specs, goal, index, e.key === "End" ? 1e6 : -1e6));
16588
+ persist();
16589
+ animate();
16590
+ } else if (e.key === "Enter" || e.key === " ") {
16591
+ const id = collapsibleSide(specs, index);
16592
+ if (id)
16593
+ setCollapsed(id, !collapsed[id]);
16594
+ else
16595
+ handled = false;
16596
+ } else if (e.key === "Escape")
16597
+ d.blur();
16598
+ else
16599
+ handled = false;
16600
+ if (handled) {
16601
+ e.preventDefault();
16602
+ e.stopPropagation();
16603
+ }
16604
+ });
16605
+ }
16606
+ function reset(...ids) {
16607
+ const which = ids.length ? ids : specs.map((p) => p.id);
16608
+ let folds = false;
16609
+ for (const id of which) {
16610
+ delete sizes[id];
16611
+ const spec = specs.find((p) => p.id === id);
16612
+ const shut = Boolean(spec?.collapsed);
16613
+ if (spec?.collapsible && collapsed[id] !== shut) {
16614
+ fold(id, shut);
16615
+ folds = true;
16616
+ }
16617
+ }
16618
+ if (which.some((id) => flex.has(id)))
16619
+ for (const id of flex)
16620
+ delete sizes[id];
16621
+ persist();
16622
+ animate(folds ? COLLAPSE : SETTLE);
16623
+ }
16624
+ {
16625
+ const first = target();
16626
+ for (const spec of specs) {
16627
+ const open = { ...collapsed, [spec.id]: false };
16628
+ views.get(spec.id).open = collapsed[spec.id] ? solve2({ panes: specs, sizes, collapsed: open, total: total(), rail })[spec.id] : first[spec.id];
16629
+ }
16630
+ render(first);
16631
+ measureChevrons();
16632
+ render(first);
16633
+ }
16634
+ const relayout = (remeasure) => {
16635
+ if (remeasure)
16636
+ measure();
16637
+ if (springs.size) {
16638
+ if (remeasure)
16639
+ measureChevrons();
16640
+ return loop.wake();
16641
+ }
16642
+ const shown = target();
16643
+ rememberOpen(shown);
16644
+ render(shown);
16645
+ if (remeasure) {
16646
+ measureChevrons();
16647
+ render(shown);
16648
+ }
16649
+ };
16650
+ const ro = new ResizeObserver(() => relayout(!measured));
16651
+ ro.observe(root);
16652
+ const restyled = () => relayout(true);
16653
+ root.addEventListener("knobs-restyle", restyled);
16654
+ const paneApi = (id) => {
16655
+ const v = views.get(id);
16656
+ if (!v)
16657
+ throw new Error(`No pane "${id}"`);
16658
+ return {
16659
+ id,
16660
+ element: v.el,
16661
+ body: v.body,
16662
+ header: v.head,
16663
+ get size() {
16664
+ return v.shown;
16665
+ },
16666
+ get collapsed() {
16667
+ return collapsed[id];
16668
+ },
16669
+ collapse: (value = !collapsed[id]) => setCollapsed(id, value),
16670
+ resize: (size) => api.resize(id, size),
16671
+ title(text, count) {
16672
+ if (v.titleEl)
16673
+ v.titleEl.textContent = text;
16674
+ if (v.countEl && count !== undefined)
16675
+ v.countEl.textContent = String(count);
16676
+ if (v.railLabel)
16677
+ v.railLabel.textContent = text;
16678
+ measureChevrons(v);
16679
+ }
16680
+ };
16681
+ };
16682
+ const unregisterHints = options.hints === false ? undefined : hints.register(root, () => [...views.values()].flatMap((v) => [
16683
+ ...v.head?.querySelectorAll(":scope > .knobs-pane-btn") ?? [],
16684
+ ...v.rail && collapsed[v.spec.id] ? [v.rail] : []
16685
+ ]), { listen: false, rank: 1 });
16686
+ const api = {
16687
+ element: root,
16688
+ pane: paneApi,
16689
+ layout: snapshot,
16690
+ collapse: (id, value) => setCollapsed(id, value ?? !collapsed[id]),
16691
+ resize(id, size) {
16692
+ const i = specs.findIndex((p) => p.id === id);
16693
+ if (i < 0)
16694
+ return;
16695
+ if (collapsed[id])
16696
+ setCollapsed(id, false);
16697
+ if (!flex.has(id))
16698
+ sizes[id] = size;
16699
+ else {
16700
+ const goal = target();
16701
+ const j = i > 0 ? i - 1 : i;
16702
+ const delta = size - goal[id];
16703
+ setSizes(nudge(specs, goal, j, j === i ? delta : -delta));
16704
+ }
16705
+ persist();
16706
+ animate();
16707
+ },
16708
+ reset: (id) => id ? reset(id) : reset(),
16709
+ dispose() {
16710
+ unregisterHints?.();
16711
+ loop.stop();
16712
+ ro.disconnect();
16713
+ root.removeEventListener("knobs-restyle", restyled);
16714
+ host?.removeAttribute("data-nested");
16715
+ root.remove();
16716
+ }
16717
+ };
16718
+ return api;
16719
+ }
16720
+
16721
+ // src/ui/index.ts
16722
+ init_split_model();
16723
+ init_timeline();
16724
+ init_timeline_model();
16725
+ init_compare();
16726
+
16727
+ // src/ui/compare-strip.ts
16728
+ init_compare_frames();
16729
+ init_compare_model();
16730
+ init_look();
16731
+ init_rgba();
16732
+ init_timeline_model();
16733
+ var BAR = 7;
16734
+ var GAP = 2;
16735
+ var DIFF = { w: 32, h: 18 };
16736
+ var DIFFS_PER_DRAW = 6;
16737
+ function compareStrip(container, options) {
16738
+ const doc = container.ownerDocument;
16739
+ const win = doc.defaultView;
16740
+ const timeline = options.timeline;
16741
+ const fps = () => options.fps ?? timeline.fps;
16742
+ const align = options.align ?? "track";
16743
+ const threshold = options.threshold ?? 4 / 255;
16744
+ const root = doc.createElement("div");
16745
+ root.className = "knobs-cmp-strip";
16746
+ root.setAttribute("role", "img");
16747
+ root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
16748
+ applyLook2(root, options);
16749
+ const canvas = doc.createElement("canvas");
16750
+ canvas.setAttribute("aria-hidden", "true");
16751
+ root.append(canvas);
16752
+ container.append(root);
16753
+ const diffs = new Map;
16754
+ const pairDiffs = new Map;
16755
+ let disposed = false;
16756
+ let near = false;
16757
+ let onScreen = false;
16758
+ const makeSide = (source) => ({
16759
+ source,
16760
+ kind: mediaKind(source.src, source.kind),
16761
+ frames: null,
16762
+ still: null,
16763
+ seen: false
16764
+ });
16765
+ let sides = [makeSide(options.before), makeSide(options.after)];
16766
+ const offs = new Map;
16767
+ const load = (s) => {
16768
+ if (s.frames || s.still)
16174
16769
  return;
16175
- }
16176
- const [side, far] = sides(v);
16177
- put(v.inner, row ? "width" : "height", folding ? `${open - m * 2}px` : "");
16178
- put(v.inner, side, folding ? "0" : "");
16179
- put(v.inner, far, folding ? "auto" : "");
16180
- fade(v.inner, content, CONTENT_BLUR);
16181
- hide(v.clip, isCollapsed && settled);
16182
- const morph = row && Boolean(v.toggleIcon) && !v.el.hasAttribute("data-nested");
16183
- v.el.toggleAttribute("data-morph", morph);
16184
- const label = v.railLabel;
16185
- const travel = morph && style.title === "travel" && Boolean(v.titleEl) && label.textContent === v.titleEl.textContent;
16186
- v.el.toggleAttribute("data-travel", travel && folding);
16187
- if (v.countEl)
16188
- put(v.countEl, "opacity", travel && folding && p > 0 ? Math.max(0, 1 - p / COUNT_CLEAR).toFixed(3) : "");
16189
- const k = 1 - p;
16190
- if (travel && !folding) {
16191
- put(label, "opacity", "0");
16192
- put(label, "filter", "");
16193
- put(label, "transform", "");
16194
- put(label, "color", "");
16195
- } else if (travel) {
16196
- const t = titleTravel(v.places, open, r, v.edge, p);
16197
- put(label, "opacity", "");
16198
- put(label, "filter", "");
16199
- put(label, "transform", k === 0 ? "" : `translate(${t.x.toFixed(2)}px,${t.y.toFixed(2)}px) rotate(${t.turn.toFixed(2)}deg)`);
16200
- put(label, "color", k === 0 ? "" : `color-mix(in srgb,var(--kv-fg) ${(k * 100).toFixed(1)}%,var(--kv-fg-2))`);
16201
- } else {
16202
- put(label, "transform", "");
16203
- put(label, "color", "");
16204
- }
16205
- if (morph) {
16206
- put(v.rail, "opacity", "1");
16207
- if (!travel)
16208
- fade(label, v.faces.rail, LABEL_BLUR);
16209
- const c = v.chev;
16210
- const dx = c.chevEnd - c.iconEnd + (v.edge === "end" ? open - r : 0);
16211
- const move = `translate(${(dx * k).toFixed(2)}px,${(c.dy * k).toFixed(2)}px)`;
16212
- const size = `scale(${(1 + (c.scale - 1) * k).toFixed(4)})`;
16213
- if (style.chevron === "turn")
16214
- put(v.turn, "transform", k === 0 ? "" : `${move} rotate(${chevronTurn(p, v.edge).toFixed(2)}deg) ${size}`);
16215
- else {
16216
- const turn = chevronFlip(v.flip.x, v.edge);
16217
- const over = v.flip.resting ? turn === 0 ? "" : "scaleX(-1)" : `perspective(${FLIP_DEPTH}px) rotateY(${turn.toFixed(2)}deg)`;
16218
- put(v.turn, "transform", k === 0 && !over ? "" : `${move} ${size} ${over}`);
16770
+ if (s.kind === "video") {
16771
+ s.frames = acquireFrames(doc, s.source.src, fps());
16772
+ offs.set(s, s.frames.listen(schedule));
16773
+ if (onScreen) {
16774
+ s.frames.visible(true);
16775
+ s.seen = true;
16219
16776
  }
16220
16777
  } else {
16221
- const a = v.faces.rail;
16222
- put(v.rail, "opacity", a >= 1 ? "1" : a.toFixed(3));
16223
- put(label, "opacity", "");
16224
- put(label, "filter", a >= 1 || a <= 0 ? "" : `blur(${(LABEL_BLUR * (1 - a)).toFixed(2)}px)`);
16225
- put(v.turn, "transform", "");
16778
+ const img = doc.createElement("img");
16779
+ img.crossOrigin = "anonymous";
16780
+ img.decoding = "async";
16781
+ img.addEventListener("load", () => {
16782
+ pairDiffs.clear();
16783
+ schedule();
16784
+ });
16785
+ img.src = s.source.src;
16786
+ s.still = img;
16226
16787
  }
16227
16788
  };
16228
- let lastReport = "";
16229
- const render = (shown) => {
16230
- for (const spec of specs) {
16231
- const v = views.get(spec.id);
16232
- const size = Math.max(0, shown[spec.id] ?? 0);
16233
- v.shown = size;
16234
- put(v.el, "flex-basis", `${size}px`);
16235
- put(v.el, row ? "width" : "height", `${size}px`);
16236
- v.el.toggleAttribute("data-collapsed", collapsed[spec.id]);
16237
- v.el.toggleAttribute("data-settled", !springs.has(spec.id));
16238
- if (spec.collapsible)
16239
- renderFold(v, size);
16789
+ const unload = (s) => {
16790
+ offs.get(s)?.();
16791
+ offs.delete(s);
16792
+ if (s.frames) {
16793
+ if (s.seen)
16794
+ s.frames.visible(false);
16795
+ s.frames.release();
16240
16796
  }
16241
- dividers.forEach((d, i) => {
16242
- const a = specs[i];
16243
- const b = specs[i + 1];
16244
- const now = String(Math.round(views.get(a.id).shown));
16245
- if (d.getAttribute("aria-valuenow") !== now)
16246
- d.setAttribute("aria-valuenow", now);
16247
- if (!d.hasAttribute("aria-controls"))
16248
- d.setAttribute("aria-controls", `${a.id} ${b.id}`);
16249
- });
16250
- const layout = snapshot();
16251
- const report = JSON.stringify(layout);
16252
- if (report !== lastReport) {
16253
- lastReport = report;
16254
- options.onLayout?.(layout);
16255
- for (const v of views.values())
16256
- v.el.dispatchEvent(new Event("knobs-pane-resize"));
16257
- notifyLayout(doc);
16797
+ s.frames = null;
16798
+ s.seen = false;
16799
+ if (s.still)
16800
+ s.still.removeAttribute("src");
16801
+ s.still = null;
16802
+ };
16803
+ let w = 1;
16804
+ let h = 1;
16805
+ let geo = { x: 0, w: 1 };
16806
+ const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
16807
+ const aspectOf = (s) => {
16808
+ if (s.still?.naturalWidth)
16809
+ return s.still.naturalWidth / s.still.naturalHeight;
16810
+ const c = s.frames?.nearest(0);
16811
+ return c ? c.bitmap.width / c.bitmap.height : 0;
16812
+ };
16813
+ const columns = () => stripColumns(timeline.view, geo.w, {
16814
+ duration: timeline.duration,
16815
+ fps: fps(),
16816
+ minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
16817
+ });
16818
+ const measure = () => {
16819
+ const r = root.getBoundingClientRect();
16820
+ w = Math.max(1, r.width);
16821
+ h = Math.max(1, r.height);
16822
+ geo = { x: 0, w };
16823
+ if (align === "track") {
16824
+ const track = timeline.element.querySelector(".knobs-tl-track");
16825
+ const t = track?.getBoundingClientRect();
16826
+ if (t && t.width > 0)
16827
+ geo = { x: t.left - r.left, w: t.width };
16258
16828
  }
16829
+ readColors();
16830
+ schedule();
16259
16831
  };
16260
- const snapshot = () => {
16261
- const s = {};
16262
- for (const [id, v] of views)
16263
- s[id] = Math.round(v.shown * 10) / 10;
16264
- return { sizes: s, collapsed: { ...collapsed } };
16832
+ const ask = (cols) => {
16833
+ const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
16834
+ for (const s of sides)
16835
+ s.frames?.want(points);
16265
16836
  };
16266
- const persist = () => {
16267
- if (!store || !key)
16837
+ const picOf = (s, c) => {
16838
+ if (s.still?.naturalWidth)
16839
+ return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
16840
+ const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
16841
+ return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
16842
+ };
16843
+ const probe = doc.createElement("canvas");
16844
+ probe.width = DIFF.w;
16845
+ probe.height = DIFF.h;
16846
+ const probeCtx = probe.getContext("2d", { willReadFrequently: true });
16847
+ const differ = (a, b) => {
16848
+ if (!probeCtx)
16268
16849
  return;
16269
16850
  try {
16270
- store.setItem(key, serializeLayout({ sizes, collapsed }));
16271
- } catch {}
16272
- };
16273
- const rememberOpen = (shown) => {
16274
- for (const spec of specs)
16275
- if (!collapsed[spec.id] && !springs.has(spec.id))
16276
- views.get(spec.id).open = shown[spec.id];
16277
- };
16278
- const loop = frameLoop(win, (dt) => {
16279
- for (const [id, s] of springs) {
16280
- s.step(dt);
16281
- if (s.resting) {
16282
- springs.delete(id);
16283
- views.get(id).folding = false;
16284
- }
16285
- }
16286
- let flipping = false;
16287
- for (const v of views.values()) {
16288
- v.flip.step(dt);
16289
- if (!v.flip.resting)
16290
- flipping = true;
16291
- }
16292
- const shown = current();
16293
- if (springs.size === 0)
16294
- rememberOpen(shown);
16295
- render(shown);
16296
- return springs.size > 0 || flipping;
16297
- });
16298
- const animate = (motion = SETTLE) => {
16299
- const to = target();
16300
- for (const v of views.values())
16301
- if (v.folding && !collapsed[v.spec.id])
16302
- v.open = to[v.spec.id];
16303
- if (reducedMotion(win)) {
16304
- springs.clear();
16305
- const arriving = [...views.values()].filter((v) => v.folding).map((v) => collapsed[v.spec.id] ? v.rail : v.rail ? v.inner : v.body);
16306
- for (const v of views.values()) {
16307
- v.folding = false;
16308
- v.flip.snap(collapsed[v.spec.id] ? 1 : 0);
16309
- }
16310
- rememberOpen(to);
16311
- render(to);
16312
- for (const el of arriving)
16313
- el?.animate([{ opacity: 0 }, { opacity: 1 }], {
16314
- duration: 160,
16315
- easing: "cubic-bezier(.2,.8,.2,1)"
16316
- });
16851
+ probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
16852
+ const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
16853
+ probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
16854
+ const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
16855
+ return meanAbsDiff(pa, pb);
16856
+ } catch {
16317
16857
  return;
16318
16858
  }
16319
- for (const spec of specs) {
16320
- const v = views.get(spec.id);
16321
- const goal = to[spec.id];
16322
- const s = springs.get(spec.id);
16323
- if (s) {
16324
- s.omega = motion.omega;
16325
- s.zeta = motion.zeta;
16326
- s.set(goal);
16327
- } else if (Math.abs(goal - v.shown) > 0.5) {
16328
- springs.set(spec.id, new Spring(v.shown, motion.omega, motion.zeta, 0.2).set(goal));
16329
- } else
16330
- v.folding = false;
16331
- }
16332
- loop.wake();
16333
16859
  };
16334
- const fold = (id, value) => {
16335
- const v = views.get(id);
16336
- if (value && !v.folding)
16337
- v.open = v.shown;
16338
- collapsed[id] = value;
16339
- v.folding = true;
16340
- v.flip.set(value ? 1 : 0);
16860
+ let colors = { surface: "", accent: "", diff: "", wash: "" };
16861
+ const rgba = (raw, fallback) => {
16862
+ const [r, g, b, a] = parseCssColor(raw, fallback);
16863
+ return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
16341
16864
  };
16342
- const release = (id) => {
16343
- springs.delete(id);
16344
- views.get(id).folding = false;
16865
+ const readColors = () => {
16866
+ const cs = win.getComputedStyle(root);
16867
+ colors = {
16868
+ surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
16869
+ accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
16870
+ diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
16871
+ wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
16872
+ };
16345
16873
  };
16346
- function setCollapsed(id, value, save = true) {
16347
- const spec = specs.find((p) => p.id === id);
16348
- if (!spec?.collapsible || collapsed[id] === value)
16349
- return;
16350
- fold(id, value);
16351
- if (save)
16352
- persist();
16353
- animate(COLLAPSE);
16874
+ let hover = null;
16875
+ let raf = 0;
16876
+ let asked = "";
16877
+ function schedule() {
16878
+ if (!raf && !disposed)
16879
+ raf = win.requestAnimationFrame(draw);
16354
16880
  }
16355
- const setSizes = (next) => {
16356
- for (const [id, s] of Object.entries(next))
16357
- sizes[id] = s;
16358
- };
16359
- function bindDivider(d, index) {
16360
- const a = specs[index];
16361
- const b = specs[index + 1];
16362
- let start;
16363
- let from = 0;
16364
- let pointer = -1;
16365
- d.addEventListener("pointerdown", (e) => {
16366
- if (e.button !== 0)
16367
- return;
16368
- e.preventDefault();
16369
- d.focus({ preventScroll: true });
16370
- const shown = current();
16371
- for (const id of [a.id, b.id])
16372
- if (!collapsed[id])
16373
- release(id);
16374
- start = { ...shown };
16375
- for (const id of [a.id, b.id])
16376
- if (collapsed[id])
16377
- start[id] = rail(id);
16378
- from = row ? e.clientX : e.clientY;
16379
- pointer = e.pointerId;
16380
- d.setPointerCapture(e.pointerId);
16381
- d.dataset.active = "";
16382
- doc.documentElement.dataset.knobsResizing = direction;
16383
- });
16384
- d.addEventListener("pointermove", (e) => {
16385
- if (!start || e.pointerId !== pointer)
16881
+ const draw = () => {
16882
+ raf = 0;
16883
+ if (!near)
16884
+ return;
16885
+ const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
16886
+ const cw = Math.round(w * dpr);
16887
+ const ch = Math.round(h * dpr);
16888
+ if (canvas.width !== cw || canvas.height !== ch) {
16889
+ canvas.width = cw;
16890
+ canvas.height = ch;
16891
+ }
16892
+ const ctx = canvas.getContext("2d");
16893
+ if (!ctx)
16894
+ return;
16895
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
16896
+ ctx.clearRect(0, 0, w, h);
16897
+ const cols = columns();
16898
+ const key = cols.map((c) => c.frame).join(",");
16899
+ if (key !== asked) {
16900
+ asked = key;
16901
+ ask(cols);
16902
+ }
16903
+ const rh = rowH();
16904
+ const radius = 3;
16905
+ let budget = DIFFS_PER_DRAW;
16906
+ let more = false;
16907
+ const ds = cols.map((c) => {
16908
+ const a = picOf(sides[0], c);
16909
+ const b = picOf(sides[1], c);
16910
+ if (!a || !b)
16386
16911
  return;
16387
- const delta = (row ? e.clientX : e.clientY) - from;
16388
- const result = drag(specs, start, index, delta, collapsed);
16389
- if (result.collapse) {
16390
- const { id, collapsed: now } = result.collapse;
16391
- if (!now) {
16392
- for (const [k, s] of Object.entries(result.sizes))
16393
- if (k === id)
16394
- sizes[k] = s;
16912
+ const k = `${a.key}|${b.key}`;
16913
+ if (!pairDiffs.has(k)) {
16914
+ if (budget-- <= 0) {
16915
+ more = true;
16916
+ return;
16395
16917
  }
16396
- setCollapsed(id, now, false);
16397
- start = { ...target() };
16398
- if (now)
16399
- start[id] = rail(id);
16400
- from = row ? e.clientX : e.clientY;
16401
- return;
16918
+ pairDiffs.set(k, differ(a, b));
16402
16919
  }
16403
- if (collapsed[a.id] || collapsed[b.id])
16404
- return;
16405
- setSizes(result.sizes);
16406
- for (const id of [a.id, b.id])
16407
- release(id);
16408
- const shown = current();
16409
- render(shown);
16410
- rememberOpen(shown);
16411
- });
16412
- const end = (e) => {
16413
- if (!start || e.pointerId !== pointer)
16414
- return;
16415
- start = undefined;
16416
- delete d.dataset.active;
16417
- delete doc.documentElement.dataset.knobsResizing;
16418
- for (const spec of specs)
16419
- if (flex.has(spec.id) && !collapsed[spec.id])
16420
- sizes[spec.id] = views.get(spec.id).shown;
16421
- persist();
16422
- };
16423
- d.addEventListener("pointerup", end);
16424
- d.addEventListener("pointercancel", end);
16425
- d.addEventListener("lostpointercapture", end);
16426
- d.addEventListener("dblclick", (e) => {
16427
- e.preventDefault();
16428
- reset(a.id, b.id);
16920
+ const d = pairDiffs.get(k);
16921
+ if (d !== undefined)
16922
+ diffs.set(c.frame, d);
16923
+ return d;
16429
16924
  });
16430
- d.addEventListener("keydown", (e) => {
16431
- if (e.metaKey || e.ctrlKey || e.altKey)
16925
+ const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
16926
+ const peaks = new Set(stripPeaks(ds, { threshold }));
16927
+ cols.forEach((c, i) => {
16928
+ const x = geo.x + c.x + GAP / 2;
16929
+ const cwid = Math.max(1, c.w - GAP);
16930
+ if (x + cwid < 0 || x > w)
16432
16931
  return;
16433
- const back = row ? "ArrowLeft" : "ArrowUp";
16434
- const fwd = row ? "ArrowRight" : "ArrowDown";
16435
- const step = e.shiftKey ? 64 : 16;
16436
- let handled = true;
16437
- if (e.key === back || e.key === fwd) {
16438
- const goal = target();
16439
- const shut = [a.id, b.id].find((id) => collapsed[id]);
16440
- if (shut)
16441
- setCollapsed(shut, false);
16442
- else {
16443
- setSizes(nudge(specs, goal, index, e.key === fwd ? step : -step));
16444
- persist();
16445
- animate();
16446
- }
16447
- } else if (e.key === "Home" || e.key === "End") {
16448
- const goal = target();
16449
- setSizes(nudge(specs, goal, index, e.key === "End" ? 1e6 : -1e6));
16450
- persist();
16451
- animate();
16452
- } else if (e.key === "Enter" || e.key === " ") {
16453
- const id = collapsibleSide(specs, index);
16454
- if (id)
16455
- setCollapsed(id, !collapsed[id]);
16456
- else
16457
- handled = false;
16458
- } else if (e.key === "Escape")
16459
- d.blur();
16460
- else
16461
- handled = false;
16462
- if (handled) {
16463
- e.preventDefault();
16464
- e.stopPropagation();
16932
+ for (const row of [0, 1]) {
16933
+ const y = row * (rh + GAP);
16934
+ const pic = picOf(sides[row], c);
16935
+ ctx.save();
16936
+ ctx.beginPath();
16937
+ ctx.roundRect(x, y, cwid, rh, radius);
16938
+ ctx.fillStyle = colors.surface;
16939
+ ctx.fill();
16940
+ if (pic) {
16941
+ ctx.clip();
16942
+ const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
16943
+ ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
16944
+ }
16945
+ ctx.restore();
16465
16946
  }
16466
- });
16467
- }
16468
- function reset(...ids) {
16469
- const which = ids.length ? ids : specs.map((p) => p.id);
16470
- let folds = false;
16471
- for (const id of which) {
16472
- delete sizes[id];
16473
- const spec = specs.find((p) => p.id === id);
16474
- const shut = Boolean(spec?.collapsed);
16475
- if (spec?.collapsible && collapsed[id] !== shut) {
16476
- fold(id, shut);
16477
- folds = true;
16947
+ if (hover === i) {
16948
+ ctx.fillStyle = colors.wash;
16949
+ ctx.beginPath();
16950
+ ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
16951
+ ctx.fill();
16478
16952
  }
16953
+ const d = ds[i];
16954
+ if (d !== undefined && d > 0) {
16955
+ const bh = Math.max(1, Math.min(1, d / max) * BAR);
16956
+ ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
16957
+ ctx.fillStyle = colors.diff;
16958
+ ctx.beginPath();
16959
+ ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
16960
+ ctx.fill();
16961
+ ctx.globalAlpha = 1;
16962
+ }
16963
+ });
16964
+ const v = timeline.view;
16965
+ const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
16966
+ if (px >= -1 && px <= w + 1) {
16967
+ ctx.fillStyle = colors.accent;
16968
+ ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
16479
16969
  }
16480
- if (which.some((id) => flex.has(id)))
16481
- for (const id of flex)
16482
- delete sizes[id];
16483
- persist();
16484
- animate(folds ? COLLAPSE : SETTLE);
16485
- }
16486
- {
16487
- const first = target();
16488
- for (const spec of specs) {
16489
- const open = { ...collapsed, [spec.id]: false };
16490
- views.get(spec.id).open = collapsed[spec.id] ? solve2({ panes: specs, sizes, collapsed: open, total: total(), rail })[spec.id] : first[spec.id];
16491
- }
16492
- render(first);
16493
- measureChevrons();
16494
- render(first);
16495
- }
16496
- const relayout = (remeasure) => {
16497
- if (remeasure)
16498
- measure();
16499
- if (springs.size) {
16500
- if (remeasure)
16501
- measureChevrons();
16502
- return loop.wake();
16503
- }
16504
- const shown = target();
16505
- rememberOpen(shown);
16506
- render(shown);
16507
- if (remeasure) {
16508
- measureChevrons();
16509
- render(shown);
16970
+ if (more)
16971
+ schedule();
16972
+ };
16973
+ const timeAt = (clientX) => {
16974
+ const r = root.getBoundingClientRect();
16975
+ const v = timeline.view;
16976
+ const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
16977
+ return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
16978
+ };
16979
+ const columnAt = (clientX) => {
16980
+ const x = clientX - root.getBoundingClientRect().left - geo.x;
16981
+ const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
16982
+ return i < 0 ? null : i;
16983
+ };
16984
+ let dragging = false;
16985
+ const pick = (clientX) => {
16986
+ const t = timeAt(clientX);
16987
+ timeline.seek(t);
16988
+ options.onPick?.(t);
16989
+ };
16990
+ root.addEventListener("pointerdown", (e) => {
16991
+ if (e.button !== 0)
16992
+ return;
16993
+ dragging = true;
16994
+ root.setPointerCapture(e.pointerId);
16995
+ const host = root.closest(".knobs-compare");
16996
+ if (host)
16997
+ host.focus({ preventScroll: true });
16998
+ else
16999
+ timeline.focus();
17000
+ if (timeline.playing)
17001
+ timeline.pause();
17002
+ pick(e.clientX);
17003
+ e.preventDefault();
17004
+ });
17005
+ root.addEventListener("pointermove", (e) => {
17006
+ if (dragging)
17007
+ pick(e.clientX);
17008
+ const i = columnAt(e.clientX);
17009
+ if (i !== hover) {
17010
+ hover = i;
17011
+ schedule();
16510
17012
  }
17013
+ });
17014
+ const end = () => {
17015
+ dragging = false;
16511
17016
  };
16512
- const ro = new ResizeObserver(() => relayout(!measured));
17017
+ root.addEventListener("pointerup", end);
17018
+ root.addEventListener("pointercancel", end);
17019
+ root.addEventListener("lostpointercapture", end);
17020
+ root.addEventListener("pointerleave", () => {
17021
+ if (hover === null)
17022
+ return;
17023
+ hover = null;
17024
+ schedule();
17025
+ });
17026
+ const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
17027
+ const ro = new win.ResizeObserver(measure);
16513
17028
  ro.observe(root);
16514
- const restyled = () => relayout(true);
16515
- root.addEventListener("knobs-restyle", restyled);
16516
- const paneApi = (id) => {
16517
- const v = views.get(id);
16518
- if (!v)
16519
- throw new Error(`No pane "${id}"`);
16520
- return {
16521
- id,
16522
- element: v.el,
16523
- body: v.body,
16524
- header: v.head,
16525
- get size() {
16526
- return v.shown;
16527
- },
16528
- get collapsed() {
16529
- return collapsed[id];
16530
- },
16531
- collapse: (value = !collapsed[id]) => setCollapsed(id, value),
16532
- resize: (size) => api.resize(id, size),
16533
- title(text, count) {
16534
- if (v.titleEl)
16535
- v.titleEl.textContent = text;
16536
- if (v.countEl && count !== undefined)
16537
- v.countEl.textContent = String(count);
16538
- if (v.railLabel)
16539
- v.railLabel.textContent = text;
16540
- measureChevrons(v);
16541
- }
16542
- };
17029
+ const track = timeline.element.querySelector(".knobs-tl-track");
17030
+ if (track)
17031
+ ro.observe(track);
17032
+ root.addEventListener("knobs-restyle", measure);
17033
+ const setOnScreen = (on) => {
17034
+ onScreen = on;
17035
+ for (const s of sides) {
17036
+ if (!s.frames || s.seen === on)
17037
+ continue;
17038
+ s.seen = on;
17039
+ s.frames.visible(on);
17040
+ }
16543
17041
  };
16544
- const unregisterHints = options.hints === false ? undefined : hints.register(root, () => [...views.values()].flatMap((v) => [
16545
- ...v.head?.querySelectorAll(":scope > .knobs-pane-btn") ?? [],
16546
- ...v.rail && collapsed[v.spec.id] ? [v.rail] : []
16547
- ]), { listen: false, rank: 1 });
16548
- const api = {
17042
+ const setNear = (on) => {
17043
+ near = on;
17044
+ if (on) {
17045
+ for (const s of sides)
17046
+ load(s);
17047
+ schedule();
17048
+ } else
17049
+ for (const s of sides)
17050
+ unload(s);
17051
+ };
17052
+ const IO = win.IntersectionObserver;
17053
+ const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
17054
+ const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
17055
+ measure();
17056
+ if (nearIo && screenIo) {
17057
+ nearIo.observe(root);
17058
+ screenIo.observe(root);
17059
+ } else {
17060
+ setNear(true);
17061
+ setOnScreen(true);
17062
+ }
17063
+ return {
16549
17064
  element: root,
16550
- pane: paneApi,
16551
- layout: snapshot,
16552
- collapse: (id, value) => setCollapsed(id, value ?? !collapsed[id]),
16553
- resize(id, size) {
16554
- const i = specs.findIndex((p) => p.id === id);
16555
- if (i < 0)
16556
- return;
16557
- if (collapsed[id])
16558
- setCollapsed(id, false);
16559
- if (!flex.has(id))
16560
- sizes[id] = size;
16561
- else {
16562
- const goal = target();
16563
- const j = i > 0 ? i - 1 : i;
16564
- const delta = size - goal[id];
16565
- setSizes(nudge(specs, goal, j, j === i ? delta : -delta));
16566
- }
16567
- persist();
16568
- animate();
17065
+ diffs,
17066
+ update(next) {
17067
+ const was = sides;
17068
+ sides = [
17069
+ next.before ? makeSide(next.before) : was[0],
17070
+ next.after ? makeSide(next.after) : was[1]
17071
+ ];
17072
+ for (const s of was)
17073
+ if (!sides.includes(s))
17074
+ unload(s);
17075
+ diffs.clear();
17076
+ pairDiffs.clear();
17077
+ asked = "";
17078
+ if (near)
17079
+ for (const s of sides)
17080
+ load(s);
17081
+ if (onScreen)
17082
+ setOnScreen(true);
17083
+ schedule();
16569
17084
  },
16570
- reset: (id) => id ? reset(id) : reset(),
16571
17085
  dispose() {
16572
- unregisterHints?.();
16573
- loop.stop();
17086
+ disposed = true;
17087
+ if (raf)
17088
+ win.cancelAnimationFrame(raf);
17089
+ unlisten();
16574
17090
  ro.disconnect();
16575
- root.removeEventListener("knobs-restyle", restyled);
16576
- host?.removeAttribute("data-nested");
17091
+ nearIo?.disconnect();
17092
+ screenIo?.disconnect();
17093
+ for (const s of sides)
17094
+ unload(s);
16577
17095
  root.remove();
16578
17096
  }
16579
17097
  };
16580
- return api;
16581
17098
  }
16582
17099
 
16583
17100
  // src/ui/index.ts
16584
- init_split_model();
16585
- init_timeline();
16586
- init_timeline_model();
16587
- init_compare();
16588
- init_compare_strip();
16589
17101
  init_player();
16590
17102
 
16591
17103
  // src/ui/history.ts
@@ -24131,9 +24643,8 @@ var walkthroughCss = `${foundationCss}
24131
24643
  .knobs-wt-slot .knobs-cmp-stage{border-radius:var(--kw-frame-r)}
24132
24644
  /* A compare is as tall as its two pictures side by side at this width, in every mode (Wipe fuses
24133
24645
  them at one side's size, so nothing grows or re-measures), plus its mode bar, the line and gaps
24134
- (92 px), and the frame strip with its gap (158 px). */
24646
+ (92 px). */
24135
24647
  .knobs-wt-fit{width:100%;height:calc(100cqw / 2 / var(--kw-aspect,1.7778) + var(--kw-chrome,92px))}
24136
- .knobs-wt-fit[data-strip]{--kw-chrome:158px}
24137
24648
  .knobs-wt-fit[data-settled]{transition:height var(--k-wt-calm-ms,${calm.duration}ms) ${calm.easing}}
24138
24649
  .knobs-wt-frame{position:relative;width:100%;overflow:hidden;border-radius:var(--kw-frame-r);background:var(--_k-fill);aspect-ratio:var(--kw-aspect,16/9)}
24139
24650
  .knobs-wt-frame>:is(video,img){position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;margin:0;border:0}
@@ -24146,16 +24657,15 @@ var walkthroughCss = `${foundationCss}
24146
24657
  /* The diff inside it: its header row 28 high on the container's padding, a hairline under it, and
24147
24658
  no line of its own under the code (the container's ring closes it). */
24148
24659
  .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"]){--kd-head:28px;--kd-x:0px}
24149
- .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"])::after{display:none}
24150
- .knobs-wt-code{margin:0;padding:var(--_k-pad-card);max-height:420px;overflow:auto;border-radius:var(--_k-radius-panel);box-shadow:inset 0 0 0 1px var(--_k-border-subtle);color:var(--_k-fg);
24660
+ .knobs-wt-slot[data-kind="diff"] :is(.knobs-ui.knobs-diff:not([data-on="pane"]),.knobs-diff-review)::after{display:none}
24661
+ .knobs-wt-code{margin:0;padding:var(--_k-pad-card);max-height:420px;overflow:auto;border-radius:var(--kw-frame-r);background:var(--_k-fill);color:var(--_k-fg);
24151
24662
  font:var(--_k-type-label)/var(--_k-leading-body) var(--_k-mono);white-space:pre;tab-size:2;scrollbar-width:thin}
24152
- .knobs-surface :is(.knobs-wt-code,.knobs-wt-slot[data-kind="diff"]>.knobs-wt-body){padding-inline:0;border-radius:0;background:transparent;box-shadow:inset 0 1px 0 var(--_k-border-subtle),inset 0 -1px 0 var(--_k-border-subtle)}
24663
+ .knobs-surface .knobs-wt-slot[data-kind="diff"]>.knobs-wt-body{padding:0;border-radius:0;background:transparent;box-shadow:inset 0 -1px 0 var(--_k-border-subtle)}
24153
24664
  .knobs-wt-code[data-terminal]{white-space:pre-wrap;overflow-wrap:anywhere}
24154
24665
  .knobs-wt-prompt{color:var(--_k-fg-3);user-select:none;-webkit-user-select:none}
24155
24666
  .knobs-wt-cmd{color:var(--_k-fg);font-weight:var(--_k-weight-label)}
24156
24667
  .knobs-wt-out{color:var(--_k-fg-2)}
24157
24668
  .knobs-wt-metric{display:flex;flex-direction:column;gap:var(--_k-space-sibling)}
24158
- .knobs-wt-mlabel{color:var(--_k-fg-2)}
24159
24669
  .knobs-wt-mvalue{display:flex;align-items:baseline;flex-wrap:wrap;gap:var(--_k-space-sibling);font-feature-settings:var(--_kr-data)}
24160
24670
  .knobs-wt-mfrom{color:var(--_k-fg-3)}
24161
24671
  .knobs-wt-mto{font-size:var(--_k-type-title);line-height:var(--_k-leading-title);font-weight:var(--_k-weight-label)}
@@ -24174,6 +24684,9 @@ var walkthroughCss = `${foundationCss}
24174
24684
  .knobs-wt-callout[data-tone="bad"]{--kw-tone:var(--_k-danger)}
24175
24685
  .knobs-wt-text{max-width:var(--kw-measure);color:var(--_k-fg-2)}
24176
24686
  .knobs-wt-hold{border-radius:var(--kw-frame-r);background:var(--_k-fill)}
24687
+ /* A diff holds its room with its own well's edge, no fill: the diff reads the colour behind it
24688
+ as it mounts, and a loading fill there would stay under its code. */
24689
+ .knobs-wt-slot[data-kind="diff"]>.knobs-wt-hold{background:transparent}
24177
24690
  .knobs-wt-failed{display:flex;align-items:center;justify-content:center;min-height:80px;color:var(--_k-fg-3);font-size:var(--_k-type-small)}
24178
24691
 
24179
24692
  /* Annotations: a calm ring round a box, a dot or an arrow at a point, a small label beside it;
@@ -24423,8 +24936,6 @@ var side = (s, fallback) => ({
24423
24936
  async function mountCompare(host, m, look, alive, onEvent) {
24424
24937
  const doc = host.ownerDocument;
24425
24938
  const fit = div(doc, "knobs-wt-fit");
24426
- if (m.strip)
24427
- fit.dataset.strip = "";
24428
24939
  const { before, after } = m;
24429
24940
  const known = parseAspect(m.aspect);
24430
24941
  fit.style.setProperty("--kw-aspect", String(known ?? DEFAULT_ASPECT));
@@ -24451,7 +24962,6 @@ async function mountCompare(host, m, look, alive, onEvent) {
24451
24962
  timeline: mediaKind(before.src) === "video" || mediaKind(after.src) === "video",
24452
24963
  lazy: false,
24453
24964
  ...aspect ? { aspect } : {},
24454
- ...m.strip ? { strip: true } : {},
24455
24965
  onMode: (next) => {
24456
24966
  onEvent?.({ what: "mode", detail: next });
24457
24967
  const layer = marks?.element;
@@ -24513,11 +25023,11 @@ async function mountCompare(host, m, look, alive, onEvent) {
24513
25023
  pills?.sync();
24514
25024
  marking?.kick();
24515
25025
  if (source !== "play")
24516
- onEvent?.({ what: "seek", time: round2(time) });
25026
+ onEvent?.({ what: "seek", time: round3(time) });
24517
25027
  },
24518
25028
  onPlay: (on) => {
24519
25029
  marking?.kick();
24520
- onEvent?.({ what: on ? "play" : "pause", time: round2(c.time) });
25030
+ onEvent?.({ what: on ? "play" : "pause", time: round3(c.time) });
24521
25031
  }
24522
25032
  });
24523
25033
  requestAnimationFrame(() => requestAnimationFrame(() => fit.dataset.settled = ""));
@@ -24535,13 +25045,13 @@ async function mountCompare(host, m, look, alive, onEvent) {
24535
25045
  const zoom = zoomedIn(c.zoom) ? c.zoom : null;
24536
25046
  return {
24537
25047
  kind: "compare",
24538
- time: round2(c.time),
24539
- duration: round2(c.duration),
25048
+ time: round3(c.time),
25049
+ duration: round3(c.duration),
24540
25050
  playing: c.playing,
24541
25051
  mode: c.mode,
24542
25052
  ...pills?.current ? { chapter: pills.current.label } : {},
24543
- ...loop ? { loop: { start: round2(loop.start), end: round2(loop.end) } } : {},
24544
- ...zoom ? { zoom: { u: round2(zoom.u), v: round2(zoom.v), w: round2(zoom.w), h: round2(zoom.h) } } : {}
25053
+ ...loop ? { loop: { start: round3(loop.start), end: round3(loop.end) } } : {},
25054
+ ...zoom ? { zoom: { u: round3(zoom.u), v: round3(zoom.v), w: round3(zoom.w), h: round3(zoom.h) } } : {}
24545
25055
  };
24546
25056
  },
24547
25057
  dispose: () => {
@@ -24554,7 +25064,7 @@ async function mountCompare(host, m, look, alive, onEvent) {
24554
25064
  }
24555
25065
  };
24556
25066
  }
24557
- var round2 = (v) => Math.round(v * 100) / 100;
25067
+ var round3 = (v) => Math.round(v * 100) / 100;
24558
25068
  var MARK_GLIDE = 450;
24559
25069
  var zoomedIn = (z) => z.w < 0.999 || z.h < 0.999;
24560
25070
  async function mountVideo(host, m, look, alive, onEvent) {
@@ -24581,12 +25091,12 @@ async function mountVideo(host, m, look, alive, onEvent) {
24581
25091
  } : {},
24582
25092
  onPlay: (on, time) => {
24583
25093
  marking?.kick();
24584
- onEvent?.({ what: on ? "play" : "pause", time: round2(time) });
25094
+ onEvent?.({ what: on ? "play" : "pause", time: round3(time) });
24585
25095
  },
24586
25096
  onSeek: (time) => {
24587
25097
  pills?.sync();
24588
25098
  marking?.kick();
24589
- onEvent?.({ what: "seek", time: round2(time) });
25099
+ onEvent?.({ what: "seek", time: round3(time) });
24590
25100
  }
24591
25101
  });
24592
25102
  const v = p.video;
@@ -24619,8 +25129,8 @@ async function mountVideo(host, m, look, alive, onEvent) {
24619
25129
  seekBy: (d) => p.seekBy(d),
24620
25130
  context: () => ({
24621
25131
  kind: "video",
24622
- time: round2(p.time),
24623
- ...p.duration ? { duration: round2(p.duration) } : {},
25132
+ time: round3(p.time),
25133
+ ...p.duration ? { duration: round3(p.duration) } : {},
24624
25134
  playing: p.playing,
24625
25135
  ...pills?.current ? { chapter: pills.current.label } : {}
24626
25136
  }),
@@ -24764,8 +25274,6 @@ var fmt = (v, unit = "") => `${v}${unit}`;
24764
25274
  function mountMetric(host, m) {
24765
25275
  const doc = host.ownerDocument;
24766
25276
  const box = div(doc, "knobs-wt-metric");
24767
- const label = div(doc, "knobs-wt-mlabel");
24768
- label.textContent = m.label;
24769
25277
  const value = div(doc, "knobs-wt-mvalue");
24770
25278
  const span = (cls, text) => {
24771
25279
  const s = doc.createElement("span");
@@ -24785,7 +25293,7 @@ function mountMetric(host, m) {
24785
25293
  delta.dataset.tone = d > 0 === (better === "up") ? "good" : "bad";
24786
25294
  value.append(delta);
24787
25295
  }
24788
- box.append(label, value);
25296
+ box.append(value);
24789
25297
  const total = num(m.of);
24790
25298
  if (Number.isFinite(total) && total > 0 && Number.isFinite(a) && Number.isFinite(b)) {
24791
25299
  const track = div(doc, "knobs-wt-mtrack");
@@ -24839,7 +25347,7 @@ function mountDecision(host, m, slot) {
24839
25347
  }
24840
25348
  var HEAVY = new Set(["compare", "video", "diff", "mount"]);
24841
25349
  var RELEASES = new Set(["compare", "video"]);
24842
- var labelOf3 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "metric" ? m.label : undefined;
25350
+ var labelOf3 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "history" ? m.label : undefined;
24843
25351
  function mediaSlot(container, given) {
24844
25352
  const doc = container.ownerDocument;
24845
25353
  injectWalkthroughStyles(doc);
@@ -24853,7 +25361,7 @@ function mediaSlot(container, given) {
24853
25361
  root.dataset.kind = m.kind;
24854
25362
  applyLook2(root, options);
24855
25363
  const label = labelOf3(m);
24856
- if (label && (m.kind === "video" || m.kind === "image" || m.kind === "code" || m.kind === "terminal" || m.kind === "facts")) {
25364
+ if (label) {
24857
25365
  const l = doc.createElement("span");
24858
25366
  l.className = "knobs-wt-slot-label";
24859
25367
  l.textContent = label;