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