fix snapshot dropping the triggering stroke; use per-frame gif palettes

Hunter
Wednesday, October 14th, 2026 at 5:15 PM

resources/io.js+85-29
serve.py+3-1

resources/io.js
 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);
serve.py
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)