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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/review.js CHANGED
@@ -629,6 +629,14 @@ var init_schema = __esm(() => {
629
629
  doc: "Panel and card radius, concentric with controls.",
630
630
  deprecated: ["--k-panel-radius", "--ku-panel-radius"]
631
631
  },
632
+ {
633
+ name: "radius-frame",
634
+ tier: "semantic",
635
+ type: "length",
636
+ group: "Shape",
637
+ source: css("calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))"),
638
+ 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."
639
+ },
632
640
  {
633
641
  name: "round",
634
642
  tier: "semantic",
@@ -2253,7 +2261,6 @@ var compareCss = `
2253
2261
  :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)}
2254
2262
  .knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--_k-fg-3);pointer-events:none}
2255
2263
  .knobs-cmp-note:empty{display:none}
2256
- .knobs-cmp-strip-host{flex:none;height:60px;min-height:0}
2257
2264
  .knobs-cmp-timeline-host{flex:none;min-height:0}
2258
2265
  /* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */
2259
2266
  .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;
@@ -2273,6 +2280,9 @@ var playerCss = `
2273
2280
  .knobs-player-media{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:contain;
2274
2281
  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)}
2275
2282
  .knobs-player[data-ready] .knobs-player-media{opacity:1;filter:none}
2283
+ /* Mid-scrub, the cached frame under the hand, over the video until it lands. */
2284
+ .knobs-player-preview{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}
2285
+ .knobs-player-preview[hidden]{display:none}
2276
2286
  .knobs-player-line{flex:none;min-height:0}
2277
2287
  /* The timeline's own stylesheet is look.ts's; what the player's row and every timeline share
2278
2288
  about the hand is set here, after it. No hand cursor anywhere (the arrow, or a resize where it
@@ -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(",");
@@ -3343,12 +3362,24 @@ function createRegistry(doc) {
3343
3362
  return;
3344
3363
  if (e.metaKey || e.ctrlKey || e.altKey || e.key !== PAGE_HINT_KEY)
3345
3364
  return;
3346
- if (isTyping(e.target) || !wide())
3365
+ if (isTyping(e.target) || !wide(e.target))
3347
3366
  return;
3348
3367
  if (registry.start())
3349
3368
  e.preventDefault();
3350
3369
  };
3351
- const wide = () => listening && (sources.length > 1 || [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel")));
3370
+ const hostHints = () => [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel"));
3371
+ const wide = (target) => {
3372
+ if (!listening)
3373
+ return false;
3374
+ if (sources.some((s) => s.listen && s.rank >= 2) || hostHints())
3375
+ return true;
3376
+ if (sources.length < 2)
3377
+ return false;
3378
+ if (target === undefined)
3379
+ return true;
3380
+ const piece = target?.closest?.(".knobs-ui,.knobs-pane-body");
3381
+ return Boolean(piece?.classList.contains("knobs-ui"));
3382
+ };
3352
3383
  const relisten = () => {
3353
3384
  const next = sources.some((s) => s.listen);
3354
3385
  if (next === listening)
@@ -4227,6 +4258,31 @@ function seeked(s, current, fps) {
4227
4258
  return { state: { seeking: true, pending: null }, apply: s.pending, settled: false };
4228
4259
  return { state: SEEK_IDLE, apply: null, settled: true };
4229
4260
  }
4261
+ function releaseSeek(current, t, fps, duration) {
4262
+ const at = seekTime(t, fps, duration);
4263
+ return sameFrame(current, at, fps) ? null : at;
4264
+ }
4265
+ function stallGuard(timers, onStall, ms = SEEK_STALL_MS) {
4266
+ let id = 0;
4267
+ const clear = () => {
4268
+ if (id)
4269
+ timers.clearTimeout(id);
4270
+ id = 0;
4271
+ };
4272
+ return {
4273
+ arm() {
4274
+ clear();
4275
+ id = timers.setTimeout(() => {
4276
+ id = 0;
4277
+ onStall();
4278
+ }, ms);
4279
+ },
4280
+ clear,
4281
+ get armed() {
4282
+ return id !== 0;
4283
+ }
4284
+ };
4285
+ }
4230
4286
  function letterbox(media, box) {
4231
4287
  if (media.w <= 0 || media.h <= 0 || box.w <= 0 || box.h <= 0)
4232
4288
  return box;
@@ -4347,7 +4403,7 @@ function stripPeaks(diffs, o = {}) {
4347
4403
  });
4348
4404
  return out;
4349
4405
  }
4350
- var ALL_MODES, MODE_KEYS, modeKey = (mode) => MODE_KEYS[mode].toUpperCase(), VIDEO2, 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) => {
4406
+ var ALL_MODES, MODE_KEYS, modeKey = (mode) => MODE_KEYS[mode].toUpperCase(), VIDEO2, 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) => {
4351
4407
  const inner = r * Math.min(1, Math.max(0, k));
4352
4408
  return { before: { left: r, right: inner }, after: { left: inner, right: r } };
4353
4409
  }, labelFold = (fold, mark, blur = 3) => {
@@ -4594,6 +4650,25 @@ var init_compare_mp4 = __esm(() => {
4594
4650
  });
4595
4651
 
4596
4652
  // src/ui/compare-frames.ts
4653
+ function scrubCaching(option, win) {
4654
+ if (option === false)
4655
+ return false;
4656
+ if (option === true)
4657
+ return true;
4658
+ const net = win?.navigator?.connection;
4659
+ if (net?.saveData)
4660
+ return false;
4661
+ return !win?.matchMedia?.("(prefers-reduced-data: reduce)").matches;
4662
+ }
4663
+ function proxyStride(bytes, count, total, budget) {
4664
+ if (count <= 0 || total <= 0)
4665
+ return 1;
4666
+ const each = bytes / count;
4667
+ let stride = 1;
4668
+ while (total / stride * each > budget && stride < total)
4669
+ stride *= 2;
4670
+ return stride;
4671
+ }
4597
4672
  function gridPoints(total, count = GRID) {
4598
4673
  if (total <= 0)
4599
4674
  return [];
@@ -4624,6 +4699,24 @@ function nearestKey(keys, frame, within = Infinity) {
4624
4699
  }
4625
4700
  return best !== null && bestD <= within ? best : null;
4626
4701
  }
4702
+ function claim(core) {
4703
+ if (!round) {
4704
+ const r = new Set;
4705
+ round = r;
4706
+ queueMicrotask(() => {
4707
+ for (const c of actives)
4708
+ if (!r.has(c)) {
4709
+ c.active = false;
4710
+ c.dropFull();
4711
+ }
4712
+ actives = r;
4713
+ round = null;
4714
+ });
4715
+ }
4716
+ round.add(core);
4717
+ actives.add(core);
4718
+ core.active = true;
4719
+ }
4627
4720
  function pump(win) {
4628
4721
  if (running)
4629
4722
  return;
@@ -4631,12 +4724,14 @@ function pump(win) {
4631
4724
  (async () => {
4632
4725
  try {
4633
4726
  for (;; ) {
4634
- const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active));
4727
+ const order = [...cores.values()].filter((c) => c.shown > 0 || c.active).sort((a, b) => Number(b.active) - Number(a.active) || a.ran - b.ran);
4635
4728
  let unit = null;
4636
4729
  for (const c of order) {
4637
4730
  unit = c.work();
4638
- if (unit)
4731
+ if (unit) {
4732
+ c.ran = ++turns;
4639
4733
  break;
4734
+ }
4640
4735
  }
4641
4736
  if (!unit)
4642
4737
  break;
@@ -4666,12 +4761,99 @@ async function toBitmap(doc, source, w, h, cap) {
4666
4761
  return createImageBitmap(c);
4667
4762
  }
4668
4763
  }
4764
+ function holdEncoders() {
4765
+ encoderUsers++;
4766
+ }
4767
+ function dropEncoders() {
4768
+ encoderUsers = Math.max(0, encoderUsers - 1);
4769
+ if (encoderUsers > 0)
4770
+ return;
4771
+ endEncoders("released");
4772
+ encoders = undefined;
4773
+ }
4774
+ function encodersOf(host) {
4775
+ if (encoders !== undefined)
4776
+ return encoders;
4777
+ encoders = null;
4778
+ const W = host.Worker;
4779
+ if (!W || !host.OffscreenCanvas)
4780
+ return null;
4781
+ const workers = [];
4782
+ let url = null;
4783
+ try {
4784
+ url = host.URL.createObjectURL(new host.Blob([ENCODER], { type: "text/javascript" }));
4785
+ for (let i = 0;i < ENCODERS; i++)
4786
+ workers.push(new W(url));
4787
+ } catch {
4788
+ for (const w of workers)
4789
+ w.terminate();
4790
+ return null;
4791
+ } finally {
4792
+ if (url)
4793
+ host.URL.revokeObjectURL(url);
4794
+ }
4795
+ const pool = { workers, waiting: new Map, next: 0 };
4796
+ for (const w of workers) {
4797
+ w.onmessage = (e) => {
4798
+ const job = pool.waiting.get(e.data.id);
4799
+ pool.waiting.delete(e.data.id);
4800
+ if (e.data.blob)
4801
+ job?.done(e.data.blob);
4802
+ else
4803
+ job?.fail(new Error(e.data.error));
4804
+ };
4805
+ w.onerror = () => {
4806
+ if (encoders !== pool)
4807
+ return;
4808
+ endEncoders("encoder");
4809
+ encoders = null;
4810
+ };
4811
+ }
4812
+ encoders = pool;
4813
+ return pool;
4814
+ }
4815
+ function encodeOn(pool, bitmap, quality) {
4816
+ const id = pool.next++;
4817
+ const worker = pool.workers[id % pool.workers.length];
4818
+ return new Promise((done, fail) => {
4819
+ pool.waiting.set(id, { done, fail });
4820
+ try {
4821
+ worker.postMessage({ id, bitmap, quality }, [bitmap]);
4822
+ } catch (err) {
4823
+ pool.waiting.delete(id);
4824
+ bitmap.close();
4825
+ fail(err);
4826
+ }
4827
+ });
4828
+ }
4829
+ async function toProxy(doc, source, w, h) {
4830
+ const bm = await toBitmap(doc, source, w, h, PROXY.size);
4831
+ const pool = encodersOf(doc.defaultView);
4832
+ if (pool)
4833
+ return encodeOn(pool, bm, PROXY.quality);
4834
+ try {
4835
+ const Off = doc.defaultView?.OffscreenCanvas;
4836
+ if (Off) {
4837
+ const c = new Off(bm.width, bm.height);
4838
+ c.getContext("2d").drawImage(bm, 0, 0);
4839
+ return await c.convertToBlob({ type: "image/jpeg", quality: PROXY.quality });
4840
+ }
4841
+ const c = doc.createElement("canvas");
4842
+ c.width = bm.width;
4843
+ c.height = bm.height;
4844
+ c.getContext("2d").drawImage(bm, 0, 0);
4845
+ return await new Promise((done, fail) => c.toBlob((b) => b ? done(b) : fail(new Error("toBlob")), "image/jpeg", PROXY.quality));
4846
+ } finally {
4847
+ bm.close();
4848
+ }
4849
+ }
4669
4850
  function acquireFrames(doc, src, fps) {
4670
4851
  const key = `${fps}|${src}`;
4671
4852
  let core = cores.get(key);
4672
4853
  if (!core) {
4673
4854
  core = makeCore(doc, key, src, fps);
4674
4855
  cores.set(key, core);
4856
+ holdEncoders();
4675
4857
  }
4676
4858
  core.refs++;
4677
4859
  return core.cache;
@@ -4692,6 +4874,71 @@ function makeCore(doc, key, src, fps) {
4692
4874
  let generation = 0;
4693
4875
  let tick = 0;
4694
4876
  let notifyRaf = 0;
4877
+ const proxies = new Map;
4878
+ let proxyBytes = 0;
4879
+ let stride = 1;
4880
+ const decoded = new Map;
4881
+ const decoding = new Set;
4882
+ let aim = null;
4883
+ let lastAsked = null;
4884
+ const storeProxy = (frame, blob) => {
4885
+ if (disposed || frame % stride || proxies.has(frame))
4886
+ return;
4887
+ proxies.set(frame, blob);
4888
+ proxyBytes += blob.size;
4889
+ const next = proxyStride(proxyBytes, proxies.size, total, PROXY.budget);
4890
+ if (next <= stride)
4891
+ return;
4892
+ stride = next;
4893
+ for (const [f, b] of proxies)
4894
+ if (f % stride) {
4895
+ proxies.delete(f);
4896
+ proxyBytes -= b.size;
4897
+ }
4898
+ };
4899
+ const proxyNear = (frame) => {
4900
+ const at = Math.round(frame / stride) * stride;
4901
+ let best = null;
4902
+ for (const f of [at, at - stride, at + stride])
4903
+ if (proxies.has(f) && (best === null || Math.abs(f - frame) < Math.abs(best - frame)))
4904
+ best = f;
4905
+ return best;
4906
+ };
4907
+ const proxyTodo = () => {
4908
+ const out = [];
4909
+ for (let f = 0;f < total; f += stride)
4910
+ if (!proxies.has(f))
4911
+ out.push(f);
4912
+ return out;
4913
+ };
4914
+ const decodeProxy = (frame) => {
4915
+ if (frame === null || decoded.has(frame) || decoding.has(frame) || disposed)
4916
+ return;
4917
+ const blob = proxies.get(frame);
4918
+ if (!blob)
4919
+ return;
4920
+ decoding.add(frame);
4921
+ createImageBitmap(blob).then((bitmap) => {
4922
+ decoding.delete(frame);
4923
+ if (disposed)
4924
+ return bitmap.close();
4925
+ decoded.set(frame, { bitmap, used: ++tick });
4926
+ if (decoded.size > PROXY.decoded) {
4927
+ const [oldest] = [...decoded].sort((a, b) => a[1].used - b[1].used);
4928
+ if (oldest && oldest[0] !== frame) {
4929
+ oldest[1].bitmap.close();
4930
+ decoded.delete(oldest[0]);
4931
+ }
4932
+ }
4933
+ for (const f of listeners)
4934
+ f();
4935
+ }, () => decoding.delete(frame));
4936
+ };
4937
+ const dropDecoded = () => {
4938
+ for (const d of decoded.values())
4939
+ d.bitmap.close();
4940
+ decoded.clear();
4941
+ };
4695
4942
  const notify = () => {
4696
4943
  if (notifyRaf)
4697
4944
  return;
@@ -4822,18 +5069,50 @@ function makeCore(doc, key, src, fps) {
4822
5069
  return;
4823
5070
  const forFocus = todo[0].kind === "focus";
4824
5071
  const gen = generation;
4825
- const stale = () => forFocus && gen !== generation;
5072
+ await decodeRun(t, data, run, forFocus ? () => gen !== generation : () => false, (vf, f) => {
5073
+ const want = plan.get(f);
5074
+ if (!want)
5075
+ return null;
5076
+ plan.delete(f);
5077
+ return toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind));
5078
+ });
5079
+ };
5080
+ const proxyUnit = async () => {
5081
+ const todo = proxyTodo();
5082
+ if (!todo.length || !track || !buf)
5083
+ return;
5084
+ const runs = decodeRuns(track.samples, new Set(todo), (pts) => frameOf(pts, fps));
5085
+ const at = aim ?? focusAt ?? 0;
5086
+ let run = runs[0];
5087
+ let best = Number.POSITIVE_INFINITY;
5088
+ for (const r of runs) {
5089
+ const d = Math.min(...r.frames.map((f) => Math.abs(f - at)));
5090
+ if (d < best) {
5091
+ best = d;
5092
+ run = r;
5093
+ }
5094
+ }
5095
+ if (!run)
5096
+ return;
5097
+ const gen = generation;
5098
+ let made = 0;
5099
+ await decodeRun(track, buf, run, () => gen !== generation || made >= PROXY.turn, (vf, f) => {
5100
+ if (f % stride || proxies.has(f) || f >= total)
5101
+ return null;
5102
+ made++;
5103
+ return toProxy(doc, vf, vf.displayWidth, vf.displayHeight).then((b) => storeProxy(f, b));
5104
+ });
5105
+ };
5106
+ const decodeRun = async (t, data, run, stale, take) => {
4826
5107
  let pending = 0;
4827
5108
  let failed = false;
4828
5109
  const decoder = new VideoDecoder({
4829
5110
  output: (vf) => {
4830
- const f = frameOf(vf.timestamp / 1e6, fps);
4831
- const want = plan.get(f);
4832
- if (!want || stale() || disposed)
5111
+ const work = stale() || disposed ? null : take(vf, frameOf(vf.timestamp / 1e6, fps));
5112
+ if (!work)
4833
5113
  return vf.close();
4834
- plan.delete(f);
4835
5114
  pending++;
4836
- toBitmap(doc, vf, vf.displayWidth, vf.displayHeight, want.full ? FULL : PREVIEW).then((bm) => store(f, bm, want.full, want.kind)).catch(() => {}).finally(() => {
5115
+ work.catch(() => {}).finally(() => {
4837
5116
  vf.close();
4838
5117
  pending--;
4839
5118
  });
@@ -4947,6 +5226,25 @@ function makeCore(doc, key, src, fps) {
4947
5226
  return points.some((p) => p.frame + p.tolerance >= f && !covered(p));
4948
5227
  }, 6000, false);
4949
5228
  };
5229
+ const videoProxyUnit = async () => {
5230
+ const v = sampler;
5231
+ const todo = proxyTodo();
5232
+ if (!v || !todo.length || !v.videoWidth)
5233
+ return;
5234
+ const at = aim ?? focusAt ?? 0;
5235
+ const first = todo.find((f) => f >= at) ?? todo[0];
5236
+ const grab = async (f) => {
5237
+ if (f % stride || proxies.has(f) || f >= total)
5238
+ return;
5239
+ storeProxy(f, await toProxy(doc, v, v.videoWidth, v.videoHeight));
5240
+ };
5241
+ await seekTo(v, (first + 0.5) / fps);
5242
+ await grab(frameOf(v.currentTime, fps));
5243
+ await playThrough(v, 1, async (f) => {
5244
+ await grab(f);
5245
+ return proxyTodo().length > 0;
5246
+ }, 4000, true);
5247
+ };
4950
5248
  const cache = {
4951
5249
  src,
4952
5250
  get backend() {
@@ -4955,29 +5253,56 @@ function makeCore(doc, key, src, fps) {
4955
5253
  get size() {
4956
5254
  return entries.size;
4957
5255
  },
4958
- nearest(frame, within = Infinity) {
5256
+ get proxies() {
5257
+ return { count: proxies.size, stride, bytes: proxyBytes };
5258
+ },
5259
+ nearest(frame, within = Infinity, decode = false) {
4959
5260
  const k = nearestKey(entries.keys(), frame, within);
5261
+ const kd = nearestKey(decoded.keys(), frame, within);
5262
+ const dk = k === null ? Number.POSITIVE_INFINITY : Math.abs(k - frame);
5263
+ const dd = kd === null ? Number.POSITIVE_INFINITY : Math.abs(kd - frame);
5264
+ if (decode) {
5265
+ const p = proxyNear(frame);
5266
+ if (p !== null && Math.abs(p - frame) < Math.min(dk, dd))
5267
+ decodeProxy(p);
5268
+ const ahead = aheadOf(frame, lastAsked, total);
5269
+ if (ahead !== null)
5270
+ decodeProxy(proxyNear(ahead));
5271
+ lastAsked = frame;
5272
+ }
5273
+ if (kd !== null && (dd < dk || dd === dk && !entries.get(k)?.full)) {
5274
+ const d = decoded.get(kd);
5275
+ d.used = ++tick;
5276
+ return { frame: kd, bitmap: d.bitmap, full: false };
5277
+ }
4960
5278
  if (k === null)
4961
5279
  return null;
4962
5280
  const e = entries.get(k);
4963
5281
  e.used = ++tick;
4964
5282
  return { frame: k, bitmap: e.bitmap, full: e.full };
4965
5283
  },
5284
+ covers(frame) {
5285
+ if (entries.has(frame) || decoded.has(frame))
5286
+ return true;
5287
+ const p = proxyNear(frame);
5288
+ return p !== null && Math.abs(p - frame) <= stride / 2;
5289
+ },
4966
5290
  focus(frame) {
4967
- if (active !== core) {
4968
- active?.dropFull();
4969
- if (active)
4970
- active.active = false;
4971
- active = core;
4972
- core.active = true;
4973
- }
5291
+ claim(core);
4974
5292
  const moved = focusAt === null || Math.abs(frame - focusAt) > FOCUS_RADIUS;
4975
5293
  focusAt = frame;
5294
+ aim = null;
4976
5295
  if (moved)
4977
5296
  generation++;
4978
5297
  start();
4979
5298
  pump(win);
4980
5299
  },
5300
+ follow(frame) {
5301
+ claim(core);
5302
+ aim = frame;
5303
+ start();
5304
+ pump(win);
5305
+ },
4981
5306
  want(points) {
4982
5307
  wants = points;
4983
5308
  if (core.shown > 0)
@@ -5015,12 +5340,15 @@ function makeCore(doc, key, src, fps) {
5015
5340
  refs: 0,
5016
5341
  shown: 0,
5017
5342
  active: false,
5343
+ ran: 0,
5018
5344
  work() {
5019
5345
  if (disposed || backend === "pending")
5020
5346
  return null;
5021
- if (!missing().length)
5022
- return null;
5023
- return backend === "webcodecs" ? decodeUnit() : videoUnit();
5347
+ if (missing().length)
5348
+ return backend === "webcodecs" ? decodeUnit() : videoUnit();
5349
+ if (proxyTodo().length)
5350
+ return backend === "webcodecs" ? proxyUnit() : videoProxyUnit();
5351
+ return null;
5024
5352
  },
5025
5353
  dropFull() {
5026
5354
  for (const [f, e] of entries)
@@ -5029,8 +5357,13 @@ function makeCore(doc, key, src, fps) {
5029
5357
  entries.delete(f);
5030
5358
  }
5031
5359
  focusAt = null;
5360
+ aim = null;
5361
+ lastAsked = null;
5362
+ dropDecoded();
5032
5363
  },
5033
5364
  dispose() {
5365
+ if (disposed)
5366
+ return;
5034
5367
  disposed = true;
5035
5368
  generation++;
5036
5369
  if (notifyRaf)
@@ -5038,6 +5371,8 @@ function makeCore(doc, key, src, fps) {
5038
5371
  for (const e of entries.values())
5039
5372
  e.bitmap.close();
5040
5373
  entries.clear();
5374
+ dropDecoded();
5375
+ proxies.clear();
5041
5376
  listeners.clear();
5042
5377
  if (sampler) {
5043
5378
  sampler.removeAttribute("src");
@@ -5045,24 +5380,45 @@ function makeCore(doc, key, src, fps) {
5045
5380
  }
5046
5381
  buf = null;
5047
5382
  track = null;
5048
- if (active === core)
5049
- active = null;
5383
+ actives.delete(core);
5384
+ round?.delete(core);
5050
5385
  cores.delete(key);
5386
+ dropEncoders();
5051
5387
  }
5052
5388
  };
5053
5389
  return core;
5054
5390
  }
5055
- var FOCUS_RADIUS = 6, GRID = 48, SEEN = 12, WANTED = 48, PREVIEW = 384, FULL = 1280, cores, active = null, running = false, idle = (win) => new Promise((done) => {
5391
+ 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, round = null, running = false, turns = 0, idle = (win) => new Promise((done) => {
5056
5392
  const ric = win.requestIdleCallback;
5057
5393
  if (ric)
5058
5394
  ric(() => done(), { timeout: 60 });
5059
5395
  else
5060
5396
  win.setTimeout(done, 4);
5061
- }), sleep = (win, ms) => new Promise((done) => win.setTimeout(done, ms));
5397
+ }), 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) => {
5398
+ const pool = encoders;
5399
+ if (!pool)
5400
+ return;
5401
+ for (const w of pool.workers) {
5402
+ w.onmessage = null;
5403
+ w.onerror = null;
5404
+ w.terminate();
5405
+ }
5406
+ for (const j of pool.waiting.values())
5407
+ j.fail(new Error(why));
5408
+ pool.waiting.clear();
5409
+ };
5062
5410
  var init_compare_frames = __esm(() => {
5063
5411
  init_compare_model();
5064
5412
  init_compare_mp4();
5413
+ PROXY = {
5414
+ size: 960,
5415
+ quality: 0.8,
5416
+ budget: 48 * 2 ** 20,
5417
+ decoded: 8,
5418
+ turn: 90
5419
+ };
5065
5420
  cores = new Map;
5421
+ actives = new Set;
5066
5422
  });
5067
5423
 
5068
5424
  // src/ui/compare-gpu.ts
@@ -5425,589 +5781,65 @@ var init_rgba = __esm(() => {
5425
5781
  cache = new Map;
5426
5782
  });
5427
5783
 
5428
- // src/ui/timeline-model.ts
5429
- function timeLabel(t, units, fps, step = 1) {
5430
- if (units === "frames")
5431
- return String(toFrame(t, fps));
5432
- const sign = t < 0 ? "-" : "";
5433
- const a = Math.abs(t);
5434
- if (a >= 60 || step >= 60 && a > 0) {
5435
- const s = Math.round(a);
5436
- const h = Math.floor(s / 3600);
5437
- const m = Math.floor(s % 3600 / 60);
5438
- const sec = String(s % 60).padStart(2, "0");
5439
- return `${sign}${h ? `${h}:${String(m).padStart(2, "0")}` : m}:${sec}`;
5784
+ // src/ui/overlay-model.ts
5785
+ function place(o) {
5786
+ const { anchor: a, size, view: v } = o;
5787
+ const offset = o.offset ?? 6;
5788
+ const margin = o.margin ?? 8;
5789
+ const [want, align = "center"] = (o.placement ?? "bottom").split("-");
5790
+ const room = (s) => s === "top" ? a.y - v.y : s === "bottom" ? v.y + v.h - (a.y + a.h) : s === "left" ? a.x - v.x : v.x + v.w - (a.x + a.w);
5791
+ const need = (s) => (s === "top" || s === "bottom" ? size.h : size.w) + offset + margin;
5792
+ let side = want;
5793
+ if (room(want) < need(want) && room(opposite[want]) > room(want))
5794
+ side = opposite[want];
5795
+ const vertical = side === "top" || side === "bottom";
5796
+ let x;
5797
+ let y;
5798
+ if (vertical) {
5799
+ y = side === "bottom" ? a.y + a.h + offset : a.y - offset - size.h;
5800
+ x = align === "start" ? a.x : align === "end" ? a.x + a.w - size.w : a.x + (a.w - size.w) / 2;
5801
+ x = clamp(x, v.x + margin, v.x + v.w - margin - size.w);
5802
+ } else {
5803
+ x = side === "right" ? a.x + a.w + offset : a.x - offset - size.w;
5804
+ y = align === "start" ? a.y : align === "end" ? a.y + a.h - size.h : a.y + (a.h - size.h) / 2;
5805
+ y = clamp(y, v.y + margin, v.y + v.h - margin - size.h);
5440
5806
  }
5441
- const decimals = step < 0.1 ? 2 : step < 1 ? 1 : 0;
5442
- return `${sign}${Number(a.toFixed(decimals))}s`;
5807
+ const origin = vertical ? { x: clamp(a.x + a.w / 2 - x, 0, size.w), y: side === "bottom" ? 0 : size.h } : { x: side === "right" ? 0 : size.w, y: clamp(a.y + a.h / 2 - y, 0, size.h) };
5808
+ return {
5809
+ x: Math.round(x),
5810
+ y: Math.round(y),
5811
+ placement: align === "center" ? side : `${side}-${align}`,
5812
+ origin
5813
+ };
5443
5814
  }
5444
- function rulerTicks(view, width, o) {
5445
- const units = o.units ?? "seconds";
5446
- const fps = o.fps;
5447
- const pxPerSec = width / Math.max(0.000000001, view.span);
5448
- const minGap = o.minGap ?? 72;
5449
- const steps = units === "frames" ? FRAME_STEPS.map((f) => f / fps) : SECOND_STEPS;
5450
- const step = steps.find((s) => s * pxPerSec >= minGap) ?? steps[steps.length - 1];
5451
- const frame = 1 / fps;
5452
- let minor = 0;
5453
- for (const div of [10, 5, 4, 2]) {
5454
- const m = step / div;
5455
- if (m * pxPerSec < 7)
5456
- continue;
5457
- if (units === "frames" && Math.abs(m * fps - Math.round(m * fps)) > 0.000001)
5458
- continue;
5459
- minor = m;
5460
- break;
5461
- }
5462
- if (frame * pxPerSec >= (o.frameGap ?? 6))
5463
- minor = Math.min(frame, step);
5464
- const end = view.start + view.span;
5465
- const major = [];
5466
- const first = Math.ceil(view.start / step - 0.000000001);
5467
- for (let i = first;i * step <= end + 0.000000001; i++) {
5468
- const t = round(i * step);
5469
- major.push({ t, label: timeLabel(t, units, fps, step) });
5470
- }
5471
- const minors = [];
5472
- if (minor > 0) {
5473
- const firstMinor = Math.ceil(view.start / minor - 0.000000001);
5474
- for (let i = firstMinor;i * minor <= end + 0.000000001; i++) {
5475
- const t = i * minor;
5476
- if (Math.abs(t / step - Math.round(t / step)) > 0.000001)
5477
- minors.push(round(t));
5478
- }
5815
+ function stepItem(count, from, by, ok) {
5816
+ for (let k = 1;k <= count; k++) {
5817
+ const i = (((from < 0 ? by > 0 ? -1 : 0 : from) + by * k) % count + count) % count;
5818
+ if (ok(i))
5819
+ return i;
5479
5820
  }
5480
- return { major, minor: minors, step };
5481
- }
5482
- function clampView(view, duration, o = {}) {
5483
- const pad = o.pad ?? 0.04;
5484
- const maxSpan = duration * (1 + pad * 2);
5485
- const span = Math.min(maxSpan, Math.max(o.minSpan ?? 0.05, view.span));
5486
- const lo = -duration * pad;
5487
- const hi = duration * (1 + pad) - span;
5488
- const start = hi < lo ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, view.start));
5489
- return { start, span };
5490
- }
5491
- function zoomView(view, factor, at) {
5492
- const span = view.span / factor;
5493
- const k = (at - view.start) / view.span;
5494
- return { start: at - k * span, span };
5495
- }
5496
- function followView(view, t, margin = 0.08) {
5497
- const m = view.span * margin;
5498
- if (t < view.start + m)
5499
- return { ...view, start: t - m };
5500
- if (t > view.start + view.span - m)
5501
- return { ...view, start: t - view.span + m };
5502
- return view;
5821
+ return from;
5503
5822
  }
5504
- function magnet(raw, stops, pxPerSec, radius = 10, stick = 0.3) {
5505
- let best = null;
5506
- let bestPx = Number.POSITIVE_INFINITY;
5507
- for (const s of stops) {
5508
- const px = Math.abs(raw - s) * pxPerSec;
5509
- if (px < bestPx) {
5510
- bestPx = px;
5511
- best = s;
5512
- }
5823
+ function matchItem(labels, from, typed) {
5824
+ const q = typed.toLowerCase();
5825
+ if (!q || !labels.length)
5826
+ return from;
5827
+ const same = q.length > 1 && [...q].every((c) => c === q[0]);
5828
+ const word = same ? q[0] : q;
5829
+ const start = q.length > 1 && !same ? Math.max(0, from) : from + 1;
5830
+ for (let k = 0;k < labels.length; k++) {
5831
+ const i = ((start + k) % labels.length + labels.length) % labels.length;
5832
+ if (labels[i]?.toLowerCase().startsWith(word))
5833
+ return i;
5513
5834
  }
5514
- if (best === null || bestPx >= radius)
5515
- return { t: raw, stop: null };
5516
- const u = bestPx / radius;
5517
- const pulled = best + Math.sign(raw - best) * (u * u * radius) / pxPerSec;
5518
- return { t: pulled, stop: u < stick ? best : null };
5835
+ return from;
5519
5836
  }
5520
- function scrubTime(raw, o) {
5521
- const clamped = Math.min(o.duration, Math.max(0, raw));
5522
- const m = o.stops?.length ? magnet(clamped, o.stops, o.pxPerSec, o.radius) : { t: clamped, stop: null };
5523
- const t = m.stop ?? (o.frames === false ? m.t : frameTime(m.t, o.fps));
5524
- return { t: Math.min(o.duration, Math.max(0, t)), shown: m.stop ?? m.t, stop: m.stop };
5525
- }
5526
- function adjacentMarker(markers, t, dir, fps) {
5527
- const eps = 0.5 / fps;
5528
- const sorted = [...markers].sort((a, b) => a.time - b.time);
5529
- if (dir > 0)
5530
- return sorted.find((m) => m.time > t + eps) ?? null;
5531
- return [...sorted].reverse().find((m) => m.time < t - eps) ?? null;
5532
- }
5533
- function advance(t, dt, o) {
5534
- const lo = o.loop ? o.loop.start : 0;
5535
- const hi = o.loop ? o.loop.end : o.duration;
5536
- let next = t + dt;
5537
- if (hi - lo <= 0.000001)
5538
- return { t: lo, ended: true, wrapped: false };
5539
- if (next < hi)
5540
- return { t: next, ended: false, wrapped: false };
5541
- if (o.loop || o.repeat) {
5542
- next = lo + (next - lo) % (hi - lo);
5543
- return { t: next, ended: false, wrapped: true };
5544
- }
5545
- return { t: hi, ended: true, wrapped: false };
5546
- }
5547
- function moveHead(head, t, motion, catching, minGap = 0) {
5548
- if (motion === "jump" && Math.abs(t - head.x) > minGap)
5549
- return { x: head.x, target: t, catching: true };
5550
- if (catching)
5551
- return { x: head.x + (t - head.target), target: t, catching: true };
5552
- return { x: t, target: t, catching: false };
5553
- }
5554
- function currentMarker(markers, t, fps) {
5555
- let best = null;
5556
- for (const m of markers)
5557
- if (m.time <= t + 0.5 / fps && (!best || m.time >= best.time))
5558
- best = m;
5559
- return best;
5560
- }
5561
- function minutes(t) {
5562
- const s = Math.max(0, Math.floor(t + 0.000001));
5563
- const h = Math.floor(s / 3600);
5564
- const m = Math.floor(s % 3600 / 60);
5565
- const ss = String(s % 60).padStart(2, "0");
5566
- return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
5567
- }
5568
- function chapterBounds(times, duration) {
5569
- const inside = times.filter((t) => t > 0.000001 && t < duration - 0.000001);
5570
- return [0, ...[...new Set(inside)].sort((a, b) => a - b), duration];
5571
- }
5572
- var SECOND_STEPS, FRAME_STEPS, toFrame = (t, fps) => Math.round(t * fps), frameTime = (t, fps) => Math.round(t * fps) / fps, round = (t) => Math.round(t * 1e6) / 1e6, CATCH_UP;
5573
- var init_timeline_model = __esm(() => {
5574
- SECOND_STEPS = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
5575
- FRAME_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 9000, 18000];
5576
- CATCH_UP = { duration: 0.15, bounce: 0 };
5577
- });
5578
-
5579
- // src/ui/compare-strip.ts
5580
- function compareStrip(container, options) {
5581
- const doc = container.ownerDocument;
5582
- const win = doc.defaultView;
5583
- const timeline = options.timeline;
5584
- const fps = () => options.fps ?? timeline.fps;
5585
- const align = options.align ?? "track";
5586
- const threshold = options.threshold ?? 4 / 255;
5587
- const root = doc.createElement("div");
5588
- root.className = "knobs-cmp-strip";
5589
- root.setAttribute("role", "img");
5590
- root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
5591
- applyLook2(root, options);
5592
- const canvas = doc.createElement("canvas");
5593
- canvas.setAttribute("aria-hidden", "true");
5594
- root.append(canvas);
5595
- container.append(root);
5596
- const diffs = new Map;
5597
- const pairDiffs = new Map;
5598
- let disposed = false;
5599
- let near = false;
5600
- let onScreen = false;
5601
- const makeSide = (source) => ({
5602
- source,
5603
- kind: mediaKind(source.src, source.kind),
5604
- frames: null,
5605
- still: null,
5606
- seen: false
5607
- });
5608
- let sides = [makeSide(options.before), makeSide(options.after)];
5609
- const offs = new Map;
5610
- const load = (s) => {
5611
- if (s.frames || s.still)
5612
- return;
5613
- if (s.kind === "video") {
5614
- s.frames = acquireFrames(doc, s.source.src, fps());
5615
- offs.set(s, s.frames.listen(schedule));
5616
- if (onScreen) {
5617
- s.frames.visible(true);
5618
- s.seen = true;
5619
- }
5620
- } else {
5621
- const img = doc.createElement("img");
5622
- img.crossOrigin = "anonymous";
5623
- img.decoding = "async";
5624
- img.addEventListener("load", () => {
5625
- pairDiffs.clear();
5626
- schedule();
5627
- });
5628
- img.src = s.source.src;
5629
- s.still = img;
5630
- }
5631
- };
5632
- const unload = (s) => {
5633
- offs.get(s)?.();
5634
- offs.delete(s);
5635
- if (s.frames) {
5636
- if (s.seen)
5637
- s.frames.visible(false);
5638
- s.frames.release();
5639
- }
5640
- s.frames = null;
5641
- s.seen = false;
5642
- if (s.still)
5643
- s.still.removeAttribute("src");
5644
- s.still = null;
5645
- };
5646
- let w = 1;
5647
- let h = 1;
5648
- let geo = { x: 0, w: 1 };
5649
- const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
5650
- const aspectOf = (s) => {
5651
- if (s.still?.naturalWidth)
5652
- return s.still.naturalWidth / s.still.naturalHeight;
5653
- const c = s.frames?.nearest(0);
5654
- return c ? c.bitmap.width / c.bitmap.height : 0;
5655
- };
5656
- const columns = () => stripColumns(timeline.view, geo.w, {
5657
- duration: timeline.duration,
5658
- fps: fps(),
5659
- minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
5660
- });
5661
- const measure = () => {
5662
- const r = root.getBoundingClientRect();
5663
- w = Math.max(1, r.width);
5664
- h = Math.max(1, r.height);
5665
- geo = { x: 0, w };
5666
- if (align === "track") {
5667
- const track = timeline.element.querySelector(".knobs-tl-track");
5668
- const t = track?.getBoundingClientRect();
5669
- if (t && t.width > 0)
5670
- geo = { x: t.left - r.left, w: t.width };
5671
- }
5672
- readColors();
5673
- schedule();
5674
- };
5675
- const ask = (cols) => {
5676
- const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
5677
- for (const s of sides)
5678
- s.frames?.want(points);
5679
- };
5680
- const picOf = (s, c) => {
5681
- if (s.still?.naturalWidth)
5682
- return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
5683
- const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
5684
- return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
5685
- };
5686
- const probe = doc.createElement("canvas");
5687
- probe.width = DIFF.w;
5688
- probe.height = DIFF.h;
5689
- const probeCtx = probe.getContext("2d", { willReadFrequently: true });
5690
- const differ = (a, b) => {
5691
- if (!probeCtx)
5692
- return;
5693
- try {
5694
- probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
5695
- const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
5696
- probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
5697
- const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
5698
- return meanAbsDiff(pa, pb);
5699
- } catch {
5700
- return;
5701
- }
5702
- };
5703
- let colors = { surface: "", accent: "", diff: "", wash: "" };
5704
- const rgba = (raw, fallback) => {
5705
- const [r, g, b, a] = parseCssColor(raw, fallback);
5706
- return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
5707
- };
5708
- const readColors = () => {
5709
- const cs = win.getComputedStyle(root);
5710
- colors = {
5711
- surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
5712
- accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
5713
- diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
5714
- wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
5715
- };
5716
- };
5717
- let hover = null;
5718
- let raf = 0;
5719
- let asked = "";
5720
- function schedule() {
5721
- if (!raf && !disposed)
5722
- raf = win.requestAnimationFrame(draw);
5723
- }
5724
- const draw = () => {
5725
- raf = 0;
5726
- if (!near)
5727
- return;
5728
- const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
5729
- const cw = Math.round(w * dpr);
5730
- const ch = Math.round(h * dpr);
5731
- if (canvas.width !== cw || canvas.height !== ch) {
5732
- canvas.width = cw;
5733
- canvas.height = ch;
5734
- }
5735
- const ctx = canvas.getContext("2d");
5736
- if (!ctx)
5737
- return;
5738
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
5739
- ctx.clearRect(0, 0, w, h);
5740
- const cols = columns();
5741
- const key = cols.map((c) => c.frame).join(",");
5742
- if (key !== asked) {
5743
- asked = key;
5744
- ask(cols);
5745
- }
5746
- const rh = rowH();
5747
- const radius = 3;
5748
- let budget = DIFFS_PER_DRAW;
5749
- let more = false;
5750
- const ds = cols.map((c) => {
5751
- const a = picOf(sides[0], c);
5752
- const b = picOf(sides[1], c);
5753
- if (!a || !b)
5754
- return;
5755
- const k = `${a.key}|${b.key}`;
5756
- if (!pairDiffs.has(k)) {
5757
- if (budget-- <= 0) {
5758
- more = true;
5759
- return;
5760
- }
5761
- pairDiffs.set(k, differ(a, b));
5762
- }
5763
- const d = pairDiffs.get(k);
5764
- if (d !== undefined)
5765
- diffs.set(c.frame, d);
5766
- return d;
5767
- });
5768
- const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
5769
- const peaks = new Set(stripPeaks(ds, { threshold }));
5770
- cols.forEach((c, i) => {
5771
- const x = geo.x + c.x + GAP / 2;
5772
- const cwid = Math.max(1, c.w - GAP);
5773
- if (x + cwid < 0 || x > w)
5774
- return;
5775
- for (const row of [0, 1]) {
5776
- const y = row * (rh + GAP);
5777
- const pic = picOf(sides[row], c);
5778
- ctx.save();
5779
- ctx.beginPath();
5780
- ctx.roundRect(x, y, cwid, rh, radius);
5781
- ctx.fillStyle = colors.surface;
5782
- ctx.fill();
5783
- if (pic) {
5784
- ctx.clip();
5785
- const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
5786
- ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
5787
- }
5788
- ctx.restore();
5789
- }
5790
- if (hover === i) {
5791
- ctx.fillStyle = colors.wash;
5792
- ctx.beginPath();
5793
- ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
5794
- ctx.fill();
5795
- }
5796
- const d = ds[i];
5797
- if (d !== undefined && d > 0) {
5798
- const bh = Math.max(1, Math.min(1, d / max) * BAR);
5799
- ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
5800
- ctx.fillStyle = colors.diff;
5801
- ctx.beginPath();
5802
- ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
5803
- ctx.fill();
5804
- ctx.globalAlpha = 1;
5805
- }
5806
- });
5807
- const v = timeline.view;
5808
- const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
5809
- if (px >= -1 && px <= w + 1) {
5810
- ctx.fillStyle = colors.accent;
5811
- ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
5812
- }
5813
- if (more)
5814
- schedule();
5815
- };
5816
- const timeAt = (clientX) => {
5817
- const r = root.getBoundingClientRect();
5818
- const v = timeline.view;
5819
- const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
5820
- return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
5821
- };
5822
- const columnAt = (clientX) => {
5823
- const x = clientX - root.getBoundingClientRect().left - geo.x;
5824
- const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
5825
- return i < 0 ? null : i;
5826
- };
5827
- let dragging = false;
5828
- const pick = (clientX) => {
5829
- const t = timeAt(clientX);
5830
- timeline.seek(t);
5831
- options.onPick?.(t);
5832
- };
5833
- root.addEventListener("pointerdown", (e) => {
5834
- if (e.button !== 0)
5835
- return;
5836
- dragging = true;
5837
- root.setPointerCapture(e.pointerId);
5838
- const host = root.closest(".knobs-compare");
5839
- if (host)
5840
- host.focus({ preventScroll: true });
5841
- else
5842
- timeline.focus();
5843
- if (timeline.playing)
5844
- timeline.pause();
5845
- pick(e.clientX);
5846
- e.preventDefault();
5847
- });
5848
- root.addEventListener("pointermove", (e) => {
5849
- if (dragging)
5850
- pick(e.clientX);
5851
- const i = columnAt(e.clientX);
5852
- if (i !== hover) {
5853
- hover = i;
5854
- schedule();
5855
- }
5856
- });
5857
- const end = () => {
5858
- dragging = false;
5859
- };
5860
- root.addEventListener("pointerup", end);
5861
- root.addEventListener("pointercancel", end);
5862
- root.addEventListener("lostpointercapture", end);
5863
- root.addEventListener("pointerleave", () => {
5864
- if (hover === null)
5865
- return;
5866
- hover = null;
5867
- schedule();
5868
- });
5869
- const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
5870
- const ro = new win.ResizeObserver(measure);
5871
- ro.observe(root);
5872
- const track = timeline.element.querySelector(".knobs-tl-track");
5873
- if (track)
5874
- ro.observe(track);
5875
- root.addEventListener("knobs-restyle", measure);
5876
- const setOnScreen = (on) => {
5877
- onScreen = on;
5878
- for (const s of sides) {
5879
- if (!s.frames || s.seen === on)
5880
- continue;
5881
- s.seen = on;
5882
- s.frames.visible(on);
5883
- }
5884
- };
5885
- const setNear = (on) => {
5886
- near = on;
5887
- if (on) {
5888
- for (const s of sides)
5889
- load(s);
5890
- schedule();
5891
- } else
5892
- for (const s of sides)
5893
- unload(s);
5894
- };
5895
- const IO = win.IntersectionObserver;
5896
- const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
5897
- const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
5898
- measure();
5899
- if (nearIo && screenIo) {
5900
- nearIo.observe(root);
5901
- screenIo.observe(root);
5902
- } else {
5903
- setNear(true);
5904
- setOnScreen(true);
5905
- }
5906
- return {
5907
- element: root,
5908
- diffs,
5909
- update(next) {
5910
- const was = sides;
5911
- sides = [
5912
- next.before ? makeSide(next.before) : was[0],
5913
- next.after ? makeSide(next.after) : was[1]
5914
- ];
5915
- for (const s of was)
5916
- if (!sides.includes(s))
5917
- unload(s);
5918
- diffs.clear();
5919
- pairDiffs.clear();
5920
- asked = "";
5921
- if (near)
5922
- for (const s of sides)
5923
- load(s);
5924
- if (onScreen)
5925
- setOnScreen(true);
5926
- schedule();
5927
- },
5928
- dispose() {
5929
- disposed = true;
5930
- if (raf)
5931
- win.cancelAnimationFrame(raf);
5932
- unlisten();
5933
- ro.disconnect();
5934
- nearIo?.disconnect();
5935
- screenIo?.disconnect();
5936
- for (const s of sides)
5937
- unload(s);
5938
- root.remove();
5939
- }
5940
- };
5941
- }
5942
- var BAR = 7, GAP = 2, DIFF, DIFFS_PER_DRAW = 6;
5943
- var init_compare_strip = __esm(() => {
5944
- init_compare_frames();
5945
- init_compare_model();
5946
- init_look();
5947
- init_rgba();
5948
- init_timeline_model();
5949
- DIFF = { w: 32, h: 18 };
5950
- });
5951
-
5952
- // src/ui/overlay-model.ts
5953
- function place(o) {
5954
- const { anchor: a, size, view: v } = o;
5955
- const offset = o.offset ?? 6;
5956
- const margin = o.margin ?? 8;
5957
- const [want, align = "center"] = (o.placement ?? "bottom").split("-");
5958
- const room = (s) => s === "top" ? a.y - v.y : s === "bottom" ? v.y + v.h - (a.y + a.h) : s === "left" ? a.x - v.x : v.x + v.w - (a.x + a.w);
5959
- const need = (s) => (s === "top" || s === "bottom" ? size.h : size.w) + offset + margin;
5960
- let side = want;
5961
- if (room(want) < need(want) && room(opposite[want]) > room(want))
5962
- side = opposite[want];
5963
- const vertical = side === "top" || side === "bottom";
5964
- let x;
5965
- let y;
5966
- if (vertical) {
5967
- y = side === "bottom" ? a.y + a.h + offset : a.y - offset - size.h;
5968
- x = align === "start" ? a.x : align === "end" ? a.x + a.w - size.w : a.x + (a.w - size.w) / 2;
5969
- x = clamp(x, v.x + margin, v.x + v.w - margin - size.w);
5970
- } else {
5971
- x = side === "right" ? a.x + a.w + offset : a.x - offset - size.w;
5972
- y = align === "start" ? a.y : align === "end" ? a.y + a.h - size.h : a.y + (a.h - size.h) / 2;
5973
- y = clamp(y, v.y + margin, v.y + v.h - margin - size.h);
5974
- }
5975
- const origin = vertical ? { x: clamp(a.x + a.w / 2 - x, 0, size.w), y: side === "bottom" ? 0 : size.h } : { x: side === "right" ? 0 : size.w, y: clamp(a.y + a.h / 2 - y, 0, size.h) };
5976
- return {
5977
- x: Math.round(x),
5978
- y: Math.round(y),
5979
- placement: align === "center" ? side : `${side}-${align}`,
5980
- origin
5981
- };
5982
- }
5983
- function stepItem(count, from, by, ok) {
5984
- for (let k = 1;k <= count; k++) {
5985
- const i = (((from < 0 ? by > 0 ? -1 : 0 : from) + by * k) % count + count) % count;
5986
- if (ok(i))
5987
- return i;
5988
- }
5989
- return from;
5990
- }
5991
- function matchItem(labels, from, typed) {
5992
- const q = typed.toLowerCase();
5993
- if (!q || !labels.length)
5994
- return from;
5995
- const same = q.length > 1 && [...q].every((c) => c === q[0]);
5996
- const word = same ? q[0] : q;
5997
- const start = q.length > 1 && !same ? Math.max(0, from) : from + 1;
5998
- for (let k = 0;k < labels.length; k++) {
5999
- const i = ((start + k) % labels.length + labels.length) % labels.length;
6000
- if (labels[i]?.toLowerCase().startsWith(word))
6001
- return i;
6002
- }
6003
- return from;
6004
- }
6005
- function inTriangle(p, a, b, c) {
6006
- const s = (p1, p2, p3) => (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
6007
- const d1 = s(p, a, b);
6008
- const d2 = s(p, b, c);
6009
- const d3 = s(p, c, a);
6010
- return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
5837
+ function inTriangle(p, a, b, c) {
5838
+ const s = (p1, p2, p3) => (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
5839
+ const d1 = s(p, a, b);
5840
+ const d2 = s(p, b, c);
5841
+ const d3 = s(p, c, a);
5842
+ return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
6011
5843
  }
6012
5844
  function safeTriangle(from, sub) {
6013
5845
  const right = sub.x >= from.x;
@@ -6440,6 +6272,157 @@ function softClamp(x, min, max, knee = 12) {
6440
6272
  }
6441
6273
  var init_split_model = () => {};
6442
6274
 
6275
+ // src/ui/timeline-model.ts
6276
+ function timeLabel(t, units, fps, step = 1) {
6277
+ if (units === "frames")
6278
+ return String(toFrame(t, fps));
6279
+ const sign = t < 0 ? "-" : "";
6280
+ const a = Math.abs(t);
6281
+ if (a >= 60 || step >= 60 && a > 0) {
6282
+ const s = Math.round(a);
6283
+ const h = Math.floor(s / 3600);
6284
+ const m = Math.floor(s % 3600 / 60);
6285
+ const sec = String(s % 60).padStart(2, "0");
6286
+ return `${sign}${h ? `${h}:${String(m).padStart(2, "0")}` : m}:${sec}`;
6287
+ }
6288
+ const decimals = step < 0.1 ? 2 : step < 1 ? 1 : 0;
6289
+ return `${sign}${Number(a.toFixed(decimals))}s`;
6290
+ }
6291
+ function rulerTicks(view, width, o) {
6292
+ const units = o.units ?? "seconds";
6293
+ const fps = o.fps;
6294
+ const pxPerSec = width / Math.max(0.000000001, view.span);
6295
+ const minGap = o.minGap ?? 72;
6296
+ const steps = units === "frames" ? FRAME_STEPS.map((f) => f / fps) : SECOND_STEPS;
6297
+ const step = steps.find((s) => s * pxPerSec >= minGap) ?? steps[steps.length - 1];
6298
+ const frame = 1 / fps;
6299
+ let minor = 0;
6300
+ for (const div of [10, 5, 4, 2]) {
6301
+ const m = step / div;
6302
+ if (m * pxPerSec < 7)
6303
+ continue;
6304
+ if (units === "frames" && Math.abs(m * fps - Math.round(m * fps)) > 0.000001)
6305
+ continue;
6306
+ minor = m;
6307
+ break;
6308
+ }
6309
+ if (frame * pxPerSec >= (o.frameGap ?? 6))
6310
+ minor = Math.min(frame, step);
6311
+ const end = view.start + view.span;
6312
+ const major = [];
6313
+ const first = Math.ceil(view.start / step - 0.000000001);
6314
+ for (let i = first;i * step <= end + 0.000000001; i++) {
6315
+ const t = round2(i * step);
6316
+ major.push({ t, label: timeLabel(t, units, fps, step) });
6317
+ }
6318
+ const minors = [];
6319
+ if (minor > 0) {
6320
+ const firstMinor = Math.ceil(view.start / minor - 0.000000001);
6321
+ for (let i = firstMinor;i * minor <= end + 0.000000001; i++) {
6322
+ const t = i * minor;
6323
+ if (Math.abs(t / step - Math.round(t / step)) > 0.000001)
6324
+ minors.push(round2(t));
6325
+ }
6326
+ }
6327
+ return { major, minor: minors, step };
6328
+ }
6329
+ function clampView(view, duration, o = {}) {
6330
+ const pad = o.pad ?? 0.04;
6331
+ const maxSpan = duration * (1 + pad * 2);
6332
+ const span = Math.min(maxSpan, Math.max(o.minSpan ?? 0.05, view.span));
6333
+ const lo = -duration * pad;
6334
+ const hi = duration * (1 + pad) - span;
6335
+ const start = hi < lo ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, view.start));
6336
+ return { start, span };
6337
+ }
6338
+ function zoomView(view, factor, at) {
6339
+ const span = view.span / factor;
6340
+ const k = (at - view.start) / view.span;
6341
+ return { start: at - k * span, span };
6342
+ }
6343
+ function followView(view, t, margin = 0.08) {
6344
+ const m = view.span * margin;
6345
+ if (t < view.start + m)
6346
+ return { ...view, start: t - m };
6347
+ if (t > view.start + view.span - m)
6348
+ return { ...view, start: t - view.span + m };
6349
+ return view;
6350
+ }
6351
+ function magnet(raw, stops, pxPerSec, radius = 10, stick = 0.3) {
6352
+ let best = null;
6353
+ let bestPx = Number.POSITIVE_INFINITY;
6354
+ for (const s of stops) {
6355
+ const px = Math.abs(raw - s) * pxPerSec;
6356
+ if (px < bestPx) {
6357
+ bestPx = px;
6358
+ best = s;
6359
+ }
6360
+ }
6361
+ if (best === null || bestPx >= radius)
6362
+ return { t: raw, stop: null };
6363
+ const u = bestPx / radius;
6364
+ const pulled = best + Math.sign(raw - best) * (u * u * radius) / pxPerSec;
6365
+ return { t: pulled, stop: u < stick ? best : null };
6366
+ }
6367
+ function scrubTime(raw, o) {
6368
+ const clamped = Math.min(o.duration, Math.max(0, raw));
6369
+ const m = o.stops?.length ? magnet(clamped, o.stops, o.pxPerSec, o.radius) : { t: clamped, stop: null };
6370
+ const t = m.stop ?? (o.frames === false ? m.t : frameTime(m.t, o.fps));
6371
+ return { t: Math.min(o.duration, Math.max(0, t)), shown: m.stop ?? m.t, stop: m.stop };
6372
+ }
6373
+ function adjacentMarker(markers, t, dir, fps) {
6374
+ const eps = 0.5 / fps;
6375
+ const sorted = [...markers].sort((a, b) => a.time - b.time);
6376
+ if (dir > 0)
6377
+ return sorted.find((m) => m.time > t + eps) ?? null;
6378
+ return [...sorted].reverse().find((m) => m.time < t - eps) ?? null;
6379
+ }
6380
+ function advance(t, dt, o) {
6381
+ const lo = o.loop ? o.loop.start : 0;
6382
+ const hi = o.loop ? o.loop.end : o.duration;
6383
+ let next = t + dt;
6384
+ if (hi - lo <= 0.000001)
6385
+ return { t: lo, ended: true, wrapped: false };
6386
+ if (next < hi)
6387
+ return { t: next, ended: false, wrapped: false };
6388
+ if (o.loop || o.repeat) {
6389
+ next = lo + (next - lo) % (hi - lo);
6390
+ return { t: next, ended: false, wrapped: true };
6391
+ }
6392
+ return { t: hi, ended: true, wrapped: false };
6393
+ }
6394
+ function moveHead(head, t, motion, catching, minGap = 0) {
6395
+ if (motion === "jump" && Math.abs(t - head.x) > minGap)
6396
+ return { x: head.x, target: t, catching: true };
6397
+ if (catching)
6398
+ return { x: head.x + (t - head.target), target: t, catching: true };
6399
+ return { x: t, target: t, catching: false };
6400
+ }
6401
+ function currentMarker(markers, t, fps) {
6402
+ let best = null;
6403
+ for (const m of markers)
6404
+ if (m.time <= t + 0.5 / fps && (!best || m.time >= best.time))
6405
+ best = m;
6406
+ return best;
6407
+ }
6408
+ function minutes(t) {
6409
+ const s = Math.max(0, Math.floor(t + 0.000001));
6410
+ const h = Math.floor(s / 3600);
6411
+ const m = Math.floor(s % 3600 / 60);
6412
+ const ss = String(s % 60).padStart(2, "0");
6413
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
6414
+ }
6415
+ function chapterBounds(times, duration) {
6416
+ const inside = times.filter((t) => t > 0.000001 && t < duration - 0.000001);
6417
+ return [0, ...[...new Set(inside)].sort((a, b) => a - b), duration];
6418
+ }
6419
+ var SECOND_STEPS, FRAME_STEPS, toFrame = (t, fps) => Math.round(t * fps), frameTime = (t, fps) => Math.round(t * fps) / fps, round2 = (t) => Math.round(t * 1e6) / 1e6, CATCH_UP;
6420
+ var init_timeline_model = __esm(() => {
6421
+ SECOND_STEPS = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
6422
+ FRAME_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 9000, 18000];
6423
+ CATCH_UP = { duration: 0.15, bounce: 0 };
6424
+ });
6425
+
6443
6426
  // src/ui/timeline.ts
6444
6427
  function timeline(container, options) {
6445
6428
  const doc = container.ownerDocument;
@@ -7552,6 +7535,7 @@ var init_timeline = __esm(() => {
7552
7535
  function compare(container, options) {
7553
7536
  const doc = container.ownerDocument;
7554
7537
  const win = doc.defaultView;
7538
+ const caching = scrubCaching(options.scrubCache, win);
7555
7539
  const look = {
7556
7540
  ...options.preset ? { preset: options.preset } : {},
7557
7541
  ...options.theme ? { theme: options.theme } : {},
@@ -7668,15 +7652,9 @@ function compare(container, options) {
7668
7652
  const note = el("div", "knobs-cmp-note");
7669
7653
  stage.append(canvas, split, seam, labels[0], labels[1], handle, zoomPill, note);
7670
7654
  root.append(bar, stage);
7671
- const wantsStrip = options.strip === true;
7672
7655
  let tl = bound;
7673
7656
  let ownTimeline = false;
7674
- let stripHost = null;
7675
- if (wantsStrip) {
7676
- stripHost = el("div", "knobs-cmp-strip-host");
7677
- root.append(stripHost);
7678
- }
7679
- if (!tl && (options.timeline === true || wantsStrip)) {
7657
+ if (!tl && options.timeline === true) {
7680
7658
  const host = el("div", "knobs-cmp-timeline-host");
7681
7659
  root.append(host);
7682
7660
  tl = timeline(host, {
@@ -7824,7 +7802,7 @@ function compare(container, options) {
7824
7802
  s.frames.keep(at, v, v.videoWidth, v.videoHeight);
7825
7803
  return;
7826
7804
  }
7827
- const c = s.frames.nearest(want);
7805
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
7828
7806
  if (c && Math.abs(c.frame - want) < Math.abs(at - want))
7829
7807
  showCached(s, c);
7830
7808
  else
@@ -7836,13 +7814,23 @@ function compare(container, options) {
7836
7814
  const want = wantOf(s);
7837
7815
  if (s.shown && !s.shown.cached && s.shown.frame === want)
7838
7816
  return;
7839
- const c = s.frames.nearest(want);
7817
+ const c = s.frames.nearest(want, Number.POSITIVE_INFINITY, true);
7840
7818
  if (!c)
7841
7819
  return;
7842
7820
  const was = s.shown ? Math.abs(s.shown.frame - want) : Number.POSITIVE_INFINITY;
7843
7821
  if (Math.abs(c.frame - want) < was)
7844
7822
  showCached(s, c);
7845
7823
  };
7824
+ const giveUp = (s) => {
7825
+ s.seek = SEEK_IDLE;
7826
+ if (!s.loaded || disposed)
7827
+ return;
7828
+ if (s.el.readyState >= 2)
7829
+ return showVideo(s);
7830
+ s.shown = null;
7831
+ setCached(s, false);
7832
+ requestDraw();
7833
+ };
7846
7834
  const makeSide = (index, source) => {
7847
7835
  const kind = mediaKind(source.src, source.kind);
7848
7836
  const media = el(kind === "video" ? "video" : "img", "knobs-cmp-media");
@@ -7874,7 +7862,9 @@ function compare(container, options) {
7874
7862
  frameAt: 0,
7875
7863
  misses: 0,
7876
7864
  failed: false,
7865
+ stall: stallGuard(win, () => giveUp(side)),
7877
7866
  stop: () => {
7867
+ side.stall.clear();
7878
7868
  for (const f of offs)
7879
7869
  f();
7880
7870
  }
@@ -7884,6 +7874,10 @@ function compare(container, options) {
7884
7874
  return;
7885
7875
  side.failed = true;
7886
7876
  note.textContent = `Couldn't load ${labelOf(index)}`;
7877
+ side.stall.clear();
7878
+ side.seek = SEEK_IDLE;
7879
+ side.shown = null;
7880
+ setCached(side, false);
7887
7881
  };
7888
7882
  if (media instanceof HTMLVideoElement) {
7889
7883
  const v = media;
@@ -7914,6 +7908,8 @@ function compare(container, options) {
7914
7908
  side.seek = step.state;
7915
7909
  if (step.apply !== null)
7916
7910
  seekVideo(side, v, step.apply);
7911
+ else
7912
+ side.stall.clear();
7917
7913
  pushFrame(side);
7918
7914
  });
7919
7915
  on("error", fail);
@@ -7946,7 +7942,7 @@ function compare(container, options) {
7946
7942
  s.loaded = true;
7947
7943
  s.failed = false;
7948
7944
  s.el.src = s.source.src;
7949
- if (s.kind === "video") {
7945
+ if (s.kind === "video" && caching) {
7950
7946
  s.frames = acquireFrames(doc, s.source.src, fps());
7951
7947
  s.unlisten = s.frames.listen(() => refreshCached(s));
7952
7948
  if (onScreen) {
@@ -7963,6 +7959,7 @@ function compare(container, options) {
7963
7959
  if (s.el instanceof HTMLVideoElement)
7964
7960
  s.el.load();
7965
7961
  s.seek = SEEK_IDLE;
7962
+ s.stall.clear();
7966
7963
  s.shown = null;
7967
7964
  setCached(s, false);
7968
7965
  s.unlisten?.();
@@ -8086,6 +8083,7 @@ function compare(container, options) {
8086
8083
  v.fastSeek(t);
8087
8084
  else
8088
8085
  v.currentTime = t;
8086
+ s.stall.arm();
8089
8087
  };
8090
8088
  const showFrame = (t, used) => {
8091
8089
  for (const s of sides) {
@@ -8097,14 +8095,21 @@ function compare(container, options) {
8097
8095
  v.playbackRate = 1;
8098
8096
  }
8099
8097
  const want = Math.min(frameOf(t, fps()), lastFrame(v.duration, fps()));
8100
- const step = requestSeek(s.seek, frameMidpoint(want, fps(), v.duration), v.currentTime, fps());
8101
- s.seek = step.state;
8102
- if (step.apply !== null)
8103
- seekVideo(s, v, step.apply);
8104
- else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
8105
- pushFrame(s);
8106
- if (used)
8107
- s.frames?.focus(want);
8098
+ if (used) {
8099
+ if (scrubbing)
8100
+ s.frames?.follow(want);
8101
+ else
8102
+ s.frames?.focus(want);
8103
+ }
8104
+ if (!(scrubbing && s.frames?.covers(want))) {
8105
+ const at = frameMidpoint(want, fps(), v.duration);
8106
+ const step = requestSeek(s.seek, at, v.currentTime, fps());
8107
+ s.seek = step.state;
8108
+ if (step.apply !== null)
8109
+ seekVideo(s, v, step.apply);
8110
+ else if (step.settled && (s.shown?.cached || s.shown?.frame !== want))
8111
+ pushFrame(s);
8112
+ }
8108
8113
  refreshCached(s);
8109
8114
  }
8110
8115
  requestDraw();
@@ -8735,7 +8740,7 @@ function compare(container, options) {
8735
8740
  holdLoupe(true);
8736
8741
  return;
8737
8742
  case "l":
8738
- if (e.altKey || e.shiftKey)
8743
+ if (e.altKey || e.shiftKey || loopBtn.hidden)
8739
8744
  return;
8740
8745
  claim();
8741
8746
  return api.setLoop(!loopNow());
@@ -8758,6 +8763,9 @@ function compare(container, options) {
8758
8763
  return glideZoom(ZOOM_FIT);
8759
8764
  }
8760
8765
  };
8766
+ const syncLoopButton = () => {
8767
+ loopBtn.hidden = !sides.some((s) => s.kind === "video");
8768
+ };
8761
8769
  const replace = (index, source) => {
8762
8770
  const old = sides[index];
8763
8771
  old.stop();
@@ -8766,6 +8774,7 @@ function compare(container, options) {
8766
8774
  old.el.replaceWith(s.el);
8767
8775
  old.preview.replaceWith(s.preview);
8768
8776
  sides[index] = s;
8777
+ syncLoopButton();
8769
8778
  note.textContent = "";
8770
8779
  setLabel(index, labelOf(index));
8771
8780
  if (near)
@@ -8780,6 +8789,7 @@ function compare(container, options) {
8780
8789
  stage.insertBefore(sides[0].preview, labels[0]);
8781
8790
  setLabel(0, labelOf(0));
8782
8791
  setLabel(1, labelOf(1));
8792
+ syncLoopButton();
8783
8793
  loupeBtn.setAttribute("aria-pressed", String(loupeOn));
8784
8794
  loopBtn.setAttribute("aria-pressed", String(loopNow() !== null));
8785
8795
  const modePill = modeList.length > 1 ? selectionPill(modes, {
@@ -8858,7 +8868,7 @@ function compare(container, options) {
8858
8868
  label: MODE_LABEL[m].long,
8859
8869
  activate: () => setMode(m)
8860
8870
  })) : [],
8861
- { el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) },
8871
+ ...loopBtn.hidden ? [] : [{ el: loopBtn, label: "Loop", activate: () => api.setLoop(!loopNow()) }],
8862
8872
  ...loupeBtn.hidden ? [] : [{ el: loupeBtn, label: "Loupe", activate: () => setLoupe(!loupeOn) }],
8863
8873
  ...zoomed() ? [{ el: zoomReset, label: "Reset zoom", activate: () => api.setZoom(null) }] : [],
8864
8874
  ...mode === "wipe" ? [
@@ -8869,14 +8879,6 @@ function compare(container, options) {
8869
8879
  }
8870
8880
  ] : []
8871
8881
  ], { listen: false, rank: 1 });
8872
- const strip = wantsStrip && stripHost && tl ? compareStrip(stripHost, {
8873
- ...look,
8874
- before: options.before,
8875
- after: options.after,
8876
- timeline: tl,
8877
- ...fpsOpt ? { fps: fpsOpt } : {},
8878
- threshold
8879
- }) : null;
8880
8882
  const api = {
8881
8883
  element: root,
8882
8884
  get mode() {
@@ -8902,7 +8904,6 @@ function compare(container, options) {
8902
8904
  return renderer;
8903
8905
  },
8904
8906
  timeline: tl,
8905
- strip,
8906
8907
  get zoom() {
8907
8908
  return clampZoom(zoomNow());
8908
8909
  },
@@ -8924,8 +8925,6 @@ function compare(container, options) {
8924
8925
  replace(1, after);
8925
8926
  else if (next.after)
8926
8927
  setLabel(1, next.after.label ?? "After");
8927
- if (before || after)
8928
- strip?.update({ ...before ? { before } : {}, ...after ? { after } : {} });
8929
8928
  if (next.before || next.after)
8930
8929
  readColors();
8931
8930
  if (next.mode !== undefined)
@@ -8992,7 +8991,6 @@ function compare(container, options) {
8992
8991
  if (ownTimeline)
8993
8992
  tl.dispose();
8994
8993
  }
8995
- strip?.dispose();
8996
8994
  for (const s of sides) {
8997
8995
  s.stop();
8998
8996
  unloadSide(s);
@@ -9016,7 +9014,6 @@ var init_compare = __esm(() => {
9016
9014
  init_compare_frames();
9017
9015
  init_compare_gpu();
9018
9016
  init_compare_model();
9019
- init_compare_strip();
9020
9017
  init_icons();
9021
9018
  init_look();
9022
9019
  init_rgba();
@@ -9074,7 +9071,10 @@ function player(container, options) {
9074
9071
  track.src = options.captions;
9075
9072
  v.append(track);
9076
9073
  }
9077
- frame.append(v);
9074
+ const preview = el("canvas", "knobs-player-preview");
9075
+ preview.setAttribute("aria-hidden", "true");
9076
+ preview.hidden = true;
9077
+ frame.append(v, preview);
9078
9078
  const host = el("div", "knobs-player-line");
9079
9079
  root.append(frame, host);
9080
9080
  container.append(root);
@@ -9104,6 +9104,57 @@ function player(container, options) {
9104
9104
  else
9105
9105
  v.currentTime = t;
9106
9106
  };
9107
+ const fps = options.fps ?? 30;
9108
+ let frames = null;
9109
+ let unlistenFrames = null;
9110
+ let scrubbing = false;
9111
+ let previewing = null;
9112
+ const want = () => frameOf(tl.time, fps);
9113
+ const emitFrame = (frame, source) => root.dispatchEvent(new CustomEvent("knobs-player-frame", { detail: { frame, source } }));
9114
+ const drawPreview = (c) => {
9115
+ if (preview.width !== c.bitmap.width || preview.height !== c.bitmap.height) {
9116
+ preview.width = c.bitmap.width;
9117
+ preview.height = c.bitmap.height;
9118
+ }
9119
+ preview.getContext("2d")?.drawImage(c.bitmap, 0, 0);
9120
+ preview.hidden = false;
9121
+ previewing = c.frame;
9122
+ emitFrame(c.frame, "cache");
9123
+ };
9124
+ const hidePreview = () => {
9125
+ if (previewing === null)
9126
+ return;
9127
+ previewing = null;
9128
+ preview.hidden = true;
9129
+ };
9130
+ const showNearest = () => {
9131
+ if (!frames || !scrubbing)
9132
+ return;
9133
+ const w = want();
9134
+ const c = frames.nearest(w, Number.POSITIVE_INFINITY, true);
9135
+ if (!c || c.frame === previewing)
9136
+ return;
9137
+ const shown = previewing ?? frameOf(v.currentTime, fps);
9138
+ if (Math.abs(c.frame - w) < Math.abs(shown - w))
9139
+ drawPreview(c);
9140
+ };
9141
+ const caching = scrubCaching(options.scrubCache, doc.defaultView);
9142
+ const startFrames = () => {
9143
+ if (frames || !caching)
9144
+ return;
9145
+ frames = acquireFrames(doc, options.src, fps);
9146
+ frames.visible(true);
9147
+ unlistenFrames = frames.listen(showNearest);
9148
+ };
9149
+ const stall = stallGuard(doc.defaultView, () => {
9150
+ pending = null;
9151
+ hidePreview();
9152
+ });
9153
+ const onError = () => {
9154
+ stall.clear();
9155
+ pending = null;
9156
+ hidePreview();
9157
+ };
9107
9158
  let reported = 0;
9108
9159
  const onSeeked = () => {
9109
9160
  if (pending !== null) {
@@ -9112,6 +9163,11 @@ function player(container, options) {
9112
9163
  v.currentTime = t;
9113
9164
  return;
9114
9165
  }
9166
+ stall.clear();
9167
+ const at = frameOf(v.currentTime, fps);
9168
+ emitFrame(at, "video");
9169
+ if (!scrubbing || previewing !== null && Math.abs(at - want()) <= Math.abs(previewing - want()))
9170
+ hidePreview();
9115
9171
  if (!v.paused || tl.playing || Math.abs(v.currentTime - reported) < 0.1)
9116
9172
  return;
9117
9173
  reported = v.currentTime;
@@ -9127,6 +9183,7 @@ function player(container, options) {
9127
9183
  v.addEventListener("loadedmetadata", onMeta);
9128
9184
  v.addEventListener("loadeddata", onFrame);
9129
9185
  v.addEventListener("seeked", onSeeked);
9186
+ v.addEventListener("error", onError);
9130
9187
  const unlisten = tl.listen({
9131
9188
  onSeek(t, source) {
9132
9189
  if (source === "play") {
@@ -9134,14 +9191,24 @@ function player(container, options) {
9134
9191
  v.currentTime = t;
9135
9192
  return;
9136
9193
  }
9137
- if (Math.abs(v.currentTime - t) < 1 / (options.fps ?? 30))
9194
+ if (scrubbing && frames) {
9195
+ frames.follow(want());
9196
+ showNearest();
9197
+ if (frames.covers(want()))
9198
+ return;
9199
+ }
9200
+ if (sameFrame(v.currentTime, t, fps)) {
9201
+ if (!scrubbing)
9202
+ hidePreview();
9138
9203
  return;
9204
+ }
9139
9205
  seekVideo(t);
9140
9206
  },
9141
9207
  onPlay(on) {
9142
- if (on)
9208
+ if (on) {
9209
+ hidePreview();
9143
9210
  v.play().catch(() => tl.pause());
9144
- else {
9211
+ } else {
9145
9212
  v.pause();
9146
9213
  reported = tl.time;
9147
9214
  }
@@ -9149,8 +9216,22 @@ function player(container, options) {
9149
9216
  options.onPlay?.(on, tl.time);
9150
9217
  },
9151
9218
  onScrub(on) {
9152
- if (on)
9219
+ scrubbing = on;
9220
+ if (on) {
9153
9221
  v.pause();
9222
+ startFrames();
9223
+ return;
9224
+ }
9225
+ if (v.readyState < 1)
9226
+ return hidePreview();
9227
+ const at = releaseSeek(v.currentTime, tl.time, fps, v.duration);
9228
+ if (at === null) {
9229
+ pending = null;
9230
+ if (!v.seeking)
9231
+ return hidePreview();
9232
+ } else
9233
+ seekVideo(at);
9234
+ stall.arm();
9154
9235
  }
9155
9236
  });
9156
9237
  tl.drive(() => {
@@ -9165,6 +9246,7 @@ function player(container, options) {
9165
9246
  tl.toggle();
9166
9247
  };
9167
9248
  frame.addEventListener("click", onClick);
9249
+ root.addEventListener("pointerenter", startFrames);
9168
9250
  v.src = options.src;
9169
9251
  const api = {
9170
9252
  element: root,
@@ -9191,9 +9273,18 @@ function player(container, options) {
9191
9273
  tl.dispose();
9192
9274
  v.pause();
9193
9275
  frame.removeEventListener("click", onClick);
9276
+ root.removeEventListener("pointerenter", startFrames);
9277
+ unlistenFrames?.();
9278
+ if (frames) {
9279
+ frames.visible(false);
9280
+ frames.release();
9281
+ frames = null;
9282
+ }
9194
9283
  v.removeEventListener("loadedmetadata", onMeta);
9195
9284
  v.removeEventListener("loadeddata", onFrame);
9196
9285
  v.removeEventListener("seeked", onSeeked);
9286
+ v.removeEventListener("error", onError);
9287
+ stall.clear();
9197
9288
  v.removeAttribute("src");
9198
9289
  v.load();
9199
9290
  root.remove();
@@ -9202,6 +9293,8 @@ function player(container, options) {
9202
9293
  return api;
9203
9294
  }
9204
9295
  var init_player = __esm(() => {
9296
+ init_compare_frames();
9297
+ init_compare_model();
9205
9298
  init_look();
9206
9299
  init_timeline();
9207
9300
  });
@@ -16121,9 +16214,8 @@ var walkthroughCss = `${foundationCss}
16121
16214
  .knobs-wt-slot .knobs-cmp-stage{border-radius:var(--kw-frame-r)}
16122
16215
  /* A compare is as tall as its two pictures side by side at this width, in every mode (Wipe fuses
16123
16216
  them at one side's size, so nothing grows or re-measures), plus its mode bar, the line and gaps
16124
- (92 px), and the frame strip with its gap (158 px). */
16217
+ (92 px). */
16125
16218
  .knobs-wt-fit{width:100%;height:calc(100cqw / 2 / var(--kw-aspect,1.7778) + var(--kw-chrome,92px))}
16126
- .knobs-wt-fit[data-strip]{--kw-chrome:158px}
16127
16219
  .knobs-wt-fit[data-settled]{transition:height var(--k-wt-calm-ms,${calm.duration}ms) ${calm.easing}}
16128
16220
  .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)}
