commit 26e85b99390a0fb3e76d246855b107aa8a16a706
parent 066ca2b66919d0d72ae3bb692aaddfe4cb09a72e
Author: Hunter
Date:   Wed, 14 Oct 2026 17:15:58 -0400

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

Diffstat:
Mresources/io.js | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mserve.py | 4+++-
2 files changed, 88 insertions(+), 30 deletions(-)

diff --git a/resources/io.js b/resources/io.js @@ -89,43 +89,92 @@ function lzwEncode(minCode, data, lookup, out) { flushBlock(); } -// 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); +// splits the colors into up to n boxes, each time cutting the box with the +// widest channel at its weighted median; each box becomes its average color +function medianCut(counts, n) { + const box = (e) => { + const lo = [255, 255, 255], hi = [0, 0, 0]; + for (const [c] of e) { + for (let ch = 0; ch < 3; ch++) { + const v = (c >> (16 - ch * 8)) & 255; + if (v < lo[ch]) lo[ch] = v; + if (v > hi[ch]) hi[ch] = v; } } - if (over) break; + let ch = 0; + for (let i = 1; i < 3; i++) if (hi[i] - lo[i] > hi[ch] - lo[ch]) ch = i; + return { e, sh: 16 - ch * 8, range: e.length > 1 ? hi[ch] - lo[ch] : 0 }; + }; + const boxes = [box([...counts])]; + while (boxes.length < n) { + let best = -1; + for (let i = 0; i < boxes.length; i++) { + if (boxes[i].range > 0 && (best < 0 || boxes[i].range > boxes[best].range)) best = i; + } + if (best < 0) break; + const { e, sh } = boxes[best]; + e.sort((a, b) => ((a[0] >> sh) & 255) - ((b[0] >> sh) & 255)); + let total = 0; + for (const [, k] of e) total += k; + let acc = 0, cut = 1; + for (; cut < e.length - 1; cut++) { + acc += e[cut - 1][1]; + if (acc >= total / 2) break; + } + boxes.splice(best, 1, box(e.slice(0, cut)), box(e.slice(cut))); } - 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); + return boxes.map(({ e }) => { + let r = 0, g = 0, b = 0, t = 0; + for (const [c, k] of e) { + r += ((c >> 16) & 255) * k; + g += ((c >> 8) & 255) * k; + b += (c & 255) * k; + t += k; } + return (Math.round(r / t) << 16) | (Math.round(g / t) << 8) | Math.round(b / t); + }); +} + +// one frame's color table: its exact colors when there are <=256, else a +// median cut of them with each color mapped to its nearest entry +function framePalette(d) { + const counts = new Map(); + for (let i = 0; i < d.length; i += 4) { + const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2]; + counts.set(c, (counts.get(c) || 0) + 1); + } + if (counts.size <= 256) { + const pal = [...counts.keys()]; + const idx = new Map(pal.map((c, i) => [c, i])); + return { pal, lookup: (c) => idx.get(c) }; } - 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++; + const pal = medianCut(counts, 256); + const cache = new Map(); + const lookup = (c) => { + let best = cache.get(c); + if (best !== undefined) return best; + const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255; + let bestD = Infinity; + for (let i = 0; i < pal.length; i++) { + const p = pal[i]; + const dr = ((p >> 16) & 255) - r, dg = ((p >> 8) & 255) - g, db = (p & 255) - b; + const dist = dr * dr + dg * dg + db * db; + if (dist < bestD) { bestD = dist; best = i; } + } + cache.set(c, best); + return best; + }; + return { pal, lookup }; +} + +// minimal GIF89a encoder: each frame carries its own color table, so a +// frame only goes lossy when it alone has more than 256 colors +function encodeGIF(images, w, h, delayMs) { const out = []; const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); }; out.push(71, 73, 70, 56, 57, 97); // "GIF89a" 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); - } + out.push(0, 0, 0); // no global color table const animated = images.length > 1; if (animated) { // NETSCAPE2.0 loop forever @@ -133,10 +182,17 @@ function encodeGIF(images, w, h, delayMs) { } const delay = clamp(Math.round(delayMs / 10), 2, 65535); for (const img of images) { + const { pal, lookup } = framePalette(img.data); + let bits = 2; + while ((1 << bits) < pal.length) bits++; if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0); out.push(0x2c); u16(0); u16(0); u16(w); u16(h); - out.push(0); + out.push(0x80 | (bits - 1)); // local color table + for (let i = 0; i < (1 << bits); i++) { + const c = pal[i] || 0; + out.push((c >> 16) & 255, (c >> 8) & 255, c & 255); + } const minCode = Math.max(2, bits); out.push(minCode); lzwEncode(minCode, img.data, lookup, out); diff --git a/serve.py b/serve.py @@ -278,7 +278,6 @@ class Room: } f['ops'].append(op) self.op_count += 1 - self.maybe_snapshot() out = dict(op) out['t'] = 'p' out['f'] = f['id'] @@ -291,6 +290,9 @@ class Room: # sends only enqueue, so nothing slow happens in here. echoed to # the painter too: the ack is what commits their stroke self.broadcast(out) + # after the broadcast, so whoever we ask to flatten already has + # this stroke queued ahead of the request + self.maybe_snapshot() elif t == 'c': msg['id'] = conn.id self.broadcast(msg, skip=conn, droppable=True)