@joewinke/jatui 0.9.1 → 0.11.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 (35) hide show
  1. package/CLAUDE.md +10 -0
  2. package/dist/components/VideoPlayer.svelte +371 -1
  3. package/dist/components/VideoPlayer.svelte.d.ts +44 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +10 -0
  6. package/dist/video/README.md +18 -3
  7. package/dist/video/advisors/README.md +20 -0
  8. package/dist/video/advisors/qr.d.ts +95 -0
  9. package/dist/video/advisors/qr.js +546 -0
  10. package/dist/video/advisors/quiet-listening.d.ts +73 -0
  11. package/dist/video/advisors/quiet-listening.js +292 -0
  12. package/dist/video/advisors/resume-point.d.ts +71 -0
  13. package/dist/video/advisors/resume-point.js +302 -0
  14. package/dist/video/advisors/smart-speed.d.ts +82 -0
  15. package/dist/video/advisors/smart-speed.js +314 -0
  16. package/dist/video/advisors/speaker-rate.d.ts +74 -0
  17. package/dist/video/advisors/speaker-rate.js +322 -0
  18. package/dist/video/band/axis.d.ts +81 -0
  19. package/dist/video/band/axis.js +106 -0
  20. package/dist/video/band/drag.d.ts +38 -0
  21. package/dist/video/band/drag.js +45 -0
  22. package/dist/video/band/luma.d.ts +62 -0
  23. package/dist/video/band/luma.js +97 -0
  24. package/dist/video/band/markers.d.ts +73 -0
  25. package/dist/video/band/markers.js +146 -0
  26. package/dist/video/band/memo.d.ts +21 -0
  27. package/dist/video/band/memo.js +15 -0
  28. package/dist/video/band/window.d.ts +35 -0
  29. package/dist/video/band/window.js +61 -0
  30. package/dist/video/band.d.ts +35 -29
  31. package/dist/video/band.js +77 -56
  32. package/dist/video/keys.d.ts +1 -1
  33. package/dist/video/keys.js +3 -0
  34. package/dist/video/settings.js +6 -5
  35. package/package.json +3 -2
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The light track → the light ribbon (mos-5lzns.30).
3
+ *
4
+ * extension-library FEATURES.md §"The light track": the host scans each file
5
+ * once for brightness (ffmpeg signalstats YAVG, limited-range `(v − 16) / 219`)
6
+ * and stores it at 4 samples/second, the MAX per bucket (the worst instant
7
+ * in a quarter second is what hits your eye), as integers 0–99, alongside
8
+ * `duration`, `rate` and `flashes`. That sidecar is exactly `VideoLumaTrack`.
9
+ *
10
+ * The track pays for itself three times: predictive dimming and the strobe
11
+ * guard (night's — mos-5lzns.33 reads the same `luma` prop) and the RIBBON,
12
+ * which is the axis owner's drawing: an amber wash `v² × 0.55`, painted UNDER
13
+ * the envelope through the band's own zoom window, so it reads as a property
14
+ * of the timeline rather than a fourth bar and it zooms with everything else.
15
+ *
16
+ * The squared alpha is the point: mid-grey footage barely tints the band,
17
+ * a white-out glows. DOM-free; node-testable.
18
+ */
19
+ import { fracToSec, spanSec } from './window';
20
+ export const LUMA_SCALE = 99;
21
+ export const LUMA_DEFAULT_RATE = 4;
22
+ /** Fewer parsed samples than this and the host writes nothing — so a track this short is not a track. */
23
+ export const LUMA_MIN_SAMPLES = 4;
24
+ /** Ribbon alpha ceiling: `v² × RIBBON_ALPHA` (FEATURES.md, the amber wash). */
25
+ export const RIBBON_ALPHA = 0.55;
26
+ /** Gradient stops for a whole-video ribbon (FEATURES.md: 120). */
27
+ export const RIBBON_STOPS = 120;
28
+ /**
29
+ * Validate + normalise a track. Never throws; garbage in ⇒ null out (a
30
+ * missing ribbon, never a wrong one). A non-finite sample inherits the
31
+ * previous one — the host's own rule for empty buckets, so a gap never reads
32
+ * as pitch black.
33
+ */
34
+ export function normalizeLuma(track) {
35
+ const raw = track?.luma;
36
+ if (!Array.isArray(raw) || raw.length < LUMA_MIN_SAMPLES)
37
+ return null;
38
+ const rate = typeof track?.rate === 'number' && Number.isFinite(track.rate) && track.rate > 0
39
+ ? track.rate
40
+ : LUMA_DEFAULT_RATE;
41
+ const values = new Array(raw.length);
42
+ let prev = 0;
43
+ let anyFinite = false;
44
+ for (let i = 0; i < raw.length; i++) {
45
+ const v = raw[i];
46
+ if (typeof v === 'number' && Number.isFinite(v)) {
47
+ prev = Math.max(0, Math.min(1, v / LUMA_SCALE));
48
+ anyFinite = true;
49
+ }
50
+ values[i] = prev;
51
+ }
52
+ if (!anyFinite)
53
+ return null;
54
+ const d = track?.durationSec;
55
+ const durationSec = typeof d === 'number' && Number.isFinite(d) && d > 0 ? d : raw.length / rate;
56
+ return { values, rate, durationSec };
57
+ }
58
+ /**
59
+ * Peak brightness over `[t0, t1]` — the max of every bucket the interval
60
+ * touches (the same max-not-mean reasoning as the scan). 0 outside the track.
61
+ */
62
+ export function lumaPeak(s, t0, t1) {
63
+ if (t1 < t0)
64
+ [t0, t1] = [t1, t0];
65
+ if (t1 < 0 || t0 > s.durationSec)
66
+ return 0;
67
+ const n = s.values.length;
68
+ const i0 = Math.max(0, Math.min(n - 1, Math.floor(t0 * s.rate)));
69
+ // End-exclusive: an interval ending exactly on a bucket edge does not
70
+ // reach into the next bucket (adjacent columns must not both claim it).
71
+ const i1 = Math.max(i0, Math.min(n - 1, Math.ceil(t1 * s.rate) - 1));
72
+ let m = 0;
73
+ for (let i = i0; i <= i1; i++)
74
+ if (s.values[i] > m)
75
+ m = s.values[i];
76
+ return m;
77
+ }
78
+ export function ribbonAlpha(v) {
79
+ const c = Math.max(0, Math.min(1, v));
80
+ return c * c * RIBBON_ALPHA;
81
+ }
82
+ /**
83
+ * Ribbon alphas for `cols` equal columns across the band's CURRENT window —
84
+ * column i covers `[fracToSec(i/cols), fracToSec((i+1)/cols)]`, so the ribbon
85
+ * maps through the same window as the peaks and zooms with them.
86
+ */
87
+ export function ribbonColumns(s, w, duration, cols) {
88
+ const out = [];
89
+ if (!(duration > 0) || cols <= 0 || !(spanSec(w, duration) > 0))
90
+ return out;
91
+ for (let i = 0; i < cols; i++) {
92
+ const t0 = fracToSec(w, duration, i / cols);
93
+ const t1 = fracToSec(w, duration, (i + 1) / cols);
94
+ out.push(ribbonAlpha(lumaPeak(s, t0, t1)));
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Social markers on the band — timestamped comments and a like heatmap
3
+ * (mos-5lzns.30's own brief; not an extension capability).
4
+ *
5
+ * The band already carries two marker families, told apart by EDGE and
6
+ * SHAPE: chapter flags (the model's) are triangles on the top edge, bookmark
7
+ * diamonds (yours) on the bottom edge. Social markers are other people's, so
8
+ * they get their own grammar rather than borrowing either:
9
+ *
10
+ * · comments — small round dots riding just under the chapter flags; hover
11
+ * shows the comment, click seeks to its exact second (no transcript
12
+ * bias — a comment is ABOUT that instant).
13
+ * · likes — never individual glyphs (a hundred likes would be a hundred
14
+ * dots). They fold into a heat strip along the bottom edge, under the
15
+ * bookmark diamonds, normalised to the window's own peak so "where did
16
+ * people react" reads at any zoom.
17
+ *
18
+ * DOM-free; node-testable.
19
+ */
20
+ import { type BandWindow } from './window';
21
+ export interface VideoMarker {
22
+ atSec: number;
23
+ kind: 'comment' | 'like';
24
+ id?: string | null;
25
+ /** Comment body (comments only). */
26
+ text?: string | null;
27
+ author?: string | null;
28
+ /** Likes only: how many reactions this entry stands for (pre-aggregated hosts). Default 1. */
29
+ weight?: number | null;
30
+ }
31
+ export declare const MARKER_HIT_PX = 6;
32
+ /** Under `(pointer: coarse)` a fingertip needs a target, not a 6px sliver. */
33
+ export declare const MARKER_HIT_PX_COARSE = 14;
34
+ /** Comment dots closer than this collapse to one (the chapter-flag merge rule). */
35
+ export declare const COMMENT_MERGE_PX = 6;
36
+ /** Heat bins are this many CSS px wide. */
37
+ export declare const HEAT_BIN_PX = 3;
38
+ /** Drop junk (non-finite / negative times, unknown kinds) and sort ascending. */
39
+ export declare function sortMarkers(markers: VideoMarker[] | null | undefined): VideoMarker[];
40
+ export declare function splitMarkers(sorted: VideoMarker[]): {
41
+ comments: VideoMarker[];
42
+ likes: VideoMarker[];
43
+ };
44
+ export interface CommentCluster {
45
+ x: number;
46
+ markers: VideoMarker[];
47
+ }
48
+ /**
49
+ * Comment dots in the visible window, x in CSS px, with neighbours closer
50
+ * than COMMENT_MERGE_PX collapsed (a long video at full zoom-out must not
51
+ * smear them into a bar). Expects ascending input.
52
+ */
53
+ export declare function commentClusters(comments: VideoMarker[], w: BandWindow, duration: number, width: number): CommentCluster[];
54
+ /** The comment cluster under the pointer, or null. */
55
+ export declare function commentAt(xCss: number, clusters: CommentCluster[], hitPx?: number): CommentCluster | null;
56
+ /**
57
+ * Like density across the visible window in `bins` equal bins, smoothed
58
+ * with a [1,2,1] kernel (a reaction lands a beat after the moment) and
59
+ * normalised to 0–1 against the window's own peak. All zeros when there is
60
+ * nothing to show.
61
+ */
62
+ export declare function likeHeat(likes: VideoMarker[], w: BandWindow, duration: number, bins: number): number[];
63
+ /**
64
+ * Previous / next comment relative to the playhead — the keyboard path to
65
+ * markers that are otherwise pointer-only. Same graces as the chapter jump
66
+ * (keys.ts): `next` skips 0.25s ahead so a press on a comment's own second
67
+ * doesn't stick; `previous` looks 0.75s back so it reaches the one BEFORE
68
+ * the comment you are sitting on. Comments sharing an instant come back
69
+ * together (a cluster is announced as one). Expects ascending input.
70
+ */
71
+ export declare function commentJump(current: number, comments: VideoMarker[], direction: 1 | -1): VideoMarker[] | null;
72
+ /** One line for a screen reader: who, when, what — and how many more. */
73
+ export declare function describeComments(group: VideoMarker[], formatAt: (sec: number) => string): string;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Social markers on the band — timestamped comments and a like heatmap
3
+ * (mos-5lzns.30's own brief; not an extension capability).
4
+ *
5
+ * The band already carries two marker families, told apart by EDGE and
6
+ * SHAPE: chapter flags (the model's) are triangles on the top edge, bookmark
7
+ * diamonds (yours) on the bottom edge. Social markers are other people's, so
8
+ * they get their own grammar rather than borrowing either:
9
+ *
10
+ * · comments — small round dots riding just under the chapter flags; hover
11
+ * shows the comment, click seeks to its exact second (no transcript
12
+ * bias — a comment is ABOUT that instant).
13
+ * · likes — never individual glyphs (a hundred likes would be a hundred
14
+ * dots). They fold into a heat strip along the bottom edge, under the
15
+ * bookmark diamonds, normalised to the window's own peak so "where did
16
+ * people react" reads at any zoom.
17
+ *
18
+ * DOM-free; node-testable.
19
+ */
20
+ import { secToFrac, spanSec } from './window';
21
+ export const MARKER_HIT_PX = 6;
22
+ /** Under `(pointer: coarse)` a fingertip needs a target, not a 6px sliver. */
23
+ export const MARKER_HIT_PX_COARSE = 14;
24
+ /** Comment dots closer than this collapse to one (the chapter-flag merge rule). */
25
+ export const COMMENT_MERGE_PX = 6;
26
+ /** Heat bins are this many CSS px wide. */
27
+ export const HEAT_BIN_PX = 3;
28
+ /** Drop junk (non-finite / negative times, unknown kinds) and sort ascending. */
29
+ export function sortMarkers(markers) {
30
+ if (!Array.isArray(markers))
31
+ return [];
32
+ return markers
33
+ .filter((m) => m != null &&
34
+ typeof m.atSec === 'number' &&
35
+ Number.isFinite(m.atSec) &&
36
+ m.atSec >= 0 &&
37
+ (m.kind === 'comment' || m.kind === 'like'))
38
+ .slice()
39
+ .sort((a, b) => a.atSec - b.atSec);
40
+ }
41
+ export function splitMarkers(sorted) {
42
+ const comments = [];
43
+ const likes = [];
44
+ for (const m of sorted)
45
+ (m.kind === 'comment' ? comments : likes).push(m);
46
+ return { comments, likes };
47
+ }
48
+ /**
49
+ * Comment dots in the visible window, x in CSS px, with neighbours closer
50
+ * than COMMENT_MERGE_PX collapsed (a long video at full zoom-out must not
51
+ * smear them into a bar). Expects ascending input.
52
+ */
53
+ export function commentClusters(comments, w, duration, width) {
54
+ const out = [];
55
+ if (!(duration > 0) || !(width > 0))
56
+ return out;
57
+ for (const m of comments) {
58
+ const x = secToFrac(w, duration, m.atSec) * width;
59
+ if (x < -4 || x > width + 4)
60
+ continue;
61
+ const last = out[out.length - 1];
62
+ if (last && x - last.x < COMMENT_MERGE_PX)
63
+ last.markers.push(m);
64
+ else
65
+ out.push({ x, markers: [m] });
66
+ }
67
+ return out;
68
+ }
69
+ /** The comment cluster under the pointer, or null. */
70
+ export function commentAt(xCss, clusters, hitPx = MARKER_HIT_PX) {
71
+ let best = null;
72
+ let bestD = hitPx;
73
+ for (const c of clusters) {
74
+ const d = Math.abs(c.x - xCss);
75
+ if (d < bestD) {
76
+ bestD = d;
77
+ best = c;
78
+ }
79
+ }
80
+ return best;
81
+ }
82
+ function likeWeight(m) {
83
+ const w = m.weight;
84
+ return typeof w === 'number' && Number.isFinite(w) && w > 0 ? w : 1;
85
+ }
86
+ /**
87
+ * Like density across the visible window in `bins` equal bins, smoothed
88
+ * with a [1,2,1] kernel (a reaction lands a beat after the moment) and
89
+ * normalised to 0–1 against the window's own peak. All zeros when there is
90
+ * nothing to show.
91
+ */
92
+ export function likeHeat(likes, w, duration, bins) {
93
+ const n = Math.max(0, Math.floor(bins));
94
+ const raw = new Array(n).fill(0);
95
+ if (!n || !(duration > 0) || !(spanSec(w, duration) > 0))
96
+ return raw;
97
+ for (const m of likes) {
98
+ const f = secToFrac(w, duration, m.atSec);
99
+ if (f < 0 || f > 1)
100
+ continue;
101
+ raw[Math.min(n - 1, Math.floor(f * n))] += likeWeight(m);
102
+ }
103
+ const out = new Array(n).fill(0);
104
+ let max = 0;
105
+ for (let i = 0; i < n; i++) {
106
+ const v = 2 * raw[i] + (raw[i - 1] ?? 0) + (raw[i + 1] ?? 0);
107
+ out[i] = v;
108
+ if (v > max)
109
+ max = v;
110
+ }
111
+ return max > 0 ? out.map((v) => v / max) : out;
112
+ }
113
+ /**
114
+ * Previous / next comment relative to the playhead — the keyboard path to
115
+ * markers that are otherwise pointer-only. Same graces as the chapter jump
116
+ * (keys.ts): `next` skips 0.25s ahead so a press on a comment's own second
117
+ * doesn't stick; `previous` looks 0.75s back so it reaches the one BEFORE
118
+ * the comment you are sitting on. Comments sharing an instant come back
119
+ * together (a cluster is announced as one). Expects ascending input.
120
+ */
121
+ export function commentJump(current, comments, direction) {
122
+ let hit;
123
+ if (direction === 1)
124
+ hit = comments.find((c) => c.atSec > current + 0.25);
125
+ else
126
+ for (const c of comments) {
127
+ if (c.atSec < current - 0.75)
128
+ hit = c;
129
+ else
130
+ break;
131
+ }
132
+ if (!hit)
133
+ return null;
134
+ const at = hit.atSec;
135
+ return comments.filter((c) => Math.abs(c.atSec - at) < 0.5);
136
+ }
137
+ /** One line for a screen reader: who, when, what — and how many more. */
138
+ export function describeComments(group, formatAt) {
139
+ if (group.length === 0)
140
+ return '';
141
+ const first = group[0];
142
+ const who = first.author ? `Comment by ${first.author}` : 'Comment';
143
+ const body = first.text ? `: ${first.text}` : '';
144
+ const more = group.length > 1 ? ` (+${group.length - 1} more)` : '';
145
+ return `${who} at ${formatAt(first.atSec)}${body}${more}`;
146
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Per-instance memo slots for the band's derived layers (mos-5lzns.30
3
+ * review). The band repaints on every timeupdate, but the ribbon columns,
4
+ * like heat and comment clusters depend only on (window, width, data) — so
5
+ * they are recomputed when one of THOSE changes, never per playhead tick.
6
+ * Deps compare by identity (`Object.is`), so data props must be replaced,
7
+ * not mutated in place — the component's own convention already.
8
+ */
9
+ export interface MemoSlot<T> {
10
+ deps: unknown[] | null;
11
+ value: T | undefined;
12
+ }
13
+ export declare function memoSlot<T>(): MemoSlot<T>;
14
+ export declare function memo<T>(slot: MemoSlot<T>, deps: unknown[], compute: () => T): T;
15
+ /** The band's three derived layers, one cache per player instance. */
16
+ export interface BandLayerCache {
17
+ ribbon: MemoSlot<number[]>;
18
+ heat: MemoSlot<number[]>;
19
+ clusters: MemoSlot<import('./markers').CommentCluster[]>;
20
+ }
21
+ export declare function createBandLayerCache(): BandLayerCache;
@@ -0,0 +1,15 @@
1
+ export function memoSlot() {
2
+ return { deps: null, value: undefined };
3
+ }
4
+ export function memo(slot, deps, compute) {
5
+ const prev = slot.deps;
6
+ if (prev && prev.length === deps.length && prev.every((d, i) => Object.is(d, deps[i]))) {
7
+ return slot.value;
8
+ }
9
+ slot.deps = deps;
10
+ slot.value = compute();
11
+ return slot.value;
12
+ }
13
+ export function createBandLayerCache() {
14
+ return { ribbon: memoSlot(), heat: memoSlot(), clusters: memoSlot() };
15
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The band's zoom window — ONE mapping every position computation goes
3
+ * through (mos-5lzns.30 moved it here from band.ts so the band/ paint layers
4
+ * — axis, luma ribbon, markers — can share it without an import cycle;
5
+ * band.ts re-exports everything, so existing import sites are unchanged).
6
+ */
7
+ export interface BandWindow {
8
+ start: number;
9
+ end: number;
10
+ }
11
+ /**
12
+ * The visible window in seconds. Falls back to the whole video until the
13
+ * duration effect has seeded `end`, so a first paint that races metadata
14
+ * still maps positions correctly instead of collapsing to zero.
15
+ */
16
+ export declare function spanSec(w: BandWindow, duration: number): number;
17
+ export declare function isZoomed(w: BandWindow, duration: number): boolean;
18
+ export declare function resetWindow(duration: number): BandWindow;
19
+ export declare function secToFrac(w: BandWindow, duration: number, sec: number): number;
20
+ export declare function fracToSec(w: BandWindow, duration: number, frac: number): number;
21
+ /**
22
+ * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
23
+ * under the pointer stays fixed while the window resizes around it, so
24
+ * scrolling repeatedly walks you inward on exactly the spot you're pointing
25
+ * at. `deltaY < 0` zooms in.
26
+ */
27
+ export declare const ZOOM_FACTOR = 1.3;
28
+ export declare const MIN_WINDOW_SEC = 3;
29
+ export declare function zoomAt(w: BandWindow, duration: number, cursorSec: number, deltaY: number): BandWindow;
30
+ /**
31
+ * Zoomed in and playback walked past the edge of the window — follow it, or
32
+ * you'd watch a frozen slice of waveform while the video plays on. Returns
33
+ * null when no follow is needed.
34
+ */
35
+ export declare function followPlayhead(w: BandWindow, duration: number, t: number): BandWindow | null;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The band's zoom window — ONE mapping every position computation goes
3
+ * through (mos-5lzns.30 moved it here from band.ts so the band/ paint layers
4
+ * — axis, luma ribbon, markers — can share it without an import cycle;
5
+ * band.ts re-exports everything, so existing import sites are unchanged).
6
+ */
7
+ /**
8
+ * The visible window in seconds. Falls back to the whole video until the
9
+ * duration effect has seeded `end`, so a first paint that races metadata
10
+ * still maps positions correctly instead of collapsing to zero.
11
+ */
12
+ export function spanSec(w, duration) {
13
+ return w.end > w.start ? w.end - w.start : duration;
14
+ }
15
+ export function isZoomed(w, duration) {
16
+ return w.end > 0 && spanSec(w, duration) < duration - 0.01;
17
+ }
18
+ export function resetWindow(duration) {
19
+ return { start: 0, end: duration };
20
+ }
21
+ export function secToFrac(w, duration, sec) {
22
+ const span = spanSec(w, duration);
23
+ return span > 0 ? (sec - w.start) / span : 0;
24
+ }
25
+ export function fracToSec(w, duration, frac) {
26
+ return w.start + frac * spanSec(w, duration);
27
+ }
28
+ /**
29
+ * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
30
+ * under the pointer stays fixed while the window resizes around it, so
31
+ * scrolling repeatedly walks you inward on exactly the spot you're pointing
32
+ * at. `deltaY < 0` zooms in.
33
+ */
34
+ export const ZOOM_FACTOR = 1.3;
35
+ export const MIN_WINDOW_SEC = 3;
36
+ export function zoomAt(w, duration, cursorSec, deltaY) {
37
+ if (!duration)
38
+ return w;
39
+ if (w.end <= 0)
40
+ w = resetWindow(duration);
41
+ const span = spanSec(w, duration);
42
+ let newSpan = deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
43
+ newSpan = Math.min(duration, Math.max(MIN_WINDOW_SEC, newSpan));
44
+ const frac = span > 0 ? (cursorSec - w.start) / span : 0.5;
45
+ const start = Math.max(0, Math.min(cursorSec - frac * newSpan, duration - newSpan));
46
+ return { start, end: start + newSpan };
47
+ }
48
+ /**
49
+ * Zoomed in and playback walked past the edge of the window — follow it, or
50
+ * you'd watch a frozen slice of waveform while the video plays on. Returns
51
+ * null when no follow is needed.
52
+ */
53
+ export function followPlayhead(w, duration, t) {
54
+ if (!isZoomed(w, duration))
55
+ return null;
56
+ if (t >= w.start && t <= w.end)
57
+ return null;
58
+ const span = spanSec(w, duration);
59
+ const start = Math.max(0, Math.min(t - span / 2, Math.max(0, duration - span)));
60
+ return { start, end: start + span };
61
+ }
@@ -23,35 +23,11 @@
23
23
  */
24
24
  import { ALL_KEY } from './model';
25
25
  import type { VideoBookmark, VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from './types';
26
- export interface BandWindow {
27
- start: number;
28
- end: number;
29
- }
30
- /**
31
- * The visible window in seconds. Falls back to the whole video until the
32
- * duration effect has seeded `end`, so a first paint that races metadata
33
- * still maps positions correctly instead of collapsing to zero.
34
- */
35
- export declare function spanSec(w: BandWindow, duration: number): number;
36
- export declare function isZoomed(w: BandWindow, duration: number): boolean;
37
- export declare function resetWindow(duration: number): BandWindow;
38
- export declare function secToFrac(w: BandWindow, duration: number, sec: number): number;
39
- export declare function fracToSec(w: BandWindow, duration: number, frac: number): number;
40
- /**
41
- * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
42
- * under the pointer stays fixed while the window resizes around it, so
43
- * scrolling repeatedly walks you inward on exactly the spot you're pointing
44
- * at. `deltaY < 0` zooms in.
45
- */
46
- export declare const ZOOM_FACTOR = 1.3;
47
- export declare const MIN_WINDOW_SEC = 3;
48
- export declare function zoomAt(w: BandWindow, duration: number, cursorSec: number, deltaY: number): BandWindow;
49
- /**
50
- * Zoomed in and playback walked past the edge of the window — follow it, or
51
- * you'd watch a frozen slice of waveform while the video plays on. Returns
52
- * null when no follow is needed.
53
- */
54
- export declare function followPlayhead(w: BandWindow, duration: number, t: number): BandWindow | null;
26
+ import { type BandWindow } from './band/window';
27
+ import { type LumaSeries } from './band/luma';
28
+ import { type VideoMarker } from './band/markers';
29
+ import { type BandLayerCache } from './band/memo';
30
+ export { fracToSec, followPlayhead, isZoomed, MIN_WINDOW_SEC, resetWindow, secToFrac, spanSec, ZOOM_FACTOR, zoomAt, type BandWindow } from './band/window';
55
31
  /**
56
32
  * Click-to-seek biases toward the START of the spoken line under the click —
57
33
  * nobody wants to land mid-sentence. Only when the transcript is dense right
@@ -92,13 +68,37 @@ export interface BandPaintState {
92
68
  mutedKeys: Set<string>;
93
69
  window: BandWindow;
94
70
  hoverChapter: number;
71
+ /** Normalised light track (band/luma.ts); null ⇒ no ribbon. */
72
+ luma?: LumaSeries | null;
73
+ /** Social markers, ascending (band/markers.ts). */
74
+ comments?: VideoMarker[];
75
+ likes?: VideoMarker[];
76
+ /**
77
+ * Memo slots for the ribbon / heat / cluster arrays (band/memo.ts). With
78
+ * one, those layers recompute only when (window, width, data) change —
79
+ * not on every playhead repaint. Without one, computed fresh each paint.
80
+ */
81
+ cache?: BandLayerCache;
95
82
  /** Theme colours, resolved by the component via getDaisyUIColor. */
96
83
  colors: {
97
84
  accent: string;
98
85
  neutral: string;
99
86
  marker: string;
87
+ /** The light ribbon's wash (FEATURES.md: amber). Defaults to `marker`. */
88
+ ribbon?: string;
89
+ /** Comment dots. Defaults to `accent`. */
90
+ comment?: string;
91
+ /** Like heat strip. Defaults to `accent`. */
92
+ like?: string;
93
+ /** Outline behind comment dots so they read over a dense envelope. */
94
+ surface?: string;
100
95
  };
101
96
  }
97
+ /** Like heat strip height, px, along the band's bottom edge (under the bookmark diamonds). */
98
+ export declare const HEAT_H = 3;
99
+ /** Comment dot radius and centre line (just under the chapter flags, which occupy y 0–4). */
100
+ export declare const COMMENT_R = 2.5;
101
+ export declare const COMMENT_Y = 7.5;
102
102
  /**
103
103
  * Size the canvas backing store for devicePixelRatio and return its 2d
104
104
  * context with the transform applied — or null when there is nothing honest
@@ -114,3 +114,9 @@ export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: n
114
114
  /** Turn → BandTurn for the paint state (resolved key, ad flag). */
115
115
  export declare function toBandTurns(turns: VideoSpeakerTurn[]): BandTurn[];
116
116
  export { ALL_KEY };
117
+ /**
118
+ * Comment clusters for this window/width — memoised when a cache is given.
119
+ * The component's hit test calls this with the SAME cache as the paint, so a
120
+ * mousemove never re-clusters what the last repaint already did.
121
+ */
122
+ export declare function bandClusters(comments: VideoMarker[], w: BandWindow, duration: number, width: number, cache?: BandLayerCache): import("./band/markers").CommentCluster[];