@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
@@ -648,7 +648,6 @@ var compareCss = `
648
648
  :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)}
649
649
  .knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--_k-fg-3);pointer-events:none}
650
650
  .knobs-cmp-note:empty{display:none}
651
- .knobs-cmp-strip-host{flex:none;height:60px;min-height:0}
652
651
  .knobs-cmp-timeline-host{flex:none;min-height:0}
653
652
  /* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */
654
653
  .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;
@@ -668,6 +667,9 @@ var playerCss = `
668
667
  .knobs-player-media{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:contain;
669
668
  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)}
670
669
  .knobs-player[data-ready] .knobs-player-media{opacity:1;filter:none}
670
+ /* Mid-scrub, the cached frame under the hand, over the video until it lands. */
671
+ .knobs-player-preview{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}
672
+ .knobs-player-preview[hidden]{display:none}
671
673
  .knobs-player-line{flex:none;min-height:0}
672
674
  /* The timeline's own stylesheet is look.ts's; what the player's row and every timeline share
673
675
  about the hand is set here, after it. No hand cursor anywhere (the arrow, or a resize where it
@@ -1449,6 +1451,14 @@ var init_schema = __esm(() => {
1449
1451
  doc: "Panel and card radius, concentric with controls.",
1450
1452
  deprecated: ["--k-panel-radius", "--ku-panel-radius"]
1451
1453
  },
1454
+ {
1455
+ name: "radius-frame",
1456
+ tier: "semantic",
1457
+ type: "length",
1458
+ group: "Shape",
1459
+ source: css("calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))"),
1460
+ 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."
1461
+ },
1452
1462
  {
1453
1463
  name: "round",
1454
1464
  tier: "semantic",
@@ -2567,7 +2577,16 @@ var init_glide = __esm(() => {
2567
2577
  "border-subtle",
2568
2578
  "bg-page"
2569
2579
  ];
2570
- LENGTH_TOKENS = ["pad", "inset", "row", "gap", "radius", "radius-panel", "font-size"];
2580
+ LENGTH_TOKENS = [
2581
+ "pad",
2582
+ "inset",
2583
+ "row",
2584
+ "gap",
2585
+ "radius",
2586
+ "radius-panel",
2587
+ "radius-frame",
2588
+ "font-size"
2589
+ ];
2571
2590
  NUMBER_TOKENS = ["weight-label", "blur", "glow", "glass", "glass-tone"];
2572
2591
  ANIMATED = [...COLOR_TOKENS, ...LENGTH_TOKENS, ...NUMBER_TOKENS, "width"];
2573
2592
  TOKEN_TRANSITIONS = ANIMATED.map((t) => `--kt-${t} var(--k-token-t)`).join(",");
@@ -4605,12 +4624,24 @@ function createRegistry(doc) {
4605
4624
  return;
4606
4625
  if (e.metaKey || e.ctrlKey || e.altKey || e.key !== PAGE_HINT_KEY)
4607
4626
  return;
4608
- if (isTyping(e.target) || !wide())
4627
+ if (isTyping(e.target) || !wide(e.target))
4609
4628
  return;
4610
4629
  if (registry.start())
4611
4630
  e.preventDefault();
4612
4631
  };
4613
- const wide = () => listening && (sources.length > 1 || [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel")));
4632
+ const hostHints = () => [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel"));
4633
+ const wide = (target) => {
4634
+ if (!listening)
4635
+ return false;
4636
+ if (sources.some((s) => s.listen && s.rank >= 2) || hostHints())
4637
+ return true;
4638
+ if (sources.length < 2)
4639
+ return false;
4640
+ if (target === undefined)
4641
+ return true;
4642
+ const piece = target?.closest?.(".knobs-ui,.knobs-pane-body");
4643
+ return Boolean(piece?.classList.contains("knobs-ui"));
4644
+ };
4614
4645
  const relisten = () => {
4615
4646
  const next = sources.some((s) => s.listen);
4616
4647
  if (next === listening)
@@ -9575,6 +9606,31 @@ function seeked(s, current, fps) {
9575
9606
  return { state: { seeking: true, pending: null }, apply: s.pending, settled: false };
9576
9607
  return { state: SEEK_IDLE, apply: null, settled: true };
9577
9608
  }
9609
+ function releaseSeek(current, t, fps, duration) {
9610
+ const at = seekTime(t, fps, duration);
9611
+ return sameFrame(current, at, fps) ? null : at;
9612
+ }
9613
+ function stallGuard(timers, onStall, ms = SEEK_STALL_MS) {
9614
+ let id = 0;
9615
+ const clear = () => {
9616
+ if (id)
9617
+ timers.clearTimeout(id);
9618
+ id = 0;
9619
+ };
9620
+ return {
9621
+ arm() {
9622
+ clear();
9623
+ id = timers.setTimeout(() => {
9624
+ id = 0;
9625
+ onStall();
9626
+ }, ms);
9627
+ },
9628
+ clear,
9629
+ get armed() {
9630
+ return id !== 0;
9631
+ }
9632
+ };
9633
+ }
9578
9634
  function letterbox(media, box) {
9579
9635
  if (media.w <= 0 || media.h <= 0 || box.w <= 0 || box.h <= 0)
9580
9636
  return box;
@@ -9695,7 +9751,7 @@ function stripPeaks(diffs, o = {}) {
9695
9751
  });
9696
9752
  return out;
9697
9753
  }
9698
- 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) => {
9754
+ 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) => {
9699
9755
  const inner = r * Math.min(1, Math.max(0, k));
9700
9756
  return { before: { left: r, right: inner }, after: { left: inner, right: r } };
9701
9757
  }, labelFold = (fold, mark, blur = 3) => {
@@ -9942,6 +9998,25 @@ var init_compare_mp4 = __esm(() => {
9942
9998
  });
9943
9999
 
9944
10000
  // src/ui/compare-frames.ts
10001
+ function scrubCaching(option, win) {
10002
+ if (option === false)
10003
+ return false;
10004
+ if (option === true)
10005
+ return true;
10006
+ const net = win?.navigator?.connection;
10007
+ if (net?.saveData)
10008
+ return false;
10009
+ return !win?.matchMedia?.("(prefers-reduced-data: reduce)").matches;
10010
+ }
10011
+ function proxyStride(bytes, count, total, budget) {
10012
+ if (count <= 0 || total <= 0)
10013
+ return 1;
10014
+ const each = bytes / count;
10015
+ let stride = 1;
10016
+ while (total / stride * each > budget && stride < total)
10017
+ stride *= 2;
10018
+ return stride;
10019
+ }
9945
10020
  function gridPoints(total, count = GRID) {
9946
10021
  if (total <= 0)
9947
10022
  return [];
@@ -9972,6 +10047,24 @@ function nearestKey(keys, frame, within = Infinity) {
9972
10047
  }
9973
10048
  return best !== null && bestD <= within ? best : null;
9974
10049
  }
10050
+ function claim(core) {
10051
+ if (!round2) {
10052
+ const r = new Set;
10053
+ round2 = r;
10054
+ queueMicrotask(() => {
10055
+ for (const c of actives)
10056
+ if (!r.has(c)) {
10057
+ c.active = false;
10058
+ c.dropFull();
10059
+ }
10060
+ actives = r;
10061
+ round2 = null;
10062
+ });
10063
+ }
10064
+ round2.add(core);
10065
+ actives.add(core);
10066
+ core.active = true;
10067
+ }
9975
10068
  function pump(win) {
9976
10069
  if (running)
9977
10070
  return;
@@ -9979,12 +10072,14 @@ function pump(win) {
9979
10072
  (async () => {
9980
10073
  try {
9981
10074
  for (;; ) {
9982
- const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active));
10075
+ const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active) || a.ran - b.ran);
9983
10076
  let unit = null;
9984
10077
  for (const c of order) {
9985
10078
  unit = c.work();
9986
- if (unit)
10079
+ if (unit) {
10080
+ c.ran = ++turns;
9987
10081
  break;
10082
+ }
9988
10083
  }
9989
10084
  if (!unit)
9990
10085
  break;
@@ -10014,12 +10109,99 @@ async function toBitmap(doc, source, w, h, cap) {
10014
10109
  return createImageBitmap(c);
10015
10110
  }
10016
10111
  }
10112
+ function holdEncoders() {
10113
+ encoderUsers++;
10114
+ }
10115
+ function dropEncoders() {
10116
+ encoderUsers = Math.max(0, encoderUsers - 1);
10117
+ if (encoderUsers > 0)
10118
+ return;
10119
+ endEncoders("released");
10120
+ encoders = undefined;
10121
+ }
10122
+ function encodersOf(host) {
10123
+ if (encoders !== undefined)
10124
+ return encoders;
10125
+ encoders = null;
10126
+ const W = host.Worker;
10127
+ if (!W || !host.OffscreenCanvas)
10128
+ return null;
10129
+ const workers = [];
10130
+ let url = null;
10131
+ try {
10132
+ url = host.URL.createObjectURL(new host.Blob([ENCODER], { type: "text/javascript" }));
10133
+ for (let i = 0;i < ENCODERS; i++)
10134
+ workers.push(new W(url));
10135
+ } catch {
10136
+ for (const w of workers)
10137
+ w.terminate();
10138
+ return null;
10139
+ } finally {
10140
+ if (url)
10141
+ host.URL.revokeObjectURL(url);
10142
+ }
10143
+ const pool = { workers, waiting: new Map, next: 0 };
10144
+ for (const w of workers) {
10145
+ w.onmessage = (e) => {
10146
+ const job = pool.waiting.get(e.data.id);
10147
+ pool.waiting.delete(e.data.id);
10148
+ if (e.data.blob)
10149
+ job?.done(e.data.blob);
10150
+ else
10151
+ job?.fail(new Error(e.data.error));
10152
+ };
10153
+ w.onerror = () => {
10154
+ if (encoders !== pool)
10155
+ return;
10156
+ endEncoders("encoder");
10157
+ encoders = null;
10158
+ };
10159
+ }
10160
+ encoders = pool;
10161
+ return pool;
10162
+ }
10163
+ function encodeOn(pool, bitmap, quality) {
10164
+ const id = pool.next++;
10165
+ const worker = pool.workers[id % pool.workers.length];
10166
+ return new Promise((done, fail) => {
10167
+ pool.waiting.set(id, { done, fail });
10168
+ try {
10169
+ worker.postMessage({ id, bitmap, quality }, [bitmap]);
10170
+ } catch (err) {
10171
+ pool.waiting.delete(id);
10172
+ bitmap.close();
10173
+ fail(err);
10174
+ }
10175
+ });
10176
+ }
10177
+ async function toProxy(doc, source, w, h) {
10178
+ const bm = await toBitmap(doc, source, w, h, PROXY.size);
10179
+ const pool = encodersOf(doc.defaultView);
10180
+ if (pool)
10181
+ return encodeOn(pool, bm, PROXY.quality);
10182
+ try {
10183
+ const Off = doc.defaultView?.OffscreenCanvas;
10184
+ if (Off) {
10185
+ const c = new Off(bm.width, bm.height);
10186
+ c.getContext("2d").drawImage(bm, 0, 0);
10187
+ return await c.convertToBlob({ type: "image/jpeg", quality: PROXY.quality });
10188
+ }
10189
+ const c = doc.createElement("canvas");
10190
+ c.width = bm.width;
10191
+ c.height = bm.height;
10192
+ c.getContext("2d").drawImage(bm, 0, 0);
10193
+ return await new Promise((done, fail) => c.toBlob((b) => b ? done(b) : fail(new Error("toBlob")), "image/jpeg", PROXY.quality));
10194
+ } finally {
10195
+ bm.close();
10196
+ }
10197
+ }
10017
10198
  function acquireFrames(doc, src, fps) {
10018
10199
  const key = `${fps}|${src}`;
10019
10200
  let core = cores.get(key);
10020
10201
  if (!core) {
10021
10202
  core = makeCore(doc, key, src, fps);
10022
10203
  cores.set(key, core);
10204
+ holdEncoders();
10023
10205
  }
10024
10206
  core.refs++;
10025
10207
  return core.cache;
@@ -10040,6 +10222,71 @@ function makeCore(doc, key, src, fps) {
10040
10222
  let generation = 0;
10041
10223
  let tick = 0;
10042
10224
  let notifyRaf = 0;
10225
+ const proxies = new Map;
10226
+ let proxyBytes = 0;
10227
+ let stride = 1;
10228
+ const decoded = new Map;
10229
+ const decoding = new Set;
10230
+ let aim = null;
10231
+ let lastAsked = null;
10232
+ const storeProxy = (frame, blob) => {
10233
+ if (disposed || frame % stride || proxies.has(frame))
10234
+ return;
10235
+ proxies.set(frame, blob);
10236
+ proxyBytes += blob.size;
10237
+ const next = proxyStride(proxyBytes, proxies.size, total, PROXY.budget);
10238
+ if (next <= stride)
10239
+ return;
10240
+ stride = next;
10241
+ for (const [f, b] of proxies)
10242
+ if (f % stride) {
10243
+ proxies.delete(f);
10244
+ proxyBytes -= b.size;
10245
+ }
10246
+ };
10247
+ const proxyNear = (frame) => {
10248
+ const at = Math.round(frame / stride) * stride;
10249
+ let best = null;
10250
+ for (const f of [at, at - stride, at + stride])
10251
+ if (proxies.has(f) && (best === null || Math.abs(f - frame) < Math.abs(best - frame)))
10252
+ best = f;
10253
+ return best;
10254
+ };
10255
+ const proxyTodo = () => {
10256
+ const out = [];
10257
+ for (let f = 0;f < total; f += stride)
10258
+ if (!proxies.has(f))
10259
+ out.push(f);
10260
+ return out;
10261
+ };
10262
+ const decodeProxy = (frame) => {
10263
+ if (frame === null || decoded.has(frame) || decoding.has(frame) || disposed)
10264
+ return;
10265
+ const blob = proxies.get(frame);
10266
+ if (!blob)
10267
+ return;
10268
+ decoding.add(frame);
10269
+ createImageBitmap(blob).then((bitmap) => {
10270
+ decoding.delete(frame);
10271
+ if (disposed)
10272
+ return bitmap.close();
10273
+ decoded.set(frame, { bitmap, used: ++tick });
10274
+ if (decoded.size > PROXY.decoded) {
10275
+ const [oldest] = [...decoded].sort((a, b) => a[1].used - b[1].used);
10276
+ if (oldest && oldest[0] !== frame) {
10277
+ oldest[1].bitmap.close();
10278
+ decoded.delete(oldest[0]);
10279
+ }
10280
+ }
10281
+ for (const f of listeners)
10282
+ f();
10283
+ }, () => decoding.delete(frame));
10284
+ };
10285
+ const dropDecoded = () => {
10286
+ for (const d of decoded.values())
10287
+ d.bitmap.close();
10288
+ decoded.clear();
10289
+ };
10043
10290
  const notify = () => {
10044
10291
  if (notifyRaf)
10045
10292
  return;
@@ -10170,18 +10417,50 @@ function makeCore(doc, key, src, fps) {
10170
10417
  return;
10171
10418
  const forFocus = todo[0].kind === "focus";
10172
10419
  const gen = generation;
10173
- const stale = () => forFocus && gen !== generation;
10420
+ await decodeRun(t, data, run, forFocus ? () => gen !== generation : () => false, (vf, f) => {
10421
+ const want = plan.get(f);
10422
+ if (!want)
10423
+ return null;
10424
+ plan.delete(f);
10425
+ return toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind));
10426
+ });
10427
+ };
10428
+ const proxyUnit = async () => {
10429
+ const todo = proxyTodo();
10430
+ if (!todo.length || !track || !buf)
10431
+ return;
10432
+ const runs = decodeRuns(track.samples, new Set(todo), (pts) => frameOf(pts, fps));
10433
+ const at = aim ?? focusAt ?? 0;
10434
+ let run = runs[0];
10435
+ let best = Number.POSITIVE_INFINITY;
10436
+ for (const r of runs) {
10437
+ const d = Math.min(...r.frames.map((f) => Math.abs(f - at)));
10438
+ if (d < best) {
10439
+ best = d;
10440
+ run = r;
10441
+ }
10442
+ }
10443
+ if (!run)
10444
+ return;
10445
+ const gen = generation;
10446
+ let made = 0;
10447
+ await decodeRun(track, buf, run, () => gen !== generation || made >= PROXY.turn, (vf, f) => {
10448
+ if (f % stride || proxies.has(f) || f >= total)
10449
+ return null;
10450
+ made++;
10451
+ return toProxy(doc, vf, vf.displayWidth, vf.displayHeight).then((b) => storeProxy(f, b));
10452
+ });
10453
+ };
10454
+ const decodeRun = async (t, data, run, stale, take) => {
10174
10455
  let pending = 0;
10175
10456
  let failed = false;
10176
10457
  const decoder = new VideoDecoder({
10177
10458
  output: (vf) => {
10178
- const f = frameOf(vf.timestamp / 1e6, fps);
10179
- const want = plan.get(f);
10180
- if (!want || stale() || disposed)
10459
+ const work = stale() || disposed ? null : take(vf, frameOf(vf.timestamp / 1e6, fps));
10460
+ if (!work)
10181
10461
  return vf.close();
10182
- plan.delete(f);
10183
10462
  pending++;
10184
- toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind)).catch(() => {}).finally(() => {
10463
+ work.catch(() => {}).finally(() => {
10185
10464
  vf.close();
10186
10465
  pending--;
10187
10466
  });
@@ -10295,6 +10574,25 @@ function makeCore(doc, key, src, fps) {
10295
10574
  return points.some((p) => p.frame + p.tolerance >= f && !covered(p));
10296
10575
  }, 6000, false);
10297
10576
  };
10577
+ const videoProxyUnit = async () => {
10578
+ const v = sampler;
10579
+ const todo = proxyTodo();
10580
+ if (!v || !todo.length || !v.videoWidth)
10581
+ return;
10582
+ const at = aim ?? focusAt ?? 0;
10583
+ const first = todo.find((f) => f >= at) ?? todo[0];
10584
+ const grab = async (f) => {
10585
+ if (f % stride || proxies.has(f) || f >= total)
10586
+ return;
10587
+ storeProxy(f, await toProxy(doc, v, v.videoWidth, v.videoHeight));
10588
+ };
10589
+ await seekTo(v, (first + 0.5) / fps);
10590
+ await grab(frameOf(v.currentTime, fps));
10591
+ await playThrough(v, 1, async (f) => {
10592
+ await grab(f);
10593
+ return proxyTodo().length > 0;
10594
+ }, 4000, true);
10595
+ };
10298
10596
  const cache = {
10299
10597
  src,
10300
10598
  get backend() {
@@ -10303,29 +10601,56 @@ function makeCore(doc, key, src, fps) {
10303
10601
  get size() {
10304
10602
  return entries.size;
10305
10603
  },
10306
- nearest(frame, within = Infinity) {
10604
+ get proxies() {
10605
+ return { count: proxies.size, stride, bytes: proxyBytes };
10606
+ },
10607
+ nearest(frame, within = Infinity, decode = false) {
10307
10608
  const k = nearestKey(entries.keys(), frame, within);
10609
+ const kd = nearestKey(decoded.keys(), frame, within);
10610
+ const dk = k === null ? Number.POSITIVE_INFINITY : Math.abs(k - frame);
10611
+ const dd = kd === null ? Number.POSITIVE_INFINITY : Math.abs(kd - frame);
10612
+ if (decode) {
10613
+ const p = proxyNear(frame);
10614
+ if (p !== null && Math.abs(p - frame) < Math.min(dk, dd))
10615
+ decodeProxy(p);
10616
+ const ahead = aheadOf(frame, lastAsked, total);
10617
+ if (ahead !== null)
10618
+ decodeProxy(proxyNear(ahead));
10619
+ lastAsked = frame;
10620
+ }
10621
+ if (kd !== null && (dd < dk || dd === dk && !entries.get(k)?.full)) {
10622
+ const d = decoded.get(kd);
10623
+ d.used = ++tick;
10624
+ return { frame: kd, bitmap: d.bitmap, full: false };
10625
+ }
10308
10626
  if (k === null)
10309
10627
  return null;
10310
10628
  const e = entries.get(k);
10311
10629
  e.used = ++tick;
10312
10630
  return { frame: k, bitmap: e.bitmap, full: e.full };
10313
10631
  },
10632
+ covers(frame) {
10633
+ if (entries.has(frame) || decoded.has(frame))
10634
+ return true;
10635
+ const p = proxyNear(frame);
10636
+ return p !== null && Math.abs(p - frame) <= stride / 2;
10637
+ },
10314
10638
  focus(frame) {
10315
- if (active !== core) {
10316
- active?.dropFull();
10317
- if (active)
10318
- active.active = false;
10319
- active = core;
10320
- core.active = true;
10321
- }
10639
+ claim(core);
10322
10640
  const moved = focusAt === null || Math.abs(frame - focusAt) > FOCUS_RADIUS;
10323
10641
  focusAt = frame;
10642
+ aim = null;
10324
10643
  if (moved)
10325
10644
  generation++;
10326
10645
  start();
10327
10646
  pump(win);
10328
10647
  },
10648
+ follow(frame) {
10649
+ claim(core);
10650
+ aim = frame;
10651
+ start();
10652
+ pump(win);
10653
+ },
10329
10654
  want(points) {
10330
10655
  wants = points;
10331
10656
  if (core.shown > 0)
@@ -10363,12 +10688,15 @@ function makeCore(doc, key, src, fps) {
10363
10688
  refs: 0,
10364
10689
  shown: 0,
10365
10690
  active: false,
10691
+ ran: 0,
10366
10692
  work() {
10367
10693
  if (disposed || backend === "pending")
10368
10694
  return null;
10369
- if (!missing().length)
10370
- return null;
10371
- return backend === "webcodecs" ? decodeUnit() : videoUnit();
10695
+ if (missing().length)
10696
+ return backend === "webcodecs" ? decodeUnit() : videoUnit();
10697
+ if (proxyTodo().length)
10698
+ return backend === "webcodecs" ? proxyUnit() : videoProxyUnit();
10699
+ return null;
10372
10700
  },
10373
10701
  dropFull() {
10374
10702
  for (const [f, e] of entries)
@@ -10377,8 +10705,13 @@ function makeCore(doc, key, src, fps) {
10377
10705
  entries.delete(f);
10378
10706
  }
10379
10707
  focusAt = null;
10708
+ aim = null;
10709
+ lastAsked = null;
10710
+ dropDecoded();
10380
10711
  },
10381
10712
  dispose() {
10713
+ if (disposed)
10714
+ return;
10382
10715
  disposed = true;
10383
10716
  generation++;
10384
10717
  if (notifyRaf)
@@ -10386,6 +10719,8 @@ function makeCore(doc, key, src, fps) {
10386
10719
  for (const e of entries.values())
10387
10720
  e.bitmap.close();
10388
10721
  entries.clear();
10722
+ dropDecoded();
10723
+ proxies.clear();
10389
10724
  listeners.clear();
10390
10725
  if (sampler) {
10391
10726
  sampler.removeAttribute("src");
@@ -10393,24 +10728,45 @@ function makeCore(doc, key, src, fps) {
10393
10728
  }
10394
10729
  buf = null;
10395
10730
  track = null;
10396
- if (active === core)
10397
- active = null;
10731
+ actives.delete(core);
10732
+ round2?.delete(core);
10398
10733
  cores.delete(key);
10734
+ dropEncoders();
10399
10735
  }
10400
10736
  };
10401
10737
  return core;
10402
10738
  }
10403
- var FOCUS_RADIUS = 6, GRID = 48, SEEN = 12, WANTED = 48, PREVIEW = 384, FULL = 1280, cores, active = null, running = false, idle = (win) => new Promise((done) => {
10739
+ 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) => {
10404
10740
  const ric = win.requestIdleCallback;
10405
10741
  if (ric)
10406
10742
  ric(() => done(), { timeout: 60 });
10407
10743
  else
10408
10744
  win.setTimeout(done, 4);
10409
- }), sleep = (win, ms) => new Promise((done) => win.setTimeout(done, ms));
10745
+ }), 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) => {
10746
+ const pool = encoders;
10747
+ if (!pool)
10748
+ return;
10749
+ for (const w of pool.workers) {
10750
+ w.onmessage = null;
10751
+ w.onerror = null;
10752
+ w.terminate();
10753
+ }
10754
+ for (const j of pool.waiting.values())
10755
+ j.fail(new Error(why));
10756
+ pool.waiting.clear();
10757
+ };
10410
10758
  var init_compare_frames = __esm(() => {
10411
10759
  init_compare_model();
10412
10760
  init_compare_mp4();
10761
+ PROXY = {
10762
+ size: 960,
10763
+ quality: 0.8,
10764
+ budget: 48 * 2 ** 20,
10765
+ decoded: 8,
10766
+ turn: 90
10767
+ };
10413
10768
  cores = new Map;
10769
+ actives = new Set;
10414
10770
  });
10415
10771
 
10416
10772
  // src/ui/compare-gpu.ts
@@ -10676,656 +11032,278 @@ var init_compare_gpu = __esm(() => {
10676
11032
  lostListeners = new Set;
10677
11033
  });
10678
11034
 
10679
- // src/ui/compare-strip.ts
10680
- function compareStrip(container, options) {
11035
+ // src/ui/compare.ts
11036
+ function compare(container, options) {
10681
11037
  const doc = container.ownerDocument;
10682
11038
  const win = doc.defaultView;
10683
- const timeline = options.timeline;
10684
- const fps = () => options.fps ?? timeline.fps;
10685
- const align = options.align ?? "track";
10686
- const threshold = options.threshold ?? 4 / 255;
10687
- const root = doc.createElement("div");
10688
- root.className = "knobs-cmp-strip";
10689
- root.setAttribute("role", "img");
10690
- root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
11039
+ const caching = scrubCaching(options.scrubCache, win);
11040
+ const look = {
11041
+ ...options.preset ? { preset: options.preset } : {},
11042
+ ...options.theme ? { theme: options.theme } : {},
11043
+ ...options.appearance ? { appearance: options.appearance } : {}
11044
+ };
11045
+ const offered = compareModes(options.modes, options.mode);
11046
+ const modeList = offered.modes;
11047
+ let mode = offered.mode;
11048
+ let wipe = Math.min(1, Math.max(0, options.wipe ?? 0.5));
11049
+ let loupeOn = options.loupe ?? false;
11050
+ let threshold = options.threshold ?? 4 / 255;
11051
+ let fpsOpt = options.fps;
11052
+ const bound = typeof options.timeline === "object" ? options.timeline : null;
11053
+ const fps = () => fpsOpt ?? bound?.fps ?? 30;
11054
+ const auto = container.clientHeight < 1;
11055
+ const el = (tag, cls) => {
11056
+ const n = doc.createElement(tag);
11057
+ n.className = cls;
11058
+ return n;
11059
+ };
11060
+ const root = el("div", "knobs-compare");
11061
+ root.tabIndex = 0;
11062
+ root.setAttribute("role", "group");
11063
+ root.setAttribute("aria-roledescription", "compare");
11064
+ root.setAttribute("aria-label", options.label ?? "Compare");
11065
+ root.setAttribute("aria-keyshortcuts", `Space ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight , . ${modeList.map((m, i) => `${i + 1} ${modeKey(m)}`).join(" ")} Z L 0 = -`);
10691
11066
  applyLook2(root, options);
10692
- const canvas = doc.createElement("canvas");
11067
+ root.dataset.mode = mode;
11068
+ if (auto)
11069
+ root.dataset.auto = "";
11070
+ const bar = el("div", "knobs-cmp-bar");
11071
+ const modes = el("div", "knobs-cmp-modes");
11072
+ modes.setAttribute("role", "radiogroup");
11073
+ modes.setAttribute("aria-label", "Mode");
11074
+ modes.hidden = modeList.length < 2;
11075
+ const modeBtns = modeList.map((m, i) => {
11076
+ const b = el("button", "knobs-cmp-mode");
11077
+ b.type = "button";
11078
+ b.tabIndex = -1;
11079
+ b.textContent = MODE_LABEL[m].short;
11080
+ b.title = `${MODE_LABEL[m].long} · ${i + 1} or ${modeKey(m)}`;
11081
+ b.setAttribute("role", "radio");
11082
+ b.setAttribute("aria-keyshortcuts", `${i + 1} ${modeKey(m)}`);
11083
+ b.addEventListener("click", () => {
11084
+ setMode(m);
11085
+ root.focus({ preventScroll: true });
11086
+ });
11087
+ modes.append(b);
11088
+ return b;
11089
+ });
11090
+ const toggleBtn = (name, label, title) => {
11091
+ const b = el("button", "knobs-cmp-toggle");
11092
+ b.type = "button";
11093
+ b.tabIndex = -1;
11094
+ b.innerHTML = icon(name);
11095
+ b.setAttribute("aria-label", label);
11096
+ b.title = title;
11097
+ return b;
11098
+ };
11099
+ const loupeBtn = toggleBtn("magnifyingGlass", "Loupe", "Loupe · hold Z");
11100
+ loupeBtn.addEventListener("click", () => {
11101
+ setLoupe(!loupeOn);
11102
+ root.focus({ preventScroll: true });
11103
+ });
11104
+ const loopBtn = toggleBtn("repeat", "Loop", "Loop · L (drag its ends on the timeline)");
11105
+ loopBtn.setAttribute("aria-keyshortcuts", "L");
11106
+ loopBtn.addEventListener("click", () => {
11107
+ api.setLoop(!loopNow());
11108
+ root.focus({ preventScroll: true });
11109
+ });
11110
+ bar.append(modes, el("span", "knobs-cmp-fill"), loopBtn, loupeBtn);
11111
+ const stage = el("div", "knobs-cmp-stage");
11112
+ const canvas = el("canvas", "knobs-cmp-canvas");
10693
11113
  canvas.setAttribute("aria-hidden", "true");
10694
- root.append(canvas);
10695
- container.append(root);
10696
- const diffs = new Map;
10697
- const pairDiffs = new Map;
10698
- let disposed = false;
10699
- let near = false;
10700
- let onScreen = false;
10701
- const makeSide = (source) => ({
10702
- source,
10703
- kind: mediaKind(source.src, source.kind),
10704
- frames: null,
10705
- still: null,
10706
- seen: false
11114
+ const split = el("div", "knobs-cmp-split");
11115
+ split.setAttribute("aria-hidden", "true");
11116
+ const zoomPill = el("div", "knobs-cmp-zoom");
11117
+ const zoomPct = el("span", "knobs-cmp-zoom-pct");
11118
+ const zoomReset = el("button", "knobs-cmp-zoom-reset");
11119
+ zoomReset.type = "button";
11120
+ zoomReset.tabIndex = -1;
11121
+ zoomReset.innerHTML = icon("arrowCounterClockwise", { small: true });
11122
+ zoomReset.setAttribute("aria-label", "Reset zoom");
11123
+ zoomReset.title = "Fit · 0 or double-click";
11124
+ zoomReset.addEventListener("click", () => {
11125
+ api.setZoom(null);
11126
+ root.focus({ preventScroll: true });
10707
11127
  });
10708
- let sides = [makeSide(options.before), makeSide(options.after)];
10709
- const offs = new Map;
10710
- const load = (s) => {
10711
- if (s.frames || s.still)
10712
- return;
10713
- if (s.kind === "video") {
10714
- s.frames = acquireFrames(doc, s.source.src, fps());
10715
- offs.set(s, s.frames.listen(schedule));
10716
- if (onScreen) {
10717
- s.frames.visible(true);
10718
- s.seen = true;
10719
- }
10720
- } else {
10721
- const img = doc.createElement("img");
10722
- img.crossOrigin = "anonymous";
10723
- img.decoding = "async";
10724
- img.addEventListener("load", () => {
10725
- pairDiffs.clear();
10726
- schedule();
10727
- });
10728
- img.src = s.source.src;
10729
- s.still = img;
11128
+ zoomPill.append(zoomPct, zoomReset);
11129
+ const labels = [el("div", "knobs-cmp-label"), el("div", "knobs-cmp-label")];
11130
+ labels[0].dataset.side = "before";
11131
+ labels[1].dataset.side = "after";
11132
+ const setLabel = (index, text) => {
11133
+ const l = labels[index];
11134
+ if (!l.firstElementChild) {
11135
+ const mark = el("span", "knobs-cmp-mark");
11136
+ mark.innerHTML = icon(index === 0 ? "x" : "check", { small: true });
11137
+ l.append(mark, el("span", "knobs-cmp-name"));
10730
11138
  }
11139
+ const name = l.lastElementChild;
11140
+ if (name.textContent !== text)
11141
+ name.textContent = text;
10731
11142
  };
10732
- const unload = (s) => {
10733
- offs.get(s)?.();
10734
- offs.delete(s);
10735
- if (s.frames) {
10736
- if (s.seen)
10737
- s.frames.visible(false);
10738
- s.frames.release();
10739
- }
10740
- s.frames = null;
10741
- s.seen = false;
10742
- if (s.still)
10743
- s.still.removeAttribute("src");
10744
- s.still = null;
11143
+ const seam = el("div", "knobs-cmp-seam");
11144
+ seam.setAttribute("aria-hidden", "true");
11145
+ const handle = el("div", "knobs-cmp-handle");
11146
+ handle.setAttribute("role", "slider");
11147
+ handle.setAttribute("aria-label", "Wipe");
11148
+ handle.setAttribute("aria-valuemin", "0");
11149
+ handle.setAttribute("aria-valuemax", "100");
11150
+ const grip = el("span", "knobs-cmp-grip");
11151
+ grip.setAttribute("aria-hidden", "true");
11152
+ handle.append(grip);
11153
+ const note = el("div", "knobs-cmp-note");
11154
+ stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
11155
+ root.append(bar, stage);
11156
+ let tl = bound;
11157
+ let ownTimeline = false;
11158
+ if (!tl && options.timeline === true) {
11159
+ const host = el("div", "knobs-cmp-timeline-host");
11160
+ root.append(host);
11161
+ tl = timeline(host, {
11162
+ ...look,
11163
+ variant: "line",
11164
+ duration: 1,
11165
+ fps: fps(),
11166
+ arrows: { step: 0.5, shift: 2 },
11167
+ label: "Compare time",
11168
+ ...options.hints === false ? { hints: false } : {}
11169
+ });
11170
+ ownTimeline = true;
11171
+ }
11172
+ container.append(root);
11173
+ const sides = [null, null];
11174
+ let duration = 0;
11175
+ let renderer = "dom";
11176
+ let pending = false;
11177
+ let gpu = null;
11178
+ const lay = new Spring(mode === "side" ? 0 : 1, LAYOUT.omega, LAYOUT.zeta, 0.0001);
11179
+ const fade = new Spring(1, FADE3.omega, FADE3.zeta, 0.001);
11180
+ let fromMode = mode;
11181
+ let toMode = mode;
11182
+ const wipeLine = new Spring(wipe, WIPE_CATCH.omega, WIPE_CATCH.zeta, 0.0001);
11183
+ let wipeCatching = false;
11184
+ const gripAt = new Spring(0.5, LAYOUT.omega, LAYOUT.zeta, 0.0001);
11185
+ let loupeHeld = false;
11186
+ let pointer = null;
11187
+ let drag = null;
11188
+ let tap = null;
11189
+ const touches = new Map;
11190
+ let pinch = null;
11191
+ const zu = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11192
+ const zv = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11193
+ const zw = new Spring(1, ZOOM.omega, ZOOM.zeta, 0.00001);
11194
+ let viewSent = "0.0000,0.0000,1.0000|";
11195
+ let ownLoop = null;
11196
+ let scrubbing = false;
11197
+ let stageW = 1;
11198
+ let stageH = 1;
11199
+ let stageRect = null;
11200
+ let accent = [0.49, 0.81, 0.61];
11201
+ let labelW = [0, 0];
11202
+ let frameRadius = 0;
11203
+ let dirty = true;
11204
+ let disposed = false;
11205
+ let near = options.lazy === false;
11206
+ let onScreen = options.lazy === false;
11207
+ let releaseTimer = 0;
11208
+ let ownTime = 0;
11209
+ let ownPlaying = false;
11210
+ const videoOf = (s) => s && s.kind === "video" && s.loaded ? s.el : null;
11211
+ const master = () => videoOf(sides[1]) ?? videoOf(sides[0]);
11212
+ const follower = () => {
11213
+ const m = master();
11214
+ const v = videoOf(sides[0]);
11215
+ return m && v && v !== m ? v : null;
10745
11216
  };
10746
- let w = 1;
10747
- let h = 1;
10748
- let geo = { x: 0, w: 1 };
10749
- const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
10750
- const aspectOf = (s) => {
10751
- if (s.still?.naturalWidth)
10752
- return s.still.naturalWidth / s.still.naturalHeight;
10753
- const c = s.frames?.nearest(0);
10754
- return c ? c.bitmap.width / c.bitmap.height : 0;
11217
+ const now = () => tl ? tl.time : ownTime;
11218
+ const isPlaying = () => tl ? tl.playing : ownPlaying;
11219
+ const labelOf = (index) => sides[index]?.source.label ?? (index === 0 ? options.before.label : options.after.label) ?? (index === 0 ? "Before" : "After");
11220
+ const sizeOf = (s) => {
11221
+ if (s.size.w > 0)
11222
+ return s.size;
11223
+ const other = sides[s.index === 0 ? 1 : 0];
11224
+ if (other && other.size.w > 0)
11225
+ return other.size;
11226
+ return { w: options.aspect ?? 16 / 9, h: 1 };
10755
11227
  };
10756
- const columns = () => stripColumns(timeline.view, geo.w, {
10757
- duration: timeline.duration,
10758
- fps: fps(),
10759
- minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
10760
- });
10761
- const measure = () => {
10762
- const r = root.getBoundingClientRect();
10763
- w = Math.max(1, r.width);
10764
- h = Math.max(1, r.height);
10765
- geo = { x: 0, w };
10766
- if (align === "track") {
10767
- const track = timeline.element.querySelector(".knobs-tl-track");
10768
- const t = track?.getBoundingClientRect();
10769
- if (t && t.width > 0)
10770
- geo = { x: t.left - r.left, w: t.width };
10771
- }
10772
- readColors();
10773
- schedule();
11228
+ const wantOf = (s) => {
11229
+ const v = s.el;
11230
+ const f = frameOf(now(), fps());
11231
+ return Number.isFinite(v.duration) ? Math.min(f, lastFrame(v.duration, fps())) : f;
10774
11232
  };
10775
- const ask = (cols) => {
10776
- const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
10777
- for (const s of sides)
10778
- s.frames?.want(points);
11233
+ const emitFrame = (s, frame, source) => root.dispatchEvent(new CustomEvent("knobs-compare-frame", { detail: { side: s.index, frame, source } }));
11234
+ const setCached = (s, on) => {
11235
+ if (renderer === "dom")
11236
+ s.preview.style.display = on ? "" : "none";
10779
11237
  };
10780
- const picOf = (s, c) => {
10781
- if (s.still?.naturalWidth)
10782
- return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
10783
- const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
10784
- return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
11238
+ const showVideo = (s) => {
11239
+ const v = s.el;
11240
+ if (renderer === "gpu") {
11241
+ if (!gpu)
11242
+ return;
11243
+ if (!gpu.upload(s.index, v, v.videoWidth, v.videoHeight))
11244
+ return miss(s);
11245
+ s.misses = 0;
11246
+ }
11247
+ const f = frameOf(v.currentTime, fps());
11248
+ s.shown = { frame: f, cached: false };
11249
+ setCached(s, false);
11250
+ emitFrame(s, f, "video");
11251
+ requestDraw();
10785
11252
  };
10786
- const probe = doc.createElement("canvas");
10787
- probe.width = DIFF.w;
10788
- probe.height = DIFF.h;
10789
- const probeCtx = probe.getContext("2d", { willReadFrequently: true });
10790
- const differ = (a, b) => {
10791
- if (!probeCtx)
11253
+ const showCached = (s, c) => {
11254
+ if (s.shown?.cached && s.shown.frame === c.frame)
10792
11255
  return;
10793
- try {
10794
- probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
10795
- const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
10796
- probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
10797
- const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
10798
- return meanAbsDiff(pa, pb);
10799
- } catch {
11256
+ if (renderer === "gpu") {
11257
+ if (!gpu || !gpu.upload(2 + s.index, c.bitmap, c.bitmap.width, c.bitmap.height))
11258
+ return;
11259
+ } else if (renderer === "dom" && !pending) {
11260
+ const p = s.preview;
11261
+ if (p.width !== c.bitmap.width || p.height !== c.bitmap.height) {
11262
+ p.width = c.bitmap.width;
11263
+ p.height = c.bitmap.height;
11264
+ }
11265
+ p.getContext("2d")?.drawImage(c.bitmap, 0, 0);
11266
+ } else
10800
11267
  return;
10801
- }
10802
- };
10803
- let colors = { surface: "", accent: "", diff: "", wash: "" };
10804
- const rgba = (raw, fallback) => {
10805
- const [r, g, b, a] = parseCssColor(raw, fallback);
10806
- return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
11268
+ s.shown = { frame: c.frame, cached: true };
11269
+ setCached(s, true);
11270
+ emitFrame(s, c.frame, "cache");
11271
+ requestDraw();
10807
11272
  };
10808
- const readColors = () => {
10809
- const cs = win.getComputedStyle(root);
10810
- colors = {
10811
- surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
10812
- accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
10813
- diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
10814
- wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
10815
- };
11273
+ const miss = (s) => {
11274
+ if (s.failed || ++s.misses < 3)
11275
+ return;
11276
+ gpu?.dispose();
11277
+ gpu = null;
11278
+ setRenderer("dom");
10816
11279
  };
10817
- let hover = null;
10818
- let raf = 0;
10819
- let asked = "";
10820
- function schedule() {
10821
- if (!raf && !disposed)
10822
- raf = win.requestAnimationFrame(draw);
10823
- }
10824
- const draw = () => {
10825
- raf = 0;
10826
- if (!near)
11280
+ const pushFrame = (s) => {
11281
+ if (!s.loaded || disposed)
10827
11282
  return;
10828
- const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
10829
- const cw = Math.round(w * dpr);
10830
- const ch = Math.round(h * dpr);
10831
- if (canvas.width !== cw || canvas.height !== ch) {
10832
- canvas.width = cw;
10833
- canvas.height = ch;
11283
+ if (s.kind === "image") {
11284
+ const img = s.el;
11285
+ if (!(img.complete && img.naturalWidth))
11286
+ return;
11287
+ if (renderer === "gpu" && gpu) {
11288
+ if (!gpu.upload(s.index, img, img.naturalWidth, img.naturalHeight))
11289
+ return miss(s);
11290
+ }
11291
+ s.shown = { frame: 0, cached: false };
11292
+ return requestDraw();
10834
11293
  }
10835
- const ctx = canvas.getContext("2d");
10836
- if (!ctx)
11294
+ const v = s.el;
11295
+ if (v.readyState < 2)
11296
+ return;
11297
+ if (isPlaying() || !s.frames)
11298
+ return showVideo(s);
11299
+ const want = wantOf(s);
11300
+ const at = frameOf(v.currentTime, fps());
11301
+ if (at === want) {
11302
+ showVideo(s);
11303
+ s.frames.keep(at, v, v.videoWidth, v.videoHeight);
10837
11304
  return;
10838
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
10839
- ctx.clearRect(0, 0, w, h);
10840
- const cols = columns();
10841
- const key = cols.map((c) => c.frame).join(",");
10842
- if (key !== asked) {
10843
- asked = key;
10844
- ask(cols);
10845
11305
  }
10846
- const rh = rowH();
10847
- const radius = 3;
10848
- let budget = DIFFS_PER_DRAW;
10849
- let more = false;
10850
- const ds = cols.map((c) => {
10851
- const a = picOf(sides[0], c);
10852
- const b = picOf(sides[1], c);
10853
- if (!a || !b)
10854
- return;
10855
- const k = `${a.key}|${b.key}`;
10856
- if (!pairDiffs.has(k)) {
10857
- if (budget-- <= 0) {
10858
- more = true;
10859
- return;
10860
- }
10861
- pairDiffs.set(k, differ(a, b));
10862
- }
10863
- const d = pairDiffs.get(k);
10864
- if (d !== undefined)
10865
- diffs.set(c.frame, d);
10866
- return d;
10867
- });
10868
- const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
10869
- const peaks = new Set(stripPeaks(ds, { threshold }));
10870
- cols.forEach((c, i) => {
10871
- const x = geo.x + c.x + GAP / 2;
10872
- const cwid = Math.max(1, c.w - GAP);
10873
- if (x + cwid < 0 || x > w)
10874
- return;
10875
- for (const row of [0, 1]) {
10876
- const y = row * (rh + GAP);
10877
- const pic = picOf(sides[row], c);
10878
- ctx.save();
10879
- ctx.beginPath();
10880
- ctx.roundRect(x, y, cwid, rh, radius);
10881
- ctx.fillStyle = colors.surface;
10882
- ctx.fill();
10883
- if (pic) {
10884
- ctx.clip();
10885
- const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
10886
- ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
10887
- }
10888
- ctx.restore();
10889
- }
10890
- if (hover === i) {
10891
- ctx.fillStyle = colors.wash;
10892
- ctx.beginPath();
10893
- ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
10894
- ctx.fill();
10895
- }
10896
- const d = ds[i];
10897
- if (d !== undefined && d > 0) {
10898
- const bh = Math.max(1, Math.min(1, d / max) * BAR);
10899
- ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
10900
- ctx.fillStyle = colors.diff;
10901
- ctx.beginPath();
10902
- ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
10903
- ctx.fill();
10904
- ctx.globalAlpha = 1;
10905
- }
10906
- });
10907
- const v = timeline.view;
10908
- const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
10909
- if (px >= -1 && px <= w + 1) {
10910
- ctx.fillStyle = colors.accent;
10911
- ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
10912
- }
10913
- if (more)
10914
- schedule();
10915
- };
10916
- const timeAt = (clientX) => {
10917
- const r = root.getBoundingClientRect();
10918
- const v = timeline.view;
10919
- const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
10920
- return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
10921
- };
10922
- const columnAt = (clientX) => {
10923
- const x = clientX - root.getBoundingClientRect().left - geo.x;
10924
- const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
10925
- return i < 0 ? null : i;
10926
- };
10927
- let dragging = false;
10928
- const pick = (clientX) => {
10929
- const t = timeAt(clientX);
10930
- timeline.seek(t);
10931
- options.onPick?.(t);
10932
- };
10933
- root.addEventListener("pointerdown", (e) => {
10934
- if (e.button !== 0)
10935
- return;
10936
- dragging = true;
10937
- root.setPointerCapture(e.pointerId);
10938
- const host = root.closest(".knobs-compare");
10939
- if (host)
10940
- host.focus({ preventScroll: true });
10941
- else
10942
- timeline.focus();
10943
- if (timeline.playing)
10944
- timeline.pause();
10945
- pick(e.clientX);
10946
- e.preventDefault();
10947
- });
10948
- root.addEventListener("pointermove", (e) => {
10949
- if (dragging)
10950
- pick(e.clientX);
10951
- const i = columnAt(e.clientX);
10952
- if (i !== hover) {
10953
- hover = i;
10954
- schedule();
10955
- }
10956
- });
10957
- const end = () => {
10958
- dragging = false;
10959
- };
10960
- root.addEventListener("pointerup", end);
10961
- root.addEventListener("pointercancel", end);
10962
- root.addEventListener("lostpointercapture", end);
10963
- root.addEventListener("pointerleave", () => {
10964
- if (hover === null)
10965
- return;
10966
- hover = null;
10967
- schedule();
10968
- });
10969
- const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
10970
- const ro = new win.ResizeObserver(measure);
10971
- ro.observe(root);
10972
- const track = timeline.element.querySelector(".knobs-tl-track");
10973
- if (track)
10974
- ro.observe(track);
10975
- root.addEventListener("knobs-restyle", measure);
10976
- const setOnScreen = (on) => {
10977
- onScreen = on;
10978
- for (const s of sides) {
10979
- if (!s.frames || s.seen === on)
10980
- continue;
10981
- s.seen = on;
10982
- s.frames.visible(on);
10983
- }
10984
- };
10985
- const setNear = (on) => {
10986
- near = on;
10987
- if (on) {
10988
- for (const s of sides)
10989
- load(s);
10990
- schedule();
10991
- } else
10992
- for (const s of sides)
10993
- unload(s);
10994
- };
10995
- const IO = win.IntersectionObserver;
10996
- const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
10997
- const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
10998
- measure();
10999
- if (nearIo && screenIo) {
11000
- nearIo.observe(root);
11001
- screenIo.observe(root);
11002
- } else {
11003
- setNear(true);
11004
- setOnScreen(true);
11005
- }
11006
- return {
11007
- element: root,
11008
- diffs,
11009
- update(next) {
11010
- const was = sides;
11011
- sides = [
11012
- next.before ? makeSide(next.before) : was[0],
11013
- next.after ? makeSide(next.after) : was[1]
11014
- ];
11015
- for (const s of was)
11016
- if (!sides.includes(s))
11017
- unload(s);
11018
- diffs.clear();
11019
- pairDiffs.clear();
11020
- asked = "";
11021
- if (near)
11022
- for (const s of sides)
11023
- load(s);
11024
- if (onScreen)
11025
- setOnScreen(true);
11026
- schedule();
11027
- },
11028
- dispose() {
11029
- disposed = true;
11030
- if (raf)
11031
- win.cancelAnimationFrame(raf);
11032
- unlisten();
11033
- ro.disconnect();
11034
- nearIo?.disconnect();
11035
- screenIo?.disconnect();
11036
- for (const s of sides)
11037
- unload(s);
11038
- root.remove();
11039
- }
11040
- };
11041
- }
11042
- var BAR = 7, GAP = 2, DIFF, DIFFS_PER_DRAW = 6;
11043
- var init_compare_strip = __esm(() => {
11044
- init_compare_frames();
11045
- init_compare_model();
11046
- init_look();
11047
- init_rgba();
11048
- init_timeline_model();
11049
- DIFF = { w: 32, h: 18 };
11050
- });
11051
-
11052
- // src/ui/compare.ts
11053
- function compare(container, options) {
11054
- const doc = container.ownerDocument;
11055
- const win = doc.defaultView;
11056
- const look = {
11057
- ...options.preset ? { preset: options.preset } : {},
11058
- ...options.theme ? { theme: options.theme } : {},
11059
- ...options.appearance ? { appearance: options.appearance } : {}
11060
- };
11061
- const offered = compareModes(options.modes, options.mode);
11062
- const modeList = offered.modes;
11063
- let mode = offered.mode;
11064
- let wipe = Math.min(1, Math.max(0, options.wipe ?? 0.5));
11065
- let loupeOn = options.loupe ?? false;
11066
- let threshold = options.threshold ?? 4 / 255;
11067
- let fpsOpt = options.fps;
11068
- const bound = typeof options.timeline === "object" ? options.timeline : null;
11069
- const fps = () => fpsOpt ?? bound?.fps ?? 30;
11070
- const auto = container.clientHeight < 1;
11071
- const el = (tag, cls) => {
11072
- const n = doc.createElement(tag);
11073
- n.className = cls;
11074
- return n;
11075
- };
11076
- const root = el("div", "knobs-compare");
11077
- root.tabIndex = 0;
11078
- root.setAttribute("role", "group");
11079
- root.setAttribute("aria-roledescription", "compare");
11080
- root.setAttribute("aria-label", options.label ?? "Compare");
11081
- root.setAttribute("aria-keyshortcuts", `Space ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight , . ${modeList.map((m, i) => `${i + 1} ${modeKey(m)}`).join(" ")} Z L 0 = -`);
11082
- applyLook2(root, options);
11083
- root.dataset.mode = mode;
11084
- if (auto)
11085
- root.dataset.auto = "";
11086
- const bar = el("div", "knobs-cmp-bar");
11087
- const modes = el("div", "knobs-cmp-modes");
11088
- modes.setAttribute("role", "radiogroup");
11089
- modes.setAttribute("aria-label", "Mode");
11090
- modes.hidden = modeList.length < 2;
11091
- const modeBtns = modeList.map((m, i) => {
11092
- const b = el("button", "knobs-cmp-mode");
11093
- b.type = "button";
11094
- b.tabIndex = -1;
11095
- b.textContent = MODE_LABEL[m].short;
11096
- b.title = `${MODE_LABEL[m].long} · ${i + 1} or ${modeKey(m)}`;
11097
- b.setAttribute("role", "radio");
11098
- b.setAttribute("aria-keyshortcuts", `${i + 1} ${modeKey(m)}`);
11099
- b.addEventListener("click", () => {
11100
- setMode(m);
11101
- root.focus({ preventScroll: true });
11102
- });
11103
- modes.append(b);
11104
- return b;
11105
- });
11106
- const toggleBtn = (name, label, title) => {
11107
- const b = el("button", "knobs-cmp-toggle");
11108
- b.type = "button";
11109
- b.tabIndex = -1;
11110
- b.innerHTML = icon(name);
11111
- b.setAttribute("aria-label", label);
11112
- b.title = title;
11113
- return b;
11114
- };
11115
- const loupeBtn = toggleBtn("magnifyingGlass", "Loupe", "Loupe · hold Z");
11116
- loupeBtn.addEventListener("click", () => {
11117
- setLoupe(!loupeOn);
11118
- root.focus({ preventScroll: true });
11119
- });
11120
- const loopBtn = toggleBtn("repeat", "Loop", "Loop · L (drag its ends on the timeline)");
11121
- loopBtn.setAttribute("aria-keyshortcuts", "L");
11122
- loopBtn.addEventListener("click", () => {
11123
- api.setLoop(!loopNow());
11124
- root.focus({ preventScroll: true });
11125
- });
11126
- bar.append(modes, el("span", "knobs-cmp-fill"), loopBtn, loupeBtn);
11127
- const stage = el("div", "knobs-cmp-stage");
11128
- const canvas = el("canvas", "knobs-cmp-canvas");
11129
- canvas.setAttribute("aria-hidden", "true");
11130
- const split = el("div", "knobs-cmp-split");
11131
- split.setAttribute("aria-hidden", "true");
11132
- const zoomPill = el("div", "knobs-cmp-zoom");
11133
- const zoomPct = el("span", "knobs-cmp-zoom-pct");
11134
- const zoomReset = el("button", "knobs-cmp-zoom-reset");
11135
- zoomReset.type = "button";
11136
- zoomReset.tabIndex = -1;
11137
- zoomReset.innerHTML = icon("arrowCounterClockwise", { small: true });
11138
- zoomReset.setAttribute("aria-label", "Reset zoom");
11139
- zoomReset.title = "Fit · 0 or double-click";
11140
- zoomReset.addEventListener("click", () => {
11141
- api.setZoom(null);
11142
- root.focus({ preventScroll: true });
11143
- });
11144
- zoomPill.append(zoomPct, zoomReset);
11145
- const labels = [el("div", "knobs-cmp-label"), el("div", "knobs-cmp-label")];
11146
- labels[0].dataset.side = "before";
11147
- labels[1].dataset.side = "after";
11148
- const setLabel = (index, text) => {
11149
- const l = labels[index];
11150
- if (!l.firstElementChild) {
11151
- const mark = el("span", "knobs-cmp-mark");
11152
- mark.innerHTML = icon(index === 0 ? "x" : "check", { small: true });
11153
- l.append(mark, el("span", "knobs-cmp-name"));
11154
- }
11155
- const name = l.lastElementChild;
11156
- if (name.textContent !== text)
11157
- name.textContent = text;
11158
- };
11159
- const seam = el("div", "knobs-cmp-seam");
11160
- seam.setAttribute("aria-hidden", "true");
11161
- const handle = el("div", "knobs-cmp-handle");
11162
- handle.setAttribute("role", "slider");
11163
- handle.setAttribute("aria-label", "Wipe");
11164
- handle.setAttribute("aria-valuemin", "0");
11165
- handle.setAttribute("aria-valuemax", "100");
11166
- const grip = el("span", "knobs-cmp-grip");
11167
- grip.setAttribute("aria-hidden", "true");
11168
- handle.append(grip);
11169
- const note = el("div", "knobs-cmp-note");
11170
- stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
11171
- root.append(bar, stage);
11172
- const wantsStrip = options.strip === true;
11173
- let tl = bound;
11174
- let ownTimeline = false;
11175
- let stripHost = null;
11176
- if (wantsStrip) {
11177
- stripHost = el("div", "knobs-cmp-strip-host");
11178
- root.append(stripHost);
11179
- }
11180
- if (!tl && (options.timeline === true || wantsStrip)) {
11181
- const host = el("div", "knobs-cmp-timeline-host");
11182
- root.append(host);
11183
- tl = timeline(host, {
11184
- ...look,
11185
- variant: "line",
11186
- duration: 1,
11187
- fps: fps(),
11188
- arrows: { step: 0.5, shift: 2 },
11189
- label: "Compare time",
11190
- ...options.hints === false ? { hints: false } : {}
11191
- });
11192
- ownTimeline = true;
11193
- }
11194
- container.append(root);
11195
- const sides = [null, null];
11196
- let duration = 0;
11197
- let renderer = "dom";
11198
- let pending = false;
11199
- let gpu = null;
11200
- const lay = new Spring(mode === "side" ? 0 : 1, LAYOUT.omega, LAYOUT.zeta, 0.0001);
11201
- const fade = new Spring(1, FADE3.omega, FADE3.zeta, 0.001);
11202
- let fromMode = mode;
11203
- let toMode = mode;
11204
- const wipeLine = new Spring(wipe, WIPE_CATCH.omega, WIPE_CATCH.zeta, 0.0001);
11205
- let wipeCatching = false;
11206
- const gripAt = new Spring(0.5, LAYOUT.omega, LAYOUT.zeta, 0.0001);
11207
- let loupeHeld = false;
11208
- let pointer = null;
11209
- let drag = null;
11210
- let tap = null;
11211
- const touches = new Map;
11212
- let pinch = null;
11213
- const zu = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11214
- const zv = new Spring(0, ZOOM.omega, ZOOM.zeta, 0.00001);
11215
- const zw = new Spring(1, ZOOM.omega, ZOOM.zeta, 0.00001);
11216
- let viewSent = "0.0000,0.0000,1.0000|";
11217
- let ownLoop = null;
11218
- let scrubbing = false;
11219
- let stageW = 1;
11220
- let stageH = 1;
11221
- let stageRect = null;
11222
- let accent = [0.49, 0.81, 0.61];
11223
- let labelW = [0, 0];
11224
- let frameRadius = 0;
11225
- let dirty = true;
11226
- let disposed = false;
11227
- let near = options.lazy === false;
11228
- let onScreen = options.lazy === false;
11229
- let releaseTimer = 0;
11230
- let ownTime = 0;
11231
- let ownPlaying = false;
11232
- const videoOf = (s) => s && s.kind === "video" && s.loaded ? s.el : null;
11233
- const master = () => videoOf(sides[1]) ?? videoOf(sides[0]);
11234
- const follower = () => {
11235
- const m = master();
11236
- const v = videoOf(sides[0]);
11237
- return m && v && v !== m ? v : null;
11238
- };
11239
- const now = () => tl ? tl.time : ownTime;
11240
- const isPlaying = () => tl ? tl.playing : ownPlaying;
11241
- const labelOf = (index) => sides[index]?.source.label ?? (index === 0 ? options.before.label : options.after.label) ?? (index === 0 ? "Before" : "After");
11242
- const sizeOf = (s) => {
11243
- if (s.size.w > 0)
11244
- return s.size;
11245
- const other = sides[s.index === 0 ? 1 : 0];
11246
- if (other && other.size.w > 0)
11247
- return other.size;
11248
- return { w: options.aspect ?? 16 / 9, h: 1 };
11249
- };
11250
- const wantOf = (s) => {
11251
- const v = s.el;
11252
- const f = frameOf(now(), fps());
11253
- return Number.isFinite(v.duration) ? Math.min(f, lastFrame(v.duration, fps())) : f;
11254
- };
11255
- const emitFrame = (s, frame, source) => root.dispatchEvent(new CustomEvent("knobs-compare-frame", { detail: { side: s.index, frame, source } }));
11256
- const setCached = (s, on) => {
11257
- if (renderer === "dom")
11258
- s.preview.style.display = on ? "" : "none";
11259
- };
11260
- const showVideo = (s) => {
11261
- const v = s.el;
11262
- if (renderer === "gpu") {
11263
- if (!gpu)
11264
- return;
11265
- if (!gpu.upload(s.index, v, v.videoWidth, v.videoHeight))
11266
- return miss(s);
11267
- s.misses = 0;
11268
- }
11269
- const f = frameOf(v.currentTime, fps());
11270
- s.shown = { frame: f, cached: false };
11271
- setCached(s, false);
11272
- emitFrame(s, f, "video");
11273
- requestDraw();
11274
- };
11275
- const showCached = (s, c) => {
11276
- if (s.shown?.cached && s.shown.frame === c.frame)
11277
- return;
11278
- if (renderer === "gpu") {
11279
- if (!gpu || !gpu.upload(2 + s.index, c.bitmap, c.bitmap.width, c.bitmap.height))
11280
- return;
11281
- } else if (renderer === "dom" && !pending) {
11282
- const p = s.preview;
11283
- if (p.width !== c.bitmap.width || p.height !== c.bitmap.height) {
11284
- p.width = c.bitmap.width;
11285
- p.height = c.bitmap.height;
11286
- }
11287
- p.getContext("2d")?.drawImage(c.bitmap, 0, 0);
11288
- } else
11289
- return;
11290
- s.shown = { frame: c.frame, cached: true };
11291
- setCached(s, true);
11292
- emitFrame(s, c.frame, "cache");
11293
- requestDraw();
11294
- };
11295
- const miss = (s) => {
11296
- if (s.failed || ++s.misses < 3)
11297
- return;
11298
- gpu?.dispose();
11299
- gpu = null;
11300
- setRenderer("dom");
11301
- };
11302
- const pushFrame = (s) => {
11303
- if (!s.loaded || disposed)
11304
- return;
11305
- if (s.kind === "image") {
11306
- const img = s.el;
11307
- if (!(img.complete && img.naturalWidth))
11308
- return;
11309
- if (renderer === "gpu" && gpu) {
11310
- if (!gpu.upload(s.index, img, img.naturalWidth, img.naturalHeight))
11311
- return miss(s);
11312
- }
11313
- s.shown = { frame: 0, cached: false };
11314
- return requestDraw();
11315
- }
11316
- const v = s.el;
11317
- if (v.readyState < 2)
11318
- return;
11319
- if (isPlaying() || !s.frames)
11320
- return showVideo(s);
11321
- const want = wantOf(s);
11322
- const at = frameOf(v.currentTime, fps());
11323
- if (at === want) {
11324
- showVideo(s);
11325
- s.frames.keep(at, v, v.videoWidth, v.videoHeight);
11326
- return;
11327
- }
11328
- const c = s.frames.nearest(want);
11306
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
11329
11307
  if (c && Math.abs(c.frame - want) < Math.abs(at - want))
11330
11308
  showCached(s, c);
11331
11309
  else
@@ -11337,13 +11315,23 @@ function compare(container, options) {
11337
11315
  const want = wantOf(s);
11338
11316
  if (s.shown && !s.shown.cached && s.shown.frame === want)
11339
11317
  return;
11340
- const c = s.frames.nearest(want);
11318
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
11341
11319
  if (!c)
11342
11320
  return;
11343
11321
  const was = s.shown ? Math.abs(s.shown.frame - want) : Number.POSITIVE_INFINITY;
11344
11322
  if (Math.abs(c.frame - want) < was)
11345
11323
  showCached(s, c);
11346
11324
  };
11325
+ const giveUp = (s) => {
11326
+ s.seek = SEEK_IDLE;
11327
+ if (!s.loaded || disposed)
11328
+ return;
11329
+ if (s.el.readyState >= 2)
11330
+ return showVideo(s);
11331
+ s.shown = null;
11332
+ setCached(s, false);
11333
+ requestDraw();
11334
+ };
11347
11335
  const makeSide = (index, source) => {
11348
11336
  const kind = mediaKind(source.src, source.kind);
11349
11337
  const media = el(kind === "video" ? "video" : "img", "knobs-cmp-media");
@@ -11375,7 +11363,9 @@ function compare(container, options) {
11375
11363
  frameAt: 0,
11376
11364
  misses: 0,
11377
11365
  failed: false,
11366
+ stall: stallGuard(win, () => giveUp(side)),
11378
11367
  stop: () => {
11368
+ side.stall.clear();
11379
11369
  for (const f of offs)
11380
11370
  f();
11381
11371
  }
@@ -11385,6 +11375,10 @@ function compare(container, options) {
11385
11375
  return;
11386
11376
  side.failed = true;
11387
11377
  note.textContent = `Couldn't load ${labelOf(index)}`;
11378
+ side.stall.clear();
11379
+ side.seek = SEEK_IDLE;
11380
+ side.shown = null;
11381
+ setCached(side, false);
11388
11382
  };
11389
11383
  if (media instanceof HTMLVideoElement) {
11390
11384
  const v = media;
@@ -11415,6 +11409,8 @@ function compare(container, options) {
11415
11409
  side.seek = step.state;
11416
11410
  if (step.apply !== null)
11417
11411
  seekVideo(side, v, step.apply);
11412
+ else
11413
+ side.stall.clear();
11418
11414
  pushFrame(side);
11419
11415
  });
11420
11416
  on("error", fail);
@@ -11447,7 +11443,7 @@ function compare(container, options) {
11447
11443
  s.loaded = true;
11448
11444
  s.failed = false;
11449
11445
  s.el.src = s.source.src;
11450
- if (s.kind === "video") {
11446
+ if (s.kind === "video" && caching) {
11451
11447
  s.frames = acquireFrames(doc, s.source.src, fps());
11452
11448
  s.unlisten = s.frames.listen(() => refreshCached(s));
11453
11449
  if (onScreen) {
@@ -11464,6 +11460,7 @@ function compare(container, options) {
11464
11460
  if (s.el instanceof HTMLVideoElement)
11465
11461
  s.el.load();
11466
11462
  s.seek = SEEK_IDLE;
11463
+ s.stall.clear();
11467
11464
  s.shown = null;
11468
11465
  setCached(s, false);
11469
11466
  s.unlisten?.();
@@ -11587,6 +11584,7 @@ function compare(container, options) {
11587
11584
  v.fastSeek(t);
11588
11585
  else
11589
11586
  v.currentTime = t;
11587
+ s.stall.arm();
11590
11588
  };
11591
11589
  const showFrame = (t, used) => {
11592
11590
  for (const s of sides) {
@@ -11598,14 +11596,21 @@ function compare(container, options) {
11598
11596
  v.playbackRate = 1;
11599
11597
  }
11600
11598
  const want = Math.min(frameOf(t, fps()), lastFrame(v.duration, fps()));
11601
- const step = requestSeek(s.seek, frameMidpoint(want, fps(), v.duration), v.currentTime, fps());
11602
- s.seek = step.state;
11603
- if (step.apply !== null)
11604
- seekVideo(s, v, step.apply);
11605
- else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
11606
- pushFrame(s);
11607
- if (used)
11608
- s.frames?.focus(want);
11599
+ if (used) {
11600
+ if (scrubbing)
11601
+ s.frames?.follow(want);
11602
+ else
11603
+ s.frames?.focus(want);
11604
+ }
11605
+ if (!(scrubbing && s.frames?.covers(want))) {
11606
+ const at = frameMidpoint(want, fps(), v.duration);
11607
+ const step = requestSeek(s.seek, at, v.currentTime, fps());
11608
+ s.seek = step.state;
11609
+ if (step.apply !== null)
11610
+ seekVideo(s, v, step.apply);
11611
+ else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
11612
+ pushFrame(s);
11613
+ }
11609
11614
  refreshCached(s);
11610
11615
  }
11611
11616
  requestDraw();
@@ -12236,7 +12241,7 @@ function compare(container, options) {
12236
12241
  holdLoupe(true);
12237
12242
  return;
12238
12243
  case "l":
12239
- if (e.altKey || e.shiftKey)
12244
+ if (e.altKey || e.shiftKey || loopBtn.hidden)
12240
12245
  return;
12241
12246
  claim();
12242
12247
  return api.setLoop(!loopNow());
@@ -12259,6 +12264,9 @@ function compare(container, options) {
12259
12264
  return glideZoom(ZOOM_FIT);
12260
12265
  }
12261
12266
  };
12267
+ const syncLoopButton = () => {
12268
+ loopBtn.hidden = !sides.some((s) => s.kind === "video");
12269
+ };
12262
12270
  const replace = (index, source) => {
12263
12271
  const old = sides[index];
12264
12272
  old.stop();
@@ -12267,6 +12275,7 @@ function compare(container, options) {
12267
12275
  old.el.replaceWith(s.el);
12268
12276
  old.preview.replaceWith(s.preview);
12269
12277
  sides[index] = s;
12278
+ syncLoopButton();
12270
12279
  note.textContent = "";
12271
12280
  setLabel(index, labelOf(index));
12272
12281
  if (near)
@@ -12281,6 +12290,7 @@ function compare(container, options) {
12281
12290
  stage.insertBefore(sides[0].preview, labels[0]);
12282
12291
  setLabel(0, labelOf(0));
12283
12292
  setLabel(1, labelOf(1));
12293
+ syncLoopButton();
12284
12294
  loupeBtn.setAttribute("aria-pressed", String(loupeOn));
12285
12295
  loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
12286
12296
  const modePill = modeList.length > 1 ? selectionPill(modes, {
@@ -12359,7 +12369,7 @@ function compare(container, options) {
12359
12369
  label: MODE_LABEL[m].long,
12360
12370
  activate: () => setMode(m)
12361
12371
  })) : [],
12362
- { el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) },
12372
+ ...loopBtn.hidden ? [] : [{ el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) }],
12363
12373
  ...loupeBtn.hidden ? [] : [{ el: loupeBtn, label: "Loupe", activate: () => setLoupe(!loupeOn) }],
12364
12374
  ...zoomed() ? [{ el: zoomReset, label: "Reset zoom", activate: () => api.setZoom(null) }] : [],
12365
12375
  ...mode === "wipe" ? [
@@ -12370,14 +12380,6 @@ function compare(container, options) {
12370
12380
  }
12371
12381
  ] : []
12372
12382
  ], { listen: false, rank: 1 });
12373
- const strip = wantsStrip && stripHost && tl ? compareStrip(stripHost, {
12374
- ...look,
12375
- before: options.before,
12376
- after: options.after,
12377
- timeline: tl,
12378
- ...fpsOpt ? { fps: fpsOpt } : {},
12379
- threshold
12380
- }) : null;
12381
12383
  const api = {
12382
12384
  element: root,
12383
12385
  get mode() {
@@ -12403,7 +12405,6 @@ function compare(container, options) {
12403
12405
  return renderer;
12404
12406
  },
12405
12407
  timeline: tl,
12406
- strip,
12407
12408
  get zoom() {
12408
12409
  return clampZoom(zoomNow());
12409
12410
  },
@@ -12425,8 +12426,6 @@ function compare(container, options) {
12425
12426
  replace(1, after);
12426
12427
  else if (next.after)
12427
12428
  setLabel(1, next.after.label ?? "After");
12428
- if (before || after)
12429
- strip?.update({ ...before ? { before } : {}, ...after ? { after } : {} });
12430
12429
  if (next.before || next.after)
12431
12430
  readColors();
12432
12431
  if (next.mode !== undefined)
@@ -12493,7 +12492,6 @@ function compare(container, options) {
12493
12492
  if (ownTimeline)
12494
12493
  tl.dispose();
12495
12494
  }
12496
- strip?.dispose();
12497
12495
  for (const s of sides) {
12498
12496
  s.stop();
12499
12497
  unloadSide(s);
@@ -12517,7 +12515,6 @@ var init_compare = __esm(() => {
12517
12515
  init_compare_frames();
12518
12516
  init_compare_gpu();
12519
12517
  init_compare_model();
12520
- init_compare_strip();
12521
12518
  init_icons();
12522
12519
  init_look();
12523
12520
  init_rgba();
@@ -12537,204 +12534,672 @@ var init_compare = __esm(() => {
12537
12534
  wipe: { short: "Wipe", long: "Wipe" },
12538
12535
  difference: { short: "Difference", long: "Difference" }
12539
12536
  };
12540
- });
12537
+ });
12538
+
12539
+ // src/ui/player.ts
12540
+ function player(container, options) {
12541
+ const doc = container.ownerDocument;
12542
+ const el = (tag, cls) => {
12543
+ const n = doc.createElement(tag);
12544
+ n.className = cls;
12545
+ return n;
12546
+ };
12547
+ const look = {
12548
+ ...options.preset ? { preset: options.preset } : {},
12549
+ ...options.theme ? { theme: options.theme } : {},
12550
+ ...options.appearance ? { appearance: options.appearance } : {}
12551
+ };
12552
+ const root = el("div", "knobs-player");
12553
+ root.setAttribute("role", "group");
12554
+ root.setAttribute("aria-label", options.label ?? "Video");
12555
+ applyLook2(root, options);
12556
+ const frame = el("div", "knobs-player-frame");
12557
+ frame.style.setProperty("--kp-aspect", String(options.aspect ?? 16 / 9));
12558
+ const v = el("video", "knobs-player-media");
12559
+ v.muted = options.muted ?? true;
12560
+ v.defaultMuted = v.muted;
12561
+ v.playsInline = true;
12562
+ v.preload = "metadata";
12563
+ v.setAttribute("playsinline", "");
12564
+ if (options.poster)
12565
+ v.poster = options.poster;
12566
+ if (options.captions) {
12567
+ const track = doc.createElement("track");
12568
+ track.kind = "captions";
12569
+ track.label = "Captions";
12570
+ track.srclang = doc.documentElement.lang || "en";
12571
+ track.default = true;
12572
+ track.src = options.captions;
12573
+ v.append(track);
12574
+ }
12575
+ const preview = el("canvas", "knobs-player-preview");
12576
+ preview.setAttribute("aria-hidden", "true");
12577
+ preview.hidden = true;
12578
+ frame.append(v, preview);
12579
+ const host = el("div", "knobs-player-line");
12580
+ root.append(frame, host);
12581
+ container.append(root);
12582
+ const markers = (options.chapters ?? []).map((c, i) => ({
12583
+ id: c.id ?? `c${i}`,
12584
+ time: c.start,
12585
+ label: c.label
12586
+ }));
12587
+ const tl = timeline(host, {
12588
+ ...look,
12589
+ variant: "line",
12590
+ duration: 1,
12591
+ fps: options.fps ?? 30,
12592
+ markers,
12593
+ repeat: options.loop ?? true,
12594
+ arrows: { step: 5, shift: 10 },
12595
+ label: options.label ? `${options.label} time` : "Video time",
12596
+ ...options.hints === false ? { hints: false } : {}
12597
+ });
12598
+ const onFrame = () => root.setAttribute("data-ready", "");
12599
+ let pending = null;
12600
+ const seekVideo = (t) => {
12601
+ if (v.readyState < 1)
12602
+ return;
12603
+ if (v.seeking)
12604
+ pending = t;
12605
+ else
12606
+ v.currentTime = t;
12607
+ };
12608
+ const fps = options.fps ?? 30;
12609
+ let frames = null;
12610
+ let unlistenFrames = null;
12611
+ let scrubbing = false;
12612
+ let previewing = null;
12613
+ const want = () => frameOf(tl.time, fps);
12614
+ const emitFrame = (frame, source) => root.dispatchEvent(new CustomEvent("knobs-player-frame", { detail: { frame, source } }));
12615
+ const drawPreview = (c) => {
12616
+ if (preview.width !== c.bitmap.width || preview.height !== c.bitmap.height) {
12617
+ preview.width = c.bitmap.width;
12618
+ preview.height = c.bitmap.height;
12619
+ }
12620
+ preview.getContext("2d")?.drawImage(c.bitmap, 0, 0);
12621
+ preview.hidden = false;
12622
+ previewing = c.frame;
12623
+ emitFrame(c.frame, "cache");
12624
+ };
12625
+ const hidePreview = () => {
12626
+ if (previewing === null)
12627
+ return;
12628
+ previewing = null;
12629
+ preview.hidden = true;
12630
+ };
12631
+ const showNearest = () => {
12632
+ if (!frames || !scrubbing)
12633
+ return;
12634
+ const w = want();
12635
+ const c = frames.nearest(w, Number.POSITIVE_INFINITY, true);
12636
+ if (!c || c.frame === previewing)
12637
+ return;
12638
+ const shown = previewing ?? frameOf(v.currentTime, fps);
12639
+ if (Math.abs(c.frame - w) < Math.abs(shown - w))
12640
+ drawPreview(c);
12641
+ };
12642
+ const caching = scrubCaching(options.scrubCache, doc.defaultView);
12643
+ const startFrames = () => {
12644
+ if (frames || !caching)
12645
+ return;
12646
+ frames = acquireFrames(doc, options.src, fps);
12647
+ frames.visible(true);
12648
+ unlistenFrames = frames.listen(showNearest);
12649
+ };
12650
+ const stall = stallGuard(doc.defaultView, () => {
12651
+ pending = null;
12652
+ hidePreview();
12653
+ });
12654
+ const onError = () => {
12655
+ stall.clear();
12656
+ pending = null;
12657
+ hidePreview();
12658
+ };
12659
+ let reported = 0;
12660
+ const onSeeked = () => {
12661
+ if (pending !== null) {
12662
+ const t = pending;
12663
+ pending = null;
12664
+ v.currentTime = t;
12665
+ return;
12666
+ }
12667
+ stall.clear();
12668
+ const at = frameOf(v.currentTime, fps);
12669
+ emitFrame(at, "video");
12670
+ if (!scrubbing || previewing !== null && Math.abs(at - want()) <= Math.abs(previewing - want()))
12671
+ hidePreview();
12672
+ if (!v.paused || tl.playing || Math.abs(v.currentTime - reported) < 0.1)
12673
+ return;
12674
+ reported = v.currentTime;
12675
+ options.onSeek?.(reported);
12676
+ };
12677
+ const onMeta = () => {
12678
+ onFrame();
12679
+ if (v.videoWidth)
12680
+ frame.style.setProperty("--kp-aspect", String(v.videoWidth / v.videoHeight));
12681
+ if (Number.isFinite(v.duration))
12682
+ tl.update({ duration: v.duration });
12683
+ };
12684
+ v.addEventListener("loadedmetadata", onMeta);
12685
+ v.addEventListener("loadeddata", onFrame);
12686
+ v.addEventListener("seeked", onSeeked);
12687
+ v.addEventListener("error", onError);
12688
+ const unlisten = tl.listen({
12689
+ onSeek(t, source) {
12690
+ if (source === "play") {
12691
+ if (Math.abs(v.currentTime - t) > 0.25)
12692
+ v.currentTime = t;
12693
+ return;
12694
+ }
12695
+ if (scrubbing && frames) {
12696
+ frames.follow(want());
12697
+ showNearest();
12698
+ if (frames.covers(want()))
12699
+ return;
12700
+ }
12701
+ if (sameFrame(v.currentTime, t, fps)) {
12702
+ if (!scrubbing)
12703
+ hidePreview();
12704
+ return;
12705
+ }
12706
+ seekVideo(t);
12707
+ },
12708
+ onPlay(on) {
12709
+ if (on) {
12710
+ hidePreview();
12711
+ v.play().catch(() => tl.pause());
12712
+ } else {
12713
+ v.pause();
12714
+ reported = tl.time;
12715
+ }
12716
+ root.toggleAttribute("data-playing", on);
12717
+ options.onPlay?.(on, tl.time);
12718
+ },
12719
+ onScrub(on) {
12720
+ scrubbing = on;
12721
+ if (on) {
12722
+ v.pause();
12723
+ startFrames();
12724
+ return;
12725
+ }
12726
+ if (v.readyState < 1)
12727
+ return hidePreview();
12728
+ const at = releaseSeek(v.currentTime, tl.time, fps, v.duration);
12729
+ if (at === null) {
12730
+ pending = null;
12731
+ if (!v.seeking)
12732
+ return hidePreview();
12733
+ } else
12734
+ seekVideo(at);
12735
+ stall.arm();
12736
+ }
12737
+ });
12738
+ tl.drive(() => {
12739
+ if (v.readyState < 1)
12740
+ return Number.NaN;
12741
+ if (v.seeking)
12742
+ return tl.time;
12743
+ return v.ended ? Number.POSITIVE_INFINITY : v.currentTime;
12744
+ });
12745
+ const onClick = () => {
12746
+ tl.focus();
12747
+ tl.toggle();
12748
+ };
12749
+ frame.addEventListener("click", onClick);
12750
+ root.addEventListener("pointerenter", startFrames);
12751
+ v.src = options.src;
12752
+ const api = {
12753
+ element: root,
12754
+ video: v,
12755
+ timeline: tl,
12756
+ get time() {
12757
+ return tl.time;
12758
+ },
12759
+ get playing() {
12760
+ return tl.playing;
12761
+ },
12762
+ get duration() {
12763
+ return Number.isFinite(v.duration) ? v.duration : 0;
12764
+ },
12765
+ play: () => tl.play(),
12766
+ pause: () => tl.pause(),
12767
+ toggle: () => tl.toggle(),
12768
+ seek: (t, o) => tl.seek(t, o?.glide ? { glide: true } : undefined),
12769
+ seekBy: (d) => tl.seek(Math.min(tl.duration, Math.max(0, tl.time + d)), { glide: true }),
12770
+ focus: () => tl.focus(),
12771
+ dispose() {
12772
+ unlisten();
12773
+ tl.drive(null);
12774
+ tl.dispose();
12775
+ v.pause();
12776
+ frame.removeEventListener("click", onClick);
12777
+ root.removeEventListener("pointerenter", startFrames);
12778
+ unlistenFrames?.();
12779
+ if (frames) {
12780
+ frames.visible(false);
12781
+ frames.release();
12782
+ frames = null;
12783
+ }
12784
+ v.removeEventListener("loadedmetadata", onMeta);
12785
+ v.removeEventListener("loadeddata", onFrame);
12786
+ v.removeEventListener("seeked", onSeeked);
12787
+ v.removeEventListener("error", onError);
12788
+ stall.clear();
12789
+ v.removeAttribute("src");
12790
+ v.load();
12791
+ root.remove();
12792
+ }
12793
+ };
12794
+ return api;
12795
+ }
12796
+ var init_player = __esm(() => {
12797
+ init_compare_frames();
12798
+ init_compare_model();
12799
+ init_look();
12800
+ init_timeline();
12801
+ });
12802
+
12803
+ // src/ui/react.tsx
12804
+ import {
12805
+ Children,
12806
+ useEffect,
12807
+ useLayoutEffect,
12808
+ useRef,
12809
+ useState
12810
+ } from "react";
12811
+ import { createPortal } from "react-dom";
12812
+ init_diff();
12813
+
12814
+ // src/ui/follow.ts
12815
+ function propChanges(initial, same = Object.is) {
12816
+ let last = { ...initial };
12817
+ return (next) => {
12818
+ const changed = {};
12819
+ for (const key of Object.keys(next))
12820
+ if (!same(next[key], last[key]))
12821
+ changed[key] = next[key];
12822
+ last = { ...next };
12823
+ return changed;
12824
+ };
12825
+ }
12826
+ var anyChanged = (changed) => Object.keys(changed).length > 0;
12541
12827
 
12542
- // src/ui/player.ts
12543
- function player(container, options) {
12828
+ // src/ui/react.tsx
12829
+ init_compare();
12830
+
12831
+ // src/ui/compare-strip.ts
12832
+ init_compare_frames();
12833
+ init_compare_model();
12834
+ init_look();
12835
+ init_rgba();
12836
+ init_timeline_model();
12837
+ var BAR = 7;
12838
+ var GAP = 2;
12839
+ var DIFF = { w: 32, h: 18 };
12840
+ var DIFFS_PER_DRAW = 6;
12841
+ function compareStrip(container, options) {
12544
12842
  const doc = container.ownerDocument;
12545
- const el = (tag, cls) => {
12546
- const n = doc.createElement(tag);
12547
- n.className = cls;
12548
- return n;
12549
- };
12550
- const look = {
12551
- ...options.preset ? { preset: options.preset } : {},
12552
- ...options.theme ? { theme: options.theme } : {},
12553
- ...options.appearance ? { appearance: options.appearance } : {}
12554
- };
12555
- const root = el("div", "knobs-player");
12556
- root.setAttribute("role", "group");
12557
- root.setAttribute("aria-label", options.label ?? "Video");
12843
+ const win = doc.defaultView;
12844
+ const timeline = options.timeline;
12845
+ const fps = () => options.fps ?? timeline.fps;
12846
+ const align = options.align ?? "track";
12847
+ const threshold = options.threshold ?? 4 / 255;
12848
+ const root = doc.createElement("div");
12849
+ root.className = "knobs-cmp-strip";
12850
+ root.setAttribute("role", "img");
12851
+ root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
12558
12852
  applyLook2(root, options);
12559
- const frame = el("div", "knobs-player-frame");
12560
- frame.style.setProperty("--kp-aspect", String(options.aspect ?? 16 / 9));
12561
- const v = el("video", "knobs-player-media");
12562
- v.muted = options.muted ?? true;
12563
- v.defaultMuted = v.muted;
12564
- v.playsInline = true;
12565
- v.preload = "metadata";
12566
- v.setAttribute("playsinline", "");
12567
- if (options.poster)
12568
- v.poster = options.poster;
12569
- if (options.captions) {
12570
- const track = doc.createElement("track");
12571
- track.kind = "captions";
12572
- track.label = "Captions";
12573
- track.srclang = doc.documentElement.lang || "en";
12574
- track.default = true;
12575
- track.src = options.captions;
12576
- v.append(track);
12577
- }
12578
- frame.append(v);
12579
- const host = el("div", "knobs-player-line");
12580
- root.append(frame, host);
12853
+ const canvas = doc.createElement("canvas");
12854
+ canvas.setAttribute("aria-hidden", "true");
12855
+ root.append(canvas);
12581
12856
  container.append(root);
12582
- const markers = (options.chapters ?? []).map((c, i) => ({
12583
- id: c.id ?? `c${i}`,
12584
- time: c.start,
12585
- label: c.label
12586
- }));
12587
- const tl = timeline(host, {
12588
- ...look,
12589
- variant: "line",
12590
- duration: 1,
12591
- fps: options.fps ?? 30,
12592
- markers,
12593
- repeat: options.loop ?? true,
12594
- arrows: { step: 5, shift: 10 },
12595
- label: options.label ? `${options.label} time` : "Video time",
12596
- ...options.hints === false ? { hints: false } : {}
12857
+ const diffs = new Map;
12858
+ const pairDiffs = new Map;
12859
+ let disposed = false;
12860
+ let near = false;
12861
+ let onScreen = false;
12862
+ const makeSide = (source) => ({
12863
+ source,
12864
+ kind: mediaKind(source.src, source.kind),
12865
+ frames: null,
12866
+ still: null,
12867
+ seen: false
12597
12868
  });
12598
- const onFrame = () => root.setAttribute("data-ready", "");
12599
- let pending = null;
12600
- const seekVideo = (t) => {
12601
- if (v.readyState < 1)
12869
+ let sides = [makeSide(options.before), makeSide(options.after)];
12870
+ const offs = new Map;
12871
+ const load = (s) => {
12872
+ if (s.frames || s.still)
12602
12873
  return;
12603
- if (v.seeking)
12604
- pending = t;
12605
- else
12606
- v.currentTime = t;
12874
+ if (s.kind === "video") {
12875
+ s.frames = acquireFrames(doc, s.source.src, fps());
12876
+ offs.set(s, s.frames.listen(schedule));
12877
+ if (onScreen) {
12878
+ s.frames.visible(true);
12879
+ s.seen = true;
12880
+ }
12881
+ } else {
12882
+ const img = doc.createElement("img");
12883
+ img.crossOrigin = "anonymous";
12884
+ img.decoding = "async";
12885
+ img.addEventListener("load", () => {
12886
+ pairDiffs.clear();
12887
+ schedule();
12888
+ });
12889
+ img.src = s.source.src;
12890
+ s.still = img;
12891
+ }
12607
12892
  };
12608
- let reported = 0;
12609
- const onSeeked = () => {
12610
- if (pending !== null) {
12611
- const t = pending;
12612
- pending = null;
12613
- v.currentTime = t;
12614
- return;
12893
+ const unload = (s) => {
12894
+ offs.get(s)?.();
12895
+ offs.delete(s);
12896
+ if (s.frames) {
12897
+ if (s.seen)
12898
+ s.frames.visible(false);
12899
+ s.frames.release();
12615
12900
  }
12616
- if (!v.paused || tl.playing || Math.abs(v.currentTime - reported) < 0.1)
12901
+ s.frames = null;
12902
+ s.seen = false;
12903
+ if (s.still)
12904
+ s.still.removeAttribute("src");
12905
+ s.still = null;
12906
+ };
12907
+ let w = 1;
12908
+ let h = 1;
12909
+ let geo = { x: 0, w: 1 };
12910
+ const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
12911
+ const aspectOf = (s) => {
12912
+ if (s.still?.naturalWidth)
12913
+ return s.still.naturalWidth / s.still.naturalHeight;
12914
+ const c = s.frames?.nearest(0);
12915
+ return c ? c.bitmap.width / c.bitmap.height : 0;
12916
+ };
12917
+ const columns = () => stripColumns(timeline.view, geo.w, {
12918
+ duration: timeline.duration,
12919
+ fps: fps(),
12920
+ minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
12921
+ });
12922
+ const measure = () => {
12923
+ const r = root.getBoundingClientRect();
12924
+ w = Math.max(1, r.width);
12925
+ h = Math.max(1, r.height);
12926
+ geo = { x: 0, w };
12927
+ if (align === "track") {
12928
+ const track = timeline.element.querySelector(".knobs-tl-track");
12929
+ const t = track?.getBoundingClientRect();
12930
+ if (t && t.width > 0)
12931
+ geo = { x: t.left - r.left, w: t.width };
12932
+ }
12933
+ readColors();
12934
+ schedule();
12935
+ };
12936
+ const ask = (cols) => {
12937
+ const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
12938
+ for (const s of sides)
12939
+ s.frames?.want(points);
12940
+ };
12941
+ const picOf = (s, c) => {
12942
+ if (s.still?.naturalWidth)
12943
+ return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
12944
+ const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
12945
+ return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
12946
+ };
12947
+ const probe = doc.createElement("canvas");
12948
+ probe.width = DIFF.w;
12949
+ probe.height = DIFF.h;
12950
+ const probeCtx = probe.getContext("2d", { willReadFrequently: true });
12951
+ const differ = (a, b) => {
12952
+ if (!probeCtx)
12617
12953
  return;
12618
- reported = v.currentTime;
12619
- options.onSeek?.(reported);
12954
+ try {
12955
+ probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
12956
+ const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
12957
+ probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
12958
+ const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
12959
+ return meanAbsDiff(pa, pb);
12960
+ } catch {
12961
+ return;
12962
+ }
12620
12963
  };
12621
- const onMeta = () => {
12622
- onFrame();
12623
- if (v.videoWidth)
12624
- frame.style.setProperty("--kp-aspect", String(v.videoWidth / v.videoHeight));
12625
- if (Number.isFinite(v.duration))
12626
- tl.update({ duration: v.duration });
12964
+ let colors = { surface: "", accent: "", diff: "", wash: "" };
12965
+ const rgba = (raw, fallback) => {
12966
+ const [r, g, b, a] = parseCssColor(raw, fallback);
12967
+ return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
12627
12968
  };
12628
- v.addEventListener("loadedmetadata", onMeta);
12629
- v.addEventListener("loadeddata", onFrame);
12630
- v.addEventListener("seeked", onSeeked);
12631
- const unlisten = tl.listen({
12632
- onSeek(t, source) {
12633
- if (source === "play") {
12634
- if (Math.abs(v.currentTime - t) > 0.25)
12635
- v.currentTime = t;
12969
+ const readColors = () => {
12970
+ const cs = win.getComputedStyle(root);
12971
+ colors = {
12972
+ surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
12973
+ accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
12974
+ diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
12975
+ wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
12976
+ };
12977
+ };
12978
+ let hover = null;
12979
+ let raf = 0;
12980
+ let asked = "";
12981
+ function schedule() {
12982
+ if (!raf && !disposed)
12983
+ raf = win.requestAnimationFrame(draw);
12984
+ }
12985
+ const draw = () => {
12986
+ raf = 0;
12987
+ if (!near)
12988
+ return;
12989
+ const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
12990
+ const cw = Math.round(w * dpr);
12991
+ const ch = Math.round(h * dpr);
12992
+ if (canvas.width !== cw || canvas.height !== ch) {
12993
+ canvas.width = cw;
12994
+ canvas.height = ch;
12995
+ }
12996
+ const ctx = canvas.getContext("2d");
12997
+ if (!ctx)
12998
+ return;
12999
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
13000
+ ctx.clearRect(0, 0, w, h);
13001
+ const cols = columns();
13002
+ const key = cols.map((c) => c.frame).join(",");
13003
+ if (key !== asked) {
13004
+ asked = key;
13005
+ ask(cols);
13006
+ }
13007
+ const rh = rowH();
13008
+ const radius = 3;
13009
+ let budget = DIFFS_PER_DRAW;
13010
+ let more = false;
13011
+ const ds = cols.map((c) => {
13012
+ const a = picOf(sides[0], c);
13013
+ const b = picOf(sides[1], c);
13014
+ if (!a || !b)
12636
13015
  return;
13016
+ const k = `${a.key}|${b.key}`;
13017
+ if (!pairDiffs.has(k)) {
13018
+ if (budget-- <= 0) {
13019
+ more = true;
13020
+ return;
13021
+ }
13022
+ pairDiffs.set(k, differ(a, b));
12637
13023
  }
12638
- if (Math.abs(v.currentTime - t) < 1 / (options.fps ?? 30))
13024
+ const d = pairDiffs.get(k);
13025
+ if (d !== undefined)
13026
+ diffs.set(c.frame, d);
13027
+ return d;
13028
+ });
13029
+ const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
13030
+ const peaks = new Set(stripPeaks(ds, { threshold }));
13031
+ cols.forEach((c, i) => {
13032
+ const x = geo.x + c.x + GAP / 2;
13033
+ const cwid = Math.max(1, c.w - GAP);
13034
+ if (x + cwid < 0 || x > w)
12639
13035
  return;
12640
- seekVideo(t);
12641
- },
12642
- onPlay(on) {
12643
- if (on)
12644
- v.play().catch(() => tl.pause());
12645
- else {
12646
- v.pause();
12647
- reported = tl.time;
13036
+ for (const row of [0, 1]) {
13037
+ const y = row * (rh + GAP);
13038
+ const pic = picOf(sides[row], c);
13039
+ ctx.save();
13040
+ ctx.beginPath();
13041
+ ctx.roundRect(x, y, cwid, rh, radius);
13042
+ ctx.fillStyle = colors.surface;
13043
+ ctx.fill();
13044
+ if (pic) {
13045
+ ctx.clip();
13046
+ const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
13047
+ ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
13048
+ }
13049
+ ctx.restore();
12648
13050
  }
12649
- root.toggleAttribute("data-playing", on);
12650
- options.onPlay?.(on, tl.time);
12651
- },
12652
- onScrub(on) {
12653
- if (on)
12654
- v.pause();
13051
+ if (hover === i) {
13052
+ ctx.fillStyle = colors.wash;
13053
+ ctx.beginPath();
13054
+ ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
13055
+ ctx.fill();
13056
+ }
13057
+ const d = ds[i];
13058
+ if (d !== undefined && d > 0) {
13059
+ const bh = Math.max(1, Math.min(1, d / max) * BAR);
13060
+ ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
13061
+ ctx.fillStyle = colors.diff;
13062
+ ctx.beginPath();
13063
+ ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
13064
+ ctx.fill();
13065
+ ctx.globalAlpha = 1;
13066
+ }
13067
+ });
13068
+ const v = timeline.view;
13069
+ const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
13070
+ if (px >= -1 && px <= w + 1) {
13071
+ ctx.fillStyle = colors.accent;
13072
+ ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
12655
13073
  }
13074
+ if (more)
13075
+ schedule();
13076
+ };
13077
+ const timeAt = (clientX) => {
13078
+ const r = root.getBoundingClientRect();
13079
+ const v = timeline.view;
13080
+ const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
13081
+ return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
13082
+ };
13083
+ const columnAt = (clientX) => {
13084
+ const x = clientX - root.getBoundingClientRect().left - geo.x;
13085
+ const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
13086
+ return i < 0 ? null : i;
13087
+ };
13088
+ let dragging = false;
13089
+ const pick = (clientX) => {
13090
+ const t = timeAt(clientX);
13091
+ timeline.seek(t);
13092
+ options.onPick?.(t);
13093
+ };
13094
+ root.addEventListener("pointerdown", (e) => {
13095
+ if (e.button !== 0)
13096
+ return;
13097
+ dragging = true;
13098
+ root.setPointerCapture(e.pointerId);
13099
+ const host = root.closest(".knobs-compare");
13100
+ if (host)
13101
+ host.focus({ preventScroll: true });
13102
+ else
13103
+ timeline.focus();
13104
+ if (timeline.playing)
13105
+ timeline.pause();
13106
+ pick(e.clientX);
13107
+ e.preventDefault();
12656
13108
  });
12657
- tl.drive(() => {
12658
- if (v.readyState < 1)
12659
- return Number.NaN;
12660
- if (v.seeking)
12661
- return tl.time;
12662
- return v.ended ? Number.POSITIVE_INFINITY : v.currentTime;
13109
+ root.addEventListener("pointermove", (e) => {
13110
+ if (dragging)
13111
+ pick(e.clientX);
13112
+ const i = columnAt(e.clientX);
13113
+ if (i !== hover) {
13114
+ hover = i;
13115
+ schedule();
13116
+ }
12663
13117
  });
12664
- const onClick = () => {
12665
- tl.focus();
12666
- tl.toggle();
13118
+ const end = () => {
13119
+ dragging = false;
12667
13120
  };
12668
- frame.addEventListener("click", onClick);
12669
- v.src = options.src;
12670
- const api = {
13121
+ root.addEventListener("pointerup", end);
13122
+ root.addEventListener("pointercancel", end);
13123
+ root.addEventListener("lostpointercapture", end);
13124
+ root.addEventListener("pointerleave", () => {
13125
+ if (hover === null)
13126
+ return;
13127
+ hover = null;
13128
+ schedule();
13129
+ });
13130
+ const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
13131
+ const ro = new win.ResizeObserver(measure);
13132
+ ro.observe(root);
13133
+ const track = timeline.element.querySelector(".knobs-tl-track");
13134
+ if (track)
13135
+ ro.observe(track);
13136
+ root.addEventListener("knobs-restyle", measure);
13137
+ const setOnScreen = (on) => {
13138
+ onScreen = on;
13139
+ for (const s of sides) {
13140
+ if (!s.frames || s.seen === on)
13141
+ continue;
13142
+ s.seen = on;
13143
+ s.frames.visible(on);
13144
+ }
13145
+ };
13146
+ const setNear = (on) => {
13147
+ near = on;
13148
+ if (on) {
13149
+ for (const s of sides)
13150
+ load(s);
13151
+ schedule();
13152
+ } else
13153
+ for (const s of sides)
13154
+ unload(s);
13155
+ };
13156
+ const IO = win.IntersectionObserver;
13157
+ const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
13158
+ const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
13159
+ measure();
13160
+ if (nearIo && screenIo) {
13161
+ nearIo.observe(root);
13162
+ screenIo.observe(root);
13163
+ } else {
13164
+ setNear(true);
13165
+ setOnScreen(true);
13166
+ }
13167
+ return {
12671
13168
  element: root,
12672
- video: v,
12673
- timeline: tl,
12674
- get time() {
12675
- return tl.time;
12676
- },
12677
- get playing() {
12678
- return tl.playing;
12679
- },
12680
- get duration() {
12681
- return Number.isFinite(v.duration) ? v.duration : 0;
13169
+ diffs,
13170
+ update(next) {
13171
+ const was = sides;
13172
+ sides = [
13173
+ next.before ? makeSide(next.before) : was[0],
13174
+ next.after ? makeSide(next.after) : was[1]
13175
+ ];
13176
+ for (const s of was)
13177
+ if (!sides.includes(s))
13178
+ unload(s);
13179
+ diffs.clear();
13180
+ pairDiffs.clear();
13181
+ asked = "";
13182
+ if (near)
13183
+ for (const s of sides)
13184
+ load(s);
13185
+ if (onScreen)
13186
+ setOnScreen(true);
13187
+ schedule();
12682
13188
  },
12683
- play: () => tl.play(),
12684
- pause: () => tl.pause(),
12685
- toggle: () => tl.toggle(),
12686
- seek: (t, o) => tl.seek(t, o?.glide ? { glide: true } : undefined),
12687
- seekBy: (d) => tl.seek(Math.min(tl.duration, Math.max(0, tl.time + d)), { glide: true }),
12688
- focus: () => tl.focus(),
12689
13189
  dispose() {
13190
+ disposed = true;
13191
+ if (raf)
13192
+ win.cancelAnimationFrame(raf);
12690
13193
  unlisten();
12691
- tl.drive(null);
12692
- tl.dispose();
12693
- v.pause();
12694
- frame.removeEventListener("click", onClick);
12695
- v.removeEventListener("loadedmetadata", onMeta);
12696
- v.removeEventListener("loadeddata", onFrame);
12697
- v.removeEventListener("seeked", onSeeked);
12698
- v.removeAttribute("src");
12699
- v.load();
13194
+ ro.disconnect();
13195
+ nearIo?.disconnect();
13196
+ screenIo?.disconnect();
13197
+ for (const s of sides)
13198
+ unload(s);
12700
13199
  root.remove();
12701
13200
  }
12702
13201
  };
12703
- return api;
12704
- }
12705
- var init_player = __esm(() => {
12706
- init_look();
12707
- init_timeline();
12708
- });
12709
-
12710
- // src/ui/react.tsx
12711
- import {
12712
- Children,
12713
- useEffect,
12714
- useLayoutEffect,
12715
- useRef,
12716
- useState
12717
- } from "react";
12718
- import { createPortal } from "react-dom";
12719
- init_diff();
12720
-
12721
- // src/ui/follow.ts
12722
- function propChanges(initial, same = Object.is) {
12723
- let last = { ...initial };
12724
- return (next) => {
12725
- const changed = {};
12726
- for (const key of Object.keys(next))
12727
- if (!same(next[key], last[key]))
12728
- changed[key] = next[key];
12729
- last = { ...next };
12730
- return changed;
12731
- };
12732
13202
  }
12733
- var anyChanged = (changed) => Object.keys(changed).length > 0;
12734
-
12735
- // src/ui/react.tsx
12736
- init_compare();
12737
- init_compare_strip();
12738
13203
 
12739
13204
  // src/ui/walkthrough.ts
12740
13205
  init_motion();
@@ -16029,9 +16494,8 @@ var walkthroughCss = `${foundationCss}
16029
16494
  .knobs-wt-slot .knobs-cmp-stage{border-radius:var(--kw-frame-r)}
16030
16495
  /* A compare is as tall as its two pictures side by side at this width, in every mode (Wipe fuses
16031
16496
  them at one side's size, so nothing grows or re-measures), plus its mode bar, the line and gaps
16032
- (92 px), and the frame strip with its gap (158 px). */
16497
+ (92 px). */
16033
16498
  .knobs-wt-fit{width:100%;height:calc(100cqw / 2 / var(--kw-aspect,1.7778) + var(--kw-chrome,92px))}
