cursedbelt 5.76.0 → 5.78.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/dist/react/canvas-hud/CanvasToolbar.d.ts +1 -1
  2. package/dist/react/components/BuildFreshness.d.ts +2 -1
  3. package/dist/react/components/BuildFreshness.d.ts.map +1 -1
  4. package/dist/react/components/BuildFreshness.js +11 -4
  5. package/dist/react/components/BuildFreshness.js.map +1 -1
  6. package/dist/react/components/DataTable/TableFilterBuilder.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/TableFilterBuilder.js +2 -2
  8. package/dist/react/components/DataTable/TableFilterBuilder.js.map +1 -1
  9. package/dist/react/components/ListToolbar.d.ts +2 -2
  10. package/dist/react/components/ListToolbar.d.ts.map +1 -1
  11. package/dist/react/components/ListToolbar.js +1 -1
  12. package/dist/react/hooks/useOutsidePress.d.ts.map +1 -1
  13. package/dist/react/hooks/useOutsidePress.js +18 -3
  14. package/dist/react/hooks/useOutsidePress.js.map +1 -1
  15. package/dist/react/hooks/useScrollMemory.d.ts +4 -3
  16. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  17. package/dist/react/hooks/useScrollMemory.js +16 -2
  18. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  19. package/dist/react/media/FocusViewer.d.ts +8 -1
  20. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  21. package/dist/react/media/FocusViewer.js +174 -10
  22. package/dist/react/media/FocusViewer.js.map +1 -1
  23. package/dist/react/media/focusViewer.css +31 -0
  24. package/dist/react/media/gifClip.d.ts +46 -0
  25. package/dist/react/media/gifClip.d.ts.map +1 -0
  26. package/dist/react/media/gifClip.js +74 -0
  27. package/dist/react/media/gifClip.js.map +1 -0
  28. package/dist/react/media/gifEncode.d.ts +46 -0
  29. package/dist/react/media/gifEncode.d.ts.map +1 -0
  30. package/dist/react/media/gifEncode.js +288 -0
  31. package/dist/react/media/gifEncode.js.map +1 -0
  32. package/dist/react/media/gifRecord.d.ts +26 -0
  33. package/dist/react/media/gifRecord.d.ts.map +1 -0
  34. package/dist/react/media/gifRecord.js +93 -0
  35. package/dist/react/media/gifRecord.js.map +1 -0
  36. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  37. package/dist/styles-areas/analytics.css +9 -9
  38. package/dist/styles-areas/data-table.css +10 -10
  39. package/dist/styles-areas/media.css +2 -2
  40. package/package.json +3 -3
  41. package/scripts/guardrailsEnforce.spec.ts +10 -0
  42. package/scripts/guardrailsEnforce.ts +4 -1
  43. package/scripts/styleAreas.ts +3 -1
  44. package/src/react/canvas-hud/CanvasToolbar.tsx +1 -1
  45. package/src/react/components/BuildFreshness.spec.tsx +10 -0
  46. package/src/react/components/BuildFreshness.tsx +13 -4
  47. package/src/react/components/DataTable/TableFilterBuilder.tsx +9 -13
  48. package/src/react/components/DataTable/tableSystem.spec.tsx +15 -7
  49. package/src/react/components/ListToolbar.tsx +2 -2
  50. package/src/react/hooks/useOutsidePress.spec.tsx +20 -0
  51. package/src/react/hooks/useOutsidePress.ts +17 -2
  52. package/src/react/hooks/useScrollMemory.spec.tsx +45 -2
  53. package/src/react/hooks/useScrollMemory.ts +20 -5
  54. package/src/react/media/FocusViewer.tsx +322 -25
  55. package/src/react/media/focusViewer.css +31 -0
  56. package/src/react/media/gifClip.ts +90 -0
  57. package/src/react/media/gifEncode.spec.ts +213 -0
  58. package/src/react/media/gifEncode.ts +298 -0
  59. package/src/react/media/gifRecord.ts +103 -0
  60. package/src/styles-areas/analytics.css +9 -9
  61. package/src/styles-areas/data-table.css +10 -10
  62. package/src/styles-areas/media.css +2 -2
