@kitlangton/knobs 0.2.0 → 0.4.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +197 -19
  2. package/dist/dev/{chunk-gezhc24g.js → chunk-7xgkerd2.js} +14 -5
  3. package/dist/dev/{chunk-693jd1gj.js → chunk-pmkvb9ep.js} +1544 -160
  4. package/dist/dev/{chunk-5cr85vv6.js → chunk-stbcgz1y.js} +7924 -7931
  5. package/dist/dev/{chunk-4km87zfx.js → chunk-t68smqkc.js} +18 -7
  6. package/dist/dev/index.js +187 -31
  7. package/dist/index.js +197 -33
  8. package/dist/panel.js +20342 -18461
  9. package/dist/types/core/keys.d.ts +2 -0
  10. package/dist/types/core/remote.d.ts +2 -0
  11. package/dist/types/core/schema.d.ts +5 -0
  12. package/dist/types/core/store.d.ts +29 -2
  13. package/dist/types/core/unlock.d.ts +77 -0
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/panel/attach.d.ts +2 -0
  16. package/dist/types/panel/crumbs.d.ts +19 -0
  17. package/dist/types/panel/editors.d.ts +9 -0
  18. package/dist/types/panel/feel.d.ts +103 -0
  19. package/dist/types/panel/give.d.ts +34 -1
  20. package/dist/types/panel/gpu/color.d.ts +4 -4
  21. package/dist/types/panel/gpu/renderer.d.ts +32 -0
  22. package/dist/types/panel/gpu/shader.wgsl.d.ts +1 -1
  23. package/dist/types/panel/index.d.ts +2 -1
  24. package/dist/types/panel/inspector.d.ts +1 -1
  25. package/dist/types/panel/lens.d.ts +113 -0
  26. package/dist/types/panel/motion.d.ts +2 -0
  27. package/dist/types/panel/page-hints.d.ts +2 -1
  28. package/dist/types/panel/prefs.d.ts +2 -2
  29. package/dist/types/panel/presence.d.ts +20 -0
  30. package/dist/types/panel/scope.d.ts +29 -0
  31. package/dist/types/panel/slider.d.ts +21 -4
  32. package/dist/types/panel/tab-transition.d.ts +15 -0
  33. package/dist/types/panel/tabs.d.ts +1 -1
  34. package/dist/types/panel/toggle.d.ts +27 -0
  35. package/dist/types/ui/compare-css.d.ts +1 -0
  36. package/dist/types/ui/compare-frames.d.ts +41 -0
  37. package/dist/types/ui/compare-gpu.d.ts +58 -0
  38. package/dist/types/ui/compare-model.d.ts +169 -0
  39. package/dist/types/ui/compare-mp4.d.ts +29 -0
  40. package/dist/types/ui/compare-strip.d.ts +28 -0
  41. package/dist/types/ui/compare.d.ts +103 -0
  42. package/dist/types/ui/diff-css.d.ts +1 -0
  43. package/dist/types/ui/diff-model.d.ts +128 -0
  44. package/dist/types/ui/diff-scroll.d.ts +29 -0
  45. package/dist/types/ui/diff.d.ts +158 -0
  46. package/dist/types/ui/index.d.ts +9 -1
  47. package/dist/types/ui/look.d.ts +1 -1
  48. package/dist/types/ui/react.d.ts +66 -0
  49. package/dist/types/ui/scrollbar.d.ts +12 -0
  50. package/dist/types/ui/solid.d.ts +52 -0
  51. package/dist/types/ui/split-model.d.ts +4 -3
  52. package/dist/types/ui/split.d.ts +8 -6
  53. package/dist/types/ui/timeline.d.ts +35 -2
  54. package/dist/types/ui/tree.d.ts +7 -2
  55. package/dist/types/ui/walkthrough-css.d.ts +1 -0
  56. package/dist/types/ui/walkthrough-media.d.ts +29 -0
  57. package/dist/types/ui/walkthrough-model.d.ts +447 -0
  58. package/dist/types/ui/walkthrough.d.ts +63 -0
  59. package/dist/ui-react.js +16486 -8140
  60. package/dist/ui-solid.js +16030 -7775
  61. package/dist/ui.js +17510 -9506
  62. package/package.json +6 -1
@@ -1,7 +1,7 @@
1
1
  import type { PanelCommand, PanelKeys } from "../core/store.js";
2
2
  /** How a panel reads keys that aren't commands (see `PanelKeys`): resolved once per panel. */