16034
- .knobs-wt-fit[data-strip]{--kw-chrome:158px}
16035
16499
  .knobs-wt-fit[data-settled]{transition:height var(--k-wt-calm-ms,${calm.duration}ms) ${calm.easing}}
16036
16500
  .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)}
16037
16501
  .knobs-wt-frame>:is(video,img){position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;margin:0;border:0}
@@ -16044,16 +16508,15 @@ var walkthroughCss = `${foundationCss}
16044
16508
  /* The diff inside it: its header row 28 high on the container's padding, a hairline under it, and
16045
16509
  no line of its own under the code (the container's ring closes it). */
16046
16510
  .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"]){--kd-head:28px;--kd-x:0px}
16047
- .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"])::after{display:none}
16048
- .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);
16511
+ .knobs-wt-slot[data-kind="diff"] :is(.knobs-ui.knobs-diff:not([data-on="pane"]),.knobs-diff-review)::after{display:none}
16512
+ .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);
16049
16513
  font:var(--_k-type-label)/var(--_k-leading-body) var(--_k-mono);white-space:pre;tab-size:2;scrollbar-width:thin}
16050
- .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)}
16514
+ .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)}
16051
16515
  .knobs-wt-code[data-terminal]{white-space:pre-wrap;overflow-wrap:anywhere}
16052
16516
  .knobs-wt-prompt{color:var(--_k-fg-3);user-select:none;-webkit-user-select:none}
16053
16517
  .knobs-wt-cmd{color:var(--_k-fg);font-weight:var(--_k-weight-label)}
16054
16518
  .knobs-wt-out{color:var(--_k-fg-2)}
16055
16519
  .knobs-wt-metric{display:flex;flex-direction:column;gap:var(--_k-space-sibling)}
16056
- .knobs-wt-mlabel{color:var(--_k-fg-2)}
16057
16520
  .knobs-wt-mvalue{display:flex;align-items:baseline;flex-wrap:wrap;gap:var(--_k-space-sibling);font-feature-settings:var(--_kr-data)}
16058
16521
  .knobs-wt-mfrom{color:var(--_k-fg-3)}
16059
16522
  .knobs-wt-mto{font-size:var(--_k-type-title);line-height:var(--_k-leading-title);font-weight:var(--_k-weight-label)}
@@ -16072,6 +16535,9 @@ var walkthroughCss = `${foundationCss}
16072
16535
  .knobs-wt-callout[data-tone="bad"]{--kw-tone:var(--_k-danger)}
