@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-solid.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(",");
@@ -4604,12 +4623,24 @@ function createRegistry(doc) {
4604
4623
  return;
4605
4624
  if (e.metaKey || e.ctrlKey || e.altKey || e.key !== PAGE_HINT_KEY)
4606
4625
  return;
4607
- if (isTyping(e.target) || !wide())
4626
+ if (isTyping(e.target) || !wide(e.target))
4608
4627
  return;
4609
4628
  if (registry.start())
4610
4629
  e.preventDefault();
4611
4630
  };
4612
- const wide = () => listening && (sources.length > 1 || [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel")));
4631
+ const hostHints = () => [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel"));
4632
+ const wide = (target) => {
4633
+ if (!listening)
4634
+ return false;
4635
+ if (sources.some((s) => s.listen && s.rank >= 2) || hostHints())
4636
+ return true;
4637
+ if (sources.length < 2)
4638
+ return false;
4639
+ if (target === undefined)
4640
+ return true;
4641
+ const piece = target?.closest?.(".knobs-ui,.knobs-pane-body");
4642
+ return Boolean(piece?.classList.contains("knobs-ui"));
4643
+ };
4613
4644
  const relisten = () => {
4614
4645
  const next = sources.some((s) => s.listen);
4615
4646
  if (next === listening)
@@ -9756,6 +9787,31 @@ function seeked(s, current, fps) {
9756
9787
  return { state: { seeking: true, pending: null }, apply: s.pending, settled: false };
9757
9788
  return { state: SEEK_IDLE, apply: null, settled: true };
9758
9789
  }
9790
+ function releaseSeek(current, t, fps, duration) {
9791
+ const at = seekTime(t, fps, duration);
9792
+ return sameFrame(current, at, fps) ? null : at;
9793
+ }
9794
+ function stallGuard(timers, onStall, ms = SEEK_STALL_MS) {
9795
+ let id = 0;
9796
+ const clear = () => {
9797
+ if (id)
9798
+ timers.clearTimeout(id);
9799
+ id = 0;
9800
+ };
9801
+ return {
9802
+ arm() {
9803
+ clear();
9804
+ id = timers.setTimeout(() => {
9805
+ id = 0;
9806
+ onStall();
9807
+ }, ms);
9808
+ },
9809
+ clear,
9810
+ get armed() {
9811
+ return id !== 0;
9812
+ }
9813
+ };
9814
+ }
9759
9815
  function letterbox(media, box) {
9760
9816
  if (media.w <= 0 || media.h <= 0 || box.w <= 0 || box.h <= 0)
9761
9817
  return box;
@@ -9876,7 +9932,7 @@ function stripPeaks(diffs, o = {}) {
9876
9932
  });
9877
9933
  return out;
9878
9934
  }
9879
- 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), sameFrame = (a, b, fps) => frameOf(a, fps) === frameOf(b, fps), SEEK_IDLE, FUSE, fuseCorners = (r, k) => {
9935
+ 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) => {
9880
9936
  const inner = r * Math.min(1, Math.max(0, k));
9881
9937
  return { before: { left: r, right: inner }, after: { left: inner, right: r } };
9882
9938
  }, labelFold = (fold, mark, blur = 3) => {
@@ -10123,6 +10179,25 @@ var init_compare_mp4 = __esm(() => {
10123
10179
  });
10124
10180
 
10125
10181
  // src/ui/compare-frames.ts
10182
+ function scrubCaching(option, win) {
10183
+ if (option === false)
10184
+ return false;
10185
+ if (option === true)
10186
+ return true;
10187
+ const net = win?.navigator?.connection;
10188
+ if (net?.saveData)
10189
+ return false;
10190
+ return !win?.matchMedia?.("(prefers-reduced-data: reduce)").matches;
10191
+ }
10192
+ function proxyStride(bytes, count, total, budget) {
10193
+ if (count <= 0 || total <= 0)
10194
+ return 1;
10195
+ const each = bytes / count;
10196
+ let stride = 1;
10197
+ while (total / stride * each > budget && stride < total)
10198
+ stride *= 2;
10199
+ return stride;
10200
+ }
10126
10201
  function gridPoints(total, count = GRID) {
10127
10202
  if (total <= 0)
10128
10203
  return [];
@@ -10153,6 +10228,24 @@ function nearestKey(keys, frame, within = Infinity) {
10153
10228
  }
10154
10229
  return best !== null && bestD <= within ? best : null;
10155
10230
  }
10231
+ function claim(core) {
10232
+ if (!round2) {
10233
+ const r = new Set;
10234
+ round2 = r;
10235
+ queueMicrotask(() => {
10236
+ for (const c of actives)
10237
+ if (!r.has(c)) {
10238
+ c.active = false;
10239
+ c.dropFull();
10240
+ }
10241
+ actives = r;
10242
+ round2 = null;
10243
+ });
10244
+ }
10245
+ round2.add(core);
10246
+ actives.add(core);
10247
+ core.active = true;
10248
+ }
10156
10249
  function pump(win) {
10157
10250
  if (running)
10158
10251
  return;
@@ -10160,12 +10253,14 @@ function pump(win) {
10160
10253
  (async () => {
10161
10254
  try {
10162
10255
  for (;; ) {
10163
- const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active));
10256
+ const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active) || a.ran - b.ran);
10164
10257
  let unit = null;
10165
10258
  for (const c of order) {
10166
10259
  unit = c.work();
10167
- if (unit)
10260
+ if (unit) {
10261
+ c.ran = ++turns;
10168
10262
  break;
10263
+ }
10169
10264
  }
10170
10265
  if (!unit)
10171
10266
  break;
@@ -10195,12 +10290,99 @@ async function toBitmap(doc, source, w, h, cap) {
10195
10290
  return createImageBitmap(c);
10196
10291
  }
10197
10292
  }
10293
+ function holdEncoders() {
10294
+ encoderUsers++;
10295
+ }
10296
+ function dropEncoders() {
10297
+ encoderUsers = Math.max(0, encoderUsers - 1);
10298
+ if (encoderUsers > 0)
10299
+ return;
10300
+ endEncoders("released");
10301
+ encoders = undefined;
10302
+ }
10303
+ function encodersOf(host) {
10304
+ if (encoders !== undefined)
10305
+ return encoders;
10306
+ encoders = null;
10307
+ const W = host.Worker;
10308
+ if (!W || !host.OffscreenCanvas)
10309
+ return null;
10310
+ const workers = [];
10311
+ let url = null;
10312
+ try {
10313
+ url = host.URL.createObjectURL(new host.Blob([ENCODER], { type: "text/javascript" }));
10314
+ for (let i = 0;i < ENCODERS; i++)
10315
+ workers.push(new W(url));
10316
+ } catch {
10317
+ for (const w of workers)
10318
+ w.terminate();
10319
+ return null;
10320
+ } finally {
10321
+ if (url)
10322
+ host.URL.revokeObjectURL(url);
10323
+ }
10324
+ const pool = { workers, waiting: new Map, next: 0 };
10325
+ for (const w of workers) {
10326
+ w.onmessage = (e) => {
10327
+ const job = pool.waiting.get(e.data.id);
10328
+ pool.waiting.delete(e.data.id);
10329
+ if (e.data.blob)
10330
+ job?.done(e.data.blob);
10331
+ else
10332
+ job?.fail(new Error(e.data.error));
10333
+ };
10334
+ w.onerror = () => {
10335
+ if (encoders !== pool)
10336
+ return;
10337
+ endEncoders("encoder");
10338
+ encoders = null;
10339
+ };
10340
+ }
10341
+ encoders = pool;
10342
+ return pool;
10343
+ }
10344
+ function encodeOn(pool, bitmap, quality) {
10345
+ const id = pool.next++;
10346
+ const worker = pool.workers[id % pool.workers.length];
10347
+ return new Promise((done, fail) => {
10348
+ pool.waiting.set(id, { done, fail });
10349
+ try {
10350
+ worker.postMessage({ id, bitmap, quality }, [bitmap]);
10351
+ } catch (err) {
10352
+ pool.waiting.delete(id);
10353
+ bitmap.close();
10354
+ fail(err);
10355
+ }
10356
+ });
10357
+ }
10358
+ async function toProxy(doc, source, w, h) {
10359
+ const bm = await toBitmap(doc, source, w, h, PROXY.size);
10360
+ const pool = encodersOf(doc.defaultView);
10361
+ if (pool)
10362
+ return encodeOn(pool, bm, PROXY.quality);
10363
+ try {
10364
+ const Off = doc.defaultView?.OffscreenCanvas;
10365
+ if (Off) {
10366
+ const c = new Off(bm.width, bm.height);
10367
+ c.getContext("2d").drawImage(bm, 0, 0);
10368
+ return await c.convertToBlob({ type: "image/jpeg", quality: PROXY.quality });
10369
+ }
10370
+ const c = doc.createElement("canvas");
10371
+ c.width = bm.width;
10372
+ c.height = bm.height;
10373
+ c.getContext("2d").drawImage(bm, 0, 0);
10374
+ return await new Promise((done, fail) => c.toBlob((b) => b ? done(b) : fail(new Error("toBlob")), "image/jpeg", PROXY.quality));
10375
+ } finally {
10376
+ bm.close();
10377
+ }
10378
+ }
10198
10379
  function acquireFrames(doc, src, fps) {
10199
10380
  const key = `${fps}|${src}`;
10200
10381
  let core = cores.get(key);
10201
10382
  if (!core) {
10202
10383
  core = makeCore(doc, key, src, fps);
10203
10384
  cores.set(key, core);
10385
+ holdEncoders();
10204
10386
  }
10205
10387
  core.refs++;
10206
10388
  return core.cache;
@@ -10221,6 +10403,71 @@ function makeCore(doc, key, src, fps) {
10221
10403
  let generation = 0;
10222
10404
  let tick = 0;
10223
10405
  let notifyRaf = 0;
10406
+ const proxies = new Map;
10407
+ let proxyBytes = 0;
10408
+ let stride = 1;
10409
+ const decoded = new Map;
10410
+ const decoding = new Set;
10411
+ let aim = null;
10412
+ let lastAsked = null;
10413
+ const storeProxy = (frame, blob) => {
10414
+ if (disposed || frame % stride || proxies.has(frame))
10415
+ return;
10416
+ proxies.set(frame, blob);
10417
+ proxyBytes += blob.size;
10418
+ const next = proxyStride(proxyBytes, proxies.size, total, PROXY.budget);
10419
+ if (next <= stride)
10420
+ return;
10421
+ stride = next;
10422
+ for (const [f, b] of proxies)
10423
+ if (f % stride) {
10424
+ proxies.delete(f);
10425
+ proxyBytes -= b.size;
10426
+ }
10427
+ };
10428
+ const proxyNear = (frame) => {
10429
+ const at = Math.round(frame / stride) * stride;
10430
+ let best = null;
10431
+ for (const f of [at, at - stride, at + stride])
10432
+ if (proxies.has(f) && (best === null || Math.abs(f - frame) < Math.abs(best - frame)))
10433
+ best = f;
10434
+ return best;
10435
+ };
10436
+ const proxyTodo = () => {
10437
+ const out = [];
10438
+ for (let f = 0;f < total; f += stride)
10439
+ if (!proxies.has(f))
10440
+ out.push(f);
10441
+ return out;
10442
+ };
10443
+ const decodeProxy = (frame) => {
10444
+ if (frame === null || decoded.has(frame) || decoding.has(frame) || disposed)
10445
+ return;
10446
+ const blob = proxies.get(frame);
10447
+ if (!blob)
10448
+ return;
10449
+ decoding.add(frame);
10450
+ createImageBitmap(blob).then((bitmap) => {
10451
+ decoding.delete(frame);
10452
+ if (disposed)
10453
+ return bitmap.close();
10454
+ decoded.set(frame, { bitmap, used: ++tick });
10455
+ if (decoded.size > PROXY.decoded) {
10456
+ const [oldest] = [...decoded].sort((a, b) => a[1].used - b[1].used);
10457
+ if (oldest && oldest[0] !== frame) {
10458
+ oldest[1].bitmap.close();
10459
+ decoded.delete(oldest[0]);
10460
+ }
10461
+ }
10462
+ for (const f of listeners)
10463
+ f();
10464
+ }, () => decoding.delete(frame));
10465
+ };
10466
+ const dropDecoded = () => {
10467
+ for (const d of decoded.values())
10468
+ d.bitmap.close();
10469
+ decoded.clear();
10470
+ };
10224
10471
  const notify = () => {
10225
10472
  if (notifyRaf)
10226
10473
  return;
@@ -10351,18 +10598,50 @@ function makeCore(doc, key, src, fps) {
10351
10598
  return;
10352
10599
  const forFocus = todo[0].kind === "focus";
10353
10600
  const gen = generation;
10354
- const stale = () => forFocus && gen !== generation;
10601
+ await decodeRun(t, data, run, forFocus ? () => gen !== generation : () => false, (vf, f) => {
10602
+ const want = plan.get(f);
10603
+ if (!want)
10604
+ return null;
10605
+ plan.delete(f);
10606
+ return toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind));
10607
+ });
10608
+ };
10609
+ const proxyUnit = async () => {
10610
+ const todo = proxyTodo();
10611
+ if (!todo.length || !track || !buf)
10612
+ return;
10613
+ const runs = decodeRuns(track.samples, new Set(todo), (pts) => frameOf(pts, fps));
10614
+ const at = aim ?? focusAt ?? 0;
10615
+ let run = runs[0];
10616
+ let best = Number.POSITIVE_INFINITY;
10617
+ for (const r of runs) {
10618
+ const d = Math.min(...r.frames.map((f) => Math.abs(f - at)));
10619
+ if (d < best) {
10620
+ best = d;
10621
+ run = r;
10622
+ }
10623
+ }
10624
+ if (!run)
10625
+ return;
10626
+ const gen = generation;
10627
+ let made = 0;
10628
+ await decodeRun(track, buf, run, () => gen !== generation || made >= PROXY.turn, (vf, f) => {
10629
+ if (f % stride || proxies.has(f) || f >= total)
10630
+ return null;
10631
+ made++;
10632
+ return toProxy(doc, vf, vf.displayWidth, vf.displayHeight).then((b) => storeProxy(f, b));
10633
+ });
10634
+ };
10635
+ const decodeRun = async (t, data, run, stale, take) => {
10355
10636
  let pending = 0;
10356
10637
  let failed = false;
10357
10638
  const decoder = new VideoDecoder({
10358
10639
  output: (vf) => {
10359
- const f = frameOf(vf.timestamp / 1e6, fps);
10360
- const want = plan.get(f);
10361
- if (!want || stale() || disposed)
10640
+ const work = stale() || disposed ? null : take(vf, frameOf(vf.timestamp / 1e6, fps));
10641
+ if (!work)
10362
10642
  return vf.close();
10363
- plan.delete(f);
10364
10643
  pending++;
10365
- toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind)).catch(() => {}).finally(() => {
10644
+ work.catch(() => {}).finally(() => {
10366
10645
  vf.close();
10367
10646
  pending--;
10368
10647
  });
@@ -10476,6 +10755,25 @@ function makeCore(doc, key, src, fps) {
10476
10755
  return points.some((p) => p.frame + p.tolerance >= f && !covered(p));
10477
10756
  }, 6000, false);
10478
10757
  };
10758
+ const videoProxyUnit = async () => {
10759
+ const v = sampler;
10760
+ const todo = proxyTodo();
10761
+ if (!v || !todo.length || !v.videoWidth)
10762
+ return;
10763
+ const at = aim ?? focusAt ?? 0;
10764
+ const first = todo.find((f) => f >= at) ?? todo[0];
10765
+ const grab = async (f) => {
10766
+ if (f % stride || proxies.has(f) || f >= total)
10767
+ return;
10768
+ storeProxy(f, await toProxy(doc, v, v.videoWidth, v.videoHeight));
10769
+ };
10770
+ await seekTo(v, (first + 0.5) / fps);
10771
+ await grab(frameOf(v.currentTime, fps));
10772
+ await playThrough(v, 1, async (f) => {
10773
+ await grab(f);
10774
+ return proxyTodo().length > 0;
10775
+ }, 4000, true);
10776
+ };
10479
10777
  const cache = {
10480
10778
  src,
10481
10779
  get backend() {
@@ -10484,29 +10782,56 @@ function makeCore(doc, key, src, fps) {
10484
10782
  get size() {
10485
10783
  return entries.size;
10486
10784
  },
10487
- nearest(frame, within = Infinity) {
10785
+ get proxies() {
10786
+ return { count: proxies.size, stride, bytes: proxyBytes };
10787
+ },
10788
+ nearest(frame, within = Infinity, decode = false) {
10488
10789
  const k = nearestKey(entries.keys(), frame, within);
10790
+ const kd = nearestKey(decoded.keys(), frame, within);
10791
+ const dk = k === null ? Number.POSITIVE_INFINITY : Math.abs(k - frame);
10792
+ const dd = kd === null ? Number.POSITIVE_INFINITY : Math.abs(kd - frame);
10793
+ if (decode) {
10794
+ const p = proxyNear(frame);
10795
+ if (p !== null && Math.abs(p - frame) < Math.min(dk, dd))
10796
+ decodeProxy(p);
10797
+ const ahead = aheadOf(frame, lastAsked, total);
10798
+ if (ahead !== null)
10799
+ decodeProxy(proxyNear(ahead));
10800
+ lastAsked = frame;
10801
+ }
10802
+ if (kd !== null && (dd < dk || dd === dk && !entries.get(k)?.full)) {
10803
+ const d = decoded.get(kd);
10804
+ d.used = ++tick;
10805
+ return { frame: kd, bitmap: d.bitmap, full: false };
10806
+ }
10489
10807
  if (k === null)
10490
10808
  return null;
10491
10809
  const e = entries.get(k);
10492
10810
  e.used = ++tick;
10493
10811
  return { frame: k, bitmap: e.bitmap, full: e.full };
10494
10812
  },
10813
+ covers(frame) {
10814
+ if (entries.has(frame) || decoded.has(frame))
10815
+ return true;
10816
+ const p = proxyNear(frame);
10817
+ return p !== null && Math.abs(p - frame) <= stride / 2;
10818
+ },
10495
10819
  focus(frame) {
10496
- if (active !== core) {
10497
- active?.dropFull();
10498
- if (active)
10499
- active.active = false;
10500
- active = core;
10501
- core.active = true;
10502
- }
10820
+ claim(core);
10503
10821
  const moved = focusAt === null || Math.abs(frame - focusAt) > FOCUS_RADIUS;
10504
10822
  focusAt = frame;
10823
+ aim = null;
10505
10824
  if (moved)
10506
10825
  generation++;
10507
10826
  start();
10508
10827
  pump(win);
10509
10828
  },
10829
+ follow(frame) {
10830
+ claim(core);
10831
+ aim = frame;
10832
+ start();
10833
+ pump(win);
10834
+ },
10510
10835
  want(points) {
10511
10836
  wants = points;
10512
10837
  if (core.shown > 0)
@@ -10544,12 +10869,15 @@ function makeCore(doc, key, src, fps) {
10544
10869
  refs: 0,
10545
10870
  shown: 0,
10546
10871
  active: false,
10872
+ ran: 0,
10547
10873
  work() {
10548
10874
  if (disposed || backend === "pending")
10549
10875
  return null;
10550
- if (!missing().length)
10551
- return null;
10552
- return backend === "webcodecs" ? decodeUnit() : videoUnit();
10876
+ if (missing().length)
10877
+ return backend === "webcodecs" ? decodeUnit() : videoUnit();
10878
+ if (proxyTodo().length)
10879
+ return backend === "webcodecs" ? proxyUnit() : videoProxyUnit();
10880
+ return null;
10553
10881
  },
10554
10882
  dropFull() {
10555
10883
  for (const [f, e] of entries)
@@ -10558,8 +10886,13 @@ function makeCore(doc, key, src, fps) {
10558
10886
  entries.delete(f);
10559
10887
  }
10560
10888
  focusAt = null;
10889
+ aim = null;
10890
+ lastAsked = null;
10891
+ dropDecoded();
10561
10892
  },
10562
10893
  dispose() {
10894
+ if (disposed)
10895
+ return;
10563
10896
  disposed = true;
10564
10897
  generation++;
10565
10898
  if (notifyRaf)
@@ -10567,6 +10900,8 @@ function makeCore(doc, key, src, fps) {
10567
10900
  for (const e of entries.values())
10568
10901
  e.bitmap.close();
10569
10902
  entries.clear();
10903
+ dropDecoded();
10904
+ proxies.clear();
10570
10905
  listeners.clear();
10571
10906
  if (sampler) {
10572
10907
  sampler.removeAttribute("src");
@@ -10574,24 +10909,45 @@ function makeCore(doc, key, src, fps) {
10574
10909
  }
10575
10910
  buf = null;
10576
10911
  track = null;
10577
- if (active === core)
10578
- active = null;
10912
+ actives.delete(core);
10913
+ round2?.delete(core);
10579
10914
  cores.delete(key);
10915
+ dropEncoders();
10580
10916
  }
10581
10917
  };
10582
10918
  return core;
10583
10919
  }
10584
- var FOCUS_RADIUS = 6, GRID = 48, SEEN = 12, WANTED = 48, PREVIEW = 384, FULL = 1280, cores, active = null, running = false, idle = (win) => new Promise((done) => {
10920
+ 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) => {
10585
10921
  const ric = win.requestIdleCallback;
10586
10922
  if (ric)
10587
10923
  ric(() => done(), { timeout: 60 });
10588
10924
  else
10589
10925
  win.setTimeout(done, 4);
10590
- }), sleep = (win, ms) => new Promise((done) => win.setTimeout(done, ms));
10926
+ }), 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) => {
10927
+ const pool = encoders;
10928
+ if (!pool)
10929
+ return;
10930
+ for (const w of pool.workers) {
10931
+ w.onmessage = null;
10932
+ w.onerror = null;
10933
+ w.terminate();
10934
+ }
10935
+ for (const j of pool.waiting.values())
10936
+ j.fail(new Error(why));
10937
+ pool.waiting.clear();
10938
+ };
10591
10939
  var init_compare_frames = __esm(() => {
10592
10940
  init_compare_model();
10593
10941
  init_compare_mp4();
10942
+ PROXY = {
10943
+ size: 960,
10944
+ quality: 0.8,
10945
+ budget: 48 * 2 ** 20,
10946
+ decoded: 8,
10947
+ turn: 90
10948
+ };
10594
10949
  cores = new Map;
10950
+ actives = new Set;
10595
10951
  });
10596
10952
 
10597
10953
  // src/ui/compare-gpu.ts
@@ -10857,1062 +11213,711 @@ var init_compare_gpu = __esm(() => {
10857
11213
  lostListeners = new Set;
10858
11214
  });
10859
11215
 
10860
- // src/ui/compare-strip.ts
10861
- function compareStrip(container, options) {
11216
+ // src/ui/compare.ts
11217
+ function compare(container, options) {
10862
11218
  const doc = container.ownerDocument;
10863
11219
  const win = doc.defaultView;
10864
- const timeline = options.timeline;
10865
- const fps = () => options.fps ?? timeline.fps;
10866
- const align = options.align ?? "track";
10867
- const threshold = options.threshold ?? 4 / 255;
10868
- const root = doc.createElement("div");
10869
- root.className = "knobs-cmp-strip";
10870
- root.setAttribute("role", "img");
10871
- root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
11220
+ const caching = scrubCaching(options.scrubCache, win);
11221
+ const look = {
11222
+ ...options.preset ? { preset: options.preset } : {},
11223
+ ...options.theme ? { theme: options.theme } : {},
11224
+ ...options.appearance ? { appearance: options.appearance } : {}
11225
+ };
11226
+ const offered = compareModes(options.modes, options.mode);
11227
+ const modeList = offered.modes;
11228
+ let mode = offered.mode;
11229
+ let wipe = Math.min(1, Math.max(0, options.wipe ?? 0.5));
11230
+ let loupeOn = options.loupe ?? false;
11231
+ let threshold = options.threshold ?? 4 / 255;
11232
+ let fpsOpt = options.fps;
11233
+ const bound = typeof options.timeline === "object" ? options.timeline : null;
11234
+ const fps = () => fpsOpt ?? bound?.fps ?? 30;
11235
+ const auto = container.clientHeight < 1;
11236
+ const el = (tag, cls) => {
11237
+ const n = doc.createElement(tag);
11238
+ n.className = cls;
11239
+ return n;
11240
+ };
11241
+ const root = el("div", "knobs-compare");
11242
+ root.tabIndex = 0;
11243
+ root.setAttribute("role", "group");
11244
+ root.setAttribute("aria-roledescription", "compare");
11245
+ root.setAttribute("aria-label", options.label ?? "Compare");
11246
+ root.setAttribute("aria-keyshortcuts", `Space ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight , . ${modeList.map((m, i) => `${i + 1} ${modeKey(m)}`).join(" ")} Z L 0 = -`);
10872
11247
  applyLook2(root, options);
10873
- const canvas = doc.createElement("canvas");
11248
+ root.dataset.mode = mode;
11249
+ if (auto)
11250
+ root.dataset.auto = "";
11251
+ const bar = el("div", "knobs-cmp-bar");
11252
+ const modes = el("div", "knobs-cmp-modes");
11253
+ modes.setAttribute("role", "radiogroup");
11254
+ modes.setAttribute("aria-label", "Mode");
11255
+ modes.hidden = modeList.length < 2;
11256
+ const modeBtns = modeList.map((m, i) => {
11257
+ const b = el("button", "knobs-cmp-mode");
11258
+ b.type = "button";
11259
+ b.tabIndex = -1;
11260
+ b.textContent = MODE_LABEL[m].short;
11261
+ b.title = `${MODE_LABEL[m].long} · ${i + 1} or ${modeKey(m)}`;
11262
+ b.setAttribute("role", "radio");
11263
+ b.setAttribute("aria-keyshortcuts", `${i + 1} ${modeKey(m)}`);
11264
+ b.addEventListener("click", () => {
11265
+ setMode(m);
11266
+ root.focus({ preventScroll: true });
11267
+ });
11268
+ modes.append(b);
11269
+ return b;
11270
+ });
11271
+ const toggleBtn = (name, label, title) => {
11272
+ const b = el("button", "knobs-cmp-toggle");
11273
+ b.type = "button";
11274
+ b.tabIndex = -1;
11275
+ b.innerHTML = icon(name);
11276
+ b.setAttribute("aria-label", label);
11277
+ b.title = title;
11278
+ return b;
11279
+ };
11280
+ const loupeBtn = toggleBtn("magnifyingGlass", "Loupe", "Loupe · hold Z");
11281
+ loupeBtn.addEventListener("click", () => {
11282
+ setLoupe(!loupeOn);
11283
+ root.focus({ preventScroll: true });
11284
+ });
11285
+ const loopBtn = toggleBtn("repeat", "Loop", "Loop · L (drag its ends on the timeline)");
11286
+ loopBtn.setAttribute("aria-keyshortcuts", "L");
11287
+ loopBtn.addEventListener("click", () => {
11288
+ api.setLoop(!loopNow());
11289
+ root.focus({ preventScroll: true });
11290
+ });
11291
+ bar.append(modes, el("span", "knobs-cmp-fill"), loopBtn, loupeBtn);
11292
+ const stage = el("div", "knobs-cmp-stage");
11293
+ const canvas = el("canvas", "knobs-cmp-canvas");
10874
11294
  canvas.setAttribute("aria-hidden", "true");
10875
- root.append(canvas);
10876
- container.append(root);
10877
- const diffs = new Map;
10878
- const pairDiffs = new Map;
10879
- let disposed = false;
10880
- let near = false;
10881
- let onScreen = false;
10882
- const makeSide = (source) => ({
10883
- source,
10884
- kind: mediaKind(source.src, source.kind),
10885
- frames: null,
10886
- still: null,
10887
- seen: false
11295
+ const split = el("div", "knobs-cmp-split");
11296
+ split.setAttribute("aria-hidden", "true");
11297
+ const zoomPill = el("div", "knobs-cmp-zoom");
11298
+ const zoomPct = el("span", "knobs-cmp-zoom-pct");
11299
+ const zoomReset = el("button", "knobs-cmp-zoom-reset");
11300
+ zoomReset.type = "button";
11301
+ zoomReset.tabIndex = -1;
11302
+ zoomReset.innerHTML = icon("arrowCounterClockwise", { small: true });
11303
+ zoomReset.setAttribute("aria-label", "Reset zoom");
11304
+ zoomReset.title = "Fit · 0 or double-click";
11305
+ zoomReset.addEventListener("click", () => {
11306
+ api.setZoom(null);
11307
+ root.focus({ preventScroll: true });
10888
11308
  });
10889
- let sides = [makeSide(options.before), makeSide(options.after)];
10890
- const offs = new Map;
10891
- const load = (s) => {
10892
- if (s.frames || s.still)
10893
- return;
10894
- if (s.kind === "video") {
10895
- s.frames = acquireFrames(doc, s.source.src, fps());
10896
- offs.set(s, s.frames.listen(schedule));
10897
- if (onScreen) {
10898
- s.frames.visible(true);
10899
- s.seen = true;
10900
- }
10901
- } else {
10902
- const img = doc.createElement("img");
10903
- img.crossOrigin = "anonymous";
10904
- img.decoding = "async";
10905
- img.addEventListener("load", () => {
10906
- pairDiffs.clear();
10907
- schedule();
10908
- });
10909
- img.src = s.source.src;
10910
- s.still = img;
11309
+ zoomPill.append(zoomPct, zoomReset);
11310
+ const labels = [el("div", "knobs-cmp-label"), el("div", "knobs-cmp-label")];
11311
+ labels[0].dataset.side = "before";
11312
+ labels[1].dataset.side = "after";
11313
+ const setLabel = (index, text) => {
11314
+ const l = labels[index];
11315
+ if (!l.firstElementChild) {
11316
+ const mark = el("span", "knobs-cmp-mark");
11317
+ mark.innerHTML = icon(index === 0 ? "x" : "check", { small: true });
11318
+ l.append(mark, el("span", "knobs-cmp-name"));
10911
11319
  }
11320
+ const name = l.lastElementChild;
11321
+ if (name.textContent !== text)
11322
+ name.textContent = text;
10912
11323
  };
10913
- const unload = (s) => {
10914
- offs.get(s)?.();
10915
- offs.delete(s);
10916
- if (s.frames) {
10917
- if (s.seen)
10918
- s.frames.visible(false);
10919
- s.frames.release();
10920
- }
10921
- s.frames = null;
10922
- s.seen = false;
10923
- if (s.still)
10924
- s.still.removeAttribute("src");
10925
- s.still = null;
11324
+ const seam = el("div", "knobs-cmp-seam");
11325
+ seam.setAttribute("aria-hidden", "true");
11326
+ const handle = el("div", "knobs-cmp-handle");
11327
+ handle.setAttribute("role", "slider");
11328
+ handle.setAttribute("aria-label", "Wipe");
11329
+ handle.setAttribute("aria-valuemin", "0");
11330
+ handle.setAttribute("aria-valuemax", "100");
11331
+ const grip = el("span", "knobs-cmp-grip");
11332
+ grip.setAttribute("aria-hidden", "true");
11333
+ handle.append(grip);
11334
+ const note = el("div", "knobs-cmp-note");
11335
+ stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
11336
+ root.append(bar, stage);
11337
+ let tl = bound;
11338
+ let ownTimeline = false;
11339
+ if (!tl && options.timeline === true) {
11340
+ const host = el("div", "knobs-cmp-timeline-host");
11341
+ root.append(host);
11342
+ tl = timeline(host, {
11343
+ ...look,
11344
+ variant: "line",
11345
+ duration: 1,
11346
+ fps: fps(),
11347
+ arrows: { step: 0.5, shift: 2 },
11348
+ label: "Compare time",
11349
+ ...options.hints === false ? { hints: false } : {}
11350
+ });
11351
+ ownTimeline = true;
11352
+ }
11353
+ container.append(root);
11354
+ const sides = [null, null];
11355
+ let duration = 0;
11356
+ let renderer = "dom";
11357
+ let pending = false;
11358
+ let gpu = null;
11359
+ const lay = new Spring(mode === "side" ? 0 : 1, LAYOUT3.omega, LAYOUT3.zeta, 0.0001);
11360
+ const fade = new Spring(1, FADE6.omega, FADE6.zeta, 0.001);
11361
+ let fromMode = mode;
11362
+ let toMode = mode;
11363
+ const wipeLine = new Spring(wipe, WIPE_CATCH.omega, WIPE_CATCH.zeta, 0.0001);
11364
+ let wipeCatching = false;
11365
+ const gripAt = new Spring(0.5, LAYOUT3.omega, LAYOUT3.zeta, 0.0001);
11366
+ let loupeHeld = false;
11367
+ let pointer = null;
11368
+ let drag = null;
11369
+ let tap = null;
11370
+ const touches = new Map;
11371
+ let pinch = null;
11372
+ const zu = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11373
+ const zv = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11374
+ const zw = new Spring(1, ZOOM.omega, ZOOM.zeta, 0.00001);
11375
+ let viewSent = "0.0000,0.0000,1.0000|";
11376
+ let ownLoop = null;
11377
+ let scrubbing = false;
11378
+ let stageW = 1;
11379
+ let stageH = 1;
11380
+ let stageRect = null;
11381
+ let accent = [0.49, 0.81, 0.61];
11382
+ let labelW = [0, 0];
11383
+ let frameRadius = 0;
11384
+ let dirty = true;
11385
+ let disposed = false;
11386
+ let near = options.lazy === false;
11387
+ let onScreen = options.lazy === false;
11388
+ let releaseTimer = 0;
11389
+ let ownTime = 0;
11390
+ let ownPlaying = false;
11391
+ const videoOf = (s) => s && s.kind === "video" && s.loaded ? s.el : null;
11392
+ const master = () => videoOf(sides[1]) ?? videoOf(sides[0]);
11393
+ const follower = () => {
11394
+ const m = master();
11395
+ const v = videoOf(sides[0]);
11396
+ return m && v && v !== m ? v : null;
10926
11397
  };
10927
- let w = 1;
10928
- let h = 1;
10929
- let geo = { x: 0, w: 1 };
10930
- const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
10931
- const aspectOf = (s) => {
10932
- if (s.still?.naturalWidth)
10933
- return s.still.naturalWidth / s.still.naturalHeight;
10934
- const c = s.frames?.nearest(0);
10935
- return c ? c.bitmap.width / c.bitmap.height : 0;
11398
+ const now = () => tl ? tl.time : ownTime;
11399
+ const isPlaying = () => tl ? tl.playing : ownPlaying;
11400
+ const labelOf = (index) => sides[index]?.source.label ?? (index === 0 ? options.before.label : options.after.label) ?? (index === 0 ? "Before" : "After");
11401
+ const sizeOf = (s) => {
11402
+ if (s.size.w > 0)
11403
+ return s.size;
11404
+ const other = sides[s.index === 0 ? 1 : 0];
11405
+ if (other && other.size.w > 0)
11406
+ return other.size;
11407
+ return { w: options.aspect ?? 16 / 9, h: 1 };
10936
11408
  };
10937
- const columns = () => stripColumns(timeline.view, geo.w, {
10938
- duration: timeline.duration,
10939
- fps: fps(),
10940
- minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
10941
- });
10942
- const measure = () => {
10943
- const r = root.getBoundingClientRect();
10944
- w = Math.max(1, r.width);
10945
- h = Math.max(1, r.height);
10946
- geo = { x: 0, w };
10947
- if (align === "track") {
10948
- const track = timeline.element.querySelector(".knobs-tl-track");
10949
- const t = track?.getBoundingClientRect();
10950
- if (t && t.width > 0)
10951
- geo = { x: t.left - r.left, w: t.width };
10952
- }
10953
- readColors();
10954
- schedule();
11409
+ const wantOf = (s) => {
11410
+ const v = s.el;
11411
+ const f = frameOf(now(), fps());
11412
+ return Number.isFinite(v.duration) ? Math.min(f, lastFrame(v.duration, fps())) : f;
10955
11413
  };
10956
- const ask = (cols) => {
10957
- const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
10958
- for (const s of sides)
10959
- s.frames?.want(points);
11414
+ const emitFrame = (s, frame, source) => root.dispatchEvent(new CustomEvent("knobs-compare-frame", { detail: { side: s.index, frame, source } }));
11415
+ const setCached = (s, on) => {
11416
+ if (renderer === "dom")
11417
+ s.preview.style.display = on ? "" : "none";
10960
11418
  };
10961
- const picOf = (s, c) => {
10962
- if (s.still?.naturalWidth)
10963
- return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
10964
- const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
10965
- return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
11419
+ const showVideo = (s) => {
11420
+ const v = s.el;
11421
+ if (renderer === "gpu") {
11422
+ if (!gpu)
11423
+ return;
11424
+ if (!gpu.upload(s.index, v, v.videoWidth, v.videoHeight))
11425
+ return miss(s);
11426
+ s.misses = 0;
11427
+ }
11428
+ const f = frameOf(v.currentTime, fps());
11429
+ s.shown = { frame: f, cached: false };
11430
+ setCached(s, false);
11431
+ emitFrame(s, f, "video");
11432
+ requestDraw();
10966
11433
  };
10967
- const probe = doc.createElement("canvas");
10968
- probe.width = DIFF.w;
10969
- probe.height = DIFF.h;
10970
- const probeCtx = probe.getContext("2d", { willReadFrequently: true });
10971
- const differ = (a, b) => {
10972
- if (!probeCtx)
11434
+ const showCached = (s, c) => {
11435
+ if (s.shown?.cached && s.shown.frame === c.frame)
10973
11436
  return;
10974
- try {
10975
- probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
10976
- const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
10977
- probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
10978
- const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
10979
- return meanAbsDiff(pa, pb);
10980
- } catch {
11437
+ if (renderer === "gpu") {
11438
+ if (!gpu || !gpu.upload(2 + s.index, c.bitmap, c.bitmap.width, c.bitmap.height))
11439
+ return;
11440
+ } else if (renderer === "dom" && !pending) {
11441
+ const p = s.preview;
11442
+ if (p.width !== c.bitmap.width || p.height !== c.bitmap.height) {
11443
+ p.width = c.bitmap.width;
11444
+ p.height = c.bitmap.height;
11445
+ }
11446
+ p.getContext("2d")?.drawImage(c.bitmap, 0, 0);
11447
+ } else
10981
11448
  return;
10982
- }
10983
- };
10984
- let colors = { surface: "", accent: "", diff: "", wash: "" };
10985
- const rgba = (raw, fallback) => {
10986
- const [r, g, b, a] = parseCssColor(raw, fallback);
10987
- return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
11449
+ s.shown = { frame: c.frame, cached: true };
11450
+ setCached(s, true);
11451
+ emitFrame(s, c.frame, "cache");
11452
+ requestDraw();
10988
11453
  };
10989
- const readColors = () => {
10990
- const cs = win.getComputedStyle(root);
10991
- colors = {
10992
- surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
10993
- accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
10994
- diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
10995
- wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
10996
- };
11454
+ const miss = (s) => {
11455
+ if (s.failed || ++s.misses < 3)
11456
+ return;
11457
+ gpu?.dispose();
11458
+ gpu = null;
11459
+ setRenderer("dom");
10997
11460
  };
10998
- let hover = null;
10999
- let raf = 0;
11000
- let asked = "";
11001
- function schedule() {
11002
- if (!raf && !disposed)
11003
- raf = win.requestAnimationFrame(draw);
11004
- }
11005
- const draw = () => {
11006
- raf = 0;
11007
- if (!near)
11461
+ const pushFrame = (s) => {
11462
+ if (!s.loaded || disposed)
11008
11463
  return;
11009
- const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
11010
- const cw = Math.round(w * dpr);
11011
- const ch = Math.round(h * dpr);
11012
- if (canvas.width !== cw || canvas.height !== ch) {
11013
- canvas.width = cw;
11014
- canvas.height = ch;
11464
+ if (s.kind === "image") {
11465
+ const img = s.el;
11466
+ if (!(img.complete && img.naturalWidth))
11467
+ return;
11468
+ if (renderer === "gpu" && gpu) {
11469
+ if (!gpu.upload(s.index, img, img.naturalWidth, img.naturalHeight))
11470
+ return miss(s);
11471
+ }
11472
+ s.shown = { frame: 0, cached: false };
11473
+ return requestDraw();
11015
11474
  }
11016
- const ctx = canvas.getContext("2d");
11017
- if (!ctx)
11475
+ const v = s.el;
11476
+ if (v.readyState < 2)
11477
+ return;
11478
+ if (isPlaying() || !s.frames)
11479
+ return showVideo(s);
11480
+ const want = wantOf(s);
11481
+ const at = frameOf(v.currentTime, fps());
11482
+ if (at === want) {
11483
+ showVideo(s);
11484
+ s.frames.keep(at, v, v.videoWidth, v.videoHeight);
11018
11485
  return;
11019
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
11020
- ctx.clearRect(0, 0, w, h);
11021
- const cols = columns();
11022
- const key = cols.map((c) => c.frame).join(",");
11023
- if (key !== asked) {
11024
- asked = key;
11025
- ask(cols);
11026
11486
  }
11027
- const rh = rowH();
11028
- const radius = 3;
11029
- let budget = DIFFS_PER_DRAW;
11030
- let more = false;
11031
- const ds = cols.map((c) => {
11032
- const a = picOf(sides[0], c);
11033
- const b = picOf(sides[1], c);
11034
- if (!a || !b)
11035
- return;
11036
- const k = `${a.key}|${b.key}`;
11037
- if (!pairDiffs.has(k)) {
11038
- if (budget-- <= 0) {
11039
- more = true;
11040
- return;
11041
- }
11042
- pairDiffs.set(k, differ(a, b));
11487
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
11488
+ if (c && Math.abs(c.frame - want) < Math.abs(at - want))
11489
+ showCached(s, c);
11490
+ else
11491
+ showVideo(s);
11492
+ };
11493
+ const refreshCached = (s) => {
11494
+ if (!s.loaded || !s.frames || isPlaying())
11495
+ return;
11496
+ const want = wantOf(s);
11497
+ if (s.shown && !s.shown.cached && s.shown.frame === want)
11498
+ return;
11499
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
11500
+ if (!c)
11501
+ return;
11502
+ const was = s.shown ? Math.abs(s.shown.frame - want) : Number.POSITIVE_INFINITY;
11503
+ if (Math.abs(c.frame - want) < was)
11504
+ showCached(s, c);
11505
+ };
11506
+ const giveUp = (s) => {
11507
+ s.seek = SEEK_IDLE;
11508
+ if (!s.loaded || disposed)
11509
+ return;
11510
+ if (s.el.readyState >= 2)
11511
+ return showVideo(s);
11512
+ s.shown = null;
11513
+ setCached(s, false);
11514
+ requestDraw();
11515
+ };
11516
+ const makeSide = (index, source) => {
11517
+ const kind = mediaKind(source.src, source.kind);
11518
+ const media = el(kind === "video" ? "video" : "img", "knobs-cmp-media");
11519
+ media.dataset.side = index === 0 ? "before" : "after";
11520
+ media.crossOrigin = "anonymous";
11521
+ media.setAttribute("aria-hidden", "true");
11522
+ const preview = el("canvas", "knobs-cmp-media knobs-cmp-preview");
11523
+ preview.dataset.side = media.dataset.side;
11524
+ preview.style.display = "none";
11525
+ preview.setAttribute("aria-hidden", "true");
11526
+ const offs = [];
11527
+ const on = (type, f) => {
11528
+ media.addEventListener(type, f);
11529
+ offs.push(() => media.removeEventListener(type, f));
11530
+ };
11531
+ const side = {
11532
+ index,
11533
+ source,
11534
+ kind,
11535
+ el: media,
11536
+ preview,
11537
+ size: { w: 0, h: 0 },
11538
+ seek: SEEK_IDLE,
11539
+ loaded: false,
11540
+ frames: null,
11541
+ unlisten: null,
11542
+ seen: false,
11543
+ shown: null,
11544
+ frameAt: 0,
11545
+ misses: 0,
11546
+ failed: false,
11547
+ stall: stallGuard(win, () => giveUp(side)),
11548
+ stop: () => {
11549
+ side.stall.clear();
11550
+ for (const f of offs)
11551
+ f();
11043
11552
  }
11044
- const d = pairDiffs.get(k);
11045
- if (d !== undefined)
11046
- diffs.set(c.frame, d);
11047
- return d;
11048
- });
11049
- const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
11050
- const peaks = new Set(stripPeaks(ds, { threshold }));
11051
- cols.forEach((c, i) => {
11052
- const x = geo.x + c.x + GAP / 2;
11053
- const cwid = Math.max(1, c.w - GAP);
11054
- if (x + cwid < 0 || x > w)
11553
+ };
11554
+ const fail = () => {
11555
+ if (!side.loaded)
11055
11556
  return;
11056
- for (const row of [0, 1]) {
11057
- const y = row * (rh + GAP);
11058
- const pic = picOf(sides[row], c);
11059
- ctx.save();
11060
- ctx.beginPath();
11061
- ctx.roundRect(x, y, cwid, rh, radius);
11062
- ctx.fillStyle = colors.surface;
11063
- ctx.fill();
11064
- if (pic) {
11065
- ctx.clip();
11066
- const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
11067
- ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
11068
- }
11069
- ctx.restore();
11070
- }
11071
- if (hover === i) {
11072
- ctx.fillStyle = colors.wash;
11073
- ctx.beginPath();
11074
- ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
11075
- ctx.fill();
11076
- }
11077
- const d = ds[i];
11078
- if (d !== undefined && d > 0) {
11079
- const bh = Math.max(1, Math.min(1, d / max) * BAR);
11080
- ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
11081
- ctx.fillStyle = colors.diff;
11082
- ctx.beginPath();
11083
- ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
11084
- ctx.fill();
11085
- ctx.globalAlpha = 1;
11557
+ side.failed = true;
11558
+ note.textContent = `Couldn't load ${labelOf(index)}`;
11559
+ side.stall.clear();
11560
+ side.seek = SEEK_IDLE;
11561
+ side.shown = null;
11562
+ setCached(side, false);
11563
+ };
11564
+ if (media instanceof HTMLVideoElement) {
11565
+ const v = media;
11566
+ v.muted = true;
11567
+ v.defaultMuted = true;
11568
+ v.playsInline = true;
11569
+ v.preload = "auto";
11570
+ v.setAttribute("playsinline", "");
11571
+ v.setAttribute("muted", "");
11572
+ on("loadedmetadata", () => {
11573
+ side.size = { w: v.videoWidth, h: v.videoHeight };
11574
+ measureDuration();
11575
+ if (auto)
11576
+ measure();
11577
+ if (isPlaying())
11578
+ jumpAll(now());
11579
+ else
11580
+ showFrame(now(), false);
11581
+ requestDraw();
11582
+ });
11583
+ on("resize", () => {
11584
+ side.size = { w: v.videoWidth, h: v.videoHeight };
11585
+ requestDraw();
11586
+ });
11587
+ on("loadeddata", () => pushFrame(side));
11588
+ on("seeked", () => {
11589
+ const step = seeked(side.seek, v.currentTime, fps());
11590
+ side.seek = step.state;
11591
+ if (step.apply !== null)
11592
+ seekVideo(side, v, step.apply);
11593
+ else
11594
+ side.stall.clear();
11595
+ pushFrame(side);
11596
+ });
11597
+ on("error", fail);
11598
+ if (v.requestVideoFrameCallback) {
11599
+ let id = 0;
11600
+ const frame = () => {
11601
+ side.frameAt = performance.now();
11602
+ pushFrame(side);
11603
+ id = v.requestVideoFrameCallback(frame);
11604
+ };
11605
+ id = v.requestVideoFrameCallback(frame);
11606
+ offs.push(() => v.cancelVideoFrameCallback?.(id));
11086
11607
  }
11087
- });
11088
- const v = timeline.view;
11089
- const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
11090
- if (px >= -1 && px <= w + 1) {
11091
- ctx.fillStyle = colors.accent;
11092
- ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
11608
+ } else {
11609
+ on("load", () => {
11610
+ side.size = { w: media.naturalWidth, h: media.naturalHeight };
11611
+ if (auto)
11612
+ measure();
11613
+ pushFrame(side);
11614
+ requestDraw();
11615
+ });
11616
+ on("error", fail);
11617
+ media.decoding = "async";
11093
11618
  }
11094
- if (more)
11095
- schedule();
11096
- };
11097
- const timeAt = (clientX) => {
11098
- const r = root.getBoundingClientRect();
11099
- const v = timeline.view;
11100
- const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
11101
- return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
11102
- };
11103
- const columnAt = (clientX) => {
11104
- const x = clientX - root.getBoundingClientRect().left - geo.x;
11105
- const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
11106
- return i < 0 ? null : i;
11619
+ return side;
11107
11620
  };
11108
- let dragging = false;
11109
- const pick = (clientX) => {
11110
- const t = timeAt(clientX);
11111
- timeline.seek(t);
11112
- options.onPick?.(t);
11621
+ const loadSide = (s) => {
11622
+ if (s.loaded || disposed)
11623
+ return;
11624
+ s.loaded = true;
11625
+ s.failed = false;
11626
+ s.el.src = s.source.src;
11627
+ if (s.kind === "video" && caching) {
11628
+ s.frames = acquireFrames(doc, s.source.src, fps());
11629
+ s.unlisten = s.frames.listen(() => refreshCached(s));
11630
+ if (onScreen) {
11631
+ s.frames.visible(true);
11632
+ s.seen = true;
11633
+ }
11634
+ }
11113
11635
  };
11114
- root.addEventListener("pointerdown", (e) => {
11115
- if (e.button !== 0)
11636
+ const unloadSide = (s) => {
11637
+ if (!s.loaded)
11116
11638
  return;
11117
- dragging = true;
11118
- root.setPointerCapture(e.pointerId);
11119
- const host = root.closest(".knobs-compare");
11120
- if (host)
11121
- host.focus({ preventScroll: true });
11122
- else
11123
- timeline.focus();
11124
- if (timeline.playing)
11125
- timeline.pause();
11126
- pick(e.clientX);
11127
- e.preventDefault();
11128
- });
11129
- root.addEventListener("pointermove", (e) => {
11130
- if (dragging)
11131
- pick(e.clientX);
11132
- const i = columnAt(e.clientX);
11133
- if (i !== hover) {
11134
- hover = i;
11135
- schedule();
11639
+ s.loaded = false;
11640
+ s.el.removeAttribute("src");
11641
+ if (s.el instanceof HTMLVideoElement)
11642
+ s.el.load();
11643
+ s.seek = SEEK_IDLE;
11644
+ s.stall.clear();
11645
+ s.shown = null;
11646
+ setCached(s, false);
11647
+ s.unlisten?.();
11648
+ s.unlisten = null;
11649
+ if (s.frames) {
11650
+ if (s.seen)
11651
+ s.frames.visible(false);
11652
+ s.seen = false;
11653
+ s.frames.release();
11654
+ s.frames = null;
11136
11655
  }
11137
- });
11138
- const end = () => {
11139
- dragging = false;
11656
+ gpu?.clear(s.index);
11140
11657
  };
11141
- root.addEventListener("pointerup", end);
11142
- root.addEventListener("pointercancel", end);
11143
- root.addEventListener("lostpointercapture", end);
11144
- root.addEventListener("pointerleave", () => {
11145
- if (hover === null)
11658
+ const measureDuration = () => {
11659
+ const next = Math.max(0, ...sides.map((s) => {
11660
+ const v = videoOf(s);
11661
+ return v && Number.isFinite(v.duration) ? v.duration : 0;
11662
+ }));
11663
+ if (next <= 0 || next === duration)
11146
11664
  return;
11147
- hover = null;
11148
- schedule();
11149
- });
11150
- const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
11151
- const ro = new win.ResizeObserver(measure);
11152
- ro.observe(root);
11153
- const track = timeline.element.querySelector(".knobs-tl-track");
11154
- if (track)
11155
- ro.observe(track);
11156
- root.addEventListener("knobs-restyle", measure);
11157
- const setOnScreen = (on) => {
11158
- onScreen = on;
11665
+ duration = next;
11666
+ if (ownTimeline && tl)
11667
+ tl.update({ duration, fps: fps() });
11668
+ };
11669
+ const setRenderer = (next) => {
11670
+ pending = false;
11671
+ renderer = next;
11672
+ root.dataset.renderer = next;
11673
+ loupeBtn.hidden = next !== "gpu";
11674
+ canvas.style.display = next === "gpu" ? "" : "none";
11159
11675
  for (const s of sides) {
11160
- if (!s.frames || s.seen === on)
11161
- continue;
11162
- s.seen = on;
11163
- s.frames.visible(on);
11676
+ const st = s.el.style;
11677
+ st.clipPath = "";
11678
+ st.maskImage = "";
11679
+ st.setProperty("-webkit-mask-image", "");
11680
+ st.mixBlendMode = "";
11681
+ st.opacity = "";
11682
+ s.shown = null;
11683
+ setCached(s, false);
11164
11684
  }
11685
+ for (const s of sides)
11686
+ pushFrame(s);
11687
+ requestDraw();
11165
11688
  };
11166
- const setNear = (on) => {
11167
- near = on;
11168
- if (on) {
11169
- for (const s of sides)
11170
- load(s);
11171
- schedule();
11172
- } else
11173
- for (const s of sides)
11174
- unload(s);
11689
+ const startGpu = async (retry) => {
11690
+ const g = await createCompareGpu(canvas, () => {
11691
+ gpu = null;
11692
+ if (disposed)
11693
+ return;
11694
+ if (retry)
11695
+ win.setTimeout(() => void startGpu(false), 150);
11696
+ else
11697
+ setRenderer("dom");
11698
+ });
11699
+ if (disposed)
11700
+ return g?.dispose();
11701
+ if (!g)
11702
+ return setRenderer("dom");
11703
+ gpu = g;
11704
+ setRenderer("gpu");
11175
11705
  };
11176
- const IO = win.IntersectionObserver;
11177
- const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
11178
- const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
11179
- measure();
11180
- if (nearIo && screenIo) {
11181
- nearIo.observe(root);
11182
- screenIo.observe(root);
11183
- } else {
11184
- setNear(true);
11185
- setOnScreen(true);
11186
- }
11187
- return {
11188
- element: root,
11189
- diffs,
11190
- update(next) {
11191
- const was = sides;
11192
- sides = [
11193
- next.before ? makeSide(next.before) : was[0],
11194
- next.after ? makeSide(next.after) : was[1]
11195
- ];
11196
- for (const s of was)
11197
- if (!sides.includes(s))
11198
- unload(s);
11199
- diffs.clear();
11200
- pairDiffs.clear();
11201
- asked = "";
11202
- if (near)
11203
- for (const s of sides)
11204
- load(s);
11205
- if (onScreen)
11206
- setOnScreen(true);
11207
- schedule();
11208
- },
11209
- dispose() {
11210
- disposed = true;
11211
- if (raf)
11212
- win.cancelAnimationFrame(raf);
11213
- unlisten();
11214
- ro.disconnect();
11215
- nearIo?.disconnect();
11216
- screenIo?.disconnect();
11217
- for (const s of sides)
11218
- unload(s);
11219
- root.remove();
11706
+ const syncModeUi = () => {
11707
+ root.dataset.mode = mode;
11708
+ modeList.forEach((m, i) => {
11709
+ modeBtns[i].setAttribute("aria-checked", String(m === mode));
11710
+ });
11711
+ handle.tabIndex = mode === "wipe" ? 0 : -1;
11712
+ modePill?.update();
11713
+ };
11714
+ const setMode = (next, emit = true) => {
11715
+ if (next === mode || !modeList.includes(next))
11716
+ return;
11717
+ mode = next;
11718
+ const reduced = reducedMotion(win);
11719
+ tune(fade, reduced ? FADE_REDUCED : FADE6);
11720
+ if (fade.x >= 0.5) {
11721
+ fromMode = toMode;
11722
+ fade.snap(0);
11220
11723
  }
11724
+ toMode = next;
11725
+ fade.set(1);
11726
+ if (reduced)
11727
+ lay.snap(next === "side" ? 0 : 1);
11728
+ else
11729
+ lay.set(next === "side" ? 0 : 1);
11730
+ syncModeUi();
11731
+ if (emit)
11732
+ options.onMode?.(next);
11733
+ requestDraw();
11221
11734
  };
11222
- }
11223
- var BAR = 7, GAP = 2, DIFF, DIFFS_PER_DRAW = 6;
11224
- var init_compare_strip = __esm(() => {
11225
- init_compare_frames();
11226
- init_compare_model();
11227
- init_look();
11228
- init_rgba();
11229
- init_timeline_model();
11230
- DIFF = { w: 32, h: 18 };
11231
- });
11232
-
11233
- // src/ui/compare.ts
11234
- function compare(container, options) {
11235
- const doc = container.ownerDocument;
11236
- const win = doc.defaultView;
11237
- const look = {
11238
- ...options.preset ? { preset: options.preset } : {},
11239
- ...options.theme ? { theme: options.theme } : {},
11240
- ...options.appearance ? { appearance: options.appearance } : {}
11735
+ const setWipe = (next, emit = true, jump = false) => {
11736
+ const w = Math.min(1, Math.max(0, next));
11737
+ if (w === wipe && !jump)
11738
+ return;
11739
+ wipe = w;
11740
+ const minGap = 2 / Math.max(1, wipeRect(layoutNow().L.overlay).w);
11741
+ const head = moveHead({ x: wipeLine.x, target: wipeLine.target }, w, jump && !reducedMotion(win) ? "jump" : "follow", wipeCatching, minGap);
11742
+ wipeCatching = head.catching;
11743
+ if (head.catching) {
11744
+ wipeLine.x = head.x;
11745
+ wipeLine.target = head.target;
11746
+ } else
11747
+ wipeLine.snap(head.x);
11748
+ if (emit)
11749
+ options.onWipe?.(w);
11750
+ if (head.catching)
11751
+ requestDraw();
11752
+ else
11753
+ drawNow();
11241
11754
  };
11242
- const offered = compareModes(options.modes, options.mode);
11243
- const modeList = offered.modes;
11244
- let mode = offered.mode;
11245
- let wipe = Math.min(1, Math.max(0, options.wipe ?? 0.5));
11246
- let loupeOn = options.loupe ?? false;
11247
- let threshold = options.threshold ?? 4 / 255;
11248
- let fpsOpt = options.fps;
11249
- const bound = typeof options.timeline === "object" ? options.timeline : null;
11250
- const fps = () => fpsOpt ?? bound?.fps ?? 30;
11251
- const auto = container.clientHeight < 1;
11252
- const el = (tag, cls) => {
11253
- const n = doc.createElement(tag);
11254
- n.className = cls;
11255
- return n;
11755
+ const setLoupe = (on) => {
11756
+ loupeOn = on;
11757
+ loupeBtn.setAttribute("aria-pressed", String(on));
11758
+ requestDraw();
11256
11759
  };
11257
- const root = el("div", "knobs-compare");
11258
- root.tabIndex = 0;
11259
- root.setAttribute("role", "group");
11260
- root.setAttribute("aria-roledescription", "compare");
11261
- root.setAttribute("aria-label", options.label ?? "Compare");
11262
- root.setAttribute("aria-keyshortcuts", `Space ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight , . ${modeList.map((m, i) => `${i + 1} ${modeKey(m)}`).join(" ")} Z L 0 = -`);
11263
- applyLook2(root, options);
11264
- root.dataset.mode = mode;
11265
- if (auto)
11266
- root.dataset.auto = "";
11267
- const bar = el("div", "knobs-cmp-bar");
11268
- const modes = el("div", "knobs-cmp-modes");
11269
- modes.setAttribute("role", "radiogroup");
11270
- modes.setAttribute("aria-label", "Mode");
11271
- modes.hidden = modeList.length < 2;
11272
- const modeBtns = modeList.map((m, i) => {
11273
- const b = el("button", "knobs-cmp-mode");
11274
- b.type = "button";
11275
- b.tabIndex = -1;
11276
- b.textContent = MODE_LABEL[m].short;
11277
- b.title = `${MODE_LABEL[m].long} · ${i + 1} or ${modeKey(m)}`;
11278
- b.setAttribute("role", "radio");
11279
- b.setAttribute("aria-keyshortcuts", `${i + 1} ${modeKey(m)}`);
11280
- b.addEventListener("click", () => {
11281
- setMode(m);
11282
- root.focus({ preventScroll: true });
11283
- });
11284
- modes.append(b);
11285
- return b;
11286
- });
11287
- const toggleBtn = (name, label, title) => {
11288
- const b = el("button", "knobs-cmp-toggle");
11289
- b.type = "button";
11290
- b.tabIndex = -1;
11291
- b.innerHTML = icon(name);
11292
- b.setAttribute("aria-label", label);
11293
- b.title = title;
11294
- return b;
11760
+ const clampTime = (v, t) => Math.min(Math.max(0, t), Math.max(0, (Number.isFinite(v.duration) ? v.duration : t) - 0.001));
11761
+ const seekVideo = (s, v, t) => {
11762
+ const c = s.frames?.nearest(wantOf(s));
11763
+ const far = !c || Math.abs(c.frame - wantOf(s)) > FAST_SEEK_FRAMES;
11764
+ if (scrubbing && far && v.fastSeek)
11765
+ v.fastSeek(t);
11766
+ else
11767
+ v.currentTime = t;
11768
+ s.stall.arm();
11295
11769
  };
11296
- const loupeBtn = toggleBtn("magnifyingGlass", "Loupe", "Loupe · hold Z");
11297
- loupeBtn.addEventListener("click", () => {
11298
- setLoupe(!loupeOn);
11299
- root.focus({ preventScroll: true });
11300
- });
11301
- const loopBtn = toggleBtn("repeat", "Loop", "Loop · L (drag its ends on the timeline)");
11302
- loopBtn.setAttribute("aria-keyshortcuts", "L");
11303
- loopBtn.addEventListener("click", () => {
11304
- api.setLoop(!loopNow());
11305
- root.focus({ preventScroll: true });
11306
- });
11307
- bar.append(modes, el("span", "knobs-cmp-fill"), loopBtn, loupeBtn);
11308
- const stage = el("div", "knobs-cmp-stage");
11309
- const canvas = el("canvas", "knobs-cmp-canvas");
11310
- canvas.setAttribute("aria-hidden", "true");
11311
- const split = el("div", "knobs-cmp-split");
11312
- split.setAttribute("aria-hidden", "true");
11313
- const zoomPill = el("div", "knobs-cmp-zoom");
11314
- const zoomPct = el("span", "knobs-cmp-zoom-pct");
11315
- const zoomReset = el("button", "knobs-cmp-zoom-reset");
11316
- zoomReset.type = "button";
11317
- zoomReset.tabIndex = -1;
11318
- zoomReset.innerHTML = icon("arrowCounterClockwise", { small: true });
11319
- zoomReset.setAttribute("aria-label", "Reset zoom");
11320
- zoomReset.title = "Fit · 0 or double-click";
11321
- zoomReset.addEventListener("click", () => {
11322
- api.setZoom(null);
11323
- root.focus({ preventScroll: true });
11324
- });
11325
- zoomPill.append(zoomPct, zoomReset);
11326
- const labels = [el("div", "knobs-cmp-label"), el("div", "knobs-cmp-label")];
11327
- labels[0].dataset.side = "before";
11328
- labels[1].dataset.side = "after";
11329
- const setLabel = (index, text) => {
11330
- const l = labels[index];
11331
- if (!l.firstElementChild) {
11332
- const mark = el("span", "knobs-cmp-mark");
11333
- mark.innerHTML = icon(index === 0 ? "x" : "check", { small: true });
11334
- l.append(mark, el("span", "knobs-cmp-name"));
11770
+ const showFrame = (t, used) => {
11771
+ for (const s of sides) {
11772
+ const v = videoOf(s);
11773
+ if (!v || v.readyState < 1)
11774
+ continue;
11775
+ if (!v.paused) {
11776
+ v.pause();
11777
+ v.playbackRate = 1;
11778
+ }
11779
+ const want = Math.min(frameOf(t, fps()), lastFrame(v.duration, fps()));
11780
+ if (used) {
11781
+ if (scrubbing)
11782
+ s.frames?.follow(want);
11783
+ else
11784
+ s.frames?.focus(want);
11785
+ }
11786
+ if (!(scrubbing && s.frames?.covers(want))) {
11787
+ const at = frameMidpoint(want, fps(), v.duration);
11788
+ const step = requestSeek(s.seek, at, v.currentTime, fps());
11789
+ s.seek = step.state;
11790
+ if (step.apply !== null)
11791
+ seekVideo(s, v, step.apply);
11792
+ else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
11793
+ pushFrame(s);
11794
+ }
11795
+ refreshCached(s);
11335
11796
  }
11336
- const name = l.lastElementChild;
11337
- if (name.textContent !== text)
11338
- name.textContent = text;
11339
- };
11340
- const seam = el("div", "knobs-cmp-seam");
11341
- seam.setAttribute("aria-hidden", "true");
11342
- const handle = el("div", "knobs-cmp-handle");
11343
- handle.setAttribute("role", "slider");
11344
- handle.setAttribute("aria-label", "Wipe");
11345
- handle.setAttribute("aria-valuemin", "0");
11346
- handle.setAttribute("aria-valuemax", "100");
11347
- const grip = el("span", "knobs-cmp-grip");
11348
- grip.setAttribute("aria-hidden", "true");
11349
- handle.append(grip);
11350
- const note = el("div", "knobs-cmp-note");
11351
- stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
11352
- root.append(bar, stage);
11353
- const wantsStrip = options.strip === true;
11354
- let tl = bound;
11355
- let ownTimeline = false;
11356
- let stripHost = null;
11357
- if (wantsStrip) {
11358
- stripHost = el("div", "knobs-cmp-strip-host");
11359
- root.append(stripHost);
11360
- }
11361
- if (!tl && (options.timeline === true || wantsStrip)) {
11362
- const host = el("div", "knobs-cmp-timeline-host");
11363
- root.append(host);
11364
- tl = timeline(host, {
11365
- ...look,
11366
- variant: "line",
11367
- duration: 1,
11368
- fps: fps(),
11369
- arrows: { step: 0.5, shift: 2 },
11370
- label: "Compare time",
11371
- ...options.hints === false ? { hints: false } : {}
11372
- });
11373
- ownTimeline = true;
11374
- }
11375
- container.append(root);
11376
- const sides = [null, null];
11377
- let duration = 0;
11378
- let renderer = "dom";
11379
- let pending = false;
11380
- let gpu = null;
11381
- const lay = new Spring(mode === "side" ? 0 : 1, LAYOUT3.omega, LAYOUT3.zeta, 0.0001);
11382
- const fade = new Spring(1, FADE6.omega, FADE6.zeta, 0.001);
11383
- let fromMode = mode;
11384
- let toMode = mode;
11385
- const wipeLine = new Spring(wipe, WIPE_CATCH.omega, WIPE_CATCH.zeta, 0.0001);
11386
- let wipeCatching = false;
11387
- const gripAt = new Spring(0.5, LAYOUT3.omega, LAYOUT3.zeta, 0.0001);
11388
- let loupeHeld = false;
11389
- let pointer = null;
11390
- let drag = null;
11391
- let tap = null;
11392
- const touches = new Map;
11393
- let pinch = null;
11394
- const zu = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11395
- const zv = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11396
- const zw = new Spring(1, ZOOM.omega, ZOOM.zeta, 0.00001);
11397
- let viewSent = "0.0000,0.0000,1.0000|";
11398
- let ownLoop = null;
11399
- let scrubbing = false;
11400
- let stageW = 1;
11401
- let stageH = 1;
11402
- let stageRect = null;
11403
- let accent = [0.49, 0.81, 0.61];
11404
- let labelW = [0, 0];
11405
- let frameRadius = 0;
11406
- let dirty = true;
11407
- let disposed = false;
11408
- let near = options.lazy === false;
11409
- let onScreen = options.lazy === false;
11410
- let releaseTimer = 0;
11411
- let ownTime = 0;
11412
- let ownPlaying = false;
11413
- const videoOf = (s) => s && s.kind === "video" && s.loaded ? s.el : null;
11414
- const master = () => videoOf(sides[1]) ?? videoOf(sides[0]);
11415
- const follower = () => {
11416
- const m = master();
11417
- const v = videoOf(sides[0]);
11418
- return m && v && v !== m ? v : null;
11419
- };
11420
- const now = () => tl ? tl.time : ownTime;
11421
- const isPlaying = () => tl ? tl.playing : ownPlaying;
11422
- const labelOf = (index) => sides[index]?.source.label ?? (index === 0 ? options.before.label : options.after.label) ?? (index === 0 ? "Before" : "After");
11423
- const sizeOf = (s) => {
11424
- if (s.size.w > 0)
11425
- return s.size;
11426
- const other = sides[s.index === 0 ? 1 : 0];
11427
- if (other && other.size.w > 0)
11428
- return other.size;
11429
- return { w: options.aspect ?? 16 / 9, h: 1 };
11430
- };
11431
- const wantOf = (s) => {
11432
- const v = s.el;
11433
- const f = frameOf(now(), fps());
11434
- return Number.isFinite(v.duration) ? Math.min(f, lastFrame(v.duration, fps())) : f;
11797
+ requestDraw();
11435
11798
  };
11436
- const emitFrame = (s, frame, source) => root.dispatchEvent(new CustomEvent("knobs-compare-frame", { detail: { side: s.index, frame, source } }));
11437
- const setCached = (s, on) => {
11438
- if (renderer === "dom")
11439
- s.preview.style.display = on ? "" : "none";
11799
+ const jumpAll = (t) => {
11800
+ for (const s of sides) {
11801
+ const v = videoOf(s);
11802
+ if (!v || v.readyState < 1)
11803
+ continue;
11804
+ s.seek = SEEK_IDLE;
11805
+ v.currentTime = clampTime(v, t);
11806
+ if (v.paused)
11807
+ v.play().catch(() => {});
11808
+ }
11440
11809
  };
11441
- const showVideo = (s) => {
11442
- const v = s.el;
11443
- if (renderer === "gpu") {
11444
- if (!gpu)
11445
- return;
11446
- if (!gpu.upload(s.index, v, v.videoWidth, v.videoHeight))
11447
- return miss(s);
11448
- s.misses = 0;
11810
+ const startVideos = () => {
11811
+ const t = now();
11812
+ for (const s of sides) {
11813
+ const v = videoOf(s);
11814
+ if (!v)
11815
+ continue;
11816
+ s.seek = SEEK_IDLE;
11817
+ v.playbackRate = 1;
11818
+ if (v.readyState >= 1 && Math.abs(v.currentTime - t) > 1.5 / fps())
11819
+ v.currentTime = clampTime(v, t);
11820
+ v.play().catch(() => {});
11449
11821
  }
11450
- const f = frameOf(v.currentTime, fps());
11451
- s.shown = { frame: f, cached: false };
11452
- setCached(s, false);
11453
- emitFrame(s, f, "video");
11454
- requestDraw();
11822
+ loop.wake();
11455
11823
  };
11456
- const showCached = (s, c) => {
11457
- if (s.shown?.cached && s.shown.frame === c.frame)
11458
- return;
11459
- if (renderer === "gpu") {
11460
- if (!gpu || !gpu.upload(2 + s.index, c.bitmap, c.bitmap.width, c.bitmap.height))
11461
- return;
11462
- } else if (renderer === "dom" && !pending) {
11463
- const p = s.preview;
11464
- if (p.width !== c.bitmap.width || p.height !== c.bitmap.height) {
11465
- p.width = c.bitmap.width;
11466
- p.height = c.bitmap.height;
11824
+ const pauseVideos = () => {
11825
+ for (const s of sides) {
11826
+ const v = videoOf(s);
11827
+ if (v) {
11828
+ v.pause();
11829
+ v.playbackRate = 1;
11467
11830
  }
11468
- p.getContext("2d")?.drawImage(c.bitmap, 0, 0);
11469
- } else
11470
- return;
11471
- s.shown = { frame: c.frame, cached: true };
11472
- setCached(s, true);
11473
- emitFrame(s, c.frame, "cache");
11474
- requestDraw();
11831
+ }
11475
11832
  };
11476
- const miss = (s) => {
11477
- if (s.failed || ++s.misses < 3)
11478
- return;
11479
- gpu?.dispose();
11480
- gpu = null;
11481
- setRenderer("dom");
11833
+ const stopVideos = () => {
11834
+ pauseVideos();
11835
+ showFrame(now(), false);
11482
11836
  };
11483
- const pushFrame = (s) => {
11484
- if (!s.loaded || disposed)
11485
- return;
11486
- if (s.kind === "image") {
11487
- const img = s.el;
11488
- if (!(img.complete && img.naturalWidth))
11489
- return;
11490
- if (renderer === "gpu" && gpu) {
11491
- if (!gpu.upload(s.index, img, img.naturalWidth, img.naturalHeight))
11492
- return miss(s);
11493
- }
11494
- s.shown = { frame: 0, cached: false };
11495
- return requestDraw();
11496
- }
11497
- const v = s.el;
11498
- if (v.readyState < 2)
11499
- return;
11500
- if (isPlaying() || !s.frames)
11501
- return showVideo(s);
11502
- const want = wantOf(s);
11503
- const at = frameOf(v.currentTime, fps());
11504
- if (at === want) {
11505
- showVideo(s);
11506
- s.frames.keep(at, v, v.videoWidth, v.videoHeight);
11507
- return;
11508
- }
11509
- const c = s.frames.nearest(want);
11510
- if (c && Math.abs(c.frame - want) < Math.abs(at - want))
11511
- showCached(s, c);
11837
+ const clock = () => {
11838
+ const m = master();
11839
+ if (!m || !near)
11840
+ return Number.NaN;
11841
+ if (m.readyState < 1 || m.seeking)
11842
+ return tl.time;
11843
+ return m.ended ? Number.POSITIVE_INFINITY : m.currentTime;
11844
+ };
11845
+ const ownSeek = (t) => {
11846
+ ownTime = Math.min(Math.max(0, t), duration);
11847
+ if (ownPlaying)
11848
+ jumpAll(ownTime);
11512
11849
  else
11513
- showVideo(s);
11850
+ showFrame(ownTime, true);
11514
11851
  };
11515
- const refreshCached = (s) => {
11516
- if (!s.loaded || !s.frames || isPlaying())
11517
- return;
11518
- const want = wantOf(s);
11519
- if (s.shown && !s.shown.cached && s.shown.frame === want)
11852
+ const ownPlay = () => {
11853
+ if (ownPlaying || !master())
11520
11854
  return;
11521
- const c = s.frames.nearest(want);
11522
- if (!c)
11855
+ if (ownLoop && (ownTime < ownLoop.start || ownTime >= ownLoop.end - 0.5 / fps()))
11856
+ ownTime = ownLoop.start;
11857
+ else if (!ownLoop && ownTime >= duration - 0.5 / fps())
11858
+ ownTime = 0;
11859
+ ownPlaying = true;
11860
+ startVideos();
11861
+ };
11862
+ const ownPause = () => {
11863
+ if (!ownPlaying)
11523
11864
  return;
11524
- const was = s.shown ? Math.abs(s.shown.frame - want) : Number.POSITIVE_INFINITY;
11525
- if (Math.abs(c.frame - want) < was)
11526
- showCached(s, c);
11865
+ ownPlaying = false;
11866
+ ownTime = Math.min(duration, frameOf(ownTime, fps()) / fps());
11867
+ stopVideos();
11527
11868
  };
11528
- const makeSide = (index, source) => {
11529
- const kind = mediaKind(source.src, source.kind);
11530
- const media = el(kind === "video" ? "video" : "img", "knobs-cmp-media");
11531
- media.dataset.side = index === 0 ? "before" : "after";
11532
- media.crossOrigin = "anonymous";
11533
- media.setAttribute("aria-hidden", "true");
11534
- const preview = el("canvas", "knobs-cmp-media knobs-cmp-preview");
11535
- preview.dataset.side = media.dataset.side;
11536
- preview.style.display = "none";
11537
- preview.setAttribute("aria-hidden", "true");
11538
- const offs = [];
11539
- const on = (type, f) => {
11540
- media.addEventListener(type, f);
11541
- offs.push(() => media.removeEventListener(type, f));
11542
- };
11543
- const side = {
11544
- index,
11545
- source,
11546
- kind,
11547
- el: media,
11548
- preview,
11549
- size: { w: 0, h: 0 },
11550
- seek: SEEK_IDLE,
11551
- loaded: false,
11552
- frames: null,
11553
- unlisten: null,
11554
- seen: false,
11555
- shown: null,
11556
- frameAt: 0,
11557
- misses: 0,
11558
- failed: false,
11559
- stop: () => {
11560
- for (const f of offs)
11561
- f();
11562
- }
11563
- };
11564
- const fail = () => {
11565
- if (!side.loaded)
11566
- return;
11567
- side.failed = true;
11568
- note.textContent = `Couldn't load ${labelOf(index)}`;
11569
- };
11570
- if (media instanceof HTMLVideoElement) {
11571
- const v = media;
11572
- v.muted = true;
11573
- v.defaultMuted = true;
11574
- v.playsInline = true;
11575
- v.preload = "auto";
11576
- v.setAttribute("playsinline", "");
11577
- v.setAttribute("muted", "");
11578
- on("loadedmetadata", () => {
11579
- side.size = { w: v.videoWidth, h: v.videoHeight };
11580
- measureDuration();
11581
- if (auto)
11582
- measure();
11583
- if (isPlaying())
11584
- jumpAll(now());
11585
- else
11586
- showFrame(now(), false);
11587
- requestDraw();
11588
- });
11589
- on("resize", () => {
11590
- side.size = { w: v.videoWidth, h: v.videoHeight };
11591
- requestDraw();
11592
- });
11593
- on("loadeddata", () => pushFrame(side));
11594
- on("seeked", () => {
11595
- const step = seeked(side.seek, v.currentTime, fps());
11596
- side.seek = step.state;
11597
- if (step.apply !== null)
11598
- seekVideo(side, v, step.apply);
11599
- pushFrame(side);
11600
- });
11601
- on("error", fail);
11602
- if (v.requestVideoFrameCallback) {
11603
- let id = 0;
11604
- const frame = () => {
11605
- side.frameAt = performance.now();
11606
- pushFrame(side);
11607
- id = v.requestVideoFrameCallback(frame);
11608
- };
11609
- id = v.requestVideoFrameCallback(frame);
11610
- offs.push(() => v.cancelVideoFrameCallback?.(id));
11611
- }
11612
- } else {
11613
- on("load", () => {
11614
- side.size = { w: media.naturalWidth, h: media.naturalHeight };
11615
- if (auto)
11616
- measure();
11617
- pushFrame(side);
11618
- requestDraw();
11619
- });
11620
- on("error", fail);
11621
- media.decoding = "async";
11622
- }
11623
- return side;
11869
+ const zoomNow = () => ({ u: zu.x, v: zv.x, w: zw.x, h: zw.x });
11870
+ const zoomed = () => zw.target < 0.999;
11871
+ const layoutNow = () => {
11872
+ const box = { x: 0, y: 0, w: stageW, h: stageH };
11873
+ const L = compareLayout(box, sizeOf(sides[0]), sizeOf(sides[1]));
11874
+ const k = lay.x;
11875
+ const before = lerpRect(L.side.before, L.overlay.before, k);
11876
+ const after = lerpRect(L.side.after, L.overlay.after, k);
11877
+ const z = zoomNow();
11878
+ return { L, before, after, zBefore: zoomedRect(before, z), zAfter: zoomedRect(after, z) };
11624
11879
  };
11625
- const loadSide = (s) => {
11626
- if (s.loaded || disposed)
11627
- return;
11628
- s.loaded = true;
11629
- s.failed = false;
11630
- s.el.src = s.source.src;
11631
- if (s.kind === "video") {
11632
- s.frames = acquireFrames(doc, s.source.src, fps());
11633
- s.unlisten = s.frames.listen(() => refreshCached(s));
11634
- if (onScreen) {
11635
- s.frames.visible(true);
11636
- s.seen = true;
11637
- }
11638
- }
11880
+ const wipeRect = (r) => {
11881
+ const x = Math.min(r.before.x, r.after.x);
11882
+ const y = Math.min(r.before.y, r.after.y);
11883
+ const x1 = Math.max(r.before.x + r.before.w, r.after.x + r.after.w);
11884
+ const y1 = Math.max(r.before.y + r.before.h, r.after.y + r.after.h);
11885
+ return { x, y, w: x1 - x, h: y1 - y };
11639
11886
  };
11640
- const unloadSide = (s) => {
11641
- if (!s.loaded)
11642
- return;
11643
- s.loaded = false;
11644
- s.el.removeAttribute("src");
11645
- if (s.el instanceof HTMLVideoElement)
11646
- s.el.load();
11647
- s.seek = SEEK_IDLE;
11648
- s.shown = null;
11649
- setCached(s, false);
11650
- s.unlisten?.();
11651
- s.unlisten = null;
11652
- if (s.frames) {
11653
- if (s.seen)
11654
- s.frames.visible(false);
11655
- s.seen = false;
11656
- s.frames.release();
11657
- s.frames = null;
11658
- }
11659
- gpu?.clear(s.index);
11887
+ const baseAt = (p) => {
11888
+ const r = layoutNow();
11889
+ const inside = (b) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
11890
+ return inside(r.after) || !inside(r.before) ? r.after : r.before;
11660
11891
  };
11661
- const measureDuration = () => {
11662
- const next = Math.max(0, ...sides.map((s) => {
11663
- const v = videoOf(s);
11664
- return v && Number.isFinite(v.duration) ? v.duration : 0;
11665
- }));
11666
- if (next <= 0 || next === duration)
11667
- return;
11668
- duration = next;
11669
- if (ownTimeline && tl)
11670
- tl.update({ duration, fps: fps() });
11892
+ const loupeShown = () => renderer === "gpu" && (loupeOn || loupeHeld) && pointer !== null;
11893
+ const loopNow = () => {
11894
+ if (!tl)
11895
+ return ownLoop;
11896
+ const r = tl.ranges.find((x) => x.loop);
11897
+ return r ? { start: r.start, end: r.end } : null;
11671
11898
  };
11672
- const setRenderer = (next) => {
11673
- pending = false;
11674
- renderer = next;
11675
- root.dataset.renderer = next;
11676
- loupeBtn.hidden = next !== "gpu";
11677
- canvas.style.display = next === "gpu" ? "" : "none";
11678
- for (const s of sides) {
11679
- const st = s.el.style;
11680
- st.clipPath = "";
11681
- st.maskImage = "";
11682
- st.setProperty("-webkit-mask-image", "");
11683
- st.mixBlendMode = "";
11684
- st.opacity = "";
11685
- s.shown = null;
11686
- setCached(s, false);
11687
- }
11688
- for (const s of sides)
11689
- pushFrame(s);
11899
+ const syncLoopUi = () => {
11900
+ loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
11690
11901
  requestDraw();
11691
11902
  };
11692
- const startGpu = async (retry) => {
11693
- const g = await createCompareGpu(canvas, () => {
11694
- gpu = null;
11695
- if (disposed)
11696
- return;
11697
- if (retry)
11698
- win.setTimeout(() => void startGpu(false), 150);
11699
- else
11700
- setRenderer("dom");
11701
- });
11702
- if (disposed)
11703
- return g?.dispose();
11704
- if (!g)
11705
- return setRenderer("dom");
11706
- gpu = g;
11707
- setRenderer("gpu");
11903
+ const sendView = () => {
11904
+ if (!options.onView)
11905
+ return;
11906
+ const z = zoomNow();
11907
+ const l = loopNow();
11908
+ const key = `${z.u.toFixed(4)},${z.v.toFixed(4)},${z.w.toFixed(4)}|${l ? `${l.start},${l.end}` : ""}`;
11909
+ if (key === viewSent)
11910
+ return;
11911
+ viewSent = key;
11912
+ options.onView({ zoom: clampZoom(z), loop: l });
11708
11913
  };
11709
- const syncModeUi = () => {
11710
- root.dataset.mode = mode;
11711
- modeList.forEach((m, i) => {
11712
- modeBtns[i].setAttribute("aria-checked", String(m === mode));
11713
- });
11714
- handle.tabIndex = mode === "wipe" ? 0 : -1;
11715
- modePill?.update();
11914
+ const differenceWeight = () => {
11915
+ const f = Math.min(1, Math.max(0, fade.x));
11916
+ return (fromMode === "difference" ? 1 - f : 0) + (toMode === "difference" ? f : 0);
11716
11917
  };
11717
- const setMode = (next, emit = true) => {
11718
- if (next === mode || !modeList.includes(next))
11719
- return;
11720
- mode = next;
11721
- const reduced = reducedMotion(win);
11722
- tune(fade, reduced ? FADE_REDUCED : FADE6);
11723
- if (fade.x >= 0.5) {
11724
- fromMode = toMode;
11725
- fade.snap(0);
11726
- }
11727
- toMode = next;
11728
- fade.set(1);
11729
- if (reduced)
11730
- lay.snap(next === "side" ? 0 : 1);
11731
- else
11732
- lay.set(next === "side" ? 0 : 1);
11733
- syncModeUi();
11734
- if (emit)
11735
- options.onMode?.(next);
11736
- requestDraw();
11737
- };
11738
- const setWipe = (next, emit = true, jump = false) => {
11739
- const w = Math.min(1, Math.max(0, next));
11740
- if (w === wipe && !jump)
11741
- return;
11742
- wipe = w;
11743
- const minGap = 2 / Math.max(1, wipeRect(layoutNow().L.overlay).w);
11744
- const head = moveHead({ x: wipeLine.x, target: wipeLine.target }, w, jump && !reducedMotion(win) ? "jump" : "follow", wipeCatching, minGap);
11745
- wipeCatching = head.catching;
11746
- if (head.catching) {
11747
- wipeLine.x = head.x;
11748
- wipeLine.target = head.target;
11749
- } else
11750
- wipeLine.snap(head.x);
11751
- if (emit)
11752
- options.onWipe?.(w);
11753
- if (head.catching)
11754
- requestDraw();
11755
- else
11756
- drawNow();
11757
- };
11758
- const setLoupe = (on) => {
11759
- loupeOn = on;
11760
- loupeBtn.setAttribute("aria-pressed", String(on));
11761
- requestDraw();
11762
- };
11763
- const clampTime = (v, t) => Math.min(Math.max(0, t), Math.max(0, (Number.isFinite(v.duration) ? v.duration : t) - 0.001));
11764
- const seekVideo = (s, v, t) => {
11765
- const c = s.frames?.nearest(wantOf(s));
11766
- const far = !c || Math.abs(c.frame - wantOf(s)) > FAST_SEEK_FRAMES;
11767
- if (scrubbing && far && v.fastSeek)
11768
- v.fastSeek(t);
11769
- else
11770
- v.currentTime = t;
11771
- };
11772
- const showFrame = (t, used) => {
11773
- for (const s of sides) {
11774
- const v = videoOf(s);
11775
- if (!v || v.readyState < 1)
11776
- continue;
11777
- if (!v.paused) {
11778
- v.pause();
11779
- v.playbackRate = 1;
11780
- }
11781
- const want = Math.min(frameOf(t, fps()), lastFrame(v.duration, fps()));
11782
- const step = requestSeek(s.seek, frameMidpoint(want, fps(), v.duration), v.currentTime, fps());
11783
- s.seek = step.state;
11784
- if (step.apply !== null)
11785
- seekVideo(s, v, step.apply);
11786
- else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
11787
- pushFrame(s);
11788
- if (used)
11789
- s.frames?.focus(want);
11790
- refreshCached(s);
11791
- }
11792
- requestDraw();
11793
- };
11794
- const jumpAll = (t) => {
11795
- for (const s of sides) {
11796
- const v = videoOf(s);
11797
- if (!v || v.readyState < 1)
11798
- continue;
11799
- s.seek = SEEK_IDLE;
11800
- v.currentTime = clampTime(v, t);
11801
- if (v.paused)
11802
- v.play().catch(() => {});
11803
- }
11804
- };
11805
- const startVideos = () => {
11806
- const t = now();
11807
- for (const s of sides) {
11808
- const v = videoOf(s);
11809
- if (!v)
11810
- continue;
11811
- s.seek = SEEK_IDLE;
11812
- v.playbackRate = 1;
11813
- if (v.readyState >= 1 && Math.abs(v.currentTime - t) > 1.5 / fps())
11814
- v.currentTime = clampTime(v, t);
11815
- v.play().catch(() => {});
11816
- }
11817
- loop.wake();
11818
- };
11819
- const pauseVideos = () => {
11820
- for (const s of sides) {
11821
- const v = videoOf(s);
11822
- if (v) {
11823
- v.pause();
11824
- v.playbackRate = 1;
11825
- }
11826
- }
11827
- };
11828
- const stopVideos = () => {
11829
- pauseVideos();
11830
- showFrame(now(), false);
11831
- };
11832
- const clock = () => {
11833
- const m = master();
11834
- if (!m || !near)
11835
- return Number.NaN;
11836
- if (m.readyState < 1 || m.seeking)
11837
- return tl.time;
11838
- return m.ended ? Number.POSITIVE_INFINITY : m.currentTime;
11839
- };
11840
- const ownSeek = (t) => {
11841
- ownTime = Math.min(Math.max(0, t), duration);
11842
- if (ownPlaying)
11843
- jumpAll(ownTime);
11844
- else
11845
- showFrame(ownTime, true);
11846
- };
11847
- const ownPlay = () => {
11848
- if (ownPlaying || !master())
11849
- return;
11850
- if (ownLoop && (ownTime < ownLoop.start || ownTime >= ownLoop.end - 0.5 / fps()))
11851
- ownTime = ownLoop.start;
11852
- else if (!ownLoop && ownTime >= duration - 0.5 / fps())
11853
- ownTime = 0;
11854
- ownPlaying = true;
11855
- startVideos();
11856
- };
11857
- const ownPause = () => {
11858
- if (!ownPlaying)
11859
- return;
11860
- ownPlaying = false;
11861
- ownTime = Math.min(duration, frameOf(ownTime, fps()) / fps());
11862
- stopVideos();
11863
- };
11864
- const zoomNow = () => ({ u: zu.x, v: zv.x, w: zw.x, h: zw.x });
11865
- const zoomed = () => zw.target < 0.999;
11866
- const layoutNow = () => {
11867
- const box = { x: 0, y: 0, w: stageW, h: stageH };
11868
- const L = compareLayout(box, sizeOf(sides[0]), sizeOf(sides[1]));
11869
- const k = lay.x;
11870
- const before = lerpRect(L.side.before, L.overlay.before, k);
11871
- const after = lerpRect(L.side.after, L.overlay.after, k);
11872
- const z = zoomNow();
11873
- return { L, before, after, zBefore: zoomedRect(before, z), zAfter: zoomedRect(after, z) };
11874
- };
11875
- const wipeRect = (r) => {
11876
- const x = Math.min(r.before.x, r.after.x);
11877
- const y = Math.min(r.before.y, r.after.y);
11878
- const x1 = Math.max(r.before.x + r.before.w, r.after.x + r.after.w);
11879
- const y1 = Math.max(r.before.y + r.before.h, r.after.y + r.after.h);
11880
- return { x, y, w: x1 - x, h: y1 - y };
11881
- };
11882
- const baseAt = (p) => {
11883
- const r = layoutNow();
11884
- const inside = (b) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
11885
- return inside(r.after) || !inside(r.before) ? r.after : r.before;
11886
- };
11887
- const loupeShown = () => renderer === "gpu" && (loupeOn || loupeHeld) && pointer !== null;
11888
- const loopNow = () => {
11889
- if (!tl)
11890
- return ownLoop;
11891
- const r = tl.ranges.find((x) => x.loop);
11892
- return r ? { start: r.start, end: r.end } : null;
11893
- };
11894
- const syncLoopUi = () => {
11895
- loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
11896
- requestDraw();
11897
- };
11898
- const sendView = () => {
11899
- if (!options.onView)
11900
- return;
11901
- const z = zoomNow();
11902
- const l = loopNow();
11903
- const key = `${z.u.toFixed(4)},${z.v.toFixed(4)},${z.w.toFixed(4)}|${l ? `${l.start},${l.end}` : ""}`;
11904
- if (key === viewSent)
11905
- return;
11906
- viewSent = key;
11907
- options.onView({ zoom: clampZoom(z), loop: l });
11908
- };
11909
- const differenceWeight = () => {
11910
- const f = Math.min(1, Math.max(0, fade.x));
11911
- return (fromMode === "difference" ? 1 - f : 0) + (toMode === "difference" ? f : 0);
11912
- };
11913
- const placeGrip = (wr) => {
11914
- const gy = gripTop(wr.y + gripAt.x * wr.h, wr, GRIP.h) - wr.y;
11915
- grip.style.transform = `translate3d(0,${gy.toFixed(2)}px,0)`;
11918
+ const placeGrip = (wr) => {
11919
+ const gy = gripTop(wr.y + gripAt.x * wr.h, wr, GRIP.h) - wr.y;
11920
+ grip.style.transform = `translate3d(0,${gy.toFixed(2)}px,0)`;
11916
11921
  };
11917
11922
  let folded = 0;
11918
11923
  const draw = () => {
@@ -12417,7 +12422,7 @@ function compare(container, options) {
12417
12422
  holdLoupe(true);
12418
12423
  return;
12419
12424
  case "l":
12420
- if (e.altKey || e.shiftKey)
12425
+ if (e.altKey || e.shiftKey || loopBtn.hidden)
12421
12426
  return;
12422
12427
  claim();
12423
12428
  return api.setLoop(!loopNow());
@@ -12440,6 +12445,9 @@ function compare(container, options) {
12440
12445
  return glideZoom(ZOOM_FIT);
12441
12446
  }
12442
12447
  };
12448
+ const syncLoopButton = () => {
12449
+ loopBtn.hidden = !sides.some((s) => s.kind === "video");
12450
+ };
12443
12451
  const replace = (index, source) => {
12444
12452
  const old = sides[index];
12445
12453
  old.stop();
@@ -12448,6 +12456,7 @@ function compare(container, options) {
12448
12456
  old.el.replaceWith(s.el);
12449
12457
  old.preview.replaceWith(s.preview);
12450
12458
  sides[index] = s;
12459
+ syncLoopButton();
12451
12460
  note.textContent = "";
12452
12461
  setLabel(index, labelOf(index));
12453
12462
  if (near)
@@ -12462,6 +12471,7 @@ function compare(container, options) {
12462
12471
  stage.insertBefore(sides[0].preview, labels[0]);
12463
12472
  setLabel(0, labelOf(0));
12464
12473
  setLabel(1, labelOf(1));
12474
+ syncLoopButton();
12465
12475
  loupeBtn.setAttribute("aria-pressed", String(loupeOn));
12466
12476
  loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
12467
12477
  const modePill = modeList.length > 1 ? selectionPill(modes, {
@@ -12540,7 +12550,7 @@ function compare(container, options) {
12540
12550
  label: MODE_LABEL[m].long,
12541
12551
  activate: () => setMode(m)
12542
12552
  })) : [],
12543
- { el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) },
12553
+ ...loopBtn.hidden ? [] : [{ el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) }],
12544
12554
  ...loupeBtn.hidden ? [] : [{ el: loupeBtn, label: "Loupe", activate: () => setLoupe(!loupeOn) }],
12545
12555
  ...zoomed() ? [{ el: zoomReset, label: "Reset zoom", activate: () => api.setZoom(null) }] : [],
12546
12556
  ...mode === "wipe" ? [
@@ -12551,14 +12561,6 @@ function compare(container, options) {
12551
12561
  }
12552
12562
  ] : []
12553
12563
  ], { listen: false, rank: 1 });
12554
- const strip = wantsStrip && stripHost && tl ? compareStrip(stripHost, {
12555
- ...look,
12556
- before: options.before,
12557
- after: options.after,
12558
- timeline: tl,
12559
- ...fpsOpt ? { fps: fpsOpt } : {},
12560
- threshold
12561
- }) : null;
12562
12564
  const api = {
12563
12565
  element: root,
12564
12566
  get mode() {
@@ -12584,7 +12586,6 @@ function compare(container, options) {
12584
12586
  return renderer;
12585
12587
  },
12586
12588
  timeline: tl,
12587
- strip,
12588
12589
  get zoom() {
12589
12590
  return clampZoom(zoomNow());
12590
12591
  },
@@ -12606,8 +12607,6 @@ function compare(container, options) {
12606
12607
  replace(1, after);
12607
12608
  else if (next.after)
12608
12609
  setLabel(1, next.after.label ?? "After");
12609
- if (before || after)
12610
- strip?.update({ ...before ? { before } : {}, ...after ? { after } : {} });
12611
12610
  if (next.before || next.after)
12612
12611
  readColors();
12613
12612
  if (next.mode !== undefined)
@@ -12674,7 +12673,6 @@ function compare(container, options) {
12674
12673
  if (ownTimeline)
12675
12674
  tl.dispose();
12676
12675
  }
12677
- strip?.dispose();
12678
12676
  for (const s of sides) {
12679
12677
  s.stop();
12680
12678
  unloadSide(s);
@@ -12698,7 +12696,6 @@ var init_compare = __esm(() => {
12698
12696
  init_compare_frames();
12699
12697
  init_compare_gpu();
12700
12698
  init_compare_model();
12701
- init_compare_strip();
12702
12699
  init_icons();
12703
12700
  init_look();
12704
12701
  init_rgba();
@@ -12756,7 +12753,10 @@ function player(container, options) {
12756
12753
  track.src = options.captions;
12757
12754
  v.append(track);
12758
12755
  }
12759
- frame.append(v);
12756
+ const preview = el("canvas", "knobs-player-preview");
12757
+ preview.setAttribute("aria-hidden", "true");
12758
+ preview.hidden = true;
12759
+ frame.append(v, preview);
12760
12760
  const host = el("div", "knobs-player-line");
12761
12761
  root.append(frame, host);
12762
12762
  container.append(root);
@@ -12786,6 +12786,57 @@ function player(container, options) {
12786
12786
  else
12787
12787
  v.currentTime = t;
12788
12788
  };
12789
+ const fps = options.fps ?? 30;
12790
+ let frames = null;
12791
+ let unlistenFrames = null;
12792
+ let scrubbing = false;
12793
+ let previewing = null;
12794
+ const want = () => frameOf(tl.time, fps);
12795
+ const emitFrame = (frame, source) => root.dispatchEvent(new CustomEvent("knobs-player-frame", { detail: { frame, source } }));
12796
+ const drawPreview = (c) => {
12797
+ if (preview.width !== c.bitmap.width || preview.height !== c.bitmap.height) {
12798
+ preview.width = c.bitmap.width;
12799
+ preview.height = c.bitmap.height;
12800
+ }
12801
+ preview.getContext("2d")?.drawImage(c.bitmap, 0, 0);
12802
+ preview.hidden = false;
12803
+ previewing = c.frame;
12804
+ emitFrame(c.frame, "cache");
12805
+ };
12806
+ const hidePreview = () => {
12807
+ if (previewing === null)
12808
+ return;
12809
+ previewing = null;
12810
+ preview.hidden = true;
12811
+ };
12812
+ const showNearest = () => {
12813
+ if (!frames || !scrubbing)
12814
+ return;
12815
+ const w = want();
12816
+ const c = frames.nearest(w, Number.POSITIVE_INFINITY, true);
12817
+ if (!c || c.frame === previewing)
12818
+ return;
12819
+ const shown = previewing ?? frameOf(v.currentTime, fps);
12820
+ if (Math.abs(c.frame - w) < Math.abs(shown - w))
12821
+ drawPreview(c);
12822
+ };
12823
+ const caching = scrubCaching(options.scrubCache, doc.defaultView);
12824
+ const startFrames = () => {
12825
+ if (frames || !caching)
12826
+ return;
12827
+ frames = acquireFrames(doc, options.src, fps);
12828
+ frames.visible(true);
12829
+ unlistenFrames = frames.listen(showNearest);
12830
+ };
12831
+ const stall = stallGuard(doc.defaultView, () => {
12832
+ pending = null;
12833
+ hidePreview();
12834
+ });
12835
+ const onError = () => {
12836
+ stall.clear();
12837
+ pending = null;
12838
+ hidePreview();
12839
+ };
12789
12840
  let reported = 0;
12790
12841
  const onSeeked = () => {
12791
12842
  if (pending !== null) {
@@ -12794,6 +12845,11 @@ function player(container, options) {
12794
12845
  v.currentTime = t;
12795
12846
  return;
12796
12847
  }
12848
+ stall.clear();
12849
+ const at = frameOf(v.currentTime, fps);
12850
+ emitFrame(at, "video");
12851
+ if (!scrubbing || previewing !== null && Math.abs(at - want()) <= Math.abs(previewing - want()))
12852
+ hidePreview();
12797
12853
  if (!v.paused || tl.playing || Math.abs(v.currentTime - reported) < 0.1)
12798
12854
  return;
12799
12855
  reported = v.currentTime;
@@ -12809,6 +12865,7 @@ function player(container, options) {
12809
12865
  v.addEventListener("loadedmetadata", onMeta);
12810
12866
  v.addEventListener("loadeddata", onFrame);
12811
12867
  v.addEventListener("seeked", onSeeked);
12868
+ v.addEventListener("error", onError);
12812
12869
  const unlisten = tl.listen({
12813
12870
  onSeek(t, source) {
12814
12871
  if (source === "play") {
@@ -12816,14 +12873,24 @@ function player(container, options) {
12816
12873
  v.currentTime = t;
12817
12874
  return;
12818
12875
  }
12819
- if (Math.abs(v.currentTime - t) < 1 / (options.fps ?? 30))
12876
+ if (scrubbing && frames) {
12877
+ frames.follow(want());
12878
+ showNearest();
12879
+ if (frames.covers(want()))
12880
+ return;
12881
+ }
12882
+ if (sameFrame(v.currentTime, t, fps)) {
12883
+ if (!scrubbing)
12884
+ hidePreview();
12820
12885
  return;
12886
+ }
12821
12887
  seekVideo(t);
12822
12888
  },
12823
12889
  onPlay(on) {
12824
- if (on)
12890
+ if (on) {
12891
+ hidePreview();
12825
12892
  v.play().catch(() => tl.pause());
12826
- else {
12893
+ } else {
12827
12894
  v.pause();
12828
12895
  reported = tl.time;
12829
12896
  }
@@ -12831,8 +12898,22 @@ function player(container, options) {
12831
12898
  options.onPlay?.(on, tl.time);
12832
12899
  },
12833
12900
  onScrub(on) {
12834
- if (on)
12901
+ scrubbing = on;
12902
+ if (on) {
12835
12903
  v.pause();
12904
+ startFrames();
12905
+ return;
12906
+ }
12907
+ if (v.readyState < 1)
12908
+ return hidePreview();
12909
+ const at = releaseSeek(v.currentTime, tl.time, fps, v.duration);
12910
+ if (at === null) {
12911
+ pending = null;
12912
+ if (!v.seeking)
12913
+ return hidePreview();
12914
+ } else
12915
+ seekVideo(at);
12916
+ stall.arm();
12836
12917
  }
12837
12918
  });
12838
12919
  tl.drive(() => {
@@ -12847,6 +12928,7 @@ function player(container, options) {
12847
12928
  tl.toggle();
12848
12929
  };
12849
12930
  frame.addEventListener("click", onClick);
12931
+ root.addEventListener("pointerenter", startFrames);
12850
12932
  v.src = options.src;
12851
12933
  const api = {
12852
12934
  element: root,
@@ -12873,9 +12955,18 @@ function player(container, options) {
12873
12955
  tl.dispose();
12874
12956
  v.pause();
12875
12957
  frame.removeEventListener("click", onClick);
12958
+ root.removeEventListener("pointerenter", startFrames);
12959
+ unlistenFrames?.();
12960
+ if (frames) {
12961
+ frames.visible(false);
12962
+ frames.release();
12963
+ frames = null;
12964
+ }
12876
12965
  v.removeEventListener("loadedmetadata", onMeta);
12877
12966
  v.removeEventListener("loadeddata", onFrame);
12878
12967
  v.removeEventListener("seeked", onSeeked);
12968
+ v.removeEventListener("error", onError);
12969
+ stall.clear();
12879
12970
  v.removeAttribute("src");
12880
12971
  v.load();
12881
12972
  root.remove();
@@ -12884,6 +12975,8 @@ function player(container, options) {
12884
12975
  return api;
12885
12976
  }
12886
12977
  var init_player = __esm(() => {
12978
+ init_compare_frames();
12979
+ init_compare_model();
12887
12980
  init_look();
12888
12981
  init_timeline();
12889
12982
  });
@@ -17323,8 +17416,66 @@ init_page_hints();
17323
17416
  init_keys();
17324
17417
  init_layer();
17325
17418
  init_overlay_model();
17326
- var tips = new WeakMap;
17419
+
17420
+ // src/ui/tooltip-model.ts
17327
17421
  var HANDOFF_MS = 400;
17422
+ var TIP_DELAY_MS = 500;
17423
+ function tipSchedule(clock, view) {
17424
+ let shown = null;
17425
+ let pending = null;
17426
+ let timer;
17427
+ let hiddenAt = -Infinity;
17428
+ const cancel = () => {
17429
+ if (pending)
17430
+ clock.clear(timer);
17431
+ pending = null;
17432
+ };
17433
+ const hide = (now = false) => {
17434
+ cancel();
17435
+ if (!shown)
17436
+ return;
17437
+ shown = null;
17438
+ hiddenAt = clock.now();
17439
+ view.hide(now);
17440
+ };
17441
+ return {
17442
+ get shown() {
17443
+ return shown;
17444
+ },
17445
+ get pending() {
17446
+ return pending;
17447
+ },
17448
+ want(tip, options = {}) {
17449
+ if (tip.el === shown?.el || tip.el === pending?.el)
17450
+ return;
17451
+ cancel();
17452
+ const handoff = shown !== null || clock.now() - hiddenAt < HANDOFF_MS;
17453
+ if (handoff || options.now) {
17454
+ shown = tip;
17455
+ view.show(tip, handoff);
17456
+ return;
17457
+ }
17458
+ pending = tip;
17459
+ timer = clock.set(() => {
17460
+ pending = null;
17461
+ if (!view.connected(tip))
17462
+ return;
17463
+ shown = tip;
17464
+ view.show(tip, false);
17465
+ }, options.delay ?? TIP_DELAY_MS);
17466
+ },
17467
+ hide,
17468
+ leave(within) {
17469
+ if (pending && within(pending))
17470
+ cancel();
17471
+ if (shown && within(shown))
17472
+ hide();
17473
+ }
17474
+ };
17475
+ }
17476
+
17477
+ // src/ui/tooltip.ts
17478
+ var tips = new WeakMap;
17328
17479
  var controllers = new WeakMap;
17329
17480
  function tipOf(start) {
17330
17481
  let node = start instanceof Element ? start : null;
@@ -17358,9 +17509,6 @@ function controllerOf(doc) {
17358
17509
  let text;
17359
17510
  let keys;
17360
17511
  let target = null;
17361
- let pending = null;
17362
- let timer = 0;
17363
- let hiddenAt = -Infinity;
17364
17512
  let quiet = null;
17365
17513
  let keyboard = false;
17366
17514
  const build = (look) => {
@@ -17405,41 +17553,32 @@ function controllerOf(doc) {
17405
17553
  else
17406
17554
  motion.show();
17407
17555
  };
17408
- const hide = (now = false) => {
17409
- win.clearTimeout(timer);
17410
- pending = null;
17411
- if (!target)
17412
- return;
17413
- target.removeAttribute("aria-describedby");
17414
- target = null;
17415
- hiddenAt = performance.now();
17416
- if (now)
17417
- motion?.snap(0);
17418
- else
17419
- motion?.hide();
17420
- };
17421
- const want = (found) => {
17556
+ const schedule = tipSchedule({
17557
+ set: (run, ms) => win.setTimeout(run, ms),
17558
+ clear: (handle) => win.clearTimeout(handle),
17559
+ now: () => performance.now()
17560
+ }, {
17561
+ show: ({ el, tip }, snap) => show(el, tip, snap),
17562
+ hide: (now) => {
17563
+ target?.removeAttribute("aria-describedby");
17564
+ target = null;
17565
+ if (now)
17566
+ motion?.snap(0);
17567
+ else
17568
+ motion?.hide();
17569
+ },
17570
+ connected: ({ el }) => el.isConnected
17571
+ });
17572
+ const hide = (now = false) => schedule.hide(now);
17573
+ const want = (found, now = false) => {
17422
17574
  if (!found)
17423
17575
  return hide();
17424
- if (found.el === target || found.el === pending?.el)
17425
- return;
17426
17576
  if (found.el === quiet)
17427
17577
  return;
17428
- win.clearTimeout(timer);
17429
- const handoff = target !== null || performance.now() - hiddenAt < HANDOFF_MS;
17430
- if (handoff) {
17431
- pending = null;
17432
- show(found.el, found.tip, true);
17433
- return;
17434
- }
17435
- if (target)
17436
- hide();
17437
- pending = found;
17438
- timer = win.setTimeout(() => {
17439
- pending = null;
17440
- if (found.el.isConnected)
17441
- show(found.el, found.tip, false);
17442
- }, found.tip.opts.delay ?? 500);
17578
+ schedule.want(found, {
17579
+ now,
17580
+ ...found.tip.opts.delay !== undefined ? { delay: found.tip.opts.delay } : {}
17581
+ });
17443
17582
  };
17444
17583
  doc.addEventListener("pointerover", (e) => {
17445
17584
  if (e.pointerType === "touch")
@@ -17473,11 +17612,10 @@ function controllerOf(doc) {
17473
17612
  doc.addEventListener("focusin", (e) => {
17474
17613
  const found = tipOf(e.target);
17475
17614
  if (found && keyboard && e.target.matches(":focus-visible"))
17476
- want(found);
17615
+ want(found, true);
17477
17616
  });
17478
17617
  doc.addEventListener("focusout", (e) => {
17479
- if (target && target.contains(e.target))
17480
- hide();
17618
+ schedule.leave(({ el }) => el.contains(e.target));
17481
17619
  });
17482
17620
  doc.addEventListener("scroll", () => target && hide(true), true);
17483
17621
  const c = { hide };
@@ -17776,445 +17914,819 @@ function split(container, options) {
17776
17914
  out[id] = s.x;
17777
17915
  return out;
17778
17916
  };
17779
- const target = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail });
17780
- const current = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail, fixed: fixed() });
17781
- const SIDES = { row: ["right", "left"], column: ["bottom", "top"] };
17782
- const sides = (v) => {
17783
- const [a, b] = SIDES[direction];
17784
- return v.edge === "start" ? [a, b] : [b, a];
17917
+ const target = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail });
17918
+ const current = () => solve2({ panes: specs, sizes, collapsed, total: total(), rail, fixed: fixed() });
17919
+ const SIDES = { row: ["right", "left"], column: ["bottom", "top"] };
17920
+ const sides = (v) => {
17921
+ const [a, b] = SIDES[direction];
17922
+ return v.edge === "start" ? [a, b] : [b, a];
17923
+ };
17924
+ const renderFold = (v, size) => {
17925
+ const id = v.spec.id;
17926
+ const isCollapsed = collapsed[id];
17927
+ const folding = isCollapsed || v.folding;
17928
+ const r = rail(id);
17929
+ const open = folding ? Math.max(v.open, r + 1) : size;
17930
+ const p = folding ? foldProgress(size, open, r) : 0;
17931
+ v.faces = foldFaces(v.faces, p);
17932
+ const m = marginOf(v);
17933
+ const settled = !springs.has(id);
17934
+ const content = isCollapsed && settled ? 1 : v.faces.content;
17935
+ put(v.rail ? v.inner : v.body, "will-change", v.folding ? "opacity, filter" : "");
17936
+ if (!v.rail) {
17937
+ put(v.inner, "height", folding ? `${open - m * 2}px` : "");
17938
+ put(v.inner, "bottom", folding ? "auto" : "");
17939
+ fade(v.body, content, CONTENT_BLUR);
17940
+ if (v.toggleIcon)
17941
+ put(v.toggleIcon, "transform", p ? `rotate(${(-90 * p).toFixed(2)}deg)` : "");
17942
+ hide(v.body, isCollapsed && settled);
17943
+ return;
17944
+ }
17945
+ const [side, far] = sides(v);
17946
+ put(v.inner, row ? "width" : "height", folding ? `${open - m * 2}px` : "");
17947
+ put(v.inner, side, folding ? "0" : "");
17948
+ put(v.inner, far, folding ? "auto" : "");
17949
+ fade(v.inner, content, CONTENT_BLUR);
17950
+ hide(v.clip, isCollapsed && settled);
17951
+ const morph = row && Boolean(v.toggleIcon) && !v.el.hasAttribute("data-nested");
17952
+ v.el.toggleAttribute("data-morph", morph);
17953
+ const label = v.railLabel;
17954
+ const travel = morph && style.title === "travel" && Boolean(v.titleEl) && label.textContent === v.titleEl.textContent;
17955
+ v.el.toggleAttribute("data-travel", travel && folding);
17956
+ if (v.countEl)
17957
+ put(v.countEl, "opacity", travel && folding && p > 0 ? Math.max(0, 1 - p / COUNT_CLEAR).toFixed(3) : "");
17958
+ const k = 1 - p;
17959
+ if (travel && !folding) {
17960
+ put(label, "opacity", "0");
17961
+ put(label, "filter", "");
17962
+ put(label, "transform", "");
17963
+ put(label, "color", "");
17964
+ } else if (travel) {
17965
+ const t = titleTravel(v.places, open, r, v.edge, p);
17966
+ put(label, "opacity", "");
17967
+ put(label, "filter", "");
17968
+ put(label, "transform", k === 0 ? "" : `translate(${t.x.toFixed(2)}px,${t.y.toFixed(2)}px) rotate(${t.turn.toFixed(2)}deg)`);
17969
+ put(label, "color", k === 0 ? "" : `color-mix(in srgb,var(--kv-fg) ${(k * 100).toFixed(1)}%,var(--kv-fg-2))`);
17970
+ } else {
17971
+ put(label, "transform", "");
17972
+ put(label, "color", "");
17973
+ }
17974
+ if (morph) {
17975
+ put(v.rail, "opacity", "1");
17976
+ if (!travel)
17977
+ fade(label, v.faces.rail, LABEL_BLUR);
17978
+ const c = v.chev;
17979
+ const dx = c.chevEnd - c.iconEnd + (v.edge === "end" ? open - r : 0);
17980
+ const move = `translate(${(dx * k).toFixed(2)}px,${(c.dy * k).toFixed(2)}px)`;
17981
+ const size = `scale(${(1 + (c.scale - 1) * k).toFixed(4)})`;
17982
+ if (style.chevron === "turn")
17983
+ put(v.turn, "transform", k === 0 ? "" : `${move} rotate(${chevronTurn(p, v.edge).toFixed(2)}deg) ${size}`);
17984
+ else {
17985
+ const turn = chevronFlip(v.flip.x, v.edge);
17986
+ const over = v.flip.resting ? turn === 0 ? "" : "scaleX(-1)" : `perspective(${FLIP_DEPTH}px) rotateY(${turn.toFixed(2)}deg)`;
17987
+ put(v.turn, "transform", k === 0 && !over ? "" : `${move} ${size} ${over}`);
17988
+ }
17989
+ } else {
17990
+ const a = v.faces.rail;
17991
+ put(v.rail, "opacity", a >= 1 ? "1" : a.toFixed(3));
17992
+ put(label, "opacity", "");
17993
+ put(label, "filter", a >= 1 || a <= 0 ? "" : `blur(${(LABEL_BLUR * (1 - a)).toFixed(2)}px)`);
17994
+ put(v.turn, "transform", "");
17995
+ }
17996
+ };
17997
+ let lastReport = "";
17998
+ const render = (shown) => {
17999
+ for (const spec of specs) {
18000
+ const v = views.get(spec.id);
18001
+ const size = Math.max(0, shown[spec.id] ?? 0);
18002
+ v.shown = size;
18003
+ put(v.el, "flex-basis", `${size}px`);
18004
+ put(v.el, row ? "width" : "height", `${size}px`);
18005
+ v.el.toggleAttribute("data-collapsed", collapsed[spec.id]);
18006
+ v.el.toggleAttribute("data-settled", !springs.has(spec.id));
18007
+ if (spec.collapsible)
18008
+ renderFold(v, size);
18009
+ }
18010
+ dividers.forEach((d, i) => {
18011
+ const a = specs[i];
18012
+ const b = specs[i + 1];
18013
+ const now = String(Math.round(views.get(a.id).shown));
18014
+ if (d.getAttribute("aria-valuenow") !== now)
18015
+ d.setAttribute("aria-valuenow", now);
18016
+ if (!d.hasAttribute("aria-controls"))
18017
+ d.setAttribute("aria-controls", `${a.id} ${b.id}`);
18018
+ });
18019
+ const layout = snapshot();
18020
+ const report = JSON.stringify(layout);
18021
+ if (report !== lastReport) {
18022
+ lastReport = report;
18023
+ options.onLayout?.(layout);
18024
+ for (const v of views.values())
18025
+ v.el.dispatchEvent(new Event("knobs-pane-resize"));
18026
+ notifyLayout(doc);
18027
+ }
18028
+ };
18029
+ const snapshot = () => {
18030
+ const s = {};
18031
+ for (const [id, v] of views)
18032
+ s[id] = Math.round(v.shown * 10) / 10;
18033
+ return { sizes: s, collapsed: { ...collapsed } };
18034
+ };
18035
+ const persist = () => {
18036
+ if (!store || !key)
18037
+ return;
18038
+ try {
18039
+ store.setItem(key, serializeLayout({ sizes, collapsed }));
18040
+ } catch {}
18041
+ };
18042
+ const rememberOpen = (shown) => {
18043
+ for (const spec of specs)
18044
+ if (!collapsed[spec.id] && !springs.has(spec.id))
18045
+ views.get(spec.id).open = shown[spec.id];
18046
+ };
18047
+ const loop = frameLoop(win, (dt) => {
18048
+ for (const [id, s] of springs) {
18049
+ s.step(dt);
18050
+ if (s.resting) {
18051
+ springs.delete(id);
18052
+ views.get(id).folding = false;
18053
+ }
18054
+ }
18055
+ let flipping = false;
18056
+ for (const v of views.values()) {
18057
+ v.flip.step(dt);
18058
+ if (!v.flip.resting)
18059
+ flipping = true;
18060
+ }
18061
+ const shown = current();
18062
+ if (springs.size === 0)
18063
+ rememberOpen(shown);
18064
+ render(shown);
18065
+ return springs.size > 0 || flipping;
18066
+ });
18067
+ const animate = (motion = SETTLE) => {
18068
+ const to = target();
18069
+ for (const v of views.values())
18070
+ if (v.folding && !collapsed[v.spec.id])
18071
+ v.open = to[v.spec.id];
18072
+ if (reducedMotion(win)) {
18073
+ springs.clear();
18074
+ const arriving = [...views.values()].filter((v) => v.folding).map((v) => collapsed[v.spec.id] ? v.rail : v.rail ? v.inner : v.body);
18075
+ for (const v of views.values()) {
18076
+ v.folding = false;
18077
+ v.flip.snap(collapsed[v.spec.id] ? 1 : 0);
18078
+ }
18079
+ rememberOpen(to);
18080
+ render(to);
18081
+ for (const el of arriving)
18082
+ el?.animate([{ opacity: 0 }, { opacity: 1 }], {
18083
+ duration: 160,
18084
+ easing: "cubic-bezier(.2,.8,.2,1)"
18085
+ });
18086
+ return;
18087
+ }
18088
+ for (const spec of specs) {
18089
+ const v = views.get(spec.id);
18090
+ const goal = to[spec.id];
18091
+ const s = springs.get(spec.id);
18092
+ if (s) {
18093
+ s.omega = motion.omega;
18094
+ s.zeta = motion.zeta;
18095
+ s.set(goal);
18096
+ } else if (Math.abs(goal - v.shown) > 0.5) {
18097
+ springs.set(spec.id, new Spring(v.shown, motion.omega, motion.zeta, 0.2).set(goal));
18098
+ } else
18099
+ v.folding = false;
18100
+ }
18101
+ loop.wake();
18102
+ };
18103
+ const fold = (id, value) => {
18104
+ const v = views.get(id);
18105
+ if (value && !v.folding)
18106
+ v.open = v.shown;
18107
+ collapsed[id] = value;
18108
+ v.folding = true;
18109
+ v.flip.set(value ? 1 : 0);
18110
+ };
18111
+ const release = (id) => {
18112
+ springs.delete(id);
18113
+ views.get(id).folding = false;
18114
+ };
18115
+ function setCollapsed(id, value, save = true) {
18116
+ const spec = specs.find((p) => p.id === id);
18117
+ if (!spec?.collapsible || collapsed[id] === value)
18118
+ return;
18119
+ fold(id, value);
18120
+ if (save)
18121
+ persist();
18122
+ animate(COLLAPSE);
18123
+ }
18124
+ const setSizes = (next) => {
18125
+ for (const [id, s] of Object.entries(next))
18126
+ sizes[id] = s;
18127
+ };
18128
+ function bindDivider(d, index) {
18129
+ const a = specs[index];
18130
+ const b = specs[index + 1];
18131
+ let start;
18132
+ let from = 0;
18133
+ let pointer = -1;
18134
+ d.addEventListener("pointerdown", (e) => {
18135
+ if (e.button !== 0)
18136
+ return;
18137
+ e.preventDefault();
18138
+ d.focus({ preventScroll: true });
18139
+ const shown = current();
18140
+ for (const id of [a.id, b.id])
18141
+ if (!collapsed[id])
18142
+ release(id);
18143
+ start = { ...shown };
18144
+ for (const id of [a.id, b.id])
18145
+ if (collapsed[id])
18146
+ start[id] = rail(id);
18147
+ from = row ? e.clientX : e.clientY;
18148
+ pointer = e.pointerId;
18149
+ d.setPointerCapture(e.pointerId);
18150
+ d.dataset.active = "";
18151
+ doc.documentElement.dataset.knobsResizing = direction;
18152
+ });
18153
+ d.addEventListener("pointermove", (e) => {
18154
+ if (!start || e.pointerId !== pointer)
18155
+ return;
18156
+ const delta = (row ? e.clientX : e.clientY) - from;
18157
+ const result = drag(specs, start, index, delta, collapsed);
18158
+ if (result.collapse) {
18159
+ const { id, collapsed: now } = result.collapse;
18160
+ if (!now) {
18161
+ for (const [k, s] of Object.entries(result.sizes))
18162
+ if (k === id)
18163
+ sizes[k] = s;
18164
+ }
18165
+ setCollapsed(id, now, false);
18166
+ start = { ...target() };
18167
+ if (now)
18168
+ start[id] = rail(id);
18169
+ from = row ? e.clientX : e.clientY;
18170
+ return;
18171
+ }
18172
+ if (collapsed[a.id] || collapsed[b.id])
18173
+ return;
18174
+ setSizes(result.sizes);
18175
+ for (const id of [a.id, b.id])
18176
+ release(id);
18177
+ const shown = current();
18178
+ render(shown);
18179
+ rememberOpen(shown);
18180
+ });
18181
+ const end = (e) => {
18182
+ if (!start || e.pointerId !== pointer)
18183
+ return;
18184
+ start = undefined;
18185
+ delete d.dataset.active;
18186
+ delete doc.documentElement.dataset.knobsResizing;
18187
+ for (const spec of specs)
18188
+ if (flex.has(spec.id) && !collapsed[spec.id])
18189
+ sizes[spec.id] = views.get(spec.id).shown;
18190
+ persist();
18191
+ };
18192
+ d.addEventListener("pointerup", end);
18193
+ d.addEventListener("pointercancel", end);
18194
+ d.addEventListener("lostpointercapture", end);
18195
+ d.addEventListener("dblclick", (e) => {
18196
+ e.preventDefault();
18197
+ reset(a.id, b.id);
18198
+ });
18199
+ d.addEventListener("keydown", (e) => {
18200
+ if (e.metaKey || e.ctrlKey || e.altKey)
18201
+ return;
18202
+ const back = row ? "ArrowLeft" : "ArrowUp";
18203
+ const fwd = row ? "ArrowRight" : "ArrowDown";
18204
+ const step = e.shiftKey ? 64 : 16;
18205
+ let handled = true;
18206
+ if (e.key === back || e.key === fwd) {
18207
+ const goal = target();
18208
+ const shut = [a.id, b.id].find((id) => collapsed[id]);
18209
+ if (shut)
18210
+ setCollapsed(shut, false);
18211
+ else {
18212
+ setSizes(nudge(specs, goal, index, e.key === fwd ? step : -step));
18213
+ persist();
18214
+ animate();
18215
+ }
18216
+ } else if (e.key === "Home" || e.key === "End") {
18217
+ const goal = target();
18218
+ setSizes(nudge(specs, goal, index, e.key === "End" ? 1e6 : -1e6));
18219
+ persist();
18220
+ animate();
18221
+ } else if (e.key === "Enter" || e.key === " ") {
18222
+ const id = collapsibleSide(specs, index);
18223
+ if (id)
18224
+ setCollapsed(id, !collapsed[id]);
18225
+ else
18226
+ handled = false;
18227
+ } else if (e.key === "Escape")
18228
+ d.blur();
18229
+ else
18230
+ handled = false;
18231
+ if (handled) {
18232
+ e.preventDefault();
18233
+ e.stopPropagation();
18234
+ }
18235
+ });
18236
+ }
18237
+ function reset(...ids) {
18238
+ const which = ids.length ? ids : specs.map((p) => p.id);
18239
+ let folds = false;
18240
+ for (const id of which) {
18241
+ delete sizes[id];
18242
+ const spec = specs.find((p) => p.id === id);
18243
+ const shut = Boolean(spec?.collapsed);
18244
+ if (spec?.collapsible && collapsed[id] !== shut) {
18245
+ fold(id, shut);
18246
+ folds = true;
18247
+ }
18248
+ }
18249
+ if (which.some((id) => flex.has(id)))
18250
+ for (const id of flex)
18251
+ delete sizes[id];
18252
+ persist();
18253
+ animate(folds ? COLLAPSE : SETTLE);
18254
+ }
18255
+ {
18256
+ const first = target();
18257
+ for (const spec of specs) {
18258
+ const open = { ...collapsed, [spec.id]: false };
18259
+ views.get(spec.id).open = collapsed[spec.id] ? solve2({ panes: specs, sizes, collapsed: open, total: total(), rail })[spec.id] : first[spec.id];
18260
+ }
18261
+ render(first);
18262
+ measureChevrons();
18263
+ render(first);
18264
+ }
18265
+ const relayout = (remeasure) => {
18266
+ if (remeasure)
18267
+ measure();
18268
+ if (springs.size) {
18269
+ if (remeasure)
18270
+ measureChevrons();
18271
+ return loop.wake();
18272
+ }
18273
+ const shown = target();
18274
+ rememberOpen(shown);
18275
+ render(shown);
18276
+ if (remeasure) {
18277
+ measureChevrons();
18278
+ render(shown);
18279
+ }
18280
+ };
18281
+ const ro = new ResizeObserver(() => relayout(!measured));
18282
+ ro.observe(root);
18283
+ const restyled = () => relayout(true);
18284
+ root.addEventListener("knobs-restyle", restyled);
18285
+ const paneApi = (id) => {
18286
+ const v = views.get(id);
18287
+ if (!v)
18288
+ throw new Error(`No pane "${id}"`);
18289
+ return {
18290
+ id,
18291
+ element: v.el,
18292
+ body: v.body,
18293
+ header: v.head,
18294
+ get size() {
18295
+ return v.shown;
18296
+ },
18297
+ get collapsed() {
18298
+ return collapsed[id];
18299
+ },
18300
+ collapse: (value = !collapsed[id]) => setCollapsed(id, value),
18301
+ resize: (size) => api.resize(id, size),
18302
+ title(text, count) {
18303
+ if (v.titleEl)
18304
+ v.titleEl.textContent = text;
18305
+ if (v.countEl && count !== undefined)
18306
+ v.countEl.textContent = String(count);
18307
+ if (v.railLabel)
18308
+ v.railLabel.textContent = text;
18309
+ measureChevrons(v);
18310
+ }
18311
+ };
18312
+ };
18313
+ const unregisterHints = options.hints === false ? undefined : hints.register(root, () => [...views.values()].flatMap((v) => [
18314
+ ...v.head?.querySelectorAll(":scope > .knobs-pane-btn") ?? [],
18315
+ ...v.rail && collapsed[v.spec.id] ? [v.rail] : []
18316
+ ]), { listen: false, rank: 1 });
18317
+ const api = {
18318
+ element: root,
18319
+ pane: paneApi,
18320
+ layout: snapshot,
18321
+ collapse: (id, value) => setCollapsed(id, value ?? !collapsed[id]),
18322
+ resize(id, size) {
18323
+ const i = specs.findIndex((p) => p.id === id);
18324
+ if (i < 0)
18325
+ return;
18326
+ if (collapsed[id])
18327
+ setCollapsed(id, false);
18328
+ if (!flex.has(id))
18329
+ sizes[id] = size;
18330
+ else {
18331
+ const goal = target();
18332
+ const j = i > 0 ? i - 1 : i;
18333
+ const delta = size - goal[id];
18334
+ setSizes(nudge(specs, goal, j, j === i ? delta : -delta));
18335
+ }
18336
+ persist();
18337
+ animate();
18338
+ },
18339
+ reset: (id) => id ? reset(id) : reset(),
18340
+ dispose() {
18341
+ unregisterHints?.();
18342
+ loop.stop();
18343
+ ro.disconnect();
18344
+ root.removeEventListener("knobs-restyle", restyled);
18345
+ host?.removeAttribute("data-nested");
18346
+ root.remove();
18347
+ }
17785
18348
  };
17786
- const renderFold = (v, size) => {
17787
- const id = v.spec.id;
17788
- const isCollapsed = collapsed[id];
17789
- const folding = isCollapsed || v.folding;
17790
- const r = rail(id);
17791
- const open = folding ? Math.max(v.open, r + 1) : size;
17792
- const p = folding ? foldProgress(size, open, r) : 0;
17793
- v.faces = foldFaces(v.faces, p);
17794
- const m = marginOf(v);
17795
- const settled = !springs.has(id);
17796
- const content = isCollapsed && settled ? 1 : v.faces.content;
17797
- put(v.rail ? v.inner : v.body, "will-change", v.folding ? "opacity, filter" : "");
17798
- if (!v.rail) {
17799
- put(v.inner, "height", folding ? `${open - m * 2}px` : "");
17800
- put(v.inner, "bottom", folding ? "auto" : "");
17801
- fade(v.body, content, CONTENT_BLUR);
17802
- if (v.toggleIcon)
17803
- put(v.toggleIcon, "transform", p ? `rotate(${(-90 * p).toFixed(2)}deg)` : "");
17804
- hide(v.body, isCollapsed && settled);
18349
+ return api;
18350
+ }
18351
+
18352
+ // src/ui/solid.ts
18353
+ init_timeline();
18354
+ init_compare();
18355
+
18356
+ // src/ui/compare-strip.ts
18357
+ init_compare_frames();
18358
+ init_compare_model();
18359
+ init_look();
18360
+ init_rgba();
18361
+ init_timeline_model();
18362
+ var BAR = 7;
18363
+ var GAP = 2;
18364
+ var DIFF = { w: 32, h: 18 };
18365
+ var DIFFS_PER_DRAW = 6;
18366
+ function compareStrip(container, options) {
18367
+ const doc = container.ownerDocument;
18368
+ const win = doc.defaultView;
18369
+ const timeline = options.timeline;
18370
+ const fps = () => options.fps ?? timeline.fps;
18371
+ const align = options.align ?? "track";
18372
+ const threshold = options.threshold ?? 4 / 255;
18373
+ const root = doc.createElement("div");
18374
+ root.className = "knobs-cmp-strip";
18375
+ root.setAttribute("role", "img");
18376
+ root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
18377
+ applyLook2(root, options);
18378
+ const canvas = doc.createElement("canvas");
18379
+ canvas.setAttribute("aria-hidden", "true");
18380
+ root.append(canvas);
18381
+ container.append(root);
18382
+ const diffs = new Map;
18383
+ const pairDiffs = new Map;
18384
+ let disposed = false;
18385
+ let near = false;
18386
+ let onScreen = false;
18387
+ const makeSide = (source) => ({
18388
+ source,
18389
+ kind: mediaKind(source.src, source.kind),
18390
+ frames: null,
18391
+ still: null,
18392
+ seen: false
18393
+ });
18394
+ let sides = [makeSide(options.before), makeSide(options.after)];
18395
+ const offs = new Map;
18396
+ const load = (s) => {
18397
+ if (s.frames || s.still)
17805
18398
  return;
17806
- }
17807
- const [side, far] = sides(v);
17808
- put(v.inner, row ? "width" : "height", folding ? `${open - m * 2}px` : "");
17809
- put(v.inner, side, folding ? "0" : "");
17810
- put(v.inner, far, folding ? "auto" : "");
17811
- fade(v.inner, content, CONTENT_BLUR);
17812
- hide(v.clip, isCollapsed && settled);
17813
- const morph = row && Boolean(v.toggleIcon) && !v.el.hasAttribute("data-nested");
17814
- v.el.toggleAttribute("data-morph", morph);
17815
- const label = v.railLabel;
17816
- const travel = morph && style.title === "travel" && Boolean(v.titleEl) && label.textContent === v.titleEl.textContent;
17817
- v.el.toggleAttribute("data-travel", travel && folding);
17818
- if (v.countEl)
17819
- put(v.countEl, "opacity", travel && folding && p > 0 ? Math.max(0, 1 - p / COUNT_CLEAR).toFixed(3) : "");
17820
- const k = 1 - p;
17821
- if (travel && !folding) {
17822
- put(label, "opacity", "0");
17823
- put(label, "filter", "");
17824
- put(label, "transform", "");
17825
- put(label, "color", "");
17826
- } else if (travel) {
17827
- const t = titleTravel(v.places, open, r, v.edge, p);
17828
- put(label, "opacity", "");
17829
- put(label, "filter", "");
17830
- put(label, "transform", k === 0 ? "" : `translate(${t.x.toFixed(2)}px,${t.y.toFixed(2)}px) rotate(${t.turn.toFixed(2)}deg)`);
17831
- put(label, "color", k === 0 ? "" : `color-mix(in srgb,var(--kv-fg) ${(k * 100).toFixed(1)}%,var(--kv-fg-2))`);
17832
- } else {
17833
- put(label, "transform", "");
17834
- put(label, "color", "");
17835
- }
17836
- if (morph) {
17837
- put(v.rail, "opacity", "1");
17838
- if (!travel)
17839
- fade(label, v.faces.rail, LABEL_BLUR);
17840
- const c = v.chev;
17841
- const dx = c.chevEnd - c.iconEnd + (v.edge === "end" ? open - r : 0);
17842
- const move = `translate(${(dx * k).toFixed(2)}px,${(c.dy * k).toFixed(2)}px)`;
17843
- const size = `scale(${(1 + (c.scale - 1) * k).toFixed(4)})`;
17844
- if (style.chevron === "turn")
17845
- put(v.turn, "transform", k === 0 ? "" : `${move} rotate(${chevronTurn(p, v.edge).toFixed(2)}deg) ${size}`);
17846
- else {
17847
- const turn = chevronFlip(v.flip.x, v.edge);
17848
- const over = v.flip.resting ? turn === 0 ? "" : "scaleX(-1)" : `perspective(${FLIP_DEPTH}px) rotateY(${turn.toFixed(2)}deg)`;
17849
- put(v.turn, "transform", k === 0 && !over ? "" : `${move} ${size} ${over}`);
18399
+ if (s.kind === "video") {
18400
+ s.frames = acquireFrames(doc, s.source.src, fps());
18401
+ offs.set(s, s.frames.listen(schedule));
18402
+ if (onScreen) {
18403
+ s.frames.visible(true);
18404
+ s.seen = true;
17850
18405
  }
17851
18406
  } else {
17852
- const a = v.faces.rail;
17853
- put(v.rail, "opacity", a >= 1 ? "1" : a.toFixed(3));
17854
- put(label, "opacity", "");
17855
- put(label, "filter", a >= 1 || a <= 0 ? "" : `blur(${(LABEL_BLUR * (1 - a)).toFixed(2)}px)`);
17856
- put(v.turn, "transform", "");
18407
+ const img = doc.createElement("img");
18408
+ img.crossOrigin = "anonymous";
18409
+ img.decoding = "async";
18410
+ img.addEventListener("load", () => {
18411
+ pairDiffs.clear();
18412
+ schedule();
18413
+ });
18414
+ img.src = s.source.src;
18415
+ s.still = img;
17857
18416
  }
17858
18417
  };
17859
- let lastReport = "";
17860
- const render = (shown) => {
17861
- for (const spec of specs) {
17862
- const v = views.get(spec.id);
17863
- const size = Math.max(0, shown[spec.id] ?? 0);
17864
- v.shown = size;
17865
- put(v.el, "flex-basis", `${size}px`);
17866
- put(v.el, row ? "width" : "height", `${size}px`);
17867
- v.el.toggleAttribute("data-collapsed", collapsed[spec.id]);
17868
- v.el.toggleAttribute("data-settled", !springs.has(spec.id));
17869
- if (spec.collapsible)
17870
- renderFold(v, size);
18418
+ const unload = (s) => {
18419
+ offs.get(s)?.();
18420
+ offs.delete(s);
18421
+ if (s.frames) {
18422
+ if (s.seen)
18423
+ s.frames.visible(false);
18424
+ s.frames.release();
17871
18425
  }
17872
- dividers.forEach((d, i) => {
17873
- const a = specs[i];
17874
- const b = specs[i + 1];
17875
- const now = String(Math.round(views.get(a.id).shown));
17876
- if (d.getAttribute("aria-valuenow") !== now)
17877
- d.setAttribute("aria-valuenow", now);
17878
- if (!d.hasAttribute("aria-controls"))
17879
- d.setAttribute("aria-controls", `${a.id} ${b.id}`);
17880
- });
17881
- const layout = snapshot();
17882
- const report = JSON.stringify(layout);
17883
- if (report !== lastReport) {
17884
- lastReport = report;
17885
- options.onLayout?.(layout);
17886
- for (const v of views.values())
17887
- v.el.dispatchEvent(new Event("knobs-pane-resize"));
17888
- notifyLayout(doc);
18426
+ s.frames = null;
18427
+ s.seen = false;
18428
+ if (s.still)
18429
+ s.still.removeAttribute("src");
18430
+ s.still = null;
18431
+ };
18432
+ let w = 1;
18433
+ let h = 1;
18434
+ let geo = { x: 0, w: 1 };
18435
+ const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
18436
+ const aspectOf = (s) => {
18437
+ if (s.still?.naturalWidth)
18438
+ return s.still.naturalWidth / s.still.naturalHeight;
18439
+ const c = s.frames?.nearest(0);
18440
+ return c ? c.bitmap.width / c.bitmap.height : 0;
18441
+ };
18442
+ const columns = () => stripColumns(timeline.view, geo.w, {
18443
+ duration: timeline.duration,
18444
+ fps: fps(),
18445
+ minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
18446
+ });
18447
+ const measure = () => {
18448
+ const r = root.getBoundingClientRect();
18449
+ w = Math.max(1, r.width);
18450
+ h = Math.max(1, r.height);
18451
+ geo = { x: 0, w };
18452
+ if (align === "track") {
18453
+ const track = timeline.element.querySelector(".knobs-tl-track");
18454
+ const t = track?.getBoundingClientRect();
18455
+ if (t && t.width > 0)
18456
+ geo = { x: t.left - r.left, w: t.width };
17889
18457
  }
18458
+ readColors();
18459
+ schedule();
17890
18460
  };
17891
- const snapshot = () => {
17892
- const s = {};
17893
- for (const [id, v] of views)
17894
- s[id] = Math.round(v.shown * 10) / 10;
17895
- return { sizes: s, collapsed: { ...collapsed } };
18461
+ const ask = (cols) => {
18462
+ const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
18463
+ for (const s of sides)
18464
+ s.frames?.want(points);
17896
18465
  };
17897
- const persist = () => {
17898
- if (!store || !key)
18466
+ const picOf = (s, c) => {
18467
+ if (s.still?.naturalWidth)
18468
+ return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
18469
+ const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
18470
+ return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
18471
+ };
18472
+ const probe = doc.createElement("canvas");
18473
+ probe.width = DIFF.w;
18474
+ probe.height = DIFF.h;
18475
+ const probeCtx = probe.getContext("2d", { willReadFrequently: true });
18476
+ const differ = (a, b) => {
18477
+ if (!probeCtx)
17899
18478
  return;
17900
18479
  try {
17901
- store.setItem(key, serializeLayout({ sizes, collapsed }));
17902
- } catch {}
17903
- };
17904
- const rememberOpen = (shown) => {
17905
- for (const spec of specs)
17906
- if (!collapsed[spec.id] && !springs.has(spec.id))
17907
- views.get(spec.id).open = shown[spec.id];
17908
- };
17909
- const loop = frameLoop(win, (dt) => {
17910
- for (const [id, s] of springs) {
17911
- s.step(dt);
17912
- if (s.resting) {
17913
- springs.delete(id);
17914
- views.get(id).folding = false;
17915
- }
17916
- }
17917
- let flipping = false;
17918
- for (const v of views.values()) {
17919
- v.flip.step(dt);
17920
- if (!v.flip.resting)
17921
- flipping = true;
17922
- }
17923
- const shown = current();
17924
- if (springs.size === 0)
17925
- rememberOpen(shown);
17926
- render(shown);
17927
- return springs.size > 0 || flipping;
17928
- });
17929
- const animate = (motion = SETTLE) => {
17930
- const to = target();
17931
- for (const v of views.values())
17932
- if (v.folding && !collapsed[v.spec.id])
17933
- v.open = to[v.spec.id];
17934
- if (reducedMotion(win)) {
17935
- springs.clear();
17936
- const arriving = [...views.values()].filter((v) => v.folding).map((v) => collapsed[v.spec.id] ? v.rail : v.rail ? v.inner : v.body);
17937
- for (const v of views.values()) {
17938
- v.folding = false;
17939
- v.flip.snap(collapsed[v.spec.id] ? 1 : 0);
17940
- }
17941
- rememberOpen(to);
17942
- render(to);
17943
- for (const el of arriving)
17944
- el?.animate([{ opacity: 0 }, { opacity: 1 }], {
17945
- duration: 160,
17946
- easing: "cubic-bezier(.2,.8,.2,1)"
17947
- });
18480
+ probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
18481
+ const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
18482
+ probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
18483
+ const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
18484
+ return meanAbsDiff(pa, pb);
18485
+ } catch {
17948
18486
  return;
17949
18487
  }
17950
- for (const spec of specs) {
17951
- const v = views.get(spec.id);
17952
- const goal = to[spec.id];
17953
- const s = springs.get(spec.id);
17954
- if (s) {
17955
- s.omega = motion.omega;
17956
- s.zeta = motion.zeta;
17957
- s.set(goal);
17958
- } else if (Math.abs(goal - v.shown) > 0.5) {
17959
- springs.set(spec.id, new Spring(v.shown, motion.omega, motion.zeta, 0.2).set(goal));
17960
- } else
17961
- v.folding = false;
17962
- }
17963
- loop.wake();
17964
18488
  };
17965
- const fold = (id, value) => {
17966
- const v = views.get(id);
17967
- if (value && !v.folding)
17968
- v.open = v.shown;
17969
- collapsed[id] = value;
17970
- v.folding = true;
17971
- v.flip.set(value ? 1 : 0);
18489
+ let colors = { surface: "", accent: "", diff: "", wash: "" };
18490
+ const rgba = (raw, fallback) => {
18491
+ const [r, g, b, a] = parseCssColor(raw, fallback);
18492
+ return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
17972
18493
  };
17973
- const release = (id) => {
17974
- springs.delete(id);
17975
- views.get(id).folding = false;
18494
+ const readColors = () => {
18495
+ const cs = win.getComputedStyle(root);
18496
+ colors = {
18497
+ surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
18498
+ accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
18499
+ diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
18500
+ wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
18501
+ };
17976
18502
  };
17977
- function setCollapsed(id, value, save = true) {
17978
- const spec = specs.find((p) => p.id === id);
17979
- if (!spec?.collapsible || collapsed[id] === value)
17980
- return;
17981
- fold(id, value);
17982
- if (save)
17983
- persist();
17984
- animate(COLLAPSE);
18503
+ let hover = null;
18504
+ let raf = 0;
18505
+ let asked = "";
18506
+ function schedule() {
18507
+ if (!raf && !disposed)
18508
+ raf = win.requestAnimationFrame(draw);
17985
18509
  }
17986
- const setSizes = (next) => {
17987
- for (const [id, s] of Object.entries(next))
17988
- sizes[id] = s;
17989
- };
17990
- function bindDivider(d, index) {
17991
- const a = specs[index];
17992
- const b = specs[index + 1];
17993
- let start;
17994
- let from = 0;
17995
- let pointer = -1;
17996
- d.addEventListener("pointerdown", (e) => {
17997
- if (e.button !== 0)
17998
- return;
17999
- e.preventDefault();
18000
- d.focus({ preventScroll: true });
18001
- const shown = current();
18002
- for (const id of [a.id, b.id])
18003
- if (!collapsed[id])
18004
- release(id);
18005
- start = { ...shown };
18006
- for (const id of [a.id, b.id])
18007
- if (collapsed[id])
18008
- start[id] = rail(id);
18009
- from = row ? e.clientX : e.clientY;
18010
- pointer = e.pointerId;
18011
- d.setPointerCapture(e.pointerId);
18012
- d.dataset.active = "";
18013
- doc.documentElement.dataset.knobsResizing = direction;
18014
- });
18015
- d.addEventListener("pointermove", (e) => {
18016
- if (!start || e.pointerId !== pointer)
18510
+ const draw = () => {
18511
+ raf = 0;
18512
+ if (!near)
18513
+ return;
18514
+ const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
18515
+ const cw = Math.round(w * dpr);
18516
+ const ch = Math.round(h * dpr);
18517
+ if (canvas.width !== cw || canvas.height !== ch) {
18518
+ canvas.width = cw;
18519
+ canvas.height = ch;
18520
+ }
18521
+ const ctx = canvas.getContext("2d");
18522
+ if (!ctx)
18523
+ return;
18524
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
18525
+ ctx.clearRect(0, 0, w, h);
18526
+ const cols = columns();
18527
+ const key = cols.map((c) => c.frame).join(",");
18528
+ if (key !== asked) {
18529
+ asked = key;
18530
+ ask(cols);
18531
+ }
18532
+ const rh = rowH();
18533
+ const radius = 3;
18534
+ let budget = DIFFS_PER_DRAW;
18535
+ let more = false;
18536
+ const ds = cols.map((c) => {
18537
+ const a = picOf(sides[0], c);
18538
+ const b = picOf(sides[1], c);
18539
+ if (!a || !b)
18017
18540
  return;
18018
- const delta = (row ? e.clientX : e.clientY) - from;
18019
- const result = drag(specs, start, index, delta, collapsed);
18020
- if (result.collapse) {
18021
- const { id, collapsed: now } = result.collapse;
18022
- if (!now) {
18023
- for (const [k, s] of Object.entries(result.sizes))
18024
- if (k === id)
18025
- sizes[k] = s;
18541
+ const k = `${a.key}|${b.key}`;
18542
+ if (!pairDiffs.has(k)) {
18543
+ if (budget-- <= 0) {
18544
+ more = true;
18545
+ return;
18026
18546
  }
18027
- setCollapsed(id, now, false);
18028
- start = { ...target() };
18029
- if (now)
18030
- start[id] = rail(id);
18031
- from = row ? e.clientX : e.clientY;
18032
- return;
18547
+ pairDiffs.set(k, differ(a, b));
18033
18548
  }
18034
- if (collapsed[a.id] || collapsed[b.id])
18035
- return;
18036
- setSizes(result.sizes);
18037
- for (const id of [a.id, b.id])
18038
- release(id);
18039
- const shown = current();
18040
- render(shown);
18041
- rememberOpen(shown);
18042
- });
18043
- const end = (e) => {
18044
- if (!start || e.pointerId !== pointer)
18045
- return;
18046
- start = undefined;
18047
- delete d.dataset.active;
18048
- delete doc.documentElement.dataset.knobsResizing;
18049
- for (const spec of specs)
18050
- if (flex.has(spec.id) && !collapsed[spec.id])
18051
- sizes[spec.id] = views.get(spec.id).shown;
18052
- persist();
18053
- };
18054
- d.addEventListener("pointerup", end);
18055
- d.addEventListener("pointercancel", end);
18056
- d.addEventListener("lostpointercapture", end);
18057
- d.addEventListener("dblclick", (e) => {
18058
- e.preventDefault();
18059
- reset(a.id, b.id);
18549
+ const d = pairDiffs.get(k);
18550
+ if (d !== undefined)
18551
+ diffs.set(c.frame, d);
18552
+ return d;
18060
18553
  });
18061
- d.addEventListener("keydown", (e) => {
18062
- if (e.metaKey || e.ctrlKey || e.altKey)
18554
+ const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
18555
+ const peaks = new Set(stripPeaks(ds, { threshold }));
18556
+ cols.forEach((c, i) => {
18557
+ const x = geo.x + c.x + GAP / 2;
18558
+ const cwid = Math.max(1, c.w - GAP);
18559
+ if (x + cwid < 0 || x > w)
18063
18560
  return;
18064
- const back = row ? "ArrowLeft" : "ArrowUp";
18065
- const fwd = row ? "ArrowRight" : "ArrowDown";
18066
- const step = e.shiftKey ? 64 : 16;
18067
- let handled = true;
18068
- if (e.key === back || e.key === fwd) {
18069
- const goal = target();
18070
- const shut = [a.id, b.id].find((id) => collapsed[id]);
18071
- if (shut)
18072
- setCollapsed(shut, false);
18073
- else {
18074
- setSizes(nudge(specs, goal, index, e.key === fwd ? step : -step));
18075
- persist();
18076
- animate();
18077
- }
18078
- } else if (e.key === "Home" || e.key === "End") {
18079
- const goal = target();
18080
- setSizes(nudge(specs, goal, index, e.key === "End" ? 1e6 : -1e6));
18081
- persist();
18082
- animate();
18083
- } else if (e.key === "Enter" || e.key === " ") {
18084
- const id = collapsibleSide(specs, index);
18085
- if (id)
18086
- setCollapsed(id, !collapsed[id]);
18087
- else
18088
- handled = false;
18089
- } else if (e.key === "Escape")
18090
- d.blur();
18091
- else
18092
- handled = false;
18093
- if (handled) {
18094
- e.preventDefault();
18095
- e.stopPropagation();
18561
+ for (const row of [0, 1]) {
18562
+ const y = row * (rh + GAP);
18563
+ const pic = picOf(sides[row], c);
18564
+ ctx.save();
18565
+ ctx.beginPath();
18566
+ ctx.roundRect(x, y, cwid, rh, radius);
18567
+ ctx.fillStyle = colors.surface;
18568
+ ctx.fill();
18569
+ if (pic) {
18570
+ ctx.clip();
18571
+ const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
18572
+ ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
18573
+ }
18574
+ ctx.restore();
18096
18575
  }
18097
- });
18098
- }
18099
- function reset(...ids) {
18100
- const which = ids.length ? ids : specs.map((p) => p.id);
18101
- let folds = false;
18102
- for (const id of which) {
18103
- delete sizes[id];
18104
- const spec = specs.find((p) => p.id === id);
18105
- const shut = Boolean(spec?.collapsed);
18106
- if (spec?.collapsible && collapsed[id] !== shut) {
18107
- fold(id, shut);
18108
- folds = true;
18576
+ if (hover === i) {
18577
+ ctx.fillStyle = colors.wash;
18578
+ ctx.beginPath();
18579
+ ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
18580
+ ctx.fill();
18109
18581
  }
18582
+ const d = ds[i];
18583
+ if (d !== undefined && d > 0) {
18584
+ const bh = Math.max(1, Math.min(1, d / max) * BAR);
18585
+ ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
18586
+ ctx.fillStyle = colors.diff;
18587
+ ctx.beginPath();
18588
+ ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
18589
+ ctx.fill();
18590
+ ctx.globalAlpha = 1;
18591
+ }
18592
+ });
18593
+ const v = timeline.view;
18594
+ const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
18595
+ if (px >= -1 && px <= w + 1) {
18596
+ ctx.fillStyle = colors.accent;
18597
+ ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
18110
18598
  }
18111
- if (which.some((id) => flex.has(id)))
18112
- for (const id of flex)
18113
- delete sizes[id];
18114
- persist();
18115
- animate(folds ? COLLAPSE : SETTLE);
18116
- }
18117
- {
18118
- const first = target();
18119
- for (const spec of specs) {
18120
- const open = { ...collapsed, [spec.id]: false };
18121
- views.get(spec.id).open = collapsed[spec.id] ? solve2({ panes: specs, sizes, collapsed: open, total: total(), rail })[spec.id] : first[spec.id];
18122
- }
18123
- render(first);
18124
- measureChevrons();
18125
- render(first);
18126
- }
18127
- const relayout = (remeasure) => {
18128
- if (remeasure)
18129
- measure();
18130
- if (springs.size) {
18131
- if (remeasure)
18132
- measureChevrons();
18133
- return loop.wake();
18134
- }
18135
- const shown = target();
18136
- rememberOpen(shown);
18137
- render(shown);
18138
- if (remeasure) {
18139
- measureChevrons();
18140
- render(shown);
18599
+ if (more)
18600
+ schedule();
18601
+ };
18602
+ const timeAt = (clientX) => {
18603
+ const r = root.getBoundingClientRect();
18604
+ const v = timeline.view;
18605
+ const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
18606
+ return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
18607
+ };
18608
+ const columnAt = (clientX) => {
18609
+ const x = clientX - root.getBoundingClientRect().left - geo.x;
18610
+ const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
18611
+ return i < 0 ? null : i;
18612
+ };
18613
+ let dragging = false;
18614
+ const pick = (clientX) => {
18615
+ const t = timeAt(clientX);
18616
+ timeline.seek(t);
18617
+ options.onPick?.(t);
18618
+ };
18619
+ root.addEventListener("pointerdown", (e) => {
18620
+ if (e.button !== 0)
18621
+ return;
18622
+ dragging = true;
18623
+ root.setPointerCapture(e.pointerId);
18624
+ const host = root.closest(".knobs-compare");
18625
+ if (host)
18626
+ host.focus({ preventScroll: true });
18627
+ else
18628
+ timeline.focus();
18629
+ if (timeline.playing)
18630
+ timeline.pause();
18631
+ pick(e.clientX);
18632
+ e.preventDefault();
18633
+ });
18634
+ root.addEventListener("pointermove", (e) => {
18635
+ if (dragging)
18636
+ pick(e.clientX);
18637
+ const i = columnAt(e.clientX);
18638
+ if (i !== hover) {
18639
+ hover = i;
18640
+ schedule();
18141
18641
  }
18642
+ });
18643
+ const end = () => {
18644
+ dragging = false;
18142
18645
  };
18143
- const ro = new ResizeObserver(() => relayout(!measured));
18646
+ root.addEventListener("pointerup", end);
18647
+ root.addEventListener("pointercancel", end);
18648
+ root.addEventListener("lostpointercapture", end);
18649
+ root.addEventListener("pointerleave", () => {
18650
+ if (hover === null)
18651
+ return;
18652
+ hover = null;
18653
+ schedule();
18654
+ });
18655
+ const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
18656
+ const ro = new win.ResizeObserver(measure);
18144
18657
  ro.observe(root);
18145
- const restyled = () => relayout(true);
18146
- root.addEventListener("knobs-restyle", restyled);
18147
- const paneApi = (id) => {
18148
- const v = views.get(id);
18149
- if (!v)
18150
- throw new Error(`No pane "${id}"`);
18151
- return {
18152
- id,
18153
- element: v.el,
18154
- body: v.body,
18155
- header: v.head,
18156
- get size() {
18157
- return v.shown;
18158
- },
18159
- get collapsed() {
18160
- return collapsed[id];
18161
- },
18162
- collapse: (value = !collapsed[id]) => setCollapsed(id, value),
18163
- resize: (size) => api.resize(id, size),
18164
- title(text, count) {
18165
- if (v.titleEl)
18166
- v.titleEl.textContent = text;
18167
- if (v.countEl && count !== undefined)
18168
- v.countEl.textContent = String(count);
18169
- if (v.railLabel)
18170
- v.railLabel.textContent = text;
18171
- measureChevrons(v);
18172
- }
18173
- };
18658
+ const track = timeline.element.querySelector(".knobs-tl-track");
18659
+ if (track)
18660
+ ro.observe(track);
18661
+ root.addEventListener("knobs-restyle", measure);
18662
+ const setOnScreen = (on) => {
18663
+ onScreen = on;
18664
+ for (const s of sides) {
18665
+ if (!s.frames || s.seen === on)
18666
+ continue;
18667
+ s.seen = on;
18668
+ s.frames.visible(on);
18669
+ }
18174
18670
  };
18175
- const unregisterHints = options.hints === false ? undefined : hints.register(root, () => [...views.values()].flatMap((v) => [
18176
- ...v.head?.querySelectorAll(":scope > .knobs-pane-btn") ?? [],
18177
- ...v.rail && collapsed[v.spec.id] ? [v.rail] : []
18178
- ]), { listen: false, rank: 1 });
18179
- const api = {
18671
+ const setNear = (on) => {
18672
+ near = on;
18673
+ if (on) {
18674
+ for (const s of sides)
18675
+ load(s);
18676
+ schedule();
18677
+ } else
18678
+ for (const s of sides)
18679
+ unload(s);
18680
+ };
18681
+ const IO = win.IntersectionObserver;
18682
+ const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
18683
+ const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
18684
+ measure();
18685
+ if (nearIo && screenIo) {
18686
+ nearIo.observe(root);
18687
+ screenIo.observe(root);
18688
+ } else {
18689
+ setNear(true);
18690
+ setOnScreen(true);
18691
+ }
18692
+ return {
18180
18693
  element: root,
18181
- pane: paneApi,
18182
- layout: snapshot,
18183
- collapse: (id, value) => setCollapsed(id, value ?? !collapsed[id]),
18184
- resize(id, size) {
18185
- const i = specs.findIndex((p) => p.id === id);
18186
- if (i < 0)
18187
- return;
18188
- if (collapsed[id])
18189
- setCollapsed(id, false);
18190
- if (!flex.has(id))
18191
- sizes[id] = size;
18192
- else {
18193
- const goal = target();
18194
- const j = i > 0 ? i - 1 : i;
18195
- const delta = size - goal[id];
18196
- setSizes(nudge(specs, goal, j, j === i ? delta : -delta));
18197
- }
18198
- persist();
18199
- animate();
18694
+ diffs,
18695
+ update(next) {
18696
+ const was = sides;
18697
+ sides = [
18698
+ next.before ? makeSide(next.before) : was[0],
18699
+ next.after ? makeSide(next.after) : was[1]
18700
+ ];
18701
+ for (const s of was)
18702
+ if (!sides.includes(s))
18703
+ unload(s);
18704
+ diffs.clear();
18705
+ pairDiffs.clear();
18706
+ asked = "";
18707
+ if (near)
18708
+ for (const s of sides)
18709
+ load(s);
18710
+ if (onScreen)
18711
+ setOnScreen(true);
18712
+ schedule();
18200
18713
  },
18201
- reset: (id) => id ? reset(id) : reset(),
18202
18714
  dispose() {
18203
- unregisterHints?.();
18204
- loop.stop();
18715
+ disposed = true;
18716
+ if (raf)
18717
+ win.cancelAnimationFrame(raf);
18718
+ unlisten();
18205
18719
  ro.disconnect();
18206
- root.removeEventListener("knobs-restyle", restyled);
18207
- host?.removeAttribute("data-nested");
18720
+ nearIo?.disconnect();
18721
+ screenIo?.disconnect();
18722
+ for (const s of sides)
18723
+ unload(s);
18208
18724
  root.remove();
18209
18725
  }
18210
18726
  };
18211
- return api;
18212
18727
  }
18213
18728
 
18214
18729
  // src/ui/solid.ts
18215
- init_timeline();
18216
- init_compare();
18217
- init_compare_strip();
18218
18730
  init_tree();
18219
18731
 
18220
18732
  // src/ui/walkthrough.ts
@@ -21510,9 +22022,8 @@ var walkthroughCss = `${foundationCss}
21510
22022
  .knobs-wt-slot .knobs-cmp-stage{border-radius:var(--kw-frame-r)}
21511
22023
  /* A compare is as tall as its two pictures side by side at this width, in every mode (Wipe fuses
21512
22024
  them at one side's size, so nothing grows or re-measures), plus its mode bar, the line and gaps
21513
- (92 px), and the frame strip with its gap (158 px). */
22025
+ (92 px). */
21514
22026
  .knobs-wt-fit{width:100%;height:calc(100cqw / 2 / var(--kw-aspect,1.7778) + var(--kw-chrome,92px))}
21515
- .knobs-wt-fit[data-strip]{--kw-chrome:158px}
21516
22027
  .knobs-wt-fit[data-settled]{transition:height var(--k-wt-calm-ms,${calm.duration}ms) ${calm.easing}}
21517
22028
  .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)}
21518
22029
  .knobs-wt-frame>:is(video,img){position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;margin:0;border:0}
@@ -21525,16 +22036,15 @@ var walkthroughCss = `${foundationCss}
21525
22036
  /* The diff inside it: its header row 28 high on the container's padding, a hairline under it, and
21526
22037
  no line of its own under the code (the container's ring closes it). */
21527
22038
  .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"]){--kd-head:28px;--kd-x:0px}
21528
- .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"])::after{display:none}
21529
- .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);
22039
+ .knobs-wt-slot[data-kind="diff"] :is(.knobs-ui.knobs-diff:not([data-on="pane"]),.knobs-diff-review)::after{display:none}
22040
+ .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);
21530
22041
  font:var(--_k-type-label)/var(--_k-leading-body) var(--_k-mono);white-space:pre;tab-size:2;scrollbar-width:thin}
21531
- .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)}
22042
+ .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)}
21532
22043
  .knobs-wt-code[data-terminal]{white-space:pre-wrap;overflow-wrap:anywhere}
21533
22044
  .knobs-wt-prompt{color:var(--_k-fg-3);user-select:none;-webkit-user-select:none}
21534
22045
  .knobs-wt-cmd{color:var(--_k-fg);font-weight:var(--_k-weight-label)}
21535
22046
  .knobs-wt-out{color:var(--_k-fg-2)}
21536
22047
  .knobs-wt-metric{display:flex;flex-direction:column;gap:var(--_k-space-sibling)}
21537
- .knobs-wt-mlabel{color:var(--_k-fg-2)}
21538
22048
  .knobs-wt-mvalue{display:flex;align-items:baseline;flex-wrap:wrap;gap:var(--_k-space-sibling);font-feature-settings:var(--_kr-data)}
21539
22049
  .knobs-wt-mfrom{color:var(--_k-fg-3)}
21540
22050
  .knobs-wt-mto{font-size:var(--_k-type-title);line-height:var(--_k-leading-title);font-weight:var(--_k-weight-label)}
@@ -21553,6 +22063,9 @@ var walkthroughCss = `${foundationCss}
21553
22063
  .knobs-wt-callout[data-tone="bad"]{--kw-tone:var(--_k-danger)}
21554
22064
  .knobs-wt-text{max-width:var(--kw-measure);color:var(--_k-fg-2)}
21555
22065
  .knobs-wt-hold{border-radius:var(--kw-frame-r);background:var(--_k-fill)}
22066
+ /* A diff holds its room with its own well's edge, no fill: the diff reads the colour behind it
22067
+ as it mounts, and a loading fill there would stay under its code. */
22068
+ .knobs-wt-slot[data-kind="diff"]>.knobs-wt-hold{background:transparent}
21556
22069
  .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)}
21557
22070
 
21558
22071
  /* Annotations: a calm ring round a box, a dot or an arrow at a point, a small label beside it;
@@ -21802,8 +22315,6 @@ var side = (s, fallback) => ({
21802
22315
  async function mountCompare(host, m, look, alive, onEvent) {
21803
22316
  const doc = host.ownerDocument;
21804
22317
  const fit = div(doc, "knobs-wt-fit");
21805
- if (m.strip)
21806
- fit.dataset.strip = "";
21807
22318
  const { before, after } = m;
21808
22319
  const known = parseAspect(m.aspect);
21809
22320
  fit.style.setProperty("--kw-aspect", String(known ?? DEFAULT_ASPECT));
@@ -21830,7 +22341,6 @@ async function mountCompare(host, m, look, alive, onEvent) {
21830
22341
  timeline: mediaKind(before.src) === "video" || mediaKind(after.src) === "video",
21831
22342
  lazy: false,
21832
22343
  ...aspect ? { aspect } : {},
21833
- ...m.strip ? { strip: true } : {},
21834
22344
  onMode: (next) => {
21835
22345
  onEvent?.({ what: "mode", detail: next });
21836
22346
  const layer = marks?.element;
@@ -21892,11 +22402,11 @@ async function mountCompare(host, m, look, alive, onEvent) {
21892
22402
  pills?.sync();
21893
22403
  marking?.kick();
21894
22404
  if (source !== "play")
21895
- onEvent?.({ what: "seek", time: round2(time) });
22405
+ onEvent?.({ what: "seek", time: round3(time) });
21896
22406
  },
21897
22407
  onPlay: (on) => {
21898
22408
  marking?.kick();
21899
- onEvent?.({ what: on ? "play" : "pause", time: round2(c.time) });
22409
+ onEvent?.({ what: on ? "play" : "pause", time: round3(c.time) });
21900
22410
  }
21901
22411
  });
21902
22412
  requestAnimationFrame(() => requestAnimationFrame(() => fit.dataset.settled = ""));
@@ -21914,13 +22424,13 @@ async function mountCompare(host, m, look, alive, onEvent) {
21914
22424
  const zoom = zoomedIn(c.zoom) ? c.zoom : null;
21915
22425
  return {
21916
22426
  kind: "compare",
21917
- time: round2(c.time),
21918
- duration: round2(c.duration),
22427
+ time: round3(c.time),
22428
+ duration: round3(c.duration),
21919
22429
  playing: c.playing,
21920
22430
  mode: c.mode,
21921
22431
  ...pills?.current ? { chapter: pills.current.label } : {},
21922
- ...loop ? { loop: { start: round2(loop.start), end: round2(loop.end) } } : {},
21923
- ...zoom ? { zoom: { u: round2(zoom.u), v: round2(zoom.v), w: round2(zoom.w), h: round2(zoom.h) } } : {}
22432
+ ...loop ? { loop: { start: round3(loop.start), end: round3(loop.end) } } : {},
22433
+ ...zoom ? { zoom: { u: round3(zoom.u), v: round3(zoom.v), w: round3(zoom.w), h: round3(zoom.h) } } : {}
21924
22434
  };
21925
22435
  },
21926
22436
  dispose: () => {
@@ -21933,7 +22443,7 @@ async function mountCompare(host, m, look, alive, onEvent) {
21933
22443
  }
21934
22444
  };
21935
22445
  }
21936
- var round2 = (v) => Math.round(v * 100) / 100;
22446
+ var round3 = (v) => Math.round(v * 100) / 100;
21937
22447
  var MARK_GLIDE = 450;
21938
22448
  var zoomedIn = (z) => z.w < 0.999 || z.h < 0.999;
21939
22449
  async function mountVideo(host, m, look, alive, onEvent) {
@@ -21960,12 +22470,12 @@ async function mountVideo(host, m, look, alive, onEvent) {
21960
22470
  } : {},
21961
22471
  onPlay: (on, time) => {
21962
22472
  marking?.kick();
21963
- onEvent?.({ what: on ? "play" : "pause", time: round2(time) });
22473
+ onEvent?.({ what: on ? "play" : "pause", time: round3(time) });
21964
22474
  },
21965
22475
  onSeek: (time) => {
21966
22476
  pills?.sync();
21967
22477
  marking?.kick();
21968
- onEvent?.({ what: "seek", time: round2(time) });
22478
+ onEvent?.({ what: "seek", time: round3(time) });
21969
22479
  }
21970
22480
  });
21971
22481
  const v = p.video;
@@ -21998,8 +22508,8 @@ async function mountVideo(host, m, look, alive, onEvent) {
21998
22508
  seekBy: (d) => p.seekBy(d),
21999
22509
  context: () => ({
22000
22510
  kind: "video",
22001
- time: round2(p.time),
22002
- ...p.duration ? { duration: round2(p.duration) } : {},
22511
+ time: round3(p.time),
22512
+ ...p.duration ? { duration: round3(p.duration) } : {},
22003
22513
  playing: p.playing,
22004
22514
  ...pills?.current ? { chapter: pills.current.label } : {}
22005
22515
  }),
@@ -22143,8 +22653,6 @@ var fmt = (v, unit = "") => `${v}${unit}`;
22143
22653
  function mountMetric(host, m) {
22144
22654
  const doc = host.ownerDocument;
22145
22655
  const box = div(doc, "knobs-wt-metric");
22146
- const label = div(doc, "knobs-wt-mlabel");
22147
- label.textContent = m.label;
22148
22656
  const value = div(doc, "knobs-wt-mvalue");
22149
22657
  const span = (cls, text) => {
22150
22658
  const s = doc.createElement("span");
@@ -22164,7 +22672,7 @@ function mountMetric(host, m) {
22164
22672
  delta.dataset.tone = d > 0 === (better === "up") ? "good" : "bad";
22165
22673
  value.append(delta);
22166
22674
  }
22167
- box.append(label, value);
22675
+ box.append(value);
22168
22676
  const total = num(m.of);
22169
22677
  if (Number.isFinite(total) && total > 0 && Number.isFinite(a) && Number.isFinite(b)) {
22170
22678
  const track = div(doc, "knobs-wt-mtrack");
@@ -22218,7 +22726,7 @@ function mountDecision(host, m, slot) {
22218
22726
  }
22219
22727
  var HEAVY = new Set(["compare", "video", "diff", "mount"]);
22220
22728
  var RELEASES = new Set(["compare", "video"]);
22221
- var labelOf2 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "metric" ? m.label : undefined;
22729
+ var labelOf2 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "history" ? m.label : undefined;
22222
22730
  function mediaSlot(container, given) {
22223
22731
  const doc = container.ownerDocument;
22224
22732
  injectWalkthroughStyles(doc);
@@ -22232,7 +22740,7 @@ function mediaSlot(container, given) {
22232
22740
  root.dataset.kind = m.kind;
22233
22741
  applyLook2(root, options);
22234
22742
  const label = labelOf2(m);
22235
- if (label && (m.kind === "video" || m.kind === "image" || m.kind === "code" || m.kind === "terminal" || m.kind === "facts")) {
22743
+ if (label) {
22236
22744
  const l = doc.createElement("span");
22237
22745
  l.className = "knobs-wt-slot-label";
22238
22746
  l.textContent = label;