@@ -0,0 +1,213 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ clampGifSection,
4
+ formatGifBytes,
5
+ formatGifTime,
6
+ gifFileName,
7
+ gifFrameTimes,
8
+ gifSectionAt,
9
+ gifSize,
10
+ MAX_GIF_SECONDS,
11
+ MIN_GIF_SECONDS,
12
+ } from './gifClip.js';
13
+ import { GifWriter, lzwEncode, quantizeFrame } from './gifEncode.js';
14
+
15
+ /**
16
+ * A GIF decoder small enough to read here — the spec's independent half. It knows the format
17
+ * from the 89a spec, not from `gifEncode.ts`: a header, a loop block, and per frame a graphic
18
+ * control, a descriptor with a local table, and LZW sub-blocks.
19
+ */
20
+ function decodeGif(bytes: Uint8Array) {
21
+ const text = (at: number, n: number) => String.fromCharCode(...bytes.subarray(at, at + n));
22
+ const u16 = (at: number) => (bytes[at] as number) | ((bytes[at + 1] as number) << 8);
23
+ expect(text(0, 6)).toBe('GIF89a');
24
+ const width = u16(6);
25
+ const height = u16(8);
26
+ let at = 13 + (((bytes[10] as number) & 0x80) ? 3 << (((bytes[10] as number) & 7) + 1) : 0);
27
+ let loop: number | null = null;
28
+ const frames: { delay: number; rgb: number[][] }[] = [];
29
+ let delay = 0;
30
+ while (bytes[at] !== 0x3b) {
31
+ const kind = bytes[at];
32
+ if (kind === 0x21) {
33
+ const label = bytes[at + 1];
34
+ if (label === 0xff && text(at + 3, 11) === 'NETSCAPE2.0') loop = u16(at + 16);
35
+ if (label === 0xf9) delay = u16(at + 4);
36
+ at += 2;
37
+ while (bytes[at] !== 0) at += (bytes[at] as number) + 1;
38
+ at += 1;
39
+ } else if (kind === 0x2c) {
40
+ const w = u16(at + 5);
41
+ const h = u16(at + 7);
42
+ const packed = bytes[at + 9] as number;
43
+ expect(packed & 0x80).toBe(0x80);
44
+ const entries = 1 << ((packed & 7) + 1);
45
+ const table = bytes.subarray(at + 10, at + 10 + entries * 3);
46
+ at += 10 + entries * 3;
47
+ const minCode = bytes[at++] as number;
48
+ const data: number[] = [];
49
+ while (bytes[at] !== 0) {
50
+ const n = bytes[at] as number;
51
+ data.push(...bytes.subarray(at + 1, at + 1 + n));
52
+ at += n + 1;
53
+ }
54
+ at += 1;
55
+ const indices = lzwDecode(Uint8Array.from(data), minCode, w * h);
56
+ frames.push({
57
+ delay,
58
+ rgb: indices.map((i) => [table[i * 3] as number, table[i * 3 + 1] as number, table[i * 3 + 2] as number]),
59
+ });
60
+ } else throw new Error(`unexpected block 0x${kind?.toString(16)} at ${at}`);
61
+ }
62
+ return { width, height, loop, frames };
63
+ }
64
+
65
+ function lzwDecode(data: Uint8Array, minCode: number, pixels: number): number[] {
66
+ const clear = 1 << minCode;
67
+ const end = clear + 1;
68
+ let width = minCode + 1;
69
+ let dict: number[][] = [];
70
+ const reset = () => {
71
+ dict = Array.from({ length: clear + 2 }, (_, i) => (i < clear ? [i] : []));
72
+ width = minCode + 1;
73
+ };
74
+ reset();
75
+ const out: number[] = [];
76
+ let bitPos = 0;
77
+ let previous: number[] | null = null;
78
+ while (bitPos + width <= data.length * 8) {
79
+ let code = 0;
80
+ for (let b = 0; b < width; b++, bitPos++)
81
+ code |= (((data[bitPos >> 3] as number) >> (bitPos & 7)) & 1) << b;
82
+ if (code === clear) {
83
+ reset();
84
+ previous = null;
85
+ continue;
86
+ }
87
+ if (code === end) break;
88
+ let entry: number[];
89
+ if (code < dict.length) entry = dict[code] as number[];
90
+ else if (previous && code === dict.length) entry = [...previous, previous[0] as number];
91
+ else throw new Error(`bad code ${code} (dictionary ${dict.length})`);
92
+ out.push(...entry);
93
+ if (previous) dict.push([...previous, entry[0] as number]);
94
+ previous = entry;
95
+ if (dict.length === 1 << width && width < 12) width++;
96
+ }
97
+ expect(out.length).toBe(pixels);
98
+ return out;
99
+ }
100
+
101
+ /** RGBA from a function of the pixel position. */
102
+ const paint = (w: number, h: number, at: (x: number, y: number) => [number, number, number]) => {
103
+ const rgba = new Uint8ClampedArray(w * h * 4);
104
+ for (let y = 0; y < h; y++)
105
+ for (let x = 0; x < w; x++) {
106
+ const [r, g, b] = at(x, y);
107
+ rgba.set([r, g, b, 255], (y * w + x) * 4);
108
+ }
109
+ return rgba;
110
+ };
111
+
112
+ describe('the GIF encoder', () => {
113
+ test('🔴 a frame of a few flat colours comes back pixel for pixel, looping forever', () => {
114
+ const colours: [number, number, number][] = [
115
+ [255, 0, 0],
116
+ [0, 128, 255],
117
+ [16, 16, 16],
118
+ [248, 248, 248],
119
+ ];
120
+ const rgba = paint(37, 23, (x, y) => colours[(x * 3 + y) % 4] as [number, number, number]);
121
+ const gif = new GifWriter(37, 23);
122
+ gif.addFrame(rgba, 100);
123
+ const read = decodeGif(gif.finish());
124
+ expect(read).toMatchObject({ width: 37, height: 23, loop: 0 });
125
+ expect(read.frames).toHaveLength(1);
126
+ expect(read.frames[0]?.delay).toBe(10);
127
+ const back = read.frames[0]?.rgb ?? [];
128
+ for (let i = 0; i < 37 * 23; i++)
129
+ expect(back[i]).toEqual([...rgba.subarray(i * 4, i * 4 + 3)]);
130
+ });
131
+
132
+ test('🔴 a frame big and busy enough to fill the LZW table decodes intact — the clear-and-regrow path', () => {
133
+ // 200×150 of noise: tens of thousands of codes, so the table hits 4096 and clears many times.
134
+ let seed = 7;
135
+ const rand = () => {
136
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
137
+ return seed;
138
+ };
139
+ const rgba = paint(200, 150, () => [(rand() >> 16) & 0xf8, (rand() >> 16) & 0xf8, (rand() >> 16) & 0xf8]);
140
+ const { palette, indices } = quantizeFrame(rgba);
141
+ const encoded = lzwEncode(indices, 8);
142
+ expect(lzwDecode(encoded, 8, indices.length)).toEqual([...indices]);
143
+ expect(palette.length).toBe(256 * 3);
144
+ });
145
+
146
+ test('a gradient with more than 256 colours keeps every pixel within a few levels of the truth', () => {
147
+ const rgba = paint(256, 64, (x, y) => [x, (y * 4) & 255, 255 - x]);
148
+ const { palette, indices } = quantizeFrame(rgba);
149
+ let worst = 0;
150
+ for (let p = 0; p < indices.length; p++) {
151
+ const e = (indices[p] as number) * 3;
152
+ for (let c = 0; c < 3; c++)
153
+ worst = Math.max(worst, Math.abs((palette[e + c] as number) - (rgba[p * 4 + c] as number)));
154
+ }
155
+ expect(worst).toBeLessThanOrEqual(24);
156
+ });
157
+
158
+ test('a 15 fps clip carries the hundredths GIF cannot write, so it plays for its real length', () => {
159
+ const gif = new GifWriter(2, 2);
160
+ const black = new Uint8ClampedArray(16);
161
+ for (let i = 0; i < 45; i++) gif.addFrame(black, 1000 / 15);
162
+ const delays = decodeGif(gif.finish()).frames.map((f) => f.delay);
163
+ expect(delays.reduce((a, b) => a + b, 0)).toBe(300);
164
+ expect(new Set(delays)).toEqual(new Set([6, 7]));
165
+ });
166
+
167
+ test('a frame of the wrong size is refused with its sizes', () => {
168
+ expect(() => new GifWriter(4, 4).addFrame(new Uint8ClampedArray(10), 100)).toThrow('4×4');
169
+ });
170
+ });
171
+
172
+ describe('a GIF section', () => {
173
+ test('opens at the playhead, three seconds long, and stays inside the video', () => {
174
+ expect(gifSectionAt(10, 60)).toEqual({ startSeconds: 10, endSeconds: 13 });
175
+ expect(gifSectionAt(59, 60)).toEqual({ startSeconds: 59, endSeconds: 60 });
176
+ expect(gifSectionAt(60, 60).startSeconds).toBeCloseTo(60 - MIN_GIF_SECONDS);
177
+ });
178
+
179
+ test('🔴 moving one end past the limit moves the OTHER end, never the one in hand', () => {
180
+ expect(clampGifSection({ startSeconds: 0, endSeconds: 40 }, 60, 'end')).toEqual({
181
+ startSeconds: 40 - MAX_GIF_SECONDS,
182
+ endSeconds: 40,
183
+ });
184
+ expect(clampGifSection({ startSeconds: 5, endSeconds: 40 }, 60, 'start')).toEqual({
185
+ startSeconds: 5,
186
+ endSeconds: 5 + MAX_GIF_SECONDS,
187
+ });
188
+ expect(clampGifSection({ startSeconds: 9, endSeconds: 3 }, 60, 'end')).toEqual({
189
+ startSeconds: 3 - MIN_GIF_SECONDS,
190
+ endSeconds: 3,
191
+ });
192
+ });
193
+
194
+ test('is sampled from its start, every 1/fps, the last frame before its end', () => {
195
+ expect(gifFrameTimes({ startSeconds: 2, endSeconds: 3 }, 10).map((t) => Math.round(t * 10) / 10)).toEqual([
196
+ 2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9,
197
+ ]);
198
+ });
199
+
200
+ test('is never wider than the crop it comes from, and keeps its shape', () => {
201
+ expect(gifSize({ sw: 1920, sh: 1080 }, 480)).toEqual({ width: 480, height: 270 });
202
+ expect(gifSize({ sw: 300, sh: 200 }, 640)).toEqual({ width: 300, height: 200 });
203
+ });
204
+
205
+ test('says its times to the tenth, its weight, and a file name with no path in it', () => {
206
+ expect(formatGifTime(72.44)).toBe('1:12.4');
207
+ expect(formatGifTime(5)).toBe('0:05.0');
208
+ expect(formatGifBytes(2.4 * 1024 * 1024)).toBe('2.4 MB');
209
+ expect(formatGifBytes(840 * 1024)).toBe('840 KB');
210
+ expect(gifFileName('Holiday/Beach.mp4', 12.4)).toBe('Holiday Beach @ 0-12.4.gif');
211
+ expect(gifFileName(undefined, 0)).toBe('clip @ 0-00.0.gif');
212
+ });
213
+ });
@@ -0,0 +1,298 @@
1
+ // ── A looping GIF, encoded in the browser ────────────────────────────────────────────
2
+ // Owner, 2026-10-11: *"In collections and the cb media player in general allow making a
3
+ // looping gif out of a section of a video."* This is the encoder that ask needs, and only
4
+ // that: GIF89a, a local 256-colour palette per frame, the NETSCAPE2.0 loop block, LZW.
5
+ //
6
+ // 🔴 Written here rather than taken as a dependency. The media entry's heavy libraries are
7
+ // OPTIONAL peers an app has to install and dedupe (see `index.ts`), and the encoder this
8
+ // feature needs is ~200 lines of a frozen 1989 format — smaller than the declaration file,
9
+ // lockfile churn and peer table one more package would cost every app that shows a video.
10
+ // Pure functions over typed arrays, so `gifEncode.spec.ts` decodes what it writes and
11
+ // checks the pixels came back.
12
+ //
13
+ // Per-frame palettes rather than one global one: a clip is captured a frame at a time and
14
+ // a global palette would need every frame in memory first — 300 frames of 640×360 RGBA is
15
+ // 276 MB. Each frame is quantized, written and dropped.
16
+
17
+ /** The colours one frame is reduced to, and each pixel's index into them. */
18
+ export interface QuantizedFrame {
19
+ /** `r,g,b` triples — at most 256 of them. */
20
+ palette: Uint8Array;
21
+ indices: Uint8Array;
22
+ }
23
+
24
+ const BINS = 1 << 15; // 5 bits a channel
25
+
26
+ const binOf = (r: number, g: number, b: number): number => ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
27
+
28
+ /**
29
+ * Median-cut an RGBA frame to at most `maxColors` colours.
30
+ *
31
+ * Pixels are first counted into 32,768 bins (5 bits a channel) holding their true 8-bit
32
+ * sums, so a palette entry is the real average of what it stands for rather than a bin's
33
+ * corner. The box split is the one with the most pixels × widest channel, at its weighted
34
+ * median — the textbook cut, which keeps a big flat sky from eating every entry. A frame with
35
+ * no more than `maxColors` distinct binned colours keeps them all.
36
+ */
37
+ export function quantizeFrame(rgba: Uint8Array | Uint8ClampedArray, maxColors = 256): QuantizedFrame {
38
+ const pixels = rgba.length >> 2;
39
+ const count = new Uint32Array(BINS);
40
+ const sumR = new Float64Array(BINS);
41
+ const sumG = new Float64Array(BINS);
42
+ const sumB = new Float64Array(BINS);
43
+ for (let p = 0, i = 0; p < pixels; p++, i += 4) {
44
+ const r = rgba[i] as number;
45
+ const g = rgba[i + 1] as number;
46
+ const b = rgba[i + 2] as number;
47
+ const bin = binOf(r, g, b);
48
+ count[bin] = (count[bin] as number) + 1;
49
+ sumR[bin] = (sumR[bin] as number) + r;
50
+ sumG[bin] = (sumG[bin] as number) + g;
51
+ sumB[bin] = (sumB[bin] as number) + b;
52
+ }
53
+ const used: number[] = [];
54
+ for (let bin = 0; bin < BINS; bin++) if (count[bin]) used.push(bin);
55
+
56
+ const channel = (bin: number, c: 0 | 1 | 2): number => (bin >> (10 - 5 * c)) & 31;
57
+ type Box = { bins: number[]; pixels: number; c: 0 | 1 | 2; range: number };
58
+ // A box's widest channel is measured once, when it is made — the split loop asks for it
59
+ // of every box on every pass, and re-scanning 32k bins 256 times a frame is the cost here.
60
+ const boxOf = (bins: number[]): Box => {
61
+ let pixels = 0;
62
+ const lo = [31, 31, 31];
63
+ const hi = [0, 0, 0];
64
+ for (const bin of bins) {
65
+ pixels += count[bin] as number;
66
+ for (const c of [0, 1, 2] as const) {
67
+ const v = channel(bin, c);
68
+ if (v < (lo[c] as number)) lo[c] = v;
69
+ if (v > (hi[c] as number)) hi[c] = v;
70
+ }
71
+ }
72
+ const ranges = [0, 1, 2].map((c) => (hi[c] as number) - (lo[c] as number));
73
+ const c = ranges.indexOf(Math.max(...ranges)) as 0 | 1 | 2;
74
+ return { bins, pixels, c, range: ranges[c] as number };
75
+ };
76
+
77
+ const boxes: Box[] = [boxOf(used)];
78
+ while (boxes.length < maxColors) {
79
+ let pick = -1;
80
+ let score = 0;
81
+ for (let i = 0; i < boxes.length; i++) {
82
+ const box = boxes[i] as Box;
83
+ if (box.bins.length < 2) continue;
84
+ const s = box.pixels * (box.range + 1);
85
+ if (s > score) {
86
+ score = s;
87
+ pick = i;
88
+ }
89
+ }
90
+ if (pick < 0) break; // every box is one colour
91
+ const box = boxes[pick] as Box;
92
+ const { c } = box;
93
+ box.bins.sort((a, b) => channel(a, c) - channel(b, c));
94
+ let seen = 0;
95
+ let cut = 1;
96
+ for (let i = 0; i < box.bins.length - 1; i++) {
97
+ seen += count[box.bins[i] as number] as number;
98
+ cut = i + 1;
99
+ if (seen * 2 >= box.pixels) break;
100
+ }
101
+ boxes.splice(pick, 1, boxOf(box.bins.slice(0, cut)), boxOf(box.bins.slice(cut)));
102
+ }
103
+
104
+ const palette = new Uint8Array(Math.max(1, boxes.length) * 3);
105
+ const lut = new Uint8Array(BINS);
106
+ boxes.forEach((box, entry) => {
107
+ let r = 0;
108
+ let g = 0;
109
+ let b = 0;
110
+ for (const bin of box.bins) {
111
+ r += sumR[bin] as number;
112
+ g += sumG[bin] as number;
113
+ b += sumB[bin] as number;
114
+ }
115
+ const n = Math.max(1, box.pixels);
116
+ palette[entry * 3] = Math.round(r / n);
117
+ palette[entry * 3 + 1] = Math.round(g / n);
118
+ palette[entry * 3 + 2] = Math.round(b / n);
119
+ });
120
+ // Each bin maps to its NEAREST palette entry, not merely the box it fell in: a box's
121
+ // average can sit closer to a neighbour's members than to some of its own.
122
+ const entries = palette.length / 3;
123
+ for (const bin of used) {
124
+ const n = count[bin] as number;
125
+ const r = (sumR[bin] as number) / n;
126
+ const g = (sumG[bin] as number) / n;
127
+ const b = (sumB[bin] as number) / n;
128
+ let best = 0;
129
+ let bestDistance = Number.POSITIVE_INFINITY;
130
+ for (let e = 0; e < entries; e++) {
131
+ const dr = r - (palette[e * 3] as number);
132
+ const dg = g - (palette[e * 3 + 1] as number);
133
+ const db = b - (palette[e * 3 + 2] as number);
134
+ const d = dr * dr + dg * dg + db * db;
135
+ if (d < bestDistance) {
136
+ bestDistance = d;
137
+ best = e;
138
+ }
139
+ }
140
+ lut[bin] = best;
141
+ }
142
+ const indices = new Uint8Array(pixels);
143
+ for (let p = 0, i = 0; p < pixels; p++, i += 4)
144
+ indices[p] = lut[binOf(rgba[i] as number, rgba[i + 1] as number, rgba[i + 2] as number)] as number;
145
+ return { palette, indices };
146
+ }
147
+
148
+ /**
149
+ * GIF's variable-width LZW over 8-bit indices, as the bytes between the minimum code size and
150
+ * the block terminator (before they are cut into 255-byte sub-blocks).
151
+ *
152
+ * 🔴 The code width grows when the NEXT code to be assigned reaches `1 << width`, and only
153
+ * then — the decoder learns each entry one code later than the encoder makes it, and growing
154
+ * a code early or late desynchronises every code after it into noise.
155
+ */
156
+ export function lzwEncode(indices: Uint8Array, minCodeSize = 8): Uint8Array {
157
+ const clear = 1 << minCodeSize;
158
+ const end = clear + 1;
159
+ const out: number[] = [];
160
+ let width = minCodeSize + 1;
161
+ let next = end + 1;
162
+ let bits = 0;
163
+ let held = 0;
164
+ const emit = (code: number): void => {
165
+ held |= code << bits;
166
+ bits += width;
167
+ while (bits >= 8) {
168
+ out.push(held & 0xff);
169
+ held >>>= 8;
170
+ bits -= 8;
171
+ }
172
+ };
173
+ let table = new Map<number, number>();
174
+ emit(clear);
175
+ if (indices.length === 0) {
176
+ emit(end);
177
+ if (bits > 0) out.push(held & 0xff);
178
+ return Uint8Array.from(out);
179
+ }
180
+ let prefix = indices[0] as number;
181
+ for (let i = 1; i < indices.length; i++) {
182
+ const k = indices[i] as number;
183
+ const key = (prefix << 8) | k;
184
+ const known = table.get(key);
185
+ if (known !== undefined) {
186
+ prefix = known;
187
+ continue;
188
+ }
189
+ emit(prefix);
190
+ if (next === 4096) {
191
+ emit(clear);
192
+ table = new Map();
193
+ width = minCodeSize + 1;
194
+ next = end + 1;
195
+ } else {
196
+ if (next >= 1 << width) width++;
197
+ table.set(key, next++);
198
+ }
199
+ prefix = k;
200
+ }
201
+ emit(prefix);
202
+ emit(end);
203
+ if (bits > 0) out.push(held & 0xff);
204
+ return Uint8Array.from(out);
205
+ }
206
+
207
+ /** A GIF built a frame at a time. `finish()` closes it and hands back the file. */
208
+ export class GifWriter {
209
+ private readonly parts: Uint8Array[] = [];
210
+ /** Centiseconds owed to the next frame — see {@link addFrame}. */
211
+ private carry = 0;
212
+
213
+ constructor(
214
+ readonly width: number,
215
+ readonly height: number,
216
+ /** 0 loops forever, which is what a GIF made from a clip is for. */
217
+ loop = 0,
218
+ ) {
219
+ if (width < 1 || height < 1 || width > 0xffff || height > 0xffff) throw new Error(`A GIF cannot be ${width}×${height}.`);
220
+ const head = new Uint8Array(13 + 19);
221
+ head.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); // GIF89a
222
+ put16(head, 6, width);
223
+ put16(head, 8, height);
224
+ // No global colour table: every frame carries its own.
225
+ head.set([0x00, 0x00, 0x00], 10);
226
+ // NETSCAPE2.0 application extension — the loop count.
227
+ head.set([0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01], 13);
228
+ put16(head, 29, loop);
229
+ head[31] = 0x00;
230
+ this.parts.push(head);
231
+ }
232
+
233
+ /**
234
+ * One frame of `width × height` RGBA, shown for `delayMs`.
235
+ *
236
+ * GIF counts delay in hundredths of a second, so 15 fps (6.67 cs) cannot be written
237
+ * exactly. The remainder is carried into the next frame rather than rounded away, so a
238
+ * 3-second clip plays for 3 seconds instead of drifting to 2.7 or 3.5.
239
+ */
240
+ addFrame(rgba: Uint8Array | Uint8ClampedArray, delayMs: number): void {
241
+ if (rgba.length !== this.width * this.height * 4)
242
+ throw new Error(`A ${this.width}×${this.height} frame is ${this.width * this.height * 4} bytes, not ${rgba.length}.`);
243
+ const exact = delayMs / 10 + this.carry;
244
+ const delay = Math.max(2, Math.round(exact));
245
+ this.carry = exact - delay;
246
+
247
+ const { palette, indices } = quantizeFrame(rgba);
248
+ const tableBits = Math.max(1, Math.ceil(Math.log2(palette.length / 3)));
249
+ const table = new Uint8Array(3 << tableBits);
250
+ table.set(palette);
251
+
252
+ const control = new Uint8Array(8);
253
+ control.set([0x21, 0xf9, 0x04, 0x04]); // graphic control, disposal 1: leave the frame in place
254
+ put16(control, 4, delay);
255
+ const descriptor = new Uint8Array(10);
256
+ descriptor[0] = 0x2c;
257
+ put16(descriptor, 5, this.width);
258
+ put16(descriptor, 7, this.height);
259
+ descriptor[9] = 0x80 | (tableBits - 1); // a local colour table of 2^tableBits entries
260
+
261
+ // The minimum code size follows the table (never below GIF's floor of 2), so every index
262
+ // is a root code — what every decoder, Safari's included, expects.
263
+ const minCodeSize = Math.max(2, tableBits);
264
+ const data = lzwEncode(indices, minCodeSize);
265
+ const blocks = new Uint8Array(1 + data.length + Math.ceil(data.length / 255) + 1);
266
+ blocks[0] = minCodeSize;
267
+ let at = 1;
268
+ for (let i = 0; i < data.length; i += 255) {
269
+ const piece = data.subarray(i, i + 255);
270
+ blocks[at++] = piece.length;
271
+ blocks.set(piece, at);
272
+ at += piece.length;
273
+ }
274
+ blocks[at] = 0x00;
275
+ this.parts.push(control, descriptor, table, blocks);
276
+ }
277
+
278
+ finish(): Uint8Array {
279
+ this.parts.push(Uint8Array.of(0x3b));
280
+ const total = this.parts.reduce((n, part) => n + part.length, 0);
281
+ const file = new Uint8Array(total);
282
+ let at = 0;
283
+ for (const part of this.parts) {
284
+ file.set(part, at);
285
+ at += part.length;
286
+ }
287
+ return file;
288
+ }
289
+ }
290
+
291
+ function put16(bytes: Uint8Array, at: number, value: number): void {
292
+ bytes[at] = value & 0xff;
293
+ bytes[at + 1] = (value >> 8) & 0xff;
294
+ }
295
+
296
+ function ascii(text: string): number[] {
297
+ return [...text].map((ch) => ch.charCodeAt(0));
298
+ }
@@ -0,0 +1,103 @@
1
+ // ── Making the GIF: seek, draw, encode ────────────────────────────────────────────────
2
+ // Loaded on demand by the focus viewer (see `gifClip.ts`'s header) — it and `gifEncode.ts` are
3
+ // the bytes of this feature, and a gallery that never makes a GIF should not pay for them.
4
+ //
5
+ // 🔴 Frames are taken by SEEKING the element to each moment, never by playing it and
6
+ // sampling what goes past. Playback drops and repeats frames whenever the machine is busy —
7
+ // and encoding is what keeps it busy — so a played capture stutters exactly as much as the
8
+ // encoder is slow. A seek lands on the frame for that time however long the last one took.
9
+
10
+ import { formatGifTime, type GifSection, gifFrameTimes, gifSize } from "./gifClip.js";
11
+ import { GifWriter } from "./gifEncode.js";
12
+
13
+ export interface RecordGifOptions extends GifSection {
14
+ fps: number;
15
+ /** Requested output width; see {@link gifSize}. */
16
+ width: number;
17
+ /** The source rectangle, in video pixels — what is on screen in a zoomed viewer. Whole frame when omitted. */
18
+ crop?: { sx: number; sy: number; sw: number; sh: number } | null;
19
+ /** A CSS filter baked into every frame, the same string the preview shows. */
20
+ filter?: string;
21
+ onProgress?: (done: number, total: number) => void;
22
+ signal?: AbortSignal;
23
+ }
24
+
25
+ /**
26
+ * Seek `video` through the section and encode what it shows as a looping GIF.
27
+ *
28
+ * The element is paused for the capture and handed back where it was, paused or playing.
29
+ * Rejects with an `AbortError` when `signal` aborts, and with a sentence when a frame cannot
30
+ * be read (a cross-origin source without CORS taints the canvas) or a seek never lands.
31
+ */
32
+ export async function recordGifClip(video: HTMLVideoElement, options: RecordGifOptions): Promise<Blob> {
33
+ if (!video.videoWidth || !video.videoHeight) throw new Error("The video has not decoded a frame yet.");
34
+ const crop = options.crop ?? { sx: 0, sy: 0, sw: video.videoWidth, sh: video.videoHeight };
35
+ const size = gifSize(crop, options.width);
36
+ const times = gifFrameTimes(options, options.fps);
37
+ const canvas = document.createElement("canvas");
38
+ canvas.width = size.width;
39
+ canvas.height = size.height;
40
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
41
+ if (!ctx) throw new Error("This browser cannot capture a frame.");
42
+ ctx.imageSmoothingEnabled = true;
43
+ ctx.imageSmoothingQuality = "high";
44
+
45
+ const was = { at: video.currentTime, playing: !video.paused };
46
+ video.pause();
47
+ const writer = new GifWriter(size.width, size.height);
48
+ try {
49
+ for (const [i, at] of times.entries()) {
50
+ if (options.signal?.aborted) throw new DOMException("The GIF was cancelled.", "AbortError");
51
+ await seekForCapture(video, at);
52
+ if (options.filter && "filter" in ctx) ctx.filter = options.filter;
53
+ ctx.drawImage(video, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, size.width, size.height);
54
+ let frame: ImageData;
55
+ try {
56
+ frame = ctx.getImageData(0, 0, size.width, size.height);
57
+ } catch {
58
+ throw new Error("This video's frames cannot be read — it may be cross-origin.");
59
+ }
60
+ writer.addFrame(frame.data, 1000 / options.fps);
61
+ options.onProgress?.(i + 1, times.length);
62
+ }
63
+ return new Blob([writer.finish() as BlobPart], { type: "image/gif" });
64
+ } finally {
65
+ video.currentTime = was.at;
66
+ if (was.playing) void video.play().catch(() => undefined);
67
+ }
68
+ }
69
+
70
+ /** How long one seek may take before the capture gives up and says so. */
71
+ const SEEK_TIMEOUT_MS = 8_000;
72
+
73
+ /**
74
+ * Move to `seconds` and wait until that frame is the one a canvas will read.
75
+ *
76
+ * `seeked` says the element has the data; on WebKit the PAINTED frame can still be the old one
77
+ * for a tick after it, so where `requestVideoFrameCallback` exists the capture also waits for
78
+ * the new frame to be presented — bounded, because a seek to the frame already showing
79
+ * presents nothing new.
80
+ */
81
+ async function seekForCapture(video: HTMLVideoElement, seconds: number): Promise<void> {
82
+ if (Math.abs(video.currentTime - seconds) < 1e-4 && !video.seeking && video.readyState >= 2) return;
83
+ await new Promise<void>((resolve, reject) => {
84
+ const timer = setTimeout(() => {
85
+ video.removeEventListener("seeked", done);
86
+ reject(new Error(`The video stopped answering at ${formatGifTime(seconds)} — try a shorter section.`));
87
+ }, SEEK_TIMEOUT_MS);
88
+ function done(): void {
89
+ clearTimeout(timer);
90
+ resolve();
91
+ }
92
+ video.addEventListener("seeked", done, { once: true });
93
+ video.currentTime = seconds;
94
+ });
95
+ const presented = (video as HTMLVideoElement & {
96
+ requestVideoFrameCallback?: (callback: () => void) => number;
97
+ }).requestVideoFrameCallback;
98
+ if (typeof presented === "function")
99
+ await Promise.race([
100
+ new Promise<void>((resolve) => presented.call(video, () => resolve())),
101
+ new Promise<void>((resolve) => setTimeout(resolve, 150)),
102
+ ]);
103
+ }
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 155 candidate(s).
21
+ * 153 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -31,13 +31,13 @@
31
31
  @source inline("@max-4xl:p-0! @max-4xl:text-[length:0] @max-4xl:w-0! @max-6xl:[&>*]:hidden @max-6xl:border-0");