16073
16536
  .knobs-wt-text{max-width:var(--kw-measure);color:var(--_k-fg-2)}
16074
16537
  .knobs-wt-hold{border-radius:var(--kw-frame-r);background:var(--_k-fill)}
16538
+ /* A diff holds its room with its own well's edge, no fill: the diff reads the colour behind it
16539
+ as it mounts, and a loading fill there would stay under its code. */
16540
+ .knobs-wt-slot[data-kind="diff"]>.knobs-wt-hold{background:transparent}
16075
16541
  .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)}
16076
16542
 
16077
16543
  /* Annotations: a calm ring round a box, a dot or an arrow at a point, a small label beside it;
@@ -16321,8 +16787,6 @@ var side = (s, fallback) => ({
16321
16787
  async function mountCompare(host, m, look, alive, onEvent) {
16322
16788
  const doc = host.ownerDocument;
16323
16789
  const fit = div(doc, "knobs-wt-fit");
16324
- if (m.strip)
16325
- fit.dataset.strip = "";
16326
16790
  const { before, after } = m;
16327
16791
  const known = parseAspect(m.aspect);
16328
16792
  fit.style.setProperty("--kw-aspect", String(known ?? DEFAULT_ASPECT));
@@ -16349,7 +16813,6 @@ async function mountCompare(host, m, look, alive, onEvent) {
16349
16813
  timeline: mediaKind(before.src) === "video" || mediaKind(after.src) === "video",
16350
16814
  lazy: false,
16351
16815
  ...aspect ? { aspect } : {},
16352
- ...m.strip ? { strip: true } : {},
16353
16816
  onMode: (next) => {
16354
16817
  onEvent?.({ what: "mode", detail: next });
16355
16818
  const layer = marks?.element;
@@ -16411,11 +16874,11 @@ async function mountCompare(host, m, look, alive, onEvent) {
16411
16874
  pills?.sync();
16412
16875
  marking?.kick();
16413
16876
  if (source !== "play")
16414
- onEvent?.({ what: "seek", time: round2(time) });
16877
+ onEvent?.({ what: "seek", time: round3(time) });
16415
16878
  },
16416
16879
  onPlay: (on) => {
16417
16880
  marking?.kick();
16418
- onEvent?.({ what: on ? "play" : "pause", time: round2(c.time) });
16881
+ onEvent?.({ what: on ? "play" : "pause", time: round3(c.time) });
16419
16882
  }
16420
16883
  });
16421
16884
  requestAnimationFrame(() => requestAnimationFrame(() => fit.dataset.settled = ""));
@@ -16433,13 +16896,13 @@ async function mountCompare(host, m, look, alive, onEvent) {
16433
16896
  const zoom = zoomedIn(c.zoom) ? c.zoom : null;
16434
16897
  return {
16435
16898
  kind: "compare",
16436
- time: round2(c.time),
16437
- duration: round2(c.duration),
16899
+ time: round3(c.time),
16900
+ duration: round3(c.duration),
16438
16901
  playing: c.playing,
16439
16902
  mode: c.mode,
16440
16903
  ...pills?.current ? { chapter: pills.current.label } : {},
16441
- ...loop ? { loop: { start: round2(loop.start), end: round2(loop.end) } } : {},
16442
- ...zoom ? { zoom: { u: round2(zoom.u), v: round2(zoom.v), w: round2(zoom.w), h: round2(zoom.h) } } : {}
16904
+ ...loop ? { loop: { start: round3(loop.start), end: round3(loop.end) } } : {},
16905
+ ...zoom ? { zoom: { u: round3(zoom.u), v: round3(zoom.v), w: round3(zoom.w), h: round3(zoom.h) } } : {}
16443
16906
  };
16444
16907
  },
16445
16908
  dispose: () => {
@@ -16452,7 +16915,7 @@ async function mountCompare(host, m, look, alive, onEvent) {
16452
16915
  }
16453
16916
  };
16454
16917
  }
16455
- var round2 = (v) => Math.round(v * 100) / 100;
16918
+ var round3 = (v) => Math.round(v * 100) / 100;
16456
16919
  var MARK_GLIDE = 450;
16457
16920
  var zoomedIn = (z) => z.w < 0.999 || z.h < 0.999;
16458
16921
  async function mountVideo(host, m, look, alive, onEvent) {
@@ -16479,12 +16942,12 @@ async function mountVideo(host, m, look, alive, onEvent) {
16479
16942
  } : {},
16480
16943
  onPlay: (on, time) => {
16481
16944
  marking?.kick();
16482
- onEvent?.({ what: on ? "play" : "pause", time: round2(time) });
16945
+ onEvent?.({ what: on ? "play" : "pause", time: round3(time) });
16483
16946
  },
16484
16947
  onSeek: (time) => {
16485
16948
  pills?.sync();
16486
16949
  marking?.kick();
16487
- onEvent?.({ what: "seek", time: round2(time) });
16950
+ onEvent?.({ what: "seek", time: round3(time) });
16488
16951
  }
16489
16952
  });
16490
16953
  const v = p.video;
@@ -16517,8 +16980,8 @@ async function mountVideo(host, m, look, alive, onEvent) {
16517
16980
  seekBy: (d) => p.seekBy(d),
16518
16981
  context: () => ({
16519
16982
  kind: "video",
16520
- time: round2(p.time),
16521
- ...p.duration ? { duration: round2(p.duration) } : {},
16983
+ time: round3(p.time),
16984
+ ...p.duration ? { duration: round3(p.duration) } : {},
16522
16985
  playing: p.playing,
16523
16986
  ...pills?.current ? { chapter: pills.current.label } : {}
16524
16987
  }),
@@ -16662,8 +17125,6 @@ var fmt = (v, unit = "") => `${v}${unit}`;
16662
17125
  function mountMetric(host, m) {
16663
17126
  const doc = host.ownerDocument;
16664
17127
  const box = div(doc, "knobs-wt-metric");
16665
- const label = div(doc, "knobs-wt-mlabel");
16666
- label.textContent = m.label;
16667
17128
  const value = div(doc, "knobs-wt-mvalue");
16668
17129
  const span = (cls, text) => {
16669
17130
  const s = doc.createElement("span");
@@ -16683,7 +17144,7 @@ function mountMetric(host, m) {
16683
17144
  delta.dataset.tone = d > 0 === (better === "up") ? "good" : "bad";
16684
17145
  value.append(delta);
16685
17146
  }
16686
- box.append(label, value);
17147
+ box.append(value);
16687
17148
  const total = num(m.of);
16688
17149
  if (Number.isFinite(total) && total > 0 && Number.isFinite(a) && Number.isFinite(b)) {
16689
17150
  const track = div(doc, "knobs-wt-mtrack");
@@ -16737,7 +17198,7 @@ function mountDecision(host, m, slot) {
16737
17198
  }
16738
17199
  var HEAVY = new Set(["compare", "video", "diff", "mount"]);
16739
17200
  var RELEASES = new Set(["compare", "video"]);
16740
- var labelOf2 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "metric" ? m.label : undefined;
17201
+ var labelOf2 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "history" ? m.label : undefined;
16741
17202
  function mediaSlot(container, given) {
16742
17203
  const doc = container.ownerDocument;
16743
17204
  injectWalkthroughStyles(doc);
@@ -16751,7 +17212,7 @@ function mediaSlot(container, given) {
16751
17212
  root.dataset.kind = m.kind;
16752
17213
  applyLook2(root, options);
16753
17214
  const label = labelOf2(m);
16754
- if (label && (m.kind === "video" || m.kind === "image" || m.kind === "code" || m.kind === "terminal" || m.kind === "facts")) {
17215
+ if (label) {
16755
17216
  const l = doc.createElement("span");
16756
17217
  l.className = "knobs-wt-slot-label";
16757
17218
  l.textContent = label;
@@ -18449,7 +18910,7 @@ function Compare(props) {
18449
18910
  c.dispose();
18450
18911
  handle.current = null;
18451
18912
  };
18452
- }, [props.timeline, props.strip, props.renderer]);
18913
+ }, [props.timeline, props.renderer, props.scrubCache]);
18453
18914
  useFollow({
18454
18915
  before: props.before,
18455
18916
  after: props.after,