fix snapshot dropping the triggering stroke; use per-frame gif palettes
Hunter
Wednesday, October 14th, 2026 at 5:15 PM
89 flushBlock();
90 }
91
-// minimal GIF89a encoder: exact global palette when <=256 colors,
-// else uniform 6x6x6 quantization
-function encodeGIF(images, w, h, delayMs) {
- const colorIdx = new Map();
- let over = false;
- for (const img of images) {
- const d = img.data;
- for (let i = 0; i < d.length && !over; i += 4) {
- const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
- if (!colorIdx.has(c)) {
- if (colorIdx.size === 256) over = true;
- else colorIdx.set(c, colorIdx.size);
92 +// splits the colors into up to n boxes, each time cutting the box with the
93 +// widest channel at its weighted median; each box becomes its average color
94 +function medianCut(counts, n) {
95 + const box = (e) => {
96 + const lo = [255, 255, 255], hi = [0, 0, 0];
97 + for (const [c] of e) {
98 + for (let ch = 0; ch < 3; ch++) {
99 + const v = (c >> (16 - ch * 8)) & 255;
100 + if (v < lo[ch]) lo[ch] = v;
101 + if (v > hi[ch]) hi[ch] = v;
102 }
103 }
- if (over) break;
104 + let ch = 0;
105 + for (let i = 1; i < 3; i++) if (hi[i] - lo[i] > hi[ch] - lo[ch]) ch = i;
106 + return { e, sh: 16 - ch * 8, range: e.length > 1 ? hi[ch] - lo[ch] : 0 };
107 + };
108 + const boxes = [box([...counts])];
109 + while (boxes.length < n) {
110 + let best = -1;
111 + for (let i = 0; i < boxes.length; i++) {
112 + if (boxes[i].range > 0 && (best < 0 || boxes[i].range > boxes[best].range)) best = i;
113 + }
114 + if (best < 0) break;
115 + const { e, sh } = boxes[best];
116 + e.sort((a, b) => ((a[0] >> sh) & 255) - ((b[0] >> sh) & 255));
117 + let total = 0;
118 + for (const [, k] of e) total += k;
119 + let acc = 0, cut = 1;
120 + for (; cut < e.length - 1; cut++) {
121 + acc += e[cut - 1][1];
122 + if (acc >= total / 2) break;
123 + }
124 + boxes.splice(best, 1, box(e.slice(0, cut)), box(e.slice(cut)));
125 }
- if (over) {
- colorIdx.clear();
- for (let i = 0; i < 216; i++) {
- colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
126 + return boxes.map(({ e }) => {
127 + let r = 0, g = 0, b = 0, t = 0;
128 + for (const [c, k] of e) {
129 + r += ((c >> 16) & 255) * k;
130 + g += ((c >> 8) & 255) * k;
131 + b += (c & 255) * k;
132 + t += k;
133 }
134 + return (Math.round(r / t) << 16) | (Math.round(g / t) << 8) | Math.round(b / t);
135 + });
136 +}
137 +
138 +// one frame's color table: its exact colors when there are <=256, else a
139 +// median cut of them with each color mapped to its nearest entry
140 +function framePalette(d) {
141 + const counts = new Map();
142 + for (let i = 0; i < d.length; i += 4) {
143 + const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
144 + counts.set(c, (counts.get(c) || 0) + 1);
145 + }
146 + if (counts.size <= 256) {
147 + const pal = [...counts.keys()];
148 + const idx = new Map(pal.map((c, i) => [c, i]));
149 + return { pal, lookup: (c) => idx.get(c) };
150 }
- const lookup = (c) => over
- ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
- : colorIdx.get(c);
- let bits = 2;
- while ((1 << bits) < colorIdx.size) bits++;
151 + const pal = medianCut(counts, 256);
152 + const cache = new Map();
153 + const lookup = (c) => {
154 + let best = cache.get(c);
155 + if (best !== undefined) return best;
156 + const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
157 + let bestD = Infinity;
158 + for (let i = 0; i < pal.length; i++) {
159 + const p = pal[i];
160 + const dr = ((p >> 16) & 255) - r, dg = ((p >> 8) & 255) - g, db = (p & 255) - b;
161 + const dist = dr * dr + dg * dg + db * db;
162 + if (dist < bestD) { bestD = dist; best = i; }
163 + }
164 + cache.set(c, best);
165 + return best;
166 + };
167 + return { pal, lookup };
168 +}
169 +
170 +// minimal GIF89a encoder: each frame carries its own color table, so a
171 +// frame only goes lossy when it alone has more than 256 colors
172 +function encodeGIF(images, w, h, delayMs) {
173 const out = [];
174 const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
175 out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
176 u16(w); u16(h);
- out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
- const pal = [...colorIdx.keys()];
- for (let i = 0; i < (1 << bits); i++) {
- const c = pal[i] || 0;
- out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
- }
177 + out.push(0, 0, 0); // no global color table
178 const animated = images.length > 1;
179 if (animated) {
180 // NETSCAPE2.0 loop forever
182 }
183 const delay = clamp(Math.round(delayMs / 10), 2, 65535);
184 for (const img of images) {
185 + const { pal, lookup } = framePalette(img.data);
186 + let bits = 2;
187 + while ((1 << bits) < pal.length) bits++;
188 if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
189 out.push(0x2c);
190 u16(0); u16(0); u16(w); u16(h);
- out.push(0);
191 + out.push(0x80 | (bits - 1)); // local color table
192 + for (let i = 0; i < (1 << bits); i++) {
193 + const c = pal[i] || 0;
194 + out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
195 + }
196 const minCode = Math.max(2, bits);
197 out.push(minCode);
198 lzwEncode(minCode, img.data, lookup, out);
278 }
279 f['ops'].append(op)
280 self.op_count += 1
- self.maybe_snapshot()
281 out = dict(op)
282 out['t'] = 'p'
283 out['f'] = f['id']
290 # sends only enqueue, so nothing slow happens in here. echoed to
291 # the painter too: the ack is what commits their stroke
292 self.broadcast(out)
293 + # after the broadcast, so whoever we ask to flatten already has
294 + # this stroke queued ahead of the request
295 + self.maybe_snapshot()
296 elif t == 'c':
297 msg['id'] = conn.id
298 self.broadcast(msg, skip=conn, droppable=True)