32
32
  @source inline("@max-6xl:invisible @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:text-[length:0] @max-6xl:w-0!");
33
33
  @source inline("@max-xl:contents @xl:basis-auto @xl:block @xl:hidden [&>*]:hidden [&>*]:max-w-none");
34
- @source inline("[&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing appearance-none");
35
- @source inline("basis basis-full bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50");
36
- @source inline("border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col collapse");
37
- @source inline("cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
38
- @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
39
- @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
40
- @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
34
+ @source inline("[&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing basis basis-full");
35
+ @source inline("bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50 border-accent-solid");
36
+ @source inline("border-accent/60 border-field-error border-solid border-t-2 col collapse cursor-grab");
37
+ @source inline("data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[highlighted]:bg-accent/10");
38
+ @source inline("data-[highlighted]:text-accent-strong data-[starting-style]:opacity-0");
39
+ @source inline("data-[starting-style]:scale-95 disabled:opacity-40 duration-100 fade-in");
40
+ @source inline("focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
41
41
  @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
42
42
  @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
43
43
  @source inline("hover:border-field-ring hover:text-warning italic justify-self-end last:border-0 left-11");
@@ -45,7 +45,7 @@
45
45
  @source inline("max-sm:min-w-24 max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48");
46
46
  @source inline("max-w-60 min-h-20 min-h-dvh min-w-0! min-w-20 min-w-24 min-w-52 min-w-[var(--anchor-width)]");
47
47
  @source inline("min-w-max ml-1.5 mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1");
48
- @source inline("pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
48
+ @source inline("pr-10 pr-16 pr-3 pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
49
49
  @source inline("shadow-[1px_0_0_0_var(--color-border)] shrink-[2] size-7 slide-in-from-top-1 table-fixed");
50
50
  @source inline("text-[length:0] text-accent-solid-foreground text-field-placeholder text-success text-xl");
51
51
  @source inline("to-transparent top-3 transition-[transform,opacity] w-0 w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");