3
3
  export interface KeyPrefs {
4
- /** j/k and h/l as aliases for the arrows. */
4
+ /** j/k as aliases for ↑/↓ (l is Lock, so ←/→ have no letters). */
5
5
  readonly vim: boolean;
6
6
  /** Digits, `.`, `-` and Enter on a slider open its inline number editor. */
7
7
  readonly edit: boolean;
@@ -14,7 +14,7 @@ export declare const DEFAULT_PREFS: KeyPrefs;
14
14
  export declare const setKeyPrefs: (root: Element, prefs: KeyPrefs) => WeakMap<Element, KeyPrefs>;
15
15
  /** The preferences of the panel `node` sits in. */
16
16
  export declare function keyPrefs(node: EventTarget | null): KeyPrefs;
17
- /** -1, 1 or 0 for ←/→ (and h/l unless the panel turned the letter aliases off). */
17
+ /** -1, 1 or 0 for ←/→. */
18
18
  export declare function arrowOf(event: KeyboardEvent): -1 | 0 | 1;
19
19
  /** ⇧'s multiplier for this event (1 without ⇧). */
20
20
  export declare const bigOf: (event: KeyboardEvent) => number;
@@ -0,0 +1,20 @@
1
+ import type { MorphFrame } from "./morph.js";
2
+ export interface Presence {
3
+ readonly shown: boolean;
4
+ /** Shows or hides the panel; `animate: false` puts it there at once (a reload, a first paint). */
5
+ set(shown: boolean, animate: boolean): void;
6
+ /** The shape the GPU overlay draws while it shows or hides (see `MorphFrame`), else undefined. */
7
+ frame(now: number): MorphFrame | undefined;
8
+ dispose(): void;
9
+ }
10
+ /** What the panel looks like at `x` (0 hidden, 1 shown) on the way in or out. */
11
+ export declare function presenceLook(x: number, inline: boolean, reduced: boolean): {
12
+ opacity: number;
13
+ blur: number;
14
+ scale: number;
15
+ fold: number;
16
+ };
17
+ export declare function createPresence(root: HTMLElement, options: {
18
+ readonly inline: boolean;
19
+ readonly wake: () => void;
20
+ }): Presence;
@@ -7,6 +7,35 @@ export type CommandScope = "selection" | "row" | "group" | "all";
7
7
  export declare function commandScope(level: FocusLevel, selection?: boolean): CommandScope;
8
8
  /** How a command reads from where it would land: "Random this", "Reset group", "Random all". */
9
9
  export declare function scopeLabel(verb: string, scope: CommandScope): string;
10
+ /** A level below the panel that the header's crumbs name: a card, a row, an open editor's part. */
11
+ export type CrumbLevel = "card" | "row" | "editor";
12
+ export interface Crumb {
13
+ readonly level: CrumbLevel;
14
+ readonly label: string;
15
+ }
16
+ /**
17
+ * Where keys go, as the header reads after the panel's title: `Shape › Cohesion`. A level that
18
+ * isn't focused (or has no name) is left out; the last crumb is the deepest, the one focused.
19
+ */
20
+ export declare function crumbTrail(path: {
21
+ readonly card?: string;
22
+ readonly row?: string;
23
+ readonly editor?: string;
24
+ }): Crumb[];
25
+ /**
26
+ * Where Esc goes from the deepest crumb: the crumb before it, else the panel (`title`). Nothing
27
+ * at the panel itself (no crumbs), where Esc leaves.
28
+ */
29
+ export declare function escTarget(trail: readonly Crumb[], title: string): string | undefined;
30
+ /**
31
+ * How much of a trail fits `room` px: crumbs from `from` on show (the ones before it fold into
32
+ * one "…"), and the Esc hint shows only when it fits too. The deepest crumb always shows.
33
+ * `widths` are the crumbs' own; `sep` a separator's (with its gaps), `more` the "…"'s.
34
+ */
35
+ export declare function fitCrumbs(widths: readonly number[], room: number, sep: number, more: number, hint?: number): {
36
+ from: number;
37
+ hint: boolean;
38
+ };
10
39
  /**
11
40
  * The option a digit picks among `count` (0-based), or undefined when it names none: 1–9 pick
12
41
  * the first nine, as they read.
@@ -1,4 +1,5 @@
1
1
  import type { Knob as KnobField } from "../core/schema.js";
2
+ import { type DetentKind } from "./feel.js";
2
3
  import { type Roll, Spring, VelocityTracker } from "./motion.js";
3
4
  export interface DigitPlace {
4
5
  ch: string;
@@ -39,6 +40,17 @@ export interface Slider {
39
40
  readonly notchRatio: number;
40
41
  /** Whether a drag is currently held on the default by the notch's magnet. */
41
42
  isMagnetized(): boolean;
43
+ /** The detent holding a drag right now, if any. */
44
+ heldDetent(): DetentKind | undefined;
45
+ /** Where the active preset's value sits [0..1], when it differs from the default. */
46
+ readonly baseRatio: number | undefined;
47
+ /** When the handle last caught a detent (see `settleScale`). */
48
+ readonly settledAt: number;
49
+ /**
50
+ * How many halvings the drag in hand moves at, for its ruler (0 at 1:1 or at rest, up to 3; each
51
+ * level halves the tenths again). Each level shows `rulerAlpha(depth, level)` of the way.
52
+ */
53
+ rulerDepth(): number;
42
54
  /** When the readout last popped (a click landing on a decile, or the default's magnet). */
43
55
  poppedAt: number;
44
56
  /** Formatted numeric string without unit (e.g. "0.45" or "98"). */
@@ -83,10 +95,15 @@ export declare function stepValue(field: {
83
95
  /**
84
96
  * A DialKit-style slider: the whole row is the track, the fill is a full-height block,
85
97
  * the handle is a hairline that only shows on hover, and the label + value sit on the
86
- * track itself. Dragging follows the pointer exactly (⌥ for 1/10 speed) and the default's notch
87
- * pulls it in from a few px away; a click snaps to the nearest step (or nearest 10% when there
88
- * are many steps) with a short spring-like ease. A sideways trackpad pan moves it like a drag.
98
+ * track itself. Dragging follows the pointer exactly, slower the further the pointer strays off
99
+ * the row (and ⌥ for 1/10 speed); detents at the default, the active preset's value and round
100
+ * values hold it for a few px, and past an end the row stretches like rubber. A click snaps
101
+ * to the nearest step (or nearest 10% when there are many steps) with a short spring-like ease.
102
+ * A sideways trackpad pan moves it like a drag.
89
103
  */
90
104
  export declare function createSlider(doc: Document, key: string, initialField: KnobField, set: (value: number, options?: {
91
105
  coalesce?: boolean;
92
- }) => void, onMotionWake?: () => void): Slider;
106
+ }) => void, onMotionWake?: () => void, options?: {
107
+ /** The active preset's value for this key (what Reset goes back to first). */
108
+ readonly base?: () => number | undefined;
109
+ }): Slider;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A tab change, as one layout animation: what leaves fades out where it stood (lifted out of the
3
+ * flow, so it takes no room), what arrives fades in block by block with a small rise, what stays
4
+ * glides to its new place, and the surface (and any card whose rows change) springs to its new
5
+ * height. Every spring keeps its position and velocity when another tab is picked mid-flight.
6
+ *
7
+ * While the pointer stays on the panel the surface never shrinks: a panel anchored at its foot
8
+ * (or centred) would otherwise move its tabs out from under the pointer between two clicks. It
9
+ * settles to its size once the pointer leaves.
10
+ */
11
+ export declare function tabTransition(body: HTMLElement, root: HTMLElement, wake: () => void): {
12
+ readonly running: boolean;
13
+ finish: () => void;
14
+ to(want: Map<HTMLElement, boolean>): void;
15
+ };
@@ -28,4 +28,4 @@ export interface TabStrip {
28
28
  focus(options?: FocusOptions): void;
29
29
  dispose(): void;
30
30
  }
