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