16129
16221
  .knobs-wt-frame>:is(video,img){position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;margin:0;border:0}
@@ -16136,16 +16228,15 @@ var walkthroughCss = `${foundationCss}
16136
16228
  /* The diff inside it: its header row 28 high on the container's padding, a hairline under it, and
16137
16229
  no line of its own under the code (the container's ring closes it). */
16138
16230
  .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"]){--kd-head:28px;--kd-x:0px}
16139
- .knobs-wt-slot[data-kind="diff"] .knobs-ui.knobs-diff:not([data-on="pane"])::after{display:none}
16140
- .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);
16231
+ .knobs-wt-slot[data-kind="diff"] :is(.knobs-ui.knobs-diff:not([data-on="pane"]),.knobs-diff-review)::after{display:none}
16232
+ .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);
16141
16233
  font:var(--_k-type-label)/var(--_k-leading-body) var(--_k-mono);white-space:pre;tab-size:2;scrollbar-width:thin}
16142
- .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)}
16234
+ .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)}
16143
16235
  .knobs-wt-code[data-terminal]{white-space:pre-wrap;overflow-wrap:anywhere}
16144
16236
  .knobs-wt-prompt{color:var(--_k-fg-3);user-select:none;-webkit-user-select:none}
16145
16237
  .knobs-wt-cmd{color:var(--_k-fg);font-weight:var(--_k-weight-label)}
16146
16238
  .knobs-wt-out{color:var(--_k-fg-2)}
16147
16239
  .knobs-wt-metric{display:flex;flex-direction:column;gap:var(--_k-space-sibling)}
16148
- .knobs-wt-mlabel{color:var(--_k-fg-2)}
16149
16240
  .knobs-wt-mvalue{display:flex;align-items:baseline;flex-wrap:wrap;gap:var(--_k-space-sibling);font-feature-settings:var(--_kr-data)}
16150
16241
  .knobs-wt-mfrom{color:var(--_k-fg-3)}
16151
16242
  .knobs-wt-mto{font-size:var(--_k-type-title);line-height:var(--_k-leading-title);font-weight:var(--_k-weight-label)}
@@ -16164,6 +16255,9 @@ var walkthroughCss = `${foundationCss}
16164
16255
  .knobs-wt-callout[data-tone="bad"]{--kw-tone:var(--_k-danger)}