31
- export declare function createTabStrip(doc: Document, choose: (id: string) => void): TabStrip;
31
+ export declare function createTabStrip(doc: Document, choose: (id: string, animate?: boolean) => void): TabStrip;
@@ -0,0 +1,27 @@
1
+ import { type StoragePort } from "../core/storage.js";
2
+ type KeyEventLike = Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "repeat" | "isComposing" | "defaultPrevented" | "target">;
3
+ /** Whether a keydown is `key` for the toggle: outside a text field, with no modifier held. */
4
+ export declare function isToggleKey(event: KeyEventLike, key: string): boolean;
5
+ /** A panel or chip that comes and goes with the toggle key. */
6
+ export interface ToggleSurface {
7
+ /** Its store's id: what's remembered. */
8
+ readonly id: string;
9
+ readonly key: string;
10
+ readonly storage: () => StoragePort | null;
11
+ /** Where focus may be when it hides, to give back when it shows. */
12
+ readonly root: HTMLElement;
13
+ /** Shows or hides it; `animate: false` puts it there at once. */
14
+ show(shown: boolean, animate: boolean): void;
15
+ }
16
+ export interface ToggleLayer {
17
+ /** Joins the layer; `shown` is how it should start (what its store remembers). */
18
+ register(surface: ToggleSurface): {
19
+ readonly shown: boolean;
20
+ dispose(): void;
21
+ };
22
+ /** Shows or hides everything listening for `key`; false when nothing does. */
23
+ toggle(key: string): boolean;
24
+ }
25
+ /** The page's toggle layer, for `doc`. */
26
+ export declare function toggleLayer(doc: Document): ToggleLayer;
27
+ export {};
@@ -0,0 +1 @@
1
+ export declare const compareCss = "\n:where(.knobs-compare,.knobs-compare *,.knobs-cmp-strip,.knobs-cmp-strip *){box-sizing:border-box}\n.knobs-ui.knobs-compare{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);--ku-cmp-line:var(--k-compare-line,#fff);\n --ku-cmp-stage:var(--k-compare-stage,var(--ku-surface));\n display:flex;flex-direction:column;gap:6px;width:100%;height:100%;min-height:160px;outline:none;user-select:none;-webkit-user-select:none;\n font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text);letter-spacing:normal;text-align:left}\n:where(.knobs-ui) :is(.knobs-cmp-mode,.knobs-cmp-toggle){all:revert;box-sizing:border-box;font:inherit;color:inherit;letter-spacing:inherit;background:transparent;border:0;margin:0;padding:0;outline:none;box-shadow:none}\n.knobs-cmp-bar{flex:none;display:flex;align-items:center;gap:2px;height:24px;padding:0 2px}\n.knobs-cmp-modes{display:flex;align-items:center;gap:2px}\n.knobs-ui .knobs-cmp-mode,.knobs-ui .knobs-cmp-toggle{height:22px;padding:0 7px;border-radius:max(0px,calc(var(--ku-radius) - 2px));color:var(--ku-dim);cursor:pointer;white-space:nowrap;\n transition:color .15s,background .15s}\n.knobs-ui .knobs-cmp-mode:is(:hover,:focus-visible),.knobs-ui .knobs-cmp-toggle:is(:hover,:focus-visible){color:var(--ku-text);background:var(--ku-wash)}\n.knobs-ui .knobs-cmp-mode[aria-checked=\"true\"],.knobs-ui .knobs-cmp-toggle[aria-pressed=\"true\"]{color:var(--ku-text);font-weight:var(--ku-label-weight)}\n.knobs-cmp-fill{flex:1}\n.knobs-cmp-toggle[hidden]{display:none}\n/* One surface: on a card (a walkthrough's step, a split's pane, anything .knobs-surface) or marked\n data-nested, the stage drops its fill and the sides sit on that card. */\n:where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui.knobs-compare,.knobs-ui.knobs-compare[data-nested]{--ku-cmp-stage:var(--k-compare-stage,transparent)}\n/* In a page's flow (a container with no height), it takes the height its sides need. */\n.knobs-ui.knobs-compare[data-auto]{height:auto}\n.knobs-compare[data-auto] .knobs-cmp-stage{flex:none;min-height:0}\n.knobs-cmp-stage{position:relative;flex:1;min-height:96px;overflow:hidden;border-radius:var(--ku-panel-radius);background:var(--ku-cmp-stage);touch-action:pan-y;outline:none}\n.knobs-compare[data-frame=\"flat\"] .knobs-cmp-stage{border-radius:0}\n.knobs-compare[data-mode=\"wipe\"] .knobs-cmp-stage{cursor:ew-resize}\n/* Unzoomed, a finger still scrolls the page up and down; zoomed, the stage takes every gesture. */\n.knobs-compare[data-zoomed] .knobs-cmp-stage{cursor:grab;touch-action:none}\n.knobs-compare[data-panning],.knobs-compare[data-panning] *{cursor:grabbing!important}\n.knobs-compare[data-dragging],.knobs-compare[data-dragging] *{cursor:ew-resize!important}\n.knobs-cmp-sizer{position:absolute;left:0;right:0;top:0;height:0;visibility:hidden;pointer-events:none}\n.knobs-cmp-canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n.knobs-cmp-media{position:absolute;left:0;top:0;display:block;max-width:none;margin:0;padding:0;border:0;object-fit:fill;transform-origin:0 0;pointer-events:none;will-change:transform}\n.knobs-compare[data-renderer=\"gpu\"] .knobs-cmp-media{opacity:0}\n/* Glass: what's behind shows through, blurred, on the knobs tokens. */\n.knobs-cmp-label,.knobs-cmp-zoom{background:color-mix(in srgb,var(--ku-bg) 52%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.6);backdrop-filter:blur(14px) saturate(1.6);\n box-shadow:inset 0 0 0 .5px var(--ku-line),inset 0 1px 0 var(--ku-rim),0 2px 10px rgba(0,0,0,.18)}\n.knobs-cmp-label{position:absolute;left:0;top:0;padding:1px 7px;border-radius:999px;font-size:var(--ku-text-meta);line-height:16px;color:var(--ku-label);white-space:nowrap;pointer-events:none;will-change:transform;transition:color .15s}\n.knobs-cmp-label[data-lit]{color:var(--ku-text)}\n.knobs-cmp-label[data-dim]{color:var(--ku-dim)}\n/* The wipe: a hairline down the line with a grip at its middle; a wider strip takes the hand and\n the keys (a slider). It fades with the mode. */\n.knobs-cmp-handle{cursor:ew-resize;position:absolute;left:-12px;top:0;width:24px;height:0;opacity:0;pointer-events:none;outline:none;will-change:transform;transition:opacity .18s cubic-bezier(.4,0,1,1)}\n.knobs-cmp-handle[data-on]{opacity:1;pointer-events:auto;transition:opacity .22s cubic-bezier(.2,.8,.2,1)}\n.knobs-cmp-handle::before{content:\"\";position:absolute;top:0;bottom:0;left:11.5px;width:1px;background:var(--ku-cmp-line);box-shadow:0 0 0 .5px rgba(0,0,0,.28),0 0 6px rgba(0,0,0,.25)}\n.knobs-cmp-handle::after{content:\"\";position:absolute;top:50%;left:9px;width:6px;height:28px;margin-top:-14px;border-radius:3px;background:var(--ku-cmp-line);\n box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 .5px rgba(0,0,0,.2);transition:scale .18s var(--ku-ease),box-shadow .15s}\n.knobs-compare[data-dragging] .knobs-cmp-handle::after{scale:1.12}\n.knobs-cmp-handle:focus-visible::after,.knobs-compare[data-keyboard] .knobs-cmp-handle:focus::after{box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 3px color-mix(in srgb,var(--ku-accent) 40%,transparent)}\n/* The split's hairline, down the middle side by side. */\n.knobs-cmp-split{position:absolute;left:0;top:0;width:1px;height:0;margin-left:-.5px;background:color-mix(in srgb,var(--ku-bg) 72%,transparent);pointer-events:none;will-change:transform,opacity}\n/* Zoomed: how far, and a way back, in the stage's corner; it fades in and out, nothing shifts. */\n.knobs-cmp-zoom{position:absolute;right:8px;bottom:8px;display:flex;align-items:center;gap:4px;height:26px;padding:0 3px 0 10px;border-radius:999px;\n color:var(--ku-text);font-size:var(--ku-text-meta);font-variant-numeric:tabular-nums;font-feature-settings:\"tnum\";white-space:nowrap;\n opacity:0;scale:.96;pointer-events:none;transition:opacity .16s cubic-bezier(.4,0,1,1),scale .16s cubic-bezier(.4,0,1,1)}\n.knobs-cmp-zoom[data-on]{opacity:1;scale:1;pointer-events:auto;transition:opacity .2s cubic-bezier(.2,.8,.2,1),scale .2s cubic-bezier(.2,.8,.2,1)}\n:where(.knobs-ui) .knobs-cmp-zoom-reset{all:revert;box-sizing:border-box;font:inherit;letter-spacing:inherit;margin:0;border:0;outline:none;box-shadow:none;\n height:20px;padding:0 8px;border-radius:999px;background:transparent;color:var(--ku-label);cursor:pointer;transition:color .15s,background .15s}\n:where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){color:var(--ku-text);background:color-mix(in srgb,var(--ku-text) 12%,transparent)}\n.knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--ku-dim);font-size:var(--ku-text-meta);pointer-events:none}\n.knobs-cmp-note:empty{display:none}\n.knobs-cmp-strip-host{flex:none;height:60px;min-height:0}\n.knobs-cmp-timeline-host{flex:none;height:56px;min-height:0}\n/* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */\n.knobs-ui.knobs-cmp-strip{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;\n font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text)}\n.knobs-cmp-strip>canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n@media(prefers-reduced-motion:reduce){:is(.knobs-compare,.knobs-cmp-strip) :where(*,::before,::after){transition-duration:.12s!important}}\n";
@@ -0,0 +1,41 @@
1
+ export interface CachedFrame {
2
+ readonly frame: number;
3
+ readonly bitmap: ImageBitmap;
4
+ /** Full size (else a small grid frame). */
5
+ readonly full: boolean;
6
+ }
7
+ export interface FramePoint {
8
+ readonly frame: number;
9
+ /** Any cached frame this close (frames) will do. */
10
+ readonly tolerance: number;
11
+ }
12
+ export interface FrameCache {
13
+ readonly src: string;
14
+ /** How it decodes, once it knows. */
15
+ readonly backend: "webcodecs" | "video" | "pending";
16
+ /** Frames held now. */
17
+ readonly size: number;
18
+ /** The cached frame nearest `frame`, no further than `within` frames. */
19
+ nearest(frame: number, within?: number): CachedFrame | null;
20
+ /** The playhead of the viewer being used: full-size frames around it decode first. */
21
+ focus(frame: number): void;
22
+ /** Frames a strip shows (small, with a tolerance each). */
23
+ want(points: readonly FramePoint[]): void;
24
+ /** A frame the viewer has decoded exactly (paused on it). */
25
+ keep(frame: number, source: CanvasImageSource, width: number, height: number): void;
26
+ /** Whether a viewer of it is near the screen: only then does it decode. */
27
+ visible(on: boolean): void;
28
+ /** New frames arrived. */
29
+ listen(f: () => void): () => void;
30
+ release(): void;
31
+ }
32
+ /** Frames either side of the focus decoded at full size. */
33
+ export declare const FOCUS_RADIUS = 6;
34
+ /** The grid's frames: `count` evenly across `total`, as points with half a step's tolerance. */
35
+ export declare function gridPoints(total: number, count?: number): FramePoint[];
36
+ /** The full-size frames wanted around `focus`, nearest first. */
37
+ export declare function focusWindow(focus: number, total: number, radius?: number): number[];
38
+ /** The key in `keys` nearest `frame` within `within` (ties go to the later frame). */
39
+ export declare function nearestKey(keys: Iterable<number>, frame: number, within?: number): number | null;
40
+ /** The shared cache for `src` at `fps`; `release()` it when done. */
41
+ export declare function acquireFrames(doc: Document, src: string, fps: number): FrameCache;
@@ -0,0 +1,58 @@
1
+ import type { Rect } from "./compare-model.js";
2
+ export declare const MODE_INDEX: {
3
+ readonly side: 0;
4
+ readonly wipe: 1;
5
+ readonly difference: 2;
6
+ readonly flicker: 3;
7
+ };
8
+ export interface CompareDraw {
9
+ /** Device px: where each side's picture lands (zoomed, it's larger than its clip). */
10
+ readonly before: Rect;
11
+ readonly after: Rect;
12
+ /** Device px: the part of the stage each side may draw in. */
13
+ readonly beforeClip: Rect;
14
+ readonly afterClip: Rect;
15
+ readonly width: number;
16
+ readonly height: number;
17
+ readonly dpr: number;
18
+ /** The wipe's line (device px) and its soft edge (device px). */
19
+ readonly wipeX: number;
20
+ readonly soft: number;
21
+ readonly from: number;
22
+ readonly to: number;
23
+ /** 0 shows `from`, 1 shows `to`. */
24
+ readonly mix: number;
25
+ /** Before in After's place (X held). */
26
+ readonly flick: boolean;
27
+ /** Flicker mode's phase: Before showing. */
28
+ readonly phase: boolean;
29
+ readonly threshold: number;
30
+ readonly gain: number;
31
+ readonly accent: readonly [number, number, number];
32
+ /** Device px centre and radius, and zoom; null for none. */
33
+ readonly loupe: {
34
+ readonly x: number;
35
+ readonly y: number;
36
+ readonly r: number;
37
+ readonly zoom: number;
38
+ } | null;
39
+ /** Each side shows its preview texture (a cached frame) instead of its video's. */
40
+ readonly preview: readonly [boolean, boolean];
41
+ }
42
+ export interface CompareGpu {
43
+ /**
44
+ * Copies a frame in (0 Before, 1 After; 2 and 3 their previews); false if it couldn't (no data
45
+ * yet, or a tainted source).
46
+ */
47
+ upload(slot: 0 | 1 | 2 | 3, source: CanvasImageSource, w: number, h: number): boolean;
48
+ /** Forgets a side's frame (a new source is loading). */
49
+ clear(side: 0 | 1): void;
50
+ /** Frees the textures and shrinks the canvas (off screen); uploads bring them back. */
51
+ shrink(): void;
52
+ draw(frame: CompareDraw): void;
53
+ dispose(): void;
54
+ }
55
+ /** A renderer on `canvas`, or null where WebGPU isn't there. `onLost` hears a lost device. */
56
+ export declare function createCompareGpu(canvas: HTMLCanvasElement, onLost: () => void): Promise<CompareGpu | null>;
57
+ /** The shared device once made (null before it is, or without WebGPU): for checks. */
58
+ export declare const sharedDevice: () => Promise<unknown>;
@@ -0,0 +1,169 @@
1
+ import type { TimeView } from "./timeline-model.js";
2
+ export type CompareMode = "side" | "wipe" | "difference" | "flicker";
3
+ export type MediaKind = "video" | "image";
4
+ /** A source's kind: as given, else by its extension (a video's, or an image). */
5
+ export declare function mediaKind(src: string, kind?: MediaKind): MediaKind;
6
+ /** The frame showing at time `t` (whole frames, never negative). */
7
+ export declare const frameOf: (t: number, fps: number) => number;
8
+ /** The last whole frame of `duration` seconds. */
9
+ export declare const lastFrame: (duration: number, fps: number) => number;
10
+ /**
11
+ * The time a video seeks to for frame `f`: its midpoint, `(f + 0.5) / fps`, which never lands on a
12
+ * frame boundary, so two decoders can't round to different frames. Clamped to the last frame.
13
+ */
14
+ export declare function frameMidpoint(f: number, fps: number, duration?: number): number;
15
+ /** Where both videos seek to show time `t`: the midpoint of the frame it falls in. */
16
+ export declare const seekTime: (t: number, fps: number, duration?: number) => number;
17
+ /** Whether two times show the same frame (a seek between them presents nothing new). */
18
+ export declare const sameFrame: (a: number, b: number, fps: number) => boolean;
19
+ export interface SyncAction {
20
+ /** Hard-seek the follower here (seconds), or leave it playing. */
21
+ readonly seekTo: number | null;
22
+ readonly rate: number;
23
+ }
24
+ /**
25
+ * Keeps a playing follower on its master: past `hard` seconds of drift (90 ms) it seeks; otherwise
26
+ * its rate leans against the drift, proportionally (`gain` per second of drift), by at most `max`
27
+ * (±25%), and inside `dead` (1/120 s) it plays at the base rate.
28
+ */
29
+ export declare function syncAction(master: number, follower: number, o?: {
30
+ rate?: number;
31
+ hard?: number;
32
+ gain?: number;
33
+ max?: number;
34
+ dead?: number;
35
+ }): SyncAction;
36
+ /**
37
+ * One video's seeks, coalesced: while one is in flight only the latest target is kept, applied when
38
+ * it lands; holding a step key walks frames instead of queueing seeks.
39
+ */
40
+ export interface SeekState {
41
+ readonly seeking: boolean;
42
+ /** The latest target asked for while a seek was in flight. */
43
+ readonly pending: number | null;
44
+ }
45
+ export declare const SEEK_IDLE: SeekState;
46
+ export interface SeekStep {
47
+ readonly state: SeekState;
48
+ /** Set `currentTime` to this now, or nothing. */
49
+ readonly apply: number | null;
50
+ /** The frame asked for is already showing: no seek, and no frame callback will come. */
51
+ readonly settled: boolean;
52
+ }
53
+ /** A seek to `t` asked for while the video shows `current`. */
54
+ export declare function requestSeek(s: SeekState, t: number, current: number, fps: number): SeekStep;
55
+ /** The seek in flight landed on `current`: apply what's pending, if it's another frame. */
56
+ export declare function seeked(s: SeekState, current: number, fps: number): SeekStep;
57
+ export interface Rect {
58
+ readonly x: number;
59
+ readonly y: number;
60
+ readonly w: number;
61
+ readonly h: number;
62
+ }
63
+ export interface Size {
64
+ readonly w: number;
65
+ readonly h: number;
66
+ }
67
+ /** `media` fitted inside `box`, centred: the whole of it shows, letterboxed, never cropped. */
68
+ export declare function letterbox(media: Size, box: Rect): Rect;
69
+ export interface CompareLayout {
70
+ /**
71
+ * Side by side: a split screen down the middle, the pair fitted as a whole (After's shape, two
72
+ * up), each side in its half; `split` is the middle's x.
73
+ */
74
+ readonly side: {
75
+ readonly before: Rect;
76
+ readonly after: Rect;
77
+ readonly split: number;
78
+ };
79
+ /** Overlaid (wipe, difference, flicker): both in the same place. */
80
+ readonly overlay: {
81
+ readonly before: Rect;
82
+ readonly after: Rect;
83
+ };
84
+ }
85
+ /** Where the two sides sit inside `box`, side by side and overlaid. */
86
+ export declare function compareLayout(box: Rect, before: Size, after: Size): CompareLayout;
87
+ /** The height a split screen of `w` needs so neither side has margins: half its width, in shape. */
88
+ export declare const splitHeight: (w: number, media: Size) => number;
89
+ /**
90
+ * The part of each side shown, in that side's own 0–1 coordinates: `w` and `h` are equal (1 is
91
+ * the whole picture, 0.25 is 400%). Both sides show the same part.
92
+ */
93
+ export interface ZoomRegion {
94
+ readonly u: number;
95
+ readonly v: number;
96
+ readonly w: number;
97
+ readonly h: number;
98
+ }
99
+ export declare const ZOOM_FIT: ZoomRegion;
100
+ /** 1600% at most. */
101
+ export declare const ZOOM_MAX = 16;
102
+ /** Kept inside the picture, between the whole of it and `ZOOM_MAX`. */
103
+ export declare function clampZoom(r: {
104
+ u: number;
105
+ v: number;
106
+ w: number;
107
+ }): ZoomRegion;
108
+ /** Zoomed by `factor` (above 1 in) about a point at `pu`, `pv` (0–1 across the shown part). */
109
+ export declare function zoomAbout(r: ZoomRegion, factor: number, pu: number, pv: number): ZoomRegion;
110
+ /** Panned by a hand's move of `dx`, `dy` px over a side drawn `base` big: the picture follows it. */
111
+ export declare const panBy: (r: ZoomRegion, dx: number, dy: number, base: Rect) => ZoomRegion;
112
+ /** Where a side drawn in `base` lands when zoomed to `r` (clipped to `base` when drawn). */
113
+ export declare const zoomedRect: (base: Rect, r: ZoomRegion) => Rect;
114
+ /** A point's place across `base` (0–1 each way). */
115
+ export declare const pointIn: (base: Rect, x: number, y: number) => {
116
+ pu: number;
117
+ pv: number;
118
+ };
119
+ /** A loop's window around `time`: a fifth of the length, 1–4 s, inside it. */
120
+ export declare function loopAround(time: number, duration: number): {
121
+ start: number;
122
+ end: number;
123
+ };
124
+ export declare const lerpRect: (a: Rect, b: Rect, k: number) => Rect;
125
+ /** The wipe's line across `rect`: Before shows left of it, After right. */
126
+ export declare const wipeX: (rect: Rect, wipe: number) => number;
127
+ /** The wipe a pointer at `x` (less the grab's offset) puts it at. */
128
+ export declare const wipeAt: (x: number, rect: Rect, offset?: number) => number;
129
+ /**
130
+ * A press at `x`: on the line (within `slop` px) it takes hold where it is, so the line doesn't
131
+ * jump under the hand; elsewhere the line comes to the pointer.
132
+ */
133
+ export declare function wipeGrab(x: number, rect: Rect, wipe: number, slop?: number): {
134
+ onLine: boolean;
135
+ offset: number;
136
+ };
137
+ /** The wipe moved by a key: 1% a press, 10% with Shift. */
138
+ export declare const wipeKey: (wipe: number, dir: -1 | 1, big?: boolean) => number;
139
+ export interface StripColumn {
140
+ /** The frame it samples: its first, so zooming in by two keeps every other sample. */
141
+ readonly frame: number;
142
+ /** Seconds: that frame's midpoint. */
143
+ readonly time: number;
144
+ /** Its first frame and the one after its last. */
145
+ readonly from: number;
146
+ readonly to: number;
147
+ readonly x: number;
148
+ readonly w: number;
149
+ }
150
+ /**
151
+ * The frame strip's columns across `width` px of the timeline's view: each spans a power-of-two
152
+ * number of frames, at least `minWidth` px wide, on a grid fixed to frame 0, sampling its first
153
+ * frame, so panning reuses the samples it has and zooming in by two keeps half of them.
154
+ */
155
+ export declare function stripColumns(view: TimeView, width: number, o: {
156
+ duration: number;
157
+ fps: number;
158
+ minWidth: number;
159
+ }): StripColumn[];
160
+ /** Mean absolute difference of two RGBA buffers' colour, 0 (same) to 1. */
161
+ export declare function meanAbsDiff(a: ArrayLike<number>, b: ArrayLike<number>): number;
162
+ /**
163
+ * The columns worth a marker: where the difference peaks (no less than either neighbour), above
164
+ * `threshold` and at least `ratio` of the largest.
165
+ */
166
+ export declare function stripPeaks(diffs: readonly (number | undefined)[], o?: {
167
+ threshold?: number;
168
+ ratio?: number;
169
+ }): number[];
@@ -0,0 +1,29 @@
1
+ export interface Mp4Sample {
2
+ readonly offset: number;
3
+ readonly size: number;
4
+ /** Seconds, after the edit list: when it shows. */
5
+ readonly pts: number;
6
+ readonly duration: number;
7
+ readonly key: boolean;
8
+ }
9
+ export interface Mp4Track {
10
+ /** A WebCodecs codec string (`avc1.64001f`, `hvc1.1.6.L93.B0`). */
11
+ readonly codec: string;
12
+ readonly description: Uint8Array | undefined;
13
+ readonly width: number;
14
+ readonly height: number;
15
+ /** In decode order. */
16
+ readonly samples: readonly Mp4Sample[];
17
+ }
18
+ /** Parses `buf` (a whole file); null when it isn't a plain MP4 with a video track it knows. */
19
+ export declare function demuxMp4(buf: ArrayBuffer): Mp4Track | null;
20
+ /**
21
+ * The runs to decode for some frames: for each keyframe group (decode order, from a keyframe to
22
+ * the next) holding a wanted presentation index, its sample range. `frameOf` maps a sample's
23
+ * time to the frame it is.
24
+ */
25
+ export declare function decodeRuns(samples: readonly Mp4Sample[], wanted: ReadonlySet<number>, frameOf: (pts: number) => number): {
26
+ from: number;
27
+ to: number;
28
+ frames: number[];
29
+ }[];
@@ -0,0 +1,28 @@
1
+ import type { CompareSource } from "./compare.js";
2
+ import { type UiLook } from "./look.js";
3
+ import type { Timeline } from "./timeline.js";
4
+ export interface CompareStripOptions extends UiLook {
5
+ readonly before: CompareSource;
6
+ readonly after: CompareSource;
7
+ /** The time axis: its view, time and duration; a press here seeks it. */
8
+ readonly timeline: Timeline;
9
+ /** The timeline's by default. */
10
+ readonly fps?: number;
11
+ /** Columns under the timeline's track (`"track"`, the default) or across the strip's own width. */
12
+ readonly align?: "track" | "self";
13
+ /** Differences below this (0–1) never peak. */
14
+ readonly threshold?: number;
15
+ /** A press or drag picked a time. */
16
+ onPick?(time: number): void;
17
+ }
18
+ export interface CompareStrip {
19
+ readonly element: HTMLElement;
20
+ /** Each column's difference (by the frame it samples), 0 (same) to 1. */
21
+ readonly diffs: ReadonlyMap<number, number>;
22
+ update(next: {
23
+ before?: CompareSource;
24
+ after?: CompareSource;
25
+ }): void;
26
+ dispose(): void;
27
+ }
28
+ export declare function compareStrip(container: HTMLElement, options: CompareStripOptions): CompareStrip;
@@ -0,0 +1,103 @@
1
+ import { type CompareMode, type MediaKind, type ZoomRegion } from "./compare-model.js";
2
+ import { type CompareStrip } from "./compare-strip.js";
3
+ import { type UiLook } from "./look.js";
4
+ import { type Timeline } from "./timeline.js";
5
+ export type { CompareMode, MediaKind, ZoomRegion } from "./compare-model.js";
6
+ /** A loop's in and out (seconds). */
7
+ export interface CompareLoop {
8
+ readonly start: number;
9
+ readonly end: number;
10
+ }
11
+ /** What's in view: the zoomed part (both sides) and the loop, for a log or a note. */
12
+ export interface CompareView {
13
+ readonly zoom: ZoomRegion;
14
+ readonly loop: CompareLoop | null;
15
+ }
16
+ /**
17
+ * How to encode recordings for review: constant frame rate and a keyframe every 15 frames, so a
18
+ * seek lands on the exact frame almost at once. Raw screen recordings often have one keyframe in
19
+ * 8–18 s; the viewer copes (it shows decoded frames while seeks land), but this makes it exact.
20
+ */
21
+ export declare const COMPARE_FFMPEG = "ffmpeg -i in.mov -vf fps=30 -fps_mode cfr -c:v libx264 -preset slow -crf 18 -g 15 -keyint_min 15 -pix_fmt yuv420p -movflags +faststart -an out.mp4";
22
+ export interface CompareSource {
23
+ readonly src: string;
24
+ /** From the extension when left out (`.mp4`, `.webm`, `.mov`… are videos). */
25
+ readonly kind?: MediaKind;
26
+ /** "Before" and "After" by default. */
27
+ readonly label?: string;
28
+ }
29
+ export interface CompareOptions extends UiLook {
30
+ readonly before: CompareSource;
31
+ readonly after: CompareSource;
32
+ /** `"side"` (side by side) by default. */
33
+ readonly mode?: CompareMode;
34
+ /** Where the wipe's line sits, 0 (all After) to 1 (all Before); 0.5. */
35
+ readonly wipe?: number;
36
+ /** Frames per second (the bound timeline's, else 30): steps, and where paused videos land. */
37
+ readonly fps?: number;
38
+ /** The loupe under the pointer (`Z` held shows it too). */
39
+ readonly loupe?: boolean;
40
+ /** Difference mode hides changes below this (0–1, 4/255): codec noise. */
41
+ readonly threshold?: number;
42
+ /**
43
+ * The clock: an existing `timeline()` (its time drives both sides; while playing it follows
44
+ * After), or `true` for one of its own under the viewer. Without one, `seek`, `play` and keys
45
+ * still work.
46
+ */
47
+ readonly timeline?: Timeline | boolean;
48
+ /** The frame strip over the timeline (off; one timeline is made if there's none). */
49
+ readonly strip?: boolean;
50
+ /**
51
+ * The sources' width over height before they load (16/9). In a container without a height of
52
+ * its own, the viewer takes the height its sides need: a split screen with no margins.
53
+ */
54
+ readonly aspect?: number;
55
+ /** Load sources only near the screen and let them go well off it (true). */
56
+ readonly lazy?: boolean;
57
+ /** `"gpu"` (the default, where WebGPU is there) or `"dom"`. */
58
+ readonly renderer?: "gpu" | "dom";
59
+ readonly label?: string;
60
+ /** The modes, the loupe and the wipe take codes in the page's hint mode (`s`); on by default. */
61
+ readonly hints?: boolean;
62
+ onMode?(mode: CompareMode): void;
63
+ /** The wipe moved: dragged, keyed or set. */
64
+ onWipe?(wipe: number): void;
65
+ /** The zoom or the loop changed (at most once a frame). */
66
+ onView?(view: CompareView): void;
67
+ }
68
+ export interface Compare {
69
+ readonly element: HTMLElement;
70
+ readonly mode: CompareMode;
71
+ readonly wipe: number;
72
+ readonly loupe: boolean;
73
+ /** Seconds. */
74
+ readonly time: number;
75
+ readonly playing: boolean;
76
+ /** The longer video's, in seconds (0 for two images). */
77
+ readonly duration: number;
78
+ readonly renderer: "gpu" | "dom";
79
+ /** The bound timeline, or the one it made. */
80
+ readonly timeline: Timeline | null;
81
+ readonly strip: CompareStrip | null;
82
+ /** The part of each side in view (`{ u: 0, v: 0, w: 1, h: 1 }` is all of it). */
83
+ readonly zoom: ZoomRegion;
84
+ /** The timeline's loop, or null. */
85
+ readonly loop: CompareLoop | null;
86
+ /** New sources, mode, wipe, loupe, threshold or fps. A new mode glides in. */
87
+ update(next: Partial<Pick<CompareOptions, "before" | "after" | "mode" | "wipe" | "loupe" | "threshold" | "fps">>): void;
88
+ seek(time: number): void;
89
+ /** Moves by `seconds`, the timeline's playhead gliding there (playing carries on). */
90
+ seekBy(seconds: number): void;
91
+ play(): void;
92
+ pause(): void;
93
+ toggle(): void;
94
+ /** Steps whole frames (pausing first). */
95
+ step(frames: number): void;
96
+ /** Zooms both sides to `region` (null: all of it), gliding there unless `animate` is false. */
97
+ setZoom(region: ZoomRegion | null, animate?: boolean): void;
98
+ /** A loop around the playhead (`true`), a given one, or none; playback stays inside it. */
99
+ setLoop(loop: CompareLoop | boolean): void;
100
+ focus(): void;
101
+ dispose(): void;
102
+ }
103
+ export declare function compare(container: HTMLElement, options: CompareOptions): Compare;
@@ -0,0 +1 @@
1
+ export declare const diffCss: string;