16165
16256
  .knobs-wt-text{max-width:var(--kw-measure);color:var(--_k-fg-2)}
16166
16257
  .knobs-wt-hold{border-radius:var(--kw-frame-r);background:var(--_k-fill)}
16258
+ /* A diff holds its room with its own well's edge, no fill: the diff reads the colour behind it
16259
+ as it mounts, and a loading fill there would stay under its code. */
16260
+ .knobs-wt-slot[data-kind="diff"]>.knobs-wt-hold{background:transparent}
16167
16261
  .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)}
16168
16262
 
16169
16263
  /* Annotations: a calm ring round a box, a dot or an arrow at a point, a small label beside it;
@@ -16413,8 +16507,6 @@ var side = (s, fallback) => ({
16413
16507
  async function mountCompare(host, m, look, alive, onEvent) {
16414
16508
  const doc = host.ownerDocument;
16415
16509
  const fit = div(doc, "knobs-wt-fit");
16416
- if (m.strip)
16417
- fit.dataset.strip = "";
16418
16510
  const { before, after } = m;
16419
16511
  const known = parseAspect(m.aspect);
16420
16512
  fit.style.setProperty("--kw-aspect", String(known ?? DEFAULT_ASPECT));
@@ -16441,7 +16533,6 @@ async function mountCompare(host, m, look, alive, onEvent) {
16441
16533
  timeline: mediaKind(before.src) === "video" || mediaKind(after.src) === "video",
16442
16534
  lazy: false,
16443
16535
  ...aspect ? { aspect } : {},
16444
- ...m.strip ? { strip: true } : {},
16445
16536
  onMode: (next) => {
16446
16537
  onEvent?.({ what: "mode", detail: next });
16447
16538
  const layer = marks?.element;
@@ -16503,11 +16594,11 @@ async function mountCompare(host, m, look, alive, onEvent) {
16503
16594
  pills?.sync();
16504
16595
  marking?.kick();
16505
16596
  if (source !== "play")
16506
- onEvent?.({ what: "seek", time: round2(time) });
16597
+ onEvent?.({ what: "seek", time: round3(time) });
16507
16598
  },
16508
16599
  onPlay: (on) => {
16509
16600
  marking?.kick();
16510
- onEvent?.({ what: on ? "play" : "pause", time: round2(c.time) });
16601
+ onEvent?.({ what: on ? "play" : "pause", time: round3(c.time) });
16511
16602
  }
16512
16603
  });
16513
16604
  requestAnimationFrame(() => requestAnimationFrame(() => fit.dataset.settled = ""));
@@ -16525,13 +16616,13 @@ async function mountCompare(host, m, look, alive, onEvent) {
16525
16616
  const zoom = zoomedIn(c.zoom) ? c.zoom : null;
16526
16617
  return {
16527
16618
  kind: "compare",
16528
- time: round2(c.time),
16529
- duration: round2(c.duration),
16619
+ time: round3(c.time),
16620
+ duration: round3(c.duration),
16530
16621
  playing: c.playing,
16531
16622
  mode: c.mode,
16532
16623
  ...pills?.current ? { chapter: pills.current.label } : {},
16533
- ...loop ? { loop: { start: round2(loop.start), end: round2(loop.end) } } : {},
16534
- ...zoom ? { zoom: { u: round2(zoom.u), v: round2(zoom.v), w: round2(zoom.w), h: round2(zoom.h) } } : {}
16624
+ ...loop ? { loop: { start: round3(loop.start), end: round3(loop.end) } } : {},
16625
+ ...zoom ? { zoom: { u: round3(zoom.u), v: round3(zoom.v), w: round3(zoom.w), h: round3(zoom.h) } } : {}
16535
16626
  };
16536
16627
  },
16537
16628
  dispose: () => {
@@ -16544,7 +16635,7 @@ async function mountCompare(host, m, look, alive, onEvent) {
16544
16635
  }
16545
16636
  };
16546
16637
  }
16547
- var round2 = (v) => Math.round(v * 100) / 100;
16638
+ var round3 = (v) => Math.round(v * 100) / 100;
16548
16639
  var MARK_GLIDE = 450;
16549
16640
  var zoomedIn = (z) => z.w < 0.999 || z.h < 0.999;
16550
16641
  async function mountVideo(host, m, look, alive, onEvent) {
@@ -16571,12 +16662,12 @@ async function mountVideo(host, m, look, alive, onEvent) {
16571
16662
  } : {},
16572
16663
  onPlay: (on, time) => {
16573
16664
  marking?.kick();
16574
- onEvent?.({ what: on ? "play" : "pause", time: round2(time) });
16665
+ onEvent?.({ what: on ? "play" : "pause", time: round3(time) });
16575
16666
  },
16576
16667
  onSeek: (time) => {
16577
16668
  pills?.sync();
16578
16669
  marking?.kick();
16579
- onEvent?.({ what: "seek", time: round2(time) });
16670
+ onEvent?.({ what: "seek", time: round3(time) });
16580
16671
  }
16581
16672
  });
16582
16673
  const v = p.video;
@@ -16609,8 +16700,8 @@ async function mountVideo(host, m, look, alive, onEvent) {
16609
16700
  seekBy: (d) => p.seekBy(d),
16610
16701
  context: () => ({
16611
16702
  kind: "video",
16612
- time: round2(p.time),
16613
- ...p.duration ? { duration: round2(p.duration) } : {},
16703
+ time: round3(p.time),
16704
+ ...p.duration ? { duration: round3(p.duration) } : {},
16614
16705
  playing: p.playing,
16615
16706
  ...pills?.current ? { chapter: pills.current.label } : {}
16616
16707
  }),
@@ -16754,8 +16845,6 @@ var fmt = (v, unit = "") => `${v}${unit}`;
16754
16845
  function mountMetric(host, m) {
16755
16846
  const doc = host.ownerDocument;
16756
16847
  const box = div(doc, "knobs-wt-metric");
16757
- const label = div(doc, "knobs-wt-mlabel");
16758
- label.textContent = m.label;
16759
16848
  const value = div(doc, "knobs-wt-mvalue");
16760
16849
  const span = (cls, text) => {
16761
16850
  const s = doc.createElement("span");
@@ -16775,7 +16864,7 @@ function mountMetric(host, m) {
16775
16864
  delta.dataset.tone = d > 0 === (better === "up") ? "good" : "bad";
16776
16865
  value.append(delta);
16777
16866
  }
16778
- box.append(label, value);
16867
+ box.append(value);
16779
16868
  const total = num(m.of);
16780
16869
  if (Number.isFinite(total) && total > 0 && Number.isFinite(a) && Number.isFinite(b)) {
16781
16870
  const track = div(doc, "knobs-wt-mtrack");
@@ -16829,7 +16918,7 @@ function mountDecision(host, m, slot) {
16829
16918
  }
16830
16919
  var HEAVY = new Set(["compare", "video", "diff", "mount"]);
16831
16920
  var RELEASES = new Set(["compare", "video"]);
16832
- var labelOf2 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "metric" ? m.label : undefined;
16921
+ var labelOf2 = (m) => m.kind === "facts" ? m.title : ("label" in m) && m.kind !== "history" ? m.label : undefined;
16833
16922
  function mediaSlot(container, given) {
16834
16923
  const doc = container.ownerDocument;
16835
16924
  injectWalkthroughStyles(doc);
@@ -16843,7 +16932,7 @@ function mediaSlot(container, given) {
16843
16932
  root.dataset.kind = m.kind;
16844
16933
  applyLook2(root, options);
16845
16934
  const label = labelOf2(m);
16846
- if (label && (m.kind === "video" || m.kind === "image" || m.kind === "code" || m.kind === "terminal" || m.kind === "facts")) {
16935
+ if (label) {
16847
16936
  const l = doc.createElement("span");
16848
16937
  l.className = "knobs-wt-slot-label";
16849
16938
  l.textContent = label;
@@ -17820,641 +17909,1015 @@ function walkthrough(container, given) {
17820
17909
  bar.input.focus({ preventScroll: true });
17821
17910
  }
17822
17911
  }
17823
- };
17824
- const renderNote = (v, n, entering = false) => {
17825
- const row = el4(doc, "div", "knobs-wt-n");
17826
- row.dataset.id = n.id;
17827
- const meta = el4(doc, "span", "knobs-wt-n-meta");
17828
- const m = n.context.media;
17829
- meta.textContent = m?.time !== undefined ? formatTime(m.time) : m?.file ? m.file.split("/").pop() : "·";
17830
- if (m?.time !== undefined) {
17831
- meta.title = "Go to this moment";
17832
- meta.dataset.seek = "";
17833
- meta.addEventListener("click", () => {
17834
- const now = mediaContext(v)?.time;
17835
- if (now !== undefined) {
17836
- for (const s of slotsOf(v))
17837
- if (s.seekBy(m.time - now))
17838
- break;
17912
+ };
17913
+ const renderNote = (v, n, entering = false) => {
17914
+ const row = el4(doc, "div", "knobs-wt-n");
17915
+ row.dataset.id = n.id;
17916
+ const meta = el4(doc, "span", "knobs-wt-n-meta");
17917
+ const m = n.context.media;
17918
+ meta.textContent = m?.time !== undefined ? formatTime(m.time) : m?.file ? m.file.split("/").pop() : "·";
17919
+ if (m?.time !== undefined) {
17920
+ meta.title = "Go to this moment";
17921
+ meta.dataset.seek = "";
17922
+ meta.addEventListener("click", () => {
17923
+ const now = mediaContext(v)?.time;
17924
+ if (now !== undefined) {
17925
+ for (const s of slotsOf(v))
17926
+ if (s.seekBy(m.time - now))
17927
+ break;
17928
+ }
17929
+ });
17930
+ }
17931
+ row.append(meta);
17932
+ const body = el4(doc, "div", "knobs-wt-n-body");
17933
+ if (n.text)
17934
+ body.append(prose2(doc, "p", "knobs-wt-n-text", n.text));
17935
+ if (n.voice) {
17936
+ const a = el4(doc, "audio", "knobs-wt-n-voice");
17937
+ a.controls = true;
17938
+ a.preload = "none";
17939
+ a.src = n.voice.url;
17940
+ body.append(a);
17941
+ }
17942
+ if (n.ink?.paths.length) {
17943
+ const thumb = doc.createElementNS(SVG, "svg");
17944
+ thumb.classList.add("knobs-wt-n-ink");
17945
+ thumb.setAttribute("viewBox", "0 0 1 1");
17946
+ thumb.setAttribute("preserveAspectRatio", "none");
17947
+ thumb.style.aspectRatio = String(n.ink.aspect);
17948
+ for (const d of n.ink.paths) {
17949
+ const p = doc.createElementNS(SVG, "path");
17950
+ p.setAttribute("d", d);
17951
+ thumb.append(p);
17952
+ }
17953
+ body.append(thumb);
17954
+ }
17955
+ row.append(body);
17956
+ const x = el4(doc, "button", "knobs-wt-x");
17957
+ x.type = "button";
17958
+ x.innerHTML = ICON.close;
17959
+ x.setAttribute("aria-label", "Delete this note");
17960
+ x.addEventListener("click", () => {
17961
+ state = removeNote(state, n.id);
17962
+ if (n.voice)
17963
+ URL.revokeObjectURL(n.voice.url);
17964
+ leave(row);
17965
+ syncGlyph(v);
17966
+ syncProgress();
17967
+ emit();
17968
+ });
17969
+ row.append(x);
17970
+ v.notes.append(row);
17971
+ if (entering)
17972
+ enter(row);
17973
+ };
17974
+ const enter = (row) => {
17975
+ if (reduced())
17976
+ return;
17977
+ const h = row.offsetHeight;
17978
+ row.style.height = "0px";
17979
+ row.dataset.entering = "";
17980
+ row.offsetHeight;
17981
+ row.style.height = `${h}px`;
17982
+ delete row.dataset.entering;
17983
+ row.addEventListener("transitionend", () => row.style.height = "", { once: true });
17984
+ };
17985
+ const leave = (row) => {
17986
+ if (reduced())
17987
+ return row.remove();
17988
+ row.style.height = `${row.offsetHeight}px`;
17989
+ row.offsetHeight;
17990
+ row.dataset.leaving = "";
17991
+ row.style.height = "0px";
17992
+ const done = () => row.remove();
17993
+ row.addEventListener("transitionend", done, { once: true });
17994
+ win.setTimeout(done, 600);
17995
+ };
17996
+ const metaLine = (cls) => el4(doc, "div", `knobs-wt-metaline ${cls}`);
17997
+ const metaText = (line, text, cls) => {
17998
+ const s = el4(doc, "span", cls);
17999
+ s.textContent = text;
18000
+ line.append(s);
18001
+ return s;
18002
+ };
18003
+ const findPRIn = (n, kind = "pr") => findPR(page.prs, n, page.repo, kind);
18004
+ const metaLink = (line, text, href) => {
18005
+ const n = prNumber(href);
18006
+ const issue = n ? null : issueNumber(href);
18007
+ if (n || issue) {
18008
+ const chip = n ? prChip(doc, n, { ...findPRIn(n), href }) : prChip(doc, issue, { ...findPRIn(issue, "issue"), href });
18009
+ line.append(chip);
18010
+ return chip;
18011
+ }
18012
+ const a = el4(doc, "a");
18013
+ a.href = href;
18014
+ a.target = "_blank";
18015
+ a.rel = "noreferrer";
18016
+ a.textContent = text;
18017
+ a.insertAdjacentHTML("beforeend", ICON.out);
18018
+ line.append(a);
18019
+ return a;
18020
+ };
18021
+ const feedItem = (f, now) => {
18022
+ const li = el4(doc, "li", "knobs-wt-fi");
18023
+ li.dataset.kind = f.kind;
18024
+ const q = el4(doc, "div", "knobs-wt-fi-q");
18025
+ const meta = metaLine("knobs-wt-fi-meta");
18026
+ if (f.at !== null) {
18027
+ const when = metaText(meta, ago(f.at, now));
18028
+ when.title = new Date(f.at).toLocaleString();
18029
+ }
18030
+ const step = f.step ? page.steps.find((s) => s.key === f.step) : undefined;
18031
+ if (step) {
18032
+ const section = page.sections.find((s) => s.id === step.section);
18033
+ const b = el4(doc, "button", "knobs-wt-fi-step");
18034
+ b.type = "button";
18035
+ b.textContent = section?.link?.label ?? plainMarkdown(step.title);
18036
+ b.title = `Go to “${plainMarkdown(step.title)}”`;
18037
+ b.addEventListener("click", () => go(step.key));
18038
+ meta.append(b);
18039
+ }
18040
+ if (f.status && review) {
18041
+ const id = f.status.toLowerCase();
18042
+ const shipped = /^(ship|shipped|merged|done)$/.test(id);
18043
+ const changes = /^(changes?|changes queued)$/.test(id);
18044
+ const s = metaText(meta, shipped ? "Shipped" : changes ? "Changes queued" : f.status);
18045
+ s.dataset.status = shipped ? "on" : changes ? "warn" : "";
18046
+ }
18047
+ if (meta.childNodes.length)
18048
+ q.append(meta);
18049
+ if (f.q)
18050
+ q.append(prose2(doc, "p", "knobs-wt-fi-ask", f.q));
18051
+ else
18052
+ q.append(prose2(doc, "p", "knobs-wt-fi-text", f.text));
18053
+ li.append(q);
18054
+ if (f.q || f.trail.length) {
18055
+ const a = el4(doc, "div", "knobs-wt-fi-a");
18056
+ if (f.q)
18057
+ a.append(prose2(doc, "div", "knobs-wt-fi-answer", f.text));
18058
+ if (f.trail.length) {
18059
+ const list = el4(doc, "ol", "knobs-wt-trail");
18060
+ for (const line of f.trail) {
18061
+ const i = line.indexOf(" · ");
18062
+ const row = el4(doc, "li");
18063
+ const words = el4(doc, "span", "knobs-wt-trail-text");
18064
+ words.textContent = i < 0 ? line : line.slice(i + 3);
18065
+ row.append(words);
18066
+ if (i >= 0) {
18067
+ const at = el4(doc, "span", "knobs-wt-trail-at");
18068
+ at.textContent = line.slice(0, i);
18069
+ row.append(at);
18070
+ }
18071
+ list.append(row);
18072
+ }
18073
+ discs.push(disclosure(a, { label: "Context", content: list }));
18074
+ }
18075
+ li.append(a);
18076
+ }
18077
+ return li;
18078
+ };
18079
+ const build = () => {
18080
+ for (const v of views) {
18081
+ v.slots.forEach((s) => s.dispose());
18082
+ v.taste?.dispose();
18083
+ }
18084
+ for (const d of discs)
18085
+ d.dispose();
18086
+ discs = [];
18087
+ pill?.dispose();
18088
+ pill = null;
18089
+ tocRows = [];
18090
+ if (full) {
18091
+ full.p.snap(0);
18092
+ closeFull();
18093
+ }
18094
+ clearInk();
18095
+ views = [];
18096
+ root.textContent = "";
18097
+ root.removeAttribute("data-settled");
18098
+ root.dataset.rail = options.rail === false ? "none" : options.rail === "right" ? "right" : "left";
18099
+ root.toggleAttribute("data-cards", options.cards === true);
18100
+ root.toggleAttribute("data-review", review);
18101
+ const layout = el4(doc, "div", "knobs-wt-layout");
18102
+ const main = el4(doc, "div", "knobs-wt-main");
18103
+ const flow = el4(doc, "div", "knobs-wt-flow");
18104
+ const head = el4(doc, "header", "knobs-wt-head");
18105
+ const h1 = el4(doc, "h1", "knobs-wt-title");
18106
+ h1.textContent = page.title;
18107
+ head.append(h1);
18108
+ if (page.summary)
18109
+ head.append(prose2(doc, "p", "knobs-wt-summary", page.summary));
18110
+ if (page.more)
18111
+ head.append(more(page.more));
18112
+ if (page.meta.length) {
18113
+ const meta = el4(doc, "div", "knobs-wt-meta");
18114
+ for (const m of page.meta) {
18115
+ const s = el4(doc, "span");
18116
+ s.append(m.label);
18117
+ const b = el4(doc, "b");
18118
+ if (m.href) {
18119
+ const a = el4(doc, "a");
18120
+ a.href = m.href;
18121
+ a.target = "_blank";
18122
+ a.rel = "noreferrer";
18123
+ a.textContent = m.value;
18124
+ b.append(a);
18125
+ } else
18126
+ b.textContent = m.value;
18127
+ s.append(b);
18128
+ meta.append(s);
18129
+ }
18130
+ head.append(meta);
18131
+ }
18132
+ flow.append(head);
18133
+ let feedEl = null;
18134
+ if (page.feed.length) {
18135
+ feedEl = el4(doc, "section", "knobs-wt-feed");
18136
+ feedEl.id = "wt-latest";
18137
+ feedEl.setAttribute("aria-label", "Updates and answers");
18138
+ const fh = el4(doc, "header", "knobs-wt-feed-head");
18139
+ const h = el4(doc, "h2", "knobs-wt-feed-title");
18140
+ h.textContent = page.feed.some((f) => f.kind === "qa") ? "Updates & Q&A" : "Updates";
18141
+ fh.append(h);
18142
+ const list = el4(doc, "ol", "knobs-wt-feed-list");
18143
+ const now = Date.now();
18144
+ for (const f of page.feed)
18145
+ list.append(feedItem(f, now));
18146
+ feedEl.append(fh, list);
18147
+ flow.append(feedEl);
18148
+ }
18149
+ let rail = null;
18150
+ count = fill = tocEl = null;
18151
+ if (options.rail !== false) {
18152
+ rail = el4(doc, "aside", "knobs-wt-rail");
18153
+ rail.setAttribute("aria-label", "Contents");
18154
+ tocEl = el4(doc, "nav", "knobs-wt-toc");
18155
+ tocEl.setAttribute("aria-label", "Contents");
18156
+ if (feedEl) {
18157
+ const group = el4(doc, "div", "knobs-wt-toc-group");
18158
+ const target = feedEl;
18159
+ group.append(tocRow("Latest", false, () => target.scrollIntoView({ block: "start", behavior: reduced() ? "auto" : "smooth" })));
18160
+ tocEl.append(group);
18161
+ }
18162
+ if (review) {
18163
+ const progress = el4(doc, "div", "knobs-wt-progress");
18164
+ count = el4(doc, "div", "knobs-wt-count");
18165
+ const meter = el4(doc, "div", "knobs-wt-bar");
18166
+ fill = el4(doc, "div", "knobs-wt-fill");
18167
+ meter.append(fill);
18168
+ progress.append(count, meter);
18169
+ rail.append(progress);
18170
+ }
18171
+ rail.append(tocEl);
18172
+ if (review)
18173
+ rail.append(actions(true));
18174
+ if (review && options.keys !== false) {
18175
+ const keys = el4(doc, "div", "knobs-wt-keys");
18176
+ keys.innerHTML = "<kbd>j</kbd> <kbd>k</kbd> steps · <kbd>←</kbd> <kbd>→</kbd> seek · <kbd>f</kbd> full · <kbd>n</kbd> note" + (page.steps.some((s) => s.ask === "pick") ? " · <kbd>1</kbd>–<kbd>9</kbd> pick" : "");
18177
+ rail.append(keys);
18178
+ }
18179
+ }
18180
+ for (const section of page.sections) {
18181
+ const sec = el4(doc, "section", "knobs-wt-section");
18182
+ sec.id = `wt-${section.id}`;
18183
+ const sh = el4(doc, "header", "knobs-wt-shead");
18184
+ const status = review ? section.status : undefined;
18185
+ if (section.kicker || section.link || status) {
18186
+ const meta = metaLine("knobs-wt-kicker");
18187
+ if (section.kicker)
18188
+ metaText(meta, section.kicker, "knobs-wt-kicker-lead");
18189
+ if (section.link)
18190
+ metaLink(meta, section.link.label, section.link.href);
18191
+ if (status) {
18192
+ const s = statusLook(status);
18193
+ metaText(meta, s.label, "knobs-wt-status").dataset.tone = s.tone;
17839
18194
  }
17840
- });
17841
- }
17842
- row.append(meta);
17843
- const body = el4(doc, "div", "knobs-wt-n-body");
17844
- if (n.text)
17845
- body.append(prose2(doc, "p", "knobs-wt-n-text", n.text));
17846
- if (n.voice) {
17847
- const a = el4(doc, "audio", "knobs-wt-n-voice");
17848
- a.controls = true;
17849
- a.preload = "none";
17850
- a.src = n.voice.url;
17851
- body.append(a);
17852
- }
17853
- if (n.ink?.paths.length) {
17854
- const thumb = doc.createElementNS(SVG, "svg");
17855
- thumb.classList.add("knobs-wt-n-ink");
17856
- thumb.setAttribute("viewBox", "0 0 1 1");
17857
- thumb.setAttribute("preserveAspectRatio", "none");
17858
- thumb.style.aspectRatio = String(n.ink.aspect);
17859
- for (const d of n.ink.paths) {
17860
- const p = doc.createElementNS(SVG, "path");
17861
- p.setAttribute("d", d);
17862
- thumb.append(p);
18195
+ sh.append(meta);
17863
18196
  }
17864
- body.append(thumb);
18197
+ const h2 = el4(doc, "h2", "knobs-wt-heading");
18198
+ h2.innerHTML = inlineMarkdown(section.title);
18199
+ sh.append(h2);
18200
+ const intro = el4(doc, "div", "knobs-wt-sintro");
18201
+ intro.append(sh);
18202
+ if (section.summary)
18203
+ intro.append(prose2(doc, "p", "knobs-wt-ssum", section.summary));
18204
+ if (section.more)
18205
+ intro.append(more(section.more));
18206
+ sec.append(intro);
18207
+ let group = null;
18208
+ if (tocEl) {
18209
+ group = el4(doc, "div", "knobs-wt-toc-group");
18210
+ const first = section.steps[0]?.key;
18211
+ group.append(tocRow(plainMarkdown(section.title), false, () => first ? go(first) : undefined, section.id));
18212
+ tocEl.append(group);
18213
+ }
18214
+ for (const step of section.steps) {
18215
+ const v = stepView(step, group);
18216
+ sec.append(v.card);
18217
+ views.push(v);
18218
+ }
18219
+ flow.append(sec);
17865
18220
  }
17866
- row.append(body);
17867
- const x = el4(doc, "button", "knobs-wt-x");
17868
- x.type = "button";
17869
- x.innerHTML = ICON.close;
17870
- x.setAttribute("aria-label", "Delete this note");
17871
- x.addEventListener("click", () => {
17872
- state = removeNote(state, n.id);
17873
- if (n.voice)
17874
- URL.revokeObjectURL(n.voice.url);
17875
- leave(row);
18221
+ linkPRs(flow, findPRIn);
18222
+ main.append(flow);
18223
+ if (noteMode !== "hidden")
18224
+ main.append(bar.dock);
18225
+ if (rail)
18226
+ layout.append(rail);
18227
+ layout.append(main);
18228
+ root.append(layout, scrim);
18229
+ for (const v of views) {
17876
18230
  syncGlyph(v);
17877
- syncProgress();
17878
- emit();
17879
- });
17880
- row.append(x);
17881
- v.notes.append(row);
17882
- if (entering)
17883
- enter(row);
17884
- };
17885
- const enter = (row) => {
17886
- if (reduced())
17887
- return;
17888
- const h = row.offsetHeight;
17889
- row.style.height = "0px";
17890
- row.dataset.entering = "";
17891
- row.offsetHeight;
17892
- row.style.height = `${h}px`;
17893
- delete row.dataset.entering;
17894
- row.addEventListener("transitionend", () => row.style.height = "", { once: true });
18231
+ for (const n of notesOn(state, v.step.key))
18232
+ renderNote(v, n);
18233
+ }
18234
+ if (tocEl)
18235
+ pill = selectionPill(tocEl, {
18236
+ selected: () => viewOf(current)?.toc ?? null,
18237
+ rows: () => tocRows,
18238
+ spring: SPRINGS.snappy
18239
+ });
18240
+ syncProgress();
18241
+ current = null;
18242
+ fromScroll();
18243
+ placeMark();
18244
+ bar.sync();
18245
+ syncReserve();
18246
+ win.requestAnimationFrame(() => win.requestAnimationFrame(() => {
18247
+ if (!disposed && !reduced())
18248
+ root.dataset.settled = "";
18249
+ }));
17895
18250
  };
17896
- const leave = (row) => {
17897
- if (reduced())
17898
- return row.remove();
17899
- row.style.height = `${row.offsetHeight}px`;
17900
- row.offsetHeight;
17901
- row.dataset.leaving = "";
17902
- row.style.height = "0px";
17903
- const done = () => row.remove();
17904
- row.addEventListener("transitionend", done, { once: true });
17905
- win.setTimeout(done, 600);
18251
+ const more = (text) => {
18252
+ const box = el4(doc, "div", "knobs-wt-more");
18253
+ const body = el4(doc, "div", "knobs-wt-more-text");
18254
+ for (const para of text.split(/\n\s*\n/))
18255
+ if (para.trim())
18256
+ body.append(prose2(doc, "p", "", para.trim()));
18257
+ discs.push(disclosure(box, { label: "More", content: body }));
18258
+ return box;
17906
18259
  };
17907
- const metaLine = (cls) => el4(doc, "div", `knobs-wt-metaline ${cls}`);
17908
- const metaText = (line, text, cls) => {
17909
- const s = el4(doc, "span", cls);
17910
- s.textContent = text;
17911
- line.append(s);
17912
- return s;
18260
+ const tocRow = (label, sub, run, section) => {
18261
+ const b = el4(doc, "button", sub ? "knobs-wt-toc-row knobs-wt-toc-sub" : "knobs-wt-toc-row");
18262
+ b.type = "button";
18263
+ if (section)
18264
+ b.dataset.section = section;
18265
+ const t = el4(doc, "span", "knobs-wt-toc-label");
18266
+ t.textContent = label;
18267
+ b.title = label;
18268
+ b.append(t);
18269
+ if (review)
18270
+ b.append(el4(doc, "span", "knobs-wt-toc-end"));
18271
+ b.addEventListener("mousedown", (ev) => ev.preventDefault());
18272
+ b.addEventListener("click", run);
18273
+ tocRows.push(b);
18274
+ return b;
17913
18275
  };
17914
- const findPRIn = (n, kind = "pr") => findPR(page.prs, n, page.repo, kind);
17915
- const metaLink = (line, text, href) => {
17916
- const n = prNumber(href);
17917
- const issue = n ? null : issueNumber(href);
17918
- if (n || issue) {
17919
- const chip = n ? prChip(doc, n, { ...findPRIn(n), href }) : prChip(doc, issue, { ...findPRIn(issue, "issue"), href });
17920
- line.append(chip);
17921
- return chip;
17922
- }
17923
- const a = el4(doc, "a");
17924
- a.href = href;
17925
- a.target = "_blank";
17926
- a.rel = "noreferrer";
17927
- a.textContent = text;
17928
- a.insertAdjacentHTML("beforeend", ICON.out);
17929
- line.append(a);
17930
- return a;
18276
+ const actions = (rail) => {
18277
+ const row = el4(doc, "div", "knobs-wt-actions");
18278
+ const btn = (text, run, primary = false) => {
18279
+ const b = el4(doc, "button", "knobs-wt-btn");
18280
+ b.type = "button";
18281
+ b.textContent = text;
18282
+ if (primary)
18283
+ b.dataset.primary = "";
18284
+ b.addEventListener("click", run);
18285
+ row.append(b);
18286
+ return b;
18287
+ };
18288
+ btn("Copy feedback", () => copy("markdown"), true);
18289
+ btn("JSON", () => copy("json"));
18290
+ if (rail)
18291
+ btn("Reset", async () => {
18292
+ const ok = await confirm({
18293
+ title: "Forget every answer on this page?",
18294
+ confirm: "Reset",
18295
+ destructive: true,
18296
+ look,
18297
+ doc
18298
+ });
18299
+ if (ok)
18300
+ api.reset();
18301
+ });
18302
+ return row;
17931
18303
  };
17932
- const feedItem = (f, now) => {
17933
- const li = el4(doc, "li", "knobs-wt-fi");
17934
- li.dataset.kind = f.kind;
17935
- const q = el4(doc, "div", "knobs-wt-fi-q");
17936
- const meta = metaLine("knobs-wt-fi-meta");
17937
- if (f.at !== null) {
17938
- const when = metaText(meta, ago(f.at, now));
17939
- when.title = new Date(f.at).toLocaleString();
18304
+ const copy = async (format) => {
18305
+ const text = format === "json" ? JSON.stringify(feedback(), null, 2) : markdown();
18306
+ try {
18307
+ await win.navigator.clipboard.writeText(text);
18308
+ toast(format === "json" ? "Feedback copied as JSON" : "Feedback copied as Markdown", {
18309
+ look,
18310
+ doc
18311
+ });
18312
+ } catch {
18313
+ toast("Couldn't reach the clipboard", { tone: "warn", look, doc });
17940
18314
  }
17941
- const step = f.step ? page.steps.find((s) => s.key === f.step) : undefined;
17942
- if (step) {
17943
- const section = page.sections.find((s) => s.id === step.section);
17944
- const b = el4(doc, "button", "knobs-wt-fi-step");
17945
- b.type = "button";
17946
- b.textContent = section?.link?.label ?? plainMarkdown(step.title);
17947
- b.title = `Go to “${plainMarkdown(step.title)}”`;
17948
- b.addEventListener("click", () => go(step.key));
17949
- meta.append(b);
18315
+ };
18316
+ const stepView = (step, group) => {
18317
+ const card = el4(doc, "article", options.cards ? "knobs-wt-step knobs-surface" : "knobs-wt-step");
18318
+ card.id = `wt-${step.key.replace("/", "--")}`;
18319
+ card.tabIndex = -1;
18320
+ card.dataset.key = step.key;
18321
+ card.setAttribute("aria-label", plainMarkdown(step.title));
18322
+ const head = el4(doc, "header", "knobs-wt-step-head");
18323
+ const h3 = el4(doc, "h3", "knobs-wt-stepTitle");
18324
+ h3.innerHTML = inlineMarkdown(step.title);
18325
+ head.append(h3);
18326
+ if (step.caption)
18327
+ head.append(prose2(doc, "p", "knobs-wt-caption", step.caption));
18328
+ if (step.more)
18329
+ head.append(more(step.more));
18330
+ card.append(head);
18331
+ const slots = [];
18332
+ const onEvent = (e) => record({ ...e, step: step.key });
18333
+ let media = null;
18334
+ if (step.media.length) {
18335
+ media = el4(doc, "div", "knobs-wt-media");
18336
+ media.style.setProperty("--kw-n", String(Math.min(step.media.length, step.columns)));
18337
+ for (const m of step.media)
18338
+ slots.push(mediaSlot(media, { ...look, media: m, onEvent, steps: stepLinks, findPR: findPRIn }));
18339
+ card.append(media);
17950
18340
  }
17951
- if (f.status && review) {
17952
- const id = f.status.toLowerCase();
17953
- const shipped = /^(ship|shipped|merged|done)$/.test(id);
17954
- const changes = /^(changes?|changes queued)$/.test(id);
17955
- const s = metaText(meta, shipped ? "Shipped" : changes ? "Changes queued" : f.status);
17956
- s.dataset.status = shipped ? "on" : changes ? "warn" : "";
18341
+ let taste = null;
18342
+ if (step.question && review)
18343
+ card.append(prose2(doc, "p", "knobs-wt-question", step.question));
18344
+ if (step.ask === "pick") {
18345
+ taste = tasteTest(card, {
18346
+ ...look,
18347
+ pickable: review,
18348
+ options: step.options,
18349
+ picked: state.steps[step.key]?.pick ?? null,
18350
+ onPick: (id) => pick(step.key, id),
18351
+ steps: stepLinks
18352
+ });
18353
+ media ??= taste.element;
17957
18354
  }
17958
- if (meta.childNodes.length)
17959
- q.append(meta);
17960
- if (f.q)
17961
- q.append(prose2(doc, "p", "knobs-wt-fi-ask", f.q));
17962
- else
17963
- q.append(prose2(doc, "p", "knobs-wt-fi-text", f.text));
17964
- li.append(q);
17965
- if (f.q || f.trail.length) {
17966
- const a = el4(doc, "div", "knobs-wt-fi-a");
17967
- if (f.q)
17968
- a.append(prose2(doc, "div", "knobs-wt-fi-answer", f.text));
17969
- if (f.trail.length) {
17970
- const list = el4(doc, "ol", "knobs-wt-trail");
17971
- for (const line of f.trail) {
17972
- const i = line.indexOf(" · ");
17973
- const row = el4(doc, "li");
17974
- const words = el4(doc, "span", "knobs-wt-trail-text");
17975
- words.textContent = i < 0 ? line : line.slice(i + 3);
17976
- row.append(words);
17977
- if (i >= 0) {
17978
- const at = el4(doc, "span", "knobs-wt-trail-at");
17979
- at.textContent = line.slice(0, i);
17980
- row.append(at);
17981
- }
17982
- list.append(row);
17983
- }
17984
- discs.push(disclosure(a, { label: "Context", content: list }));
18355
+ const notes = el4(doc, "div", "knobs-wt-notes");
18356
+ if (review)
18357
+ card.append(notes);
18358
+ card.addEventListener("focusin", () => {
18359
+ if (scroll.to)
18360
+ return;
18361
+ pinUntil = performance.now() + 250;
18362
+ setCurrent(step.key);
18363
+ });
18364
+ let toc = null;
18365
+ let glyph = null;
18366
+ if (group) {
18367
+ toc = tocRow(plainMarkdown(step.title), true, () => go(step.key));
18368
+ if (review) {
18369
+ glyph = el4(doc, "span", "knobs-wt-toc-glyph");
18370
+ toc.querySelector(".knobs-wt-toc-end").append(glyph);
17985
18371
  }
17986
- li.append(a);
18372
+ group.append(toc);
17987
18373
  }
17988
- return li;
18374
+ return { step, card, media, notes, toc, glyph, taste, slots };
17989
18375
  };
17990
- const build = () => {
17991
- for (const v of views) {
17992
- v.slots.forEach((s) => s.dispose());
17993
- v.taste?.dispose();
18376
+ const pick = (key, id) => {
18377
+ state = setFeedback(state, key, { pick: id });
18378
+ const v = viewOf(key);
18379
+ if (v) {
18380
+ v.taste?.pick(id);
18381
+ syncGlyph(v);
17994
18382
  }
17995
- for (const d of discs)
17996
- d.dispose();
17997
- discs = [];
17998
- pill?.dispose();
17999
- pill = null;
18000
- tocRows = [];
18001
- if (full) {
18002
- full.p.snap(0);
18003
- closeFull();
18383
+ record({ what: "pick", step: key, detail: id ?? "cleared" });
18384
+ syncProgress();
18385
+ emit();
18386
+ };
18387
+ const LEAVE = "[role=tree],[role=listbox],[role=grid],[role=slider],[role=menu],.knobs-timeline";
18388
+ const onKey = (e) => {
18389
+ if (disposed || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey)
18390
+ return;
18391
+ if (isTypingTarget(e.target))
18392
+ return;
18393
+ const target = e.target instanceof Element ? e.target : null;
18394
+ if (target && target !== doc.body && target !== doc.documentElement && !root.contains(target))
18395
+ return;
18396
+ const own = target?.closest(LEAVE);
18397
+ const claim = () => {
18398
+ e.preventDefault();
18399
+ e.stopPropagation();
18400
+ };
18401
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
18402
+ if (key === "Escape") {
18403
+ if (ink.on)
18404
+ return claim(), setInk(false);
18405
+ if (slotsOf(viewOf(current)).some((s) => s.unzoom()))
18406
+ return claim();
18407
+ if (full)
18408
+ return claim(), setFull(false);
18409
+ return;
18004
18410
  }
18005
- clearInk();
18006
- views = [];
18007
- root.textContent = "";
18008
- root.removeAttribute("data-settled");
18009
- root.dataset.rail = options.rail === false ? "none" : options.rail === "right" ? "right" : "left";
18010
- root.toggleAttribute("data-cards", options.cards === true);
18011
- root.toggleAttribute("data-review", review);
18012
- const layout = el4(doc, "div", "knobs-wt-layout");
18013
- const main = el4(doc, "div", "knobs-wt-main");
18014
- const flow = el4(doc, "div", "knobs-wt-flow");
18015
- const head = el4(doc, "header", "knobs-wt-head");
18016
- const h1 = el4(doc, "h1", "knobs-wt-title");
18017
- h1.textContent = page.title;
18018
- head.append(h1);
18019
- if (page.summary)
18020
- head.append(prose2(doc, "p", "knobs-wt-summary", page.summary));
18021
- if (page.more)
18022
- head.append(more(page.more));
18023
- if (page.meta.length) {
18024
- const meta = el4(doc, "div", "knobs-wt-meta");
18025
- for (const m of page.meta) {
18026
- const s = el4(doc, "span");
18027
- s.append(m.label);
18028
- const b = el4(doc, "b");
18029
- if (m.href) {
18030
- const a = el4(doc, "a");
18031
- a.href = m.href;
18032
- a.target = "_blank";
18033
- a.rel = "noreferrer";
18034
- a.textContent = m.value;
18035
- b.append(a);
18036
- } else
18037
- b.textContent = m.value;
18038
- s.append(b);
18039
- meta.append(s);
18040
- }
18041
- head.append(meta);
18411
+ if (key === "f" && !e.shiftKey) {
18412
+ if (!viewOf(current)?.media)
18413
+ return;
18414
+ claim();
18415
+ return setFull(!full);
18042
18416
  }
18043
- flow.append(head);
18044
- let feedEl = null;
18045
- if (page.feed.length) {
18046
- feedEl = el4(doc, "section", "knobs-wt-feed");
18047
- feedEl.id = "wt-latest";
18048
- feedEl.setAttribute("aria-label", "Updates and answers");
18049
- const fh = el4(doc, "header", "knobs-wt-feed-head");
18050
- const h = el4(doc, "h2", "knobs-wt-feed-title");
18051
- h.textContent = page.feed.some((f) => f.kind === "qa") ? "Updates & Q&A" : "Updates";
18052
- fh.append(h);
18053
- const list = el4(doc, "ol", "knobs-wt-feed-list");
18054
- const now = Date.now();
18055
- for (const f of page.feed)
18056
- list.append(feedItem(f, now));
18057
- feedEl.append(fh, list);
18058
- flow.append(feedEl);
18417
+ if (own)
18418
+ return;
18419
+ if (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp") {
18420
+ if (e.shiftKey && key.startsWith("Arrow"))
18421
+ return;
18422
+ const dir = key === "j" || key === "ArrowDown" ? 1 : -1;
18423
+ const next = adjacentStep(views.map((v) => v.step.key), current, dir);
18424
+ if (!next)
18425
+ return;
18426
+ claim();
18427
+ if (full)
18428
+ setFull(false);
18429
+ return go(next);
18059
18430
  }
18060
- let rail = null;
18061
- count = fill = tocEl = null;
18062
- if (options.rail !== false) {
18063
- rail = el4(doc, "aside", "knobs-wt-rail");
18064
- rail.setAttribute("aria-label", "Contents");
18065
- tocEl = el4(doc, "nav", "knobs-wt-toc");
18066
- tocEl.setAttribute("aria-label", "Contents");
18067
- if (feedEl) {
18068
- const group = el4(doc, "div", "knobs-wt-toc-group");
18069
- const target = feedEl;
18070
- group.append(tocRow("Latest", false, () => target.scrollIntoView({ block: "start", behavior: reduced() ? "auto" : "smooth" })));
18071
- tocEl.append(group);
18072
- }
18073
- if (review) {
18074
- const progress = el4(doc, "div", "knobs-wt-progress");
18075
- count = el4(doc, "div", "knobs-wt-count");
18076
- const meter = el4(doc, "div", "knobs-wt-bar");
18077
- fill = el4(doc, "div", "knobs-wt-fill");
18078
- meter.append(fill);
18079
- progress.append(count, meter);
18080
- rail.append(progress);
18081
- }
18082
- rail.append(tocEl);
18083
- if (review)
18084
- rail.append(actions(true));
18085
- if (review && options.keys !== false) {
18086
- const keys = el4(doc, "div", "knobs-wt-keys");
18087
- keys.innerHTML = "<kbd>j</kbd> <kbd>k</kbd> steps · <kbd>←</kbd> <kbd>→</kbd> seek · <kbd>f</kbd> full · <kbd>n</kbd> note" + (page.steps.some((s) => s.ask === "pick") ? " · <kbd>1</kbd>–<kbd>9</kbd> pick" : "");
18088
- rail.append(keys);
18089
- }
18431
+ if ((key === "Home" || key === "End") && views.length) {
18432
+ claim();
18433
+ if (full)
18434
+ setFull(false);
18435
+ return go(views[key === "Home" ? 0 : views.length - 1].step.key);
18436
+ }
18437
+ if (key === "ArrowLeft" || key === "ArrowRight") {
18438
+ const d = (key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? 2 : 0.5);
18439
+ if (seekBy(d))
18440
+ claim();
18441
+ return;
18090
18442
  }
18091
- for (const section of page.sections) {
18092
- const sec = el4(doc, "section", "knobs-wt-section");
18093
- sec.id = `wt-${section.id}`;
18094
- const sh = el4(doc, "header", "knobs-wt-shead");
18095
- const status = review ? section.status : undefined;
18096
- if (section.kicker || section.link || status) {
18097
- const meta = metaLine("knobs-wt-kicker");
18098
- if (section.kicker)
18099
- metaText(meta, section.kicker, "knobs-wt-kicker-lead");
18100
- if (section.link)
18101
- metaLink(meta, section.link.label, section.link.href);
18102
- if (status) {
18103
- const s = statusLook(status);
18104
- metaText(meta, s.label, "knobs-wt-status").dataset.tone = s.tone;
18105
- }
18106
- sh.append(meta);
18443
+ if (key === "a" && !e.shiftKey && root.querySelector(".knobs-wt-anno")) {
18444
+ claim();
18445
+ return setAnnotations(root, !annotationsOn(root));
18446
+ }
18447
+ if (key === "n" && noteMode !== "hidden") {
18448
+ claim();
18449
+ bar.setOpen(true, true);
18450
+ return;
18451
+ }
18452
+ const v = viewOf(current);
18453
+ if (!v || e.shiftKey || !review)
18454
+ return;
18455
+ const choice = digitChoice(v.step, state.steps[v.step.key], key);
18456
+ if (choice === undefined)
18457
+ return;
18458
+ claim();
18459
+ pick(v.step.key, choice);
18460
+ };
18461
+ if (options.keys !== false)
18462
+ doc.addEventListener("keydown", onKey);
18463
+ const unregisterHints = hints.register(root, () => {
18464
+ const v = viewOf(current);
18465
+ return [
18466
+ ...v ? v.card.querySelectorAll(".knobs-wt-pick") : [],
18467
+ ...root.querySelectorAll(".knobs-wt-tool:not([hidden]),.knobs-wt-rail .knobs-wt-btn")
18468
+ ];
18469
+ }, { listen: false, rank: 1 });
18470
+ build();
18471
+ const api = {
18472
+ element: root,
18473
+ get page() {
18474
+ return page;
18475
+ },
18476
+ get state() {
18477
+ return state;
18478
+ },
18479
+ get feedback() {
18480
+ return feedback();
18481
+ },
18482
+ get current() {
18483
+ return current;
18484
+ },
18485
+ markdown,
18486
+ go: (key) => go(key),
18487
+ full: (on) => setFull(on ?? !full),
18488
+ send: (text) => send(text),
18489
+ set(next) {
18490
+ const keep = current;
18491
+ const sameId = next.id === page.id;
18492
+ page = normalizeWalkthrough(next);
18493
+ if (!sameId)
18494
+ state = load();
18495
+ build();
18496
+ if (keep && viewOf(keep))
18497
+ go(keep);
18498
+ },
18499
+ reset() {
18500
+ state = EMPTY_STATE;
18501
+ for (const v of views) {
18502
+ v.taste?.pick(null);
18503
+ v.notes.textContent = "";
18504
+ syncGlyph(v);
18107
18505
  }
18108
- const h2 = el4(doc, "h2", "knobs-wt-heading");
18109
- h2.innerHTML = inlineMarkdown(section.title);
18110
- sh.append(h2);
18111
- const intro = el4(doc, "div", "knobs-wt-sintro");
18112
- intro.append(sh);
18113
- if (section.summary)
18114
- intro.append(prose2(doc, "p", "knobs-wt-ssum", section.summary));
18115
- if (section.more)
18116
- intro.append(more(section.more));
18117
- sec.append(intro);
18118
- let group = null;
18119
- if (tocEl) {
18120
- group = el4(doc, "div", "knobs-wt-toc-group");
18121
- const first = section.steps[0]?.key;
18122
- group.append(tocRow(plainMarkdown(section.title), false, () => first ? go(first) : undefined, section.id));
18123
- tocEl.append(group);
18506
+ syncProgress();
18507
+ emit();
18508
+ },
18509
+ dispose() {
18510
+ if (disposed)
18511
+ return;
18512
+ disposed = true;
18513
+ win.clearTimeout(emitTimer);
18514
+ win.clearTimeout(backTimer);
18515
+ win.clearInterval(ticker);
18516
+ win.clearInterval(voice.timer);
18517
+ voice.recorder?.stop();
18518
+ motion.stop();
18519
+ if (scrollRaf)
18520
+ win.cancelAnimationFrame(scrollRaf);
18521
+ win.removeEventListener("scroll", onScroll, { capture: true });
18522
+ win.removeEventListener("resize", onScroll);
18523
+ win.removeEventListener("resize", onFullResize);
18524
+ for (const type of ["wheel", "touchstart"])
18525
+ win.removeEventListener(type, stopScroll);
18526
+ doc.removeEventListener("keydown", onKey);
18527
+ unregisterHints();
18528
+ bar.unobserve();
18529
+ pill?.dispose();
18530
+ for (const d of discs)
18531
+ d.dispose();
18532
+ if (padded)
18533
+ padded.el.style.scrollPaddingBottom = padded.was;
18534
+ for (const v of views) {
18535
+ v.slots.forEach((s) => s.dispose());
18536
+ v.taste?.dispose();
18124
18537
  }
18125
- for (const step of section.steps) {
18126
- const v = stepView(step, group);
18127
- sec.append(v.card);
18128
- views.push(v);
18538
+ root.remove();
18539
+ }
18540
+ };
18541
+ return api;
18542
+ }
18543
+
18544
+ // src/review/index.ts
18545
+ init_compare();
18546
+
18547
+ // src/ui/compare-strip.ts
18548
+ init_compare_frames();
18549
+ init_compare_model();
18550
+ init_look();
18551
+ init_rgba();
18552
+ init_timeline_model();
18553
+ var BAR = 7;
18554
+ var GAP = 2;
18555
+ var DIFF = { w: 32, h: 18 };
18556
+ var DIFFS_PER_DRAW = 6;
18557
+ function compareStrip(container, options) {
18558
+ const doc = container.ownerDocument;
18559
+ const win = doc.defaultView;
18560
+ const timeline = options.timeline;
18561
+ const fps = () => options.fps ?? timeline.fps;
18562
+ const align = options.align ?? "track";
18563
+ const threshold = options.threshold ?? 4 / 255;
18564
+ const root = doc.createElement("div");
18565
+ root.className = "knobs-cmp-strip";
18566
+ root.setAttribute("role", "img");
18567
+ root.setAttribute("aria-label", "Frame strip: Before over After, with how much they differ");
18568
+ applyLook2(root, options);
18569
+ const canvas = doc.createElement("canvas");
18570
+ canvas.setAttribute("aria-hidden", "true");
18571
+ root.append(canvas);
18572
+ container.append(root);
18573
+ const diffs = new Map;
18574
+ const pairDiffs = new Map;
18575
+ let disposed = false;
18576
+ let near = false;
18577
+ let onScreen = false;
18578
+ const makeSide = (source) => ({
18579
+ source,
18580
+ kind: mediaKind(source.src, source.kind),
18581
+ frames: null,
18582
+ still: null,
18583
+ seen: false
18584
+ });
18585
+ let sides = [makeSide(options.before), makeSide(options.after)];
18586
+ const offs = new Map;
18587
+ const load = (s) => {
18588
+ if (s.frames || s.still)
18589
+ return;
18590
+ if (s.kind === "video") {
18591
+ s.frames = acquireFrames(doc, s.source.src, fps());
18592
+ offs.set(s, s.frames.listen(schedule));
18593
+ if (onScreen) {
18594
+ s.frames.visible(true);
18595
+ s.seen = true;
18129
18596
  }
18130
- flow.append(sec);
18597
+ } else {
18598
+ const img = doc.createElement("img");
18599
+ img.crossOrigin = "anonymous";
18600
+ img.decoding = "async";
18601
+ img.addEventListener("load", () => {
18602
+ pairDiffs.clear();
18603
+ schedule();
18604
+ });
18605
+ img.src = s.source.src;
18606
+ s.still = img;
18131
18607
  }
18132
- linkPRs(flow, findPRIn);
18133
- main.append(flow);
18134
- if (noteMode !== "hidden")
18135
- main.append(bar.dock);
18136
- if (rail)
18137
- layout.append(rail);
18138
- layout.append(main);
18139
- root.append(layout, scrim);
18140
- for (const v of views) {
18141
- syncGlyph(v);
18142
- for (const n of notesOn(state, v.step.key))
18143
- renderNote(v, n);
18608
+ };
18609
+ const unload = (s) => {
18610
+ offs.get(s)?.();
18611
+ offs.delete(s);
18612
+ if (s.frames) {
18613
+ if (s.seen)
18614
+ s.frames.visible(false);
18615
+ s.frames.release();
18144
18616
  }
18145
- if (tocEl)
18146
- pill = selectionPill(tocEl, {
18147
- selected: () => viewOf(current)?.toc ?? null,
18148
- rows: () => tocRows,
18149
- spring: SPRINGS.snappy
18150
- });
18151
- syncProgress();
18152
- current = null;
18153
- fromScroll();
18154
- placeMark();
18155
- bar.sync();
18156
- syncReserve();
18157
- win.requestAnimationFrame(() => win.requestAnimationFrame(() => {
18158
- if (!disposed && !reduced())
18159
- root.dataset.settled = "";
18160
- }));
18617
+ s.frames = null;
18618
+ s.seen = false;
18619
+ if (s.still)
18620
+ s.still.removeAttribute("src");
18621
+ s.still = null;
18161
18622
  };
18162
- const more = (text) => {
18163
- const box = el4(doc, "div", "knobs-wt-more");
18164
- const body = el4(doc, "div", "knobs-wt-more-text");
18165
- for (const para of text.split(/\n\s*\n/))
18166
- if (para.trim())
18167
- body.append(prose2(doc, "p", "", para.trim()));
18168
- discs.push(disclosure(box, { label: "More", content: body }));
18169
- return box;
18623
+ let w = 1;
18624
+ let h = 1;
18625
+ let geo = { x: 0, w: 1 };
18626
+ const rowH = () => Math.max(4, (h - BAR - GAP * 2) / 2);
18627
+ const aspectOf = (s) => {
18628
+ if (s.still?.naturalWidth)
18629
+ return s.still.naturalWidth / s.still.naturalHeight;
18630
+ const c = s.frames?.nearest(0);
18631
+ return c ? c.bitmap.width / c.bitmap.height : 0;
18632
+ };
18633
+ const columns = () => stripColumns(timeline.view, geo.w, {
18634
+ duration: timeline.duration,
18635
+ fps: fps(),
18636
+ minWidth: rowH() * Math.max(16 / 9, ...sides.map(aspectOf)) + GAP
18637
+ });
18638
+ const measure = () => {
18639
+ const r = root.getBoundingClientRect();
18640
+ w = Math.max(1, r.width);
18641
+ h = Math.max(1, r.height);
18642
+ geo = { x: 0, w };
18643
+ if (align === "track") {
18644
+ const track = timeline.element.querySelector(".knobs-tl-track");
18645
+ const t = track?.getBoundingClientRect();
18646
+ if (t && t.width > 0)
18647
+ geo = { x: t.left - r.left, w: t.width };
18648
+ }
18649
+ readColors();
18650
+ schedule();
18170
18651
  };
18171
- const tocRow = (label, sub, run, section) => {
18172
- const b = el4(doc, "button", sub ? "knobs-wt-toc-row knobs-wt-toc-sub" : "knobs-wt-toc-row");
18173
- b.type = "button";
18174
- if (section)
18175
- b.dataset.section = section;
18176
- const t = el4(doc, "span", "knobs-wt-toc-label");
18177
- t.textContent = label;
18178
- b.title = label;
18179
- b.append(t);
18180
- if (review)
18181
- b.append(el4(doc, "span", "knobs-wt-toc-end"));
18182
- b.addEventListener("mousedown", (ev) => ev.preventDefault());
18183
- b.addEventListener("click", run);
18184
- tocRows.push(b);
18185
- return b;
18652
+ const ask = (cols) => {
18653
+ const points = cols.map((c) => ({ frame: c.frame, tolerance: Math.floor((c.to - c.from) / 2) }));
18654
+ for (const s of sides)
18655
+ s.frames?.want(points);
18186
18656
  };
18187
- const actions = (rail) => {
18188
- const row = el4(doc, "div", "knobs-wt-actions");
18189
- const btn = (text, run, primary = false) => {
18190
- const b = el4(doc, "button", "knobs-wt-btn");
18191
- b.type = "button";
18192
- b.textContent = text;
18193
- if (primary)
18194
- b.dataset.primary = "";
18195
- b.addEventListener("click", run);
18196
- row.append(b);
18197
- return b;
18198
- };
18199
- btn("Copy feedback", () => copy("markdown"), true);
18200
- btn("JSON", () => copy("json"));
18201
- if (rail)
18202
- btn("Reset", async () => {
18203
- const ok = await confirm({
18204
- title: "Forget every answer on this page?",
18205
- confirm: "Reset",
18206
- destructive: true,
18207
- look,
18208
- doc
18209
- });
18210
- if (ok)
18211
- api.reset();
18212
- });
18213
- return row;
18657
+ const picOf = (s, c) => {
18658
+ if (s.still?.naturalWidth)
18659
+ return { img: s.still, w: s.still.naturalWidth, h: s.still.naturalHeight, key: "still" };
18660
+ const f = s.frames?.nearest(c.frame, Math.max(1, c.to - c.from));
18661
+ return f ? { img: f.bitmap, w: f.bitmap.width, h: f.bitmap.height, key: String(f.frame) } : null;
18214
18662
  };
18215
- const copy = async (format) => {
18216
- const text = format === "json" ? JSON.stringify(feedback(), null, 2) : markdown();
18663
+ const probe = doc.createElement("canvas");
18664
+ probe.width = DIFF.w;
18665
+ probe.height = DIFF.h;
18666
+ const probeCtx = probe.getContext("2d", { willReadFrequently: true });
18667
+ const differ = (a, b) => {
18668
+ if (!probeCtx)
18669
+ return;
18217
18670
  try {
18218
- await win.navigator.clipboard.writeText(text);
18219
- toast(format === "json" ? "Feedback copied as JSON" : "Feedback copied as Markdown", {
18220
- look,
18221
- doc
18222
- });
18671
+ probeCtx.drawImage(a.img, 0, 0, DIFF.w, DIFF.h);
18672
+ const pa = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
18673
+ probeCtx.drawImage(b.img, 0, 0, DIFF.w, DIFF.h);
18674
+ const pb = probeCtx.getImageData(0, 0, DIFF.w, DIFF.h).data;
18675
+ return meanAbsDiff(pa, pb);
18223
18676
  } catch {
18224
- toast("Couldn't reach the clipboard", { tone: "warn", look, doc });
18677
+ return;
18225
18678
  }
18226
18679
  };
18227
- const stepView = (step, group) => {
18228
- const card = el4(doc, "article", options.cards ? "knobs-wt-step knobs-surface" : "knobs-wt-step");
18229
- card.id = `wt-${step.key.replace("/", "--")}`;
18230
- card.tabIndex = -1;
18231
- card.dataset.key = step.key;
18232
- card.setAttribute("aria-label", plainMarkdown(step.title));
18233
- const head = el4(doc, "header", "knobs-wt-step-head");
18234
- const h3 = el4(doc, "h3", "knobs-wt-stepTitle");
18235
- h3.innerHTML = inlineMarkdown(step.title);
18236
- head.append(h3);
18237
- if (step.caption)
18238
- head.append(prose2(doc, "p", "knobs-wt-caption", step.caption));
18239
- if (step.more)
18240
- head.append(more(step.more));
18241
- card.append(head);
18242
- const slots = [];
18243
- const onEvent = (e) => record({ ...e, step: step.key });
18244
- let media = null;
18245
- if (step.media.length) {
18246
- media = el4(doc, "div", "knobs-wt-media");
18247
- media.style.setProperty("--kw-n", String(Math.min(step.media.length, step.columns)));
18248
- for (const m of step.media)
18249
- slots.push(mediaSlot(media, { ...look, media: m, onEvent, steps: stepLinks, findPR: findPRIn }));
18250
- card.append(media);
18680
+ let colors = { surface: "", accent: "", diff: "", wash: "" };
18681
+ const rgba = (raw, fallback) => {
18682
+ const [r, g, b, a] = parseCssColor(raw, fallback);
18683
+ return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
18684
+ };
18685
+ const readColors = () => {
18686
+ const cs = win.getComputedStyle(root);
18687
+ colors = {
18688
+ surface: rgba(tokenCss(cs, "--kv-fill", ""), [1, 1, 1, 0.055]),
18689
+ accent: rgba(tokenCss(cs, "--kv-accent", ""), [1, 1, 1, 1]),
18690
+ diff: rgba(tokenCss(cs, "--_ku-cmp-diff", ""), [0.49, 0.81, 0.61, 1]),
18691
+ wash: rgba(tokenCss(cs, "--kv-fill-selected", ""), [1, 1, 1, 0.11])
18692
+ };
18693
+ };
18694
+ let hover = null;
18695
+ let raf = 0;
18696
+ let asked = "";
18697
+ function schedule() {
18698
+ if (!raf && !disposed)
18699
+ raf = win.requestAnimationFrame(draw);
18700
+ }
18701
+ const draw = () => {
18702
+ raf = 0;
18703
+ if (!near)
18704
+ return;
18705
+ const dpr = Math.min(3, Math.max(1, win.devicePixelRatio || 1));
18706
+ const cw = Math.round(w * dpr);
18707
+ const ch = Math.round(h * dpr);
18708
+ if (canvas.width !== cw || canvas.height !== ch) {
18709
+ canvas.width = cw;
18710
+ canvas.height = ch;
18251
18711
  }
18252
- let taste = null;
18253
- if (step.question && review)
18254
- card.append(prose2(doc, "p", "knobs-wt-question", step.question));
18255
- if (step.ask === "pick") {
18256
- taste = tasteTest(card, {
18257
- ...look,
18258
- pickable: review,
18259
- options: step.options,
18260
- picked: state.steps[step.key]?.pick ?? null,
18261
- onPick: (id) => pick(step.key, id),
18262
- steps: stepLinks
18263
- });
18264
- media ??= taste.element;
18712
+ const ctx = canvas.getContext("2d");
18713
+ if (!ctx)
18714
+ return;
18715
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
18716
+ ctx.clearRect(0, 0, w, h);
18717
+ const cols = columns();
18718
+ const key = cols.map((c) => c.frame).join(",");
18719
+ if (key !== asked) {
18720
+ asked = key;
18721
+ ask(cols);
18265
18722
  }
18266
- const notes = el4(doc, "div", "knobs-wt-notes");
18267
- if (review)
18268
- card.append(notes);
18269
- card.addEventListener("focusin", () => {
18270
- if (scroll.to)
18723
+ const rh = rowH();
18724
+ const radius = 3;
18725
+ let budget = DIFFS_PER_DRAW;
18726
+ let more = false;
18727
+ const ds = cols.map((c) => {
18728
+ const a = picOf(sides[0], c);
18729
+ const b = picOf(sides[1], c);
18730
+ if (!a || !b)
18271
18731
  return;
18272
- pinUntil = performance.now() + 250;
18273
- setCurrent(step.key);
18732
+ const k = `${a.key}|${b.key}`;
18733
+ if (!pairDiffs.has(k)) {
18734
+ if (budget-- <= 0) {
18735
+ more = true;
18736
+ return;
18737
+ }
18738
+ pairDiffs.set(k, differ(a, b));
18739
+ }
18740
+ const d = pairDiffs.get(k);
18741
+ if (d !== undefined)
18742
+ diffs.set(c.frame, d);
18743
+ return d;
18274
18744
  });
18275
- let toc = null;
18276
- let glyph = null;
18277
- if (group) {
18278
- toc = tocRow(plainMarkdown(step.title), true, () => go(step.key));
18279
- if (review) {
18280
- glyph = el4(doc, "span", "knobs-wt-toc-glyph");
18281
- toc.querySelector(".knobs-wt-toc-end").append(glyph);
18745
+ const max = Math.max(0.02, ...ds.map((d) => d ?? 0));
18746
+ const peaks = new Set(stripPeaks(ds, { threshold }));
18747
+ cols.forEach((c, i) => {
18748
+ const x = geo.x + c.x + GAP / 2;
18749
+ const cwid = Math.max(1, c.w - GAP);
18750
+ if (x + cwid < 0 || x > w)
18751
+ return;
18752
+ for (const row of [0, 1]) {
18753
+ const y = row * (rh + GAP);
18754
+ const pic = picOf(sides[row], c);
18755
+ ctx.save();
18756
+ ctx.beginPath();
18757
+ ctx.roundRect(x, y, cwid, rh, radius);
18758
+ ctx.fillStyle = colors.surface;
18759
+ ctx.fill();
18760
+ if (pic) {
18761
+ ctx.clip();
18762
+ const r = letterbox({ w: pic.w, h: pic.h }, { x, y, w: cwid, h: rh });
18763
+ ctx.drawImage(pic.img, r.x, r.y, r.w, r.h);
18764
+ }
18765
+ ctx.restore();
18282
18766
  }
18283
- group.append(toc);
18767
+ if (hover === i) {
18768
+ ctx.fillStyle = colors.wash;
18769
+ ctx.beginPath();
18770
+ ctx.roundRect(x, 0, cwid, rh * 2 + GAP, radius);
18771
+ ctx.fill();
18772
+ }
18773
+ const d = ds[i];
18774
+ if (d !== undefined && d > 0) {
18775
+ const bh = Math.max(1, Math.min(1, d / max) * BAR);
18776
+ ctx.globalAlpha = peaks.has(i) ? 1 : 0.42;
18777
+ ctx.fillStyle = colors.diff;
18778
+ ctx.beginPath();
18779
+ ctx.roundRect(x, h - bh, cwid, bh, Math.min(1.5, bh / 2));
18780
+ ctx.fill();
18781
+ ctx.globalAlpha = 1;
18782
+ }
18783
+ });
18784
+ const v = timeline.view;
18785
+ const px = geo.x + (timeline.time - v.start) / v.span * geo.w;
18786
+ if (px >= -1 && px <= w + 1) {
18787
+ ctx.fillStyle = colors.accent;
18788
+ ctx.fillRect(Math.round(px * dpr) / dpr - 0.75, 0, 1.5, h);
18284
18789
  }
18285
- return { step, card, media, notes, toc, glyph, taste, slots };
18790
+ if (more)
18791
+ schedule();
18792
+ };
18793
+ const timeAt = (clientX) => {
18794
+ const r = root.getBoundingClientRect();
18795
+ const v = timeline.view;
18796
+ const t = v.start + (clientX - r.left - geo.x) / geo.w * v.span;
18797
+ return Math.min(timeline.duration, Math.max(0, frameTime(t, fps())));
18798
+ };
18799
+ const columnAt = (clientX) => {
18800
+ const x = clientX - root.getBoundingClientRect().left - geo.x;
18801
+ const i = columns().findIndex((c) => x >= c.x && x < c.x + c.w);
18802
+ return i < 0 ? null : i;
18286
18803
  };
18287
- const pick = (key, id) => {
18288
- state = setFeedback(state, key, { pick: id });
18289
- const v = viewOf(key);
18290
- if (v) {
18291
- v.taste?.pick(id);
18292
- syncGlyph(v);
18293
- }
18294
- record({ what: "pick", step: key, detail: id ?? "cleared" });
18295
- syncProgress();
18296
- emit();
18804
+ let dragging = false;
18805
+ const pick = (clientX) => {
18806
+ const t = timeAt(clientX);
18807
+ timeline.seek(t);
18808
+ options.onPick?.(t);
18297
18809
  };
18298
- const LEAVE = "[role=tree],[role=listbox],[role=grid],[role=slider],[role=menu],.knobs-timeline";
18299
- const onKey = (e) => {
18300
- if (disposed || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey)
18301
- return;
18302
- if (isTypingTarget(e.target))
18303
- return;
18304
- const target = e.target instanceof Element ? e.target : null;
18305
- if (target && target !== doc.body && target !== doc.documentElement && !root.contains(target))
18306
- return;
18307
- const own = target?.closest(LEAVE);
18308
- const claim = () => {
18309
- e.preventDefault();
18310
- e.stopPropagation();
18311
- };
18312
- const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
18313
- if (key === "Escape") {
18314
- if (ink.on)
18315
- return claim(), setInk(false);
18316
- if (slotsOf(viewOf(current)).some((s) => s.unzoom()))
18317
- return claim();
18318
- if (full)
18319
- return claim(), setFull(false);
18320
- return;
18321
- }
18322
- if (key === "f" && !e.shiftKey) {
18323
- if (!viewOf(current)?.media)
18324
- return;
18325
- claim();
18326
- return setFull(!full);
18327
- }
18328
- if (own)
18329
- return;
18330
- if (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp") {
18331
- if (e.shiftKey && key.startsWith("Arrow"))
18332
- return;
18333
- const dir = key === "j" || key === "ArrowDown" ? 1 : -1;
18334
- const next = adjacentStep(views.map((v) => v.step.key), current, dir);
18335
- if (!next)
18336
- return;
18337
- claim();
18338
- if (full)
18339
- setFull(false);
18340
- return go(next);
18341
- }
18342
- if ((key === "Home" || key === "End") && views.length) {
18343
- claim();
18344
- if (full)
18345
- setFull(false);
18346
- return go(views[key === "Home" ? 0 : views.length - 1].step.key);
18347
- }
18348
- if (key === "ArrowLeft" || key === "ArrowRight") {
18349
- const d = (key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? 2 : 0.5);
18350
- if (seekBy(d))
18351
- claim();
18810
+ root.addEventListener("pointerdown", (e) => {
18811
+ if (e.button !== 0)
18352
18812
  return;
18813
+ dragging = true;
18814
+ root.setPointerCapture(e.pointerId);
18815
+ const host = root.closest(".knobs-compare");
18816
+ if (host)
18817
+ host.focus({ preventScroll: true });
18818
+ else
18819
+ timeline.focus();
18820
+ if (timeline.playing)
18821
+ timeline.pause();
18822
+ pick(e.clientX);
18823
+ e.preventDefault();
18824
+ });
18825
+ root.addEventListener("pointermove", (e) => {
18826
+ if (dragging)
18827
+ pick(e.clientX);
18828
+ const i = columnAt(e.clientX);
18829
+ if (i !== hover) {
18830
+ hover = i;
18831
+ schedule();
18353
18832
  }
18354
- if (key === "a" && !e.shiftKey && root.querySelector(".knobs-wt-anno")) {
18355
- claim();
18356
- return setAnnotations(root, !annotationsOn(root));
18357
- }
18358
- if (key === "n" && noteMode !== "hidden") {
18359
- claim();
18360
- bar.setOpen(true, true);
18833
+ });
18834
+ const end = () => {
18835
+ dragging = false;
18836
+ };
18837
+ root.addEventListener("pointerup", end);
18838
+ root.addEventListener("pointercancel", end);
18839
+ root.addEventListener("lostpointercapture", end);
18840
+ root.addEventListener("pointerleave", () => {
18841
+ if (hover === null)
18361
18842
  return;
18843
+ hover = null;
18844
+ schedule();
18845
+ });
18846
+ const unlisten = timeline.listen({ onSeek: schedule, onView: schedule });
18847
+ const ro = new win.ResizeObserver(measure);
18848
+ ro.observe(root);
18849
+ const track = timeline.element.querySelector(".knobs-tl-track");
18850
+ if (track)
18851
+ ro.observe(track);
18852
+ root.addEventListener("knobs-restyle", measure);
18853
+ const setOnScreen = (on) => {
18854
+ onScreen = on;
18855
+ for (const s of sides) {
18856
+ if (!s.frames || s.seen === on)
18857
+ continue;
18858
+ s.seen = on;
18859
+ s.frames.visible(on);
18362
18860
  }
18363
- const v = viewOf(current);
18364
- if (!v || e.shiftKey || !review)
18365
- return;
18366
- const choice = digitChoice(v.step, state.steps[v.step.key], key);
18367
- if (choice === undefined)
18368
- return;
18369
- claim();
18370
- pick(v.step.key, choice);
18371
18861
  };
18372
- if (options.keys !== false)
18373
- doc.addEventListener("keydown", onKey);
18374
- const unregisterHints = hints.register(root, () => {
18375
- const v = viewOf(current);
18376
- return [
18377
- ...v ? v.card.querySelectorAll(".knobs-wt-pick") : [],
18378
- ...root.querySelectorAll(".knobs-wt-tool:not([hidden]),.knobs-wt-rail .knobs-wt-btn")
18379
- ];
18380
- }, { listen: false, rank: 1 });
18381
- build();
18382
- const api = {
18862
+ const setNear = (on) => {
18863
+ near = on;
18864
+ if (on) {
18865
+ for (const s of sides)
18866
+ load(s);
18867
+ schedule();
18868
+ } else
18869
+ for (const s of sides)
18870
+ unload(s);
18871
+ };
18872
+ const IO = win.IntersectionObserver;
18873
+ const nearIo = IO ? new IO((e) => setNear(e.at(-1).isIntersecting), { rootMargin: "100% 0px" }) : null;
18874
+ const screenIo = IO ? new IO((e) => setOnScreen(e.at(-1).isIntersecting)) : null;
18875
+ measure();
18876
+ if (nearIo && screenIo) {
18877
+ nearIo.observe(root);
18878
+ screenIo.observe(root);
18879
+ } else {
18880
+ setNear(true);
18881
+ setOnScreen(true);
18882
+ }
18883
+ return {
18383
18884
  element: root,
18384
- get page() {
18385
- return page;
18386
- },
18387
- get state() {
18388
- return state;
18389
- },
18390
- get feedback() {
18391
- return feedback();
18392
- },
18393
- get current() {
18394
- return current;
18395
- },
18396
- markdown,
18397
- go: (key) => go(key),
18398
- full: (on) => setFull(on ?? !full),
18399
- send: (text) => send(text),
18400
- set(next) {
18401
- const keep = current;
18402
- const sameId = next.id === page.id;
18403
- page = normalizeWalkthrough(next);
18404
- if (!sameId)
18405
- state = load();
18406
- build();
18407
- if (keep && viewOf(keep))
18408
- go(keep);
18409
- },
18410
- reset() {
18411
- state = EMPTY_STATE;
18412
- for (const v of views) {
18413
- v.taste?.pick(null);
18414
- v.notes.textContent = "";
18415
- syncGlyph(v);
18416
- }
18417
- syncProgress();
18418
- emit();
18885
+ diffs,
18886
+ update(next) {
18887
+ const was = sides;
18888
+ sides = [
18889
+ next.before ? makeSide(next.before) : was[0],
18890
+ next.after ? makeSide(next.after) : was[1]
18891
+ ];
18892
+ for (const s of was)
18893
+ if (!sides.includes(s))
18894
+ unload(s);
18895
+ diffs.clear();
18896
+ pairDiffs.clear();
18897
+ asked = "";
18898
+ if (near)
18899
+ for (const s of sides)
18900
+ load(s);
18901
+ if (onScreen)
18902
+ setOnScreen(true);
18903
+ schedule();
18419
18904
  },
18420
18905
  dispose() {
18421
- if (disposed)
18422
- return;
18423
18906
  disposed = true;
18424
- win.clearTimeout(emitTimer);
18425
- win.clearTimeout(backTimer);
18426
- win.clearInterval(ticker);
18427
- win.clearInterval(voice.timer);
18428
- voice.recorder?.stop();
18429
- motion.stop();
18430
- if (scrollRaf)
18431
- win.cancelAnimationFrame(scrollRaf);
18432
- win.removeEventListener("scroll", onScroll, { capture: true });
18433
- win.removeEventListener("resize", onScroll);
18434
- win.removeEventListener("resize", onFullResize);
18435
- for (const type of ["wheel", "touchstart"])
18436
- win.removeEventListener(type, stopScroll);
18437
- doc.removeEventListener("keydown", onKey);
18438
- unregisterHints();
18439
- bar.unobserve();
18440
- pill?.dispose();
18441
- for (const d of discs)
18442
- d.dispose();
18443
- if (padded)
18444
- padded.el.style.scrollPaddingBottom = padded.was;
18445
- for (const v of views) {
18446
- v.slots.forEach((s) => s.dispose());
18447
- v.taste?.dispose();
18448
- }
18907
+ if (raf)
18908
+ win.cancelAnimationFrame(raf);
18909
+ unlisten();
18910
+ ro.disconnect();
18911
+ nearIo?.disconnect();
18912
+ screenIo?.disconnect();
18913
+ for (const s of sides)
18914
+ unload(s);
18449
18915
  root.remove();
18450
18916
  }
18451
18917
  };
18452
- return api;
18453
18918
  }
18454
18919
 
18455
18920
  // src/review/index.ts
18456
- init_compare();
18457
- init_compare_strip();
18458
18921
  init_player();
18459
18922
  init_diff();
18460
18923
  export {