add per-painter undo/redo, merge down, fix multi-frame gif import

Hunter
Sunday, November 1st, 2026 at 10:41 PM

index.html+1 
readme.md+5 
readme_images/controls.gif  
resources/brush.js+18-24
resources/canvas.js+167-95
resources/history.jsnew
resources/input.js+10 
resources/io.js+166-27
resources/net.js+101-62
resources/render.js+2-2
serve.py+466-104

index.html
14  <script src="resources/view.js"></script>
15  <script src="resources/render.js"></script>
16  <script src="resources/io.js"></script>
17 +<script src="resources/history.js"></script>
18  <script src="resources/input.js"></script>
19  <script src="resources/net.js"></script>
20  <script>resize(); netConnect();</script>
readme.md
37  	- the last-tapped direction sets the playback direction
38  - press `space` to toggle playback
39  	- you can continue painting while the canvas animates
40 +- press `M` to merge the current frame into the one below it
41  
42  <img src="readme_images/controls.gif">
43  
44 +### undo / redo
45 +- press `⌘ + Z` to undo (up to 10 actions)
46 +- press `⌘ + shift + Z` to redo
47 +
48  ### importing / exporting
49  - hold `⌘` (or `ctrl`) and press `S` to save your painting as a .GIF file
50  - hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas)
readme_images/controls.gif
Binary files differ.
resources/brush.js
 4  
 5  // keyed cache: the local brush and every peer's brush pull from the same one
 6  const brushShapes = new Map();
   -const BRUSH_CACHE_MAX = 64;
   -function getBrushShape() { return brushShapeFor(brush, roundness, r, g, b); }
   -function brushShapeFor(n, round, sr, sg, sb) {
   -	const key = n + ',' + round + ',' + sr + ',' + sg + ',' + sb;
 7 +const BRUSH_CACHE_MAX = 1024;
 8 +function brushShapeFor(n, round) {
 9 +	const key = n + ',' + round;
10  	const hit = brushShapes.get(key);
11  	if (hit) return hit;
12  	if (brushShapes.size >= BRUSH_CACHE_MAX) brushShapes.clear();

25  			if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
26  		}
27  	}
   -	const sprite = document.createElement('canvas');
   -	sprite.width = n;
   -	sprite.height = n;
   -	const sctx = sprite.getContext('2d');
   -	const img = sctx.createImageData(n, n);
   -	const data = img.data;
   -	for (let i = 0; i < n * n; i++) {
   -		if (inside[i]) {
   -			data[i * 4] = sr;
   -			data[i * 4 + 1] = sg;
   -			data[i * 4 + 2] = sb;
   -			data[i * 4 + 3] = 255;
   -		}
   -	}
   -	sctx.putImageData(img, 0, 0);
   -
28  	const fillRuns = [];
29  	for (let dy = 0; dy < n; dy++) {
30  		let dx = 0;

36  			dx = dx1;
37  		}
38  	}
39 +	const shape = { fillRuns, outlineRuns: null, n };
40 +	brushShapes.set(key, shape);
41 +	return shape;
42 +}
43  
44 +function brushOutline(shape) {
45 +	if (shape.outlineRuns) return shape.outlineRuns;
46 +	const n = shape.n;
47 +	const inside = new Uint8Array(n * n);
48 +	const runs = shape.fillRuns;
49 +	for (let i = 0; i < runs.length; i += 3) {
50 +		const at = runs[i + 1] * n + runs[i];
51 +		inside.fill(1, at, at + runs[i + 2]);
52 +	}
53  	// 1-pixel outline ring: cells outside the shape that are 8-way adjacent
54  	// to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with
55  	// coordinates offset by -1 so they index directly in shape-local space.

80  			x = x1;
81  		}
82  	}
   -
   -	const shape = { inside, sprite, fillRuns, outlineRuns, n };
   -	brushShapes.set(key, shape);
   -	return shape;
83 +	shape.outlineRuns = outlineRuns;
84 +	return outlineRuns;
85  }
86  
87  // outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
resources/canvas.js
  8  function newFrameMap(id) {
  9  	const m = new Map();
 10  	m.id = id || ('l' + (++frameSeq));
 11 +	m.base = new Map();
 12 +	m.log = [];
 13 +	m.into = null;
 14 +	m.limboBy = null;
 15 +	m.vers = new Map();
 16  	return m;
 17  }
 18  let frames = [newFrameMap()];

 30  
 31  function chunkKey(cx, cy) { return cx + ',' + cy; }
 32  
    -// remote strokes redirect every paint write into the frame they belong to
    -let paintMap = null;
 33 +let paintClip = null;
 34  
 35  function getOrCreateChunk(cx, cy, map) {
    -	map = map || paintMap || chunks;
 36 +	map = map || chunks;
 37  	const k = chunkKey(cx, cy);
 38  	let c = map.get(k);
 39  	if (c) return c;

 64  	return m;
 65  }
 66  
    -function paintRect(wx, wy, w, h, color) {
    -	const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
    -	const cx0 = Math.floor(x0 / CHUNK);
    -	const cy0 = Math.floor(y0 / CHUNK);
    -	const cx1 = Math.floor((x1 - 1) / CHUNK);
    -	const cy1 = Math.floor((y1 - 1) / CHUNK);
    -	for (let cy = cy0; cy <= cy1; cy++) {
    -		for (let cx = cx0; cx <= cx1; cx++) {
    -			const c = getOrCreateChunk(cx, cy);
    -			const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
    -			const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
    -			const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
    -			const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
    -			c.ctx.fillStyle = color;
    -			c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
    -		}
    -	}
    -}
    -
 67  function readPixel(wx, wy) {
 68  	const cx = Math.floor(wx / CHUNK);
 69  	const cy = Math.floor(wy / CHUNK);

 94  // frame structure changes round-trip through the server when connected, so
 95  // every painter ends up with the same list in the same order
 96  function addFrame() {
 97 +	closeStroke();
 98  	const at = dir === 1 ? frameIdx + 1 : frameIdx;
 99  	if (net.on) { net.send({ t: 'af', at }); return; }
    -	applyAddFrame(at, null, true);
    -}
    -
    -function applyAddFrame(at, id, mine) {
    -	at = clamp(at, 0, frames.length);
    -	const cur = chunks;
    -	frames.splice(at, 0, newFrameMap(id));
    -	setFrame(mine ? at : frames.indexOf(cur));
100 +	const a = localActionId();
101 +	applyAdd(null, at, a, true);
102 +	localAction(a);
103  }
104  
105  function deleteFrame() {
106  	if (frames.length === 1) return;
107 +	closeStroke();
108  	if (net.on) { net.send({ t: 'df', id: chunks.id }); return; }
    -	applyDeleteFrame(chunks.id);
109 +	const a = localActionId();
110 +	if (applyDelete(chunks.id, a)) localAction(a);
111  }
112  
    -function applyDeleteFrame(id) {
113 +function mergeDown() {
114  	if (frames.length === 1) return;
    -	const i = frames.findIndex((f) => f.id === id);
    -	if (i < 0) return;
115 +	closeStroke();
116 +	const into = frames[(frameIdx - dir + frames.length) % frames.length];
117 +	if (net.on) { net.send({ t: 'mf', id: chunks.id, into: into.id }); return; }
118 +	const a = localActionId();
119 +	if (applyMerge(chunks.id, into.id, a, true)) localAction(a);
120 +}
121 +
122 +function insertFrame(f, at, land) {
123 +	at = clamp(at, 0, frames.length);
124 +	const cur = chunks;
125 +	frames.splice(at, 0, f);
126 +	setFrame(land ? at : frames.indexOf(cur));
127 +}
128 +
129 +function removeFrame(f) {
130 +	const i = frames.indexOf(f);
131 +	if (i < 0 || frames.length === 1) return -1;
132  	const cur = chunks;
133  	frames.splice(i, 1);
134 +	movePeers(f.id, frames[Math.min(i, frames.length - 1)].id);
135  	if (playing && frames.length === 1) stopPlayback();
136  	const j = frames.indexOf(cur);
137  	setFrame(j >= 0 ? j : Math.min(i, frames.length - 1));
138 +	return i;
139 +}
140 +
141 +function mergeInto(top, bottom, only) {
142 +	const tmp = document.createElement('canvas');
143 +	tmp.width = CHUNK;
144 +	tmp.height = CHUNK;
145 +	const tctx = tmp.getContext('2d');
146 +	for (const [k, c] of top) {
147 +		if (only && !only.has(k)) continue;
148 +		const img = c.ctx.getImageData(0, 0, CHUNK, CHUNK);
149 +		const d = img.data;
150 +		for (let i = 0; i < d.length; i += 4) {
151 +			if (d[i] === 0 && d[i + 1] === 0 && d[i + 2] === 0) d[i + 3] = 0;
152 +		}
153 +		tctx.putImageData(img, 0, 0);
154 +		const [cx, cy] = k.split(',').map(Number);
155 +		getOrCreateChunk(cx, cy, bottom).ctx.drawImage(tmp, 0, 0);
156 +	}
157  }
158  
159  function stopPlayback() {

195  	return { x: cx - off, y: cy - off };
196  }
197  
    -function paintAt(cx, cy) {
    -	dirty = true;
    -	const tl = brushTopLeft(cx, cy);
    -	if (roundness === 0) {
    -		paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
    -		return;
    -	}
    -	const sprite = getBrushShape().sprite;
    -	const cx0 = Math.floor(tl.x / CHUNK);
    -	const cy0 = Math.floor(tl.y / CHUNK);
    -	const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
    -	const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
    -	for (let ccy = cy0; ccy <= cy1; ccy++) {
    -		for (let ccx = cx0; ccx <= cx1; ccx++) {
    -			const c = getOrCreateChunk(ccx, ccy);
    -			c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
    -		}
    -	}
    -}
    -
198  // every mark the local user makes goes out as a segment; a dot
199  // is just a zero-length one. in a room the pixels land on the frame's
200  // overlay - the frame itself only takes strokes in the server's order
201  function localPaintAt(x, y) {
    -	if (net.on) paintMap = overlayFor(chunks.id);
    -	try {
    -		paintAt(x, y);
    -	} finally {
    -		paintMap = null;
    -	}
    -	net.paint(x, y, x, y);
202 +	localPaintLine(x, y, x, y);
203  }
204  
205  function localPaintLine(x0, y0, x1, y1) {
    -	if (net.on) paintMap = overlayFor(chunks.id);
    -	try {
    -		paintLine(x0, y0, x1, y1);
    -	} finally {
    -		paintMap = null;
206 +	const fresh = strokeFor(chunks.id);
207 +	if (net.on) {
208 +		fillRaster(overlayFor(chunks.id), rasterize([x0, y0, x1, y1], brush, roundness), [r, g, b]);
209 +		net.paint(x0, y0, x1, y1);
210 +		return;
211  	}
    -	net.paint(x0, y0, x1, y1);
212 +	const a = 'a' + stroke.a;
213 +	applyPaint(chunks, { a, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }, fresh);
214 +	if (fresh) localAction(a);
215  }
216  
    -// replay someone else's segment into whichever frame it belongs to, with
    -// their brush, without disturbing the local one
    -function applyPaintOp(op) {
    -	const f = frames.find((fr) => fr.id === op.f);
    -	if (!f || !op.o || !op.c) return;
    -	const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness;
    -	paintMap = f;
    -	r = op.c[0]; g = op.c[1]; b = op.c[2];
    -	brush = op.b; roundness = op.s;
    -	try {
    -		for (let i = 0; i + 3 < op.o.length; i += 4) {
    -			paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]);
217 +// replay an op into whichever frame it belongs to, with its own brush,
218 +// rasterizing it only the first time
219 +function paintOpInto(f, op) {
220 +	if (!op.o || !op.c) return;
221 +	if (!op.raster) op.raster = rasterize(op.o, op.b, op.s);
222 +	fillRaster(f, op.raster, op.c);
223 +}
224 +
225 +function mergeSpans(row) {
226 +	const order = [];
227 +	for (let i = 0; i < row.length; i += 2) order.push(i);
228 +	order.sort((p, q) => row[p] - row[q]);
229 +	const out = [row[order[0]], row[order[0] + 1]];
230 +	for (let j = 1; j < order.length; j++) {
231 +		const s = row[order[j]], e = row[order[j] + 1];
232 +		const n = out.length;
233 +		if (s > out[n - 1]) out.push(s, e);
234 +		else if (e > out[n - 1]) out[n - 1] = e;
235 +	}
236 +	return out;
237 +}
238 +
239 +function rasterize(o, n, round) {
240 +	const runs = brushShapeFor(n, round).fillRuns;
241 +	const off = Math.floor(n / 2);
242 +	const rows = new Map();
243 +	const index = new Map();
244 +	const tiles = [];
245 +	for (let i = 0; i + 3 < o.length; i += 4) {
246 +		const x1 = o[i + 2], y1 = o[i + 3];
247 +		let x = o[i], y = o[i + 1];
248 +		const dx = Math.abs(x1 - x), sx = x < x1 ? 1 : -1;
249 +		const dy = -Math.abs(y1 - y), sy = y < y1 ? 1 : -1;
250 +		let err = dx + dy;
251 +		while (true) {
252 +			const tx = x - off, ty = y - off;
253 +			const cx0 = Math.floor(tx / CHUNK), cx1 = Math.floor((tx + n - 1) / CHUNK);
254 +			const cy0 = Math.floor(ty / CHUNK), cy1 = Math.floor((ty + n - 1) / CHUNK);
255 +			for (let cy = cy0; cy <= cy1; cy++) {
256 +				for (let cx = cx0; cx <= cx1; cx++) {
257 +					const k = chunkKey(cx, cy);
258 +					if (index.has(k)) continue;
259 +					index.set(k, tiles.length / 2);
260 +					tiles.push(cx, cy);
261 +				}
262 +			}
263 +			for (let j = 0; j < runs.length; j += 3) {
264 +				const ry = ty + runs[j + 1];
265 +				const s = tx + runs[j], e = s + runs[j + 2];
266 +				const row = rows.get(ry);
267 +				if (!row) {
268 +					rows.set(ry, [s, e]);
269 +					continue;
270 +				}
271 +				const m = row.length;
272 +				if (s <= row[m - 1] && e >= row[m - 2]) {
273 +					if (s < row[m - 2]) row[m - 2] = s;
274 +					if (e > row[m - 1]) row[m - 1] = e;
275 +				} else {
276 +					row.push(s, e);
277 +				}
278 +			}
279 +			if (x === x1 && y === y1) break;
280 +			const e2 = 2 * err;
281 +			if (e2 >= dy) { err += dy; x += sx; }
282 +			if (e2 <= dx) { err += dx; y += sy; }
283  		}
    -	} finally {
    -		paintMap = pm;
    -		r = pr; g = pg; b = pb;
    -		brush = pbrush; roundness = pround;
284  	}
    -	if (f === chunks) requestDraw();
285 +	const spans = [];
286 +	const open = new Map();
287 +	for (const y of [...rows.keys()].sort((p, q) => p - q)) {
288 +		const row = rows.get(y);
289 +		const merged = row.length > 2 ? mergeSpans(row) : row;
290 +		const cy = Math.floor(y / CHUNK);
291 +		const ly = y - cy * CHUNK;
292 +		for (let i = 0; i < merged.length; i += 2) {
293 +			const s = merged[i], e = merged[i + 1];
294 +			for (let cx = Math.floor(s / CHUNK); cx <= Math.floor((e - 1) / CHUNK); cx++) {
295 +				const ti = index.get(chunkKey(cx, cy));
296 +				const lx = Math.max(s, cx * CHUNK) - cx * CHUNK;
297 +				const w = Math.min(e, (cx + 1) * CHUNK) - cx * CHUNK - lx;
298 +				const key = (ti * CHUNK + lx) * (CHUNK + 1) + w;
299 +				const at = open.get(key);
300 +				if (at !== undefined && spans[at + 2] + spans[at + 4] === ly) {
301 +					spans[at + 4]++;
302 +				} else {
303 +					open.set(key, spans.length);
304 +					spans.push(ti, lx, ly, w, 1);
305 +				}
306 +			}
307 +		}
308 +	}
309 +	return { tiles, spans };
310  }
311  
    -function paintLine(x0, y0, x1, y1) {
    -	let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
    -	let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
    -	let err = dx + dy;
    -	let x = x0, y = y0;
    -	while (true) {
    -		paintAt(x, y);
    -		if (x === x1 && y === y1) break;
    -		const e2 = 2 * err;
    -		if (e2 >= dy) { err += dy; x += sx; }
    -		if (e2 <= dx) { err += dx; y += sy; }
312 +function fillRaster(map, ras, c) {
313 +	dirty = true;
314 +	const color = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
315 +	const t = ras.tiles;
316 +	const ctxs = new Array(t.length / 2);
317 +	for (let i = 0; i < t.length; i += 2) {
318 +		if (paintClip && !paintClip.has(chunkKey(t[i], t[i + 1]))) continue;
319 +		const ctx = getOrCreateChunk(t[i], t[i + 1], map).ctx;
320 +		ctx.fillStyle = color;
321 +		ctxs[i / 2] = ctx;
322 +	}
323 +	const sp = ras.spans;
324 +	for (let i = 0; i < sp.length; i += 5) {
325 +		const ctx = ctxs[sp[i]];
326 +		if (ctx) ctx.fillRect(sp[i + 1], sp[i + 2], sp[i + 3], sp[i + 4]);
327  	}
328  }
resources/history.js
  1 +// each frame is a base image plus a log of ops painted on top of it. undo
  2 +// and redo switch an action's ops off or on and redraw the tiles they cover
  3 +// from base. once an action leaves every undo stack it is sealed: folded
  4 +// into base and forgotten
  5 +const UNDO_MAX = 10;
  6 +const actions = new Map();
  7 +// frames removed by a delete or merge, kept so the action can be undone
  8 +const limbo = new Map();
  9 +const local = { undo: [], redo: [] };
 10 +let actSeq = 0;
 11 +let verSeq = 0;
 12 +let stroke = null;
 13 +
 14 +function copyCanvas(src) {
 15 +	const cnv = document.createElement('canvas');
 16 +	cnv.width = CHUNK;
 17 +	cnv.height = CHUNK;
 18 +	cnv.getContext('2d').drawImage(src, 0, 0);
 19 +	return cnv;
 20 +}
 21 +
 22 +function getFrame(id) {
 23 +	return frames.find((f) => f.id === id) || limbo.get(id) || null;
 24 +}
 25 +
 26 +function resetTimeline() {
 27 +	actions.clear();
 28 +	limbo.clear();
 29 +	local.undo.length = 0;
 30 +	local.redo.length = 0;
 31 +	stroke = null;
 32 +}
 33 +
 34 +function strokeFor(fid) {
 35 +	if (stroke && stroke.f === fid) return false;
 36 +	closeStroke();
 37 +	stroke = { f: fid, a: ++actSeq };
 38 +	return true;
 39 +}
 40 +
 41 +function closeStroke() {
 42 +	net.flush();
 43 +	stroke = null;
 44 +}
 45 +
 46 +function opKeys(op) {
 47 +	const keys = new Set();
 48 +	const off = Math.floor(op.b / 2);
 49 +	for (let i = 0; i + 3 < op.o.length; i += 4) {
 50 +		const cx0 = Math.floor((Math.min(op.o[i], op.o[i + 2]) - off) / CHUNK);
 51 +		const cx1 = Math.floor((Math.max(op.o[i], op.o[i + 2]) - off + op.b - 1) / CHUNK);
 52 +		const cy0 = Math.floor((Math.min(op.o[i + 1], op.o[i + 3]) - off) / CHUNK);
 53 +		const cy1 = Math.floor((Math.max(op.o[i + 1], op.o[i + 3]) - off + op.b - 1) / CHUNK);
 54 +		for (let cy = cy0; cy <= cy1; cy++) {
 55 +			for (let cx = cx0; cx <= cx1; cx++) keys.add(chunkKey(cx, cy));
 56 +		}
 57 +	}
 58 +	return keys;
 59 +}
 60 +
 61 +function srcKeys(src) {
 62 +	return src ? new Set([...src.keys(), ...src.base.keys()]) : new Set();
 63 +}
 64 +
 65 +// every tile write takes a fresh version, so a saved copy of a tile can
 66 +// tell whether it is still current
 67 +function bump(f, keys) {
 68 +	for (const k of keys) f.vers.set(k, ++verSeq);
 69 +}
 70 +
 71 +function versions(f, keys) {
 72 +	const v = new Map();
 73 +	for (const k of keys) v.set(k, f.vers.get(k));
 74 +	return v;
 75 +}
 76 +
 77 +function unchanged(f, v, keys) {
 78 +	for (const k of keys) {
 79 +		if (!v.has(k) || f.vers.get(k) !== v.get(k)) return false;
 80 +	}
 81 +	return true;
 82 +}
 83 +
 84 +// taken before an undo redraws, so the redo can put the tiles back instead
 85 +// of replaying, as long as they still carry the versions the undo left
 86 +function snapshot(f, keys) {
 87 +	const tiles = new Map();
 88 +	for (const k of keys) {
 89 +		const c = f.get(k);
 90 +		tiles.set(k, c ? copyCanvas(c.canvas) : null);
 91 +	}
 92 +	return { f, tiles, before: versions(f, keys), after: null };
 93 +}
 94 +
 95 +function restore(snap) {
 96 +	const f = snap.f;
 97 +	for (const [k, cnv] of snap.tiles) {
 98 +		if (!cnv) {
 99 +			f.delete(k);
100 +			continue;
101 +		}
102 +		const [cx, cy] = k.split(',').map(Number);
103 +		getOrCreateChunk(cx, cy, f).ctx.drawImage(cnv, 0, 0);
104 +	}
105 +	for (const [k, n] of snap.before) f.vers.set(k, n);
106 +	requestDraw();
107 +}
108 +
109 +// base holds only tiles some logged op touches, copied just before the
110 +// first one does
111 +function registerKeys(f, keys) {
112 +	for (const k of keys) {
113 +		if (f.base.has(k)) continue;
114 +		const c = f.get(k);
115 +		f.base.set(k, c ? copyCanvas(c.canvas) : null);
116 +	}
117 +}
118 +
119 +function applyPaint(f, op, isNew) {
120 +	if (isNew) actions.set(op.a, { kind: 'stroke', f: f.id });
121 +	const keys = opKeys(op);
122 +	registerKeys(f, keys);
123 +	const raster = rasterize(op.o, op.b, op.s);
124 +	const last = f.log[f.log.length - 1];
125 +	if (last && last.a === op.a && !last.src && last.on && last.b === op.b && last.s === op.s
126 +		&& last.c[0] === op.c[0] && last.c[1] === op.c[1] && last.c[2] === op.c[2]) {
127 +		last.o.push(...op.o);
128 +		for (const k of keys) last.keys.add(k);
129 +		last.raster = null;
130 +	} else {
131 +		f.log.push({ a: op.a, o: op.o.slice(), c: op.c, b: op.b, s: op.s, on: true, keys, raster });
132 +	}
133 +	fillRaster(f, raster, op.c);
134 +	bump(f, keys);
135 +	if (f === chunks) requestDraw();
136 +}
137 +
138 +function banish(rec, a) {
139 +	const f = frames.find((fr) => fr.id === rec.f);
140 +	if (!f) return false;
141 +	const i = removeFrame(f);
142 +	if (i < 0) return false;
143 +	rec.at = i;
144 +	f.limboBy = a;
145 +	limbo.set(f.id, f);
146 +	net.pending = net.pending.filter((p) => p.f !== f.id);
147 +	net.overlays.delete(f.id);
148 +	return true;
149 +}
150 +
151 +function unbanish(rec, a, land) {
152 +	const f = limbo.get(rec.f);
153 +	if (!f || f.limboBy !== a) return false;
154 +	limbo.delete(f.id);
155 +	f.limboBy = null;
156 +	insertFrame(f, rec.at, land);
157 +	return true;
158 +}
159 +
160 +function applyAdd(id, at, a, mine) {
161 +	const f = newFrameMap(id);
162 +	insertFrame(f, at, mine);
163 +	actions.set(a, { kind: 'add', f: f.id, at: frames.indexOf(f) });
164 +}
165 +
166 +function applyDelete(id, a) {
167 +	const rec = { kind: 'del', f: id, at: 0 };
168 +	if (!banish(rec, a)) return false;
169 +	actions.set(a, rec);
170 +	return true;
171 +}
172 +
173 +function applyMerge(id, intoId, a, mine) {
174 +	const top = frames.find((f) => f.id === id);
175 +	const bottom = frames.find((f) => f.id === intoId);
176 +	if (!top || !bottom || top === bottom) return false;
177 +	const wasCur = top === chunks;
178 +	const keys = srcKeys(top);
179 +	registerKeys(bottom, keys);
180 +	const rec = { kind: 'merge', f: id, into: intoId, at: 0 };
181 +	if (!banish(rec, a)) return false;
182 +	movePeers(id, intoId);
183 +	bottom.log.push({ a, src: id, on: true });
184 +	mergeInto(top, bottom);
185 +	bump(bottom, keys);
186 +	top.into = intoId;
187 +	actions.set(a, rec);
188 +	if (mine || wasCur) setFrame(frames.indexOf(bottom));
189 +	requestDraw();
190 +	return true;
191 +}
192 +
193 +function overlaps(keys, only) {
194 +	for (const k of keys) {
195 +		if (only.has(k)) return true;
196 +	}
197 +	return false;
198 +}
199 +
200 +function actionKeys(f, a) {
201 +	const keys = new Set();
202 +	for (const op of f.log) {
203 +		if (op.a !== a) continue;
204 +		for (const k of op.keys) keys.add(k);
205 +	}
206 +	return keys;
207 +}
208 +
209 +function rebuild(f, only) {
210 +	if (only) {
211 +		registerKeys(f, only);
212 +	} else {
213 +		for (const op of f.log) registerKeys(f, op.src ? srcKeys(getFrame(op.src)) : op.keys);
214 +		only = new Set(f.base.keys());
215 +	}
216 +	bump(f, only);
217 +	for (const k of only) {
218 +		const cnv = f.base.get(k);
219 +		if (!cnv) {
220 +			f.delete(k);
221 +			continue;
222 +		}
223 +		const [cx, cy] = k.split(',').map(Number);
224 +		getOrCreateChunk(cx, cy, f).ctx.drawImage(cnv, 0, 0);
225 +	}
226 +	// ops reach past the tiles being redrawn; the rest must stay as they are
227 +	paintClip = only;
228 +	try {
229 +		for (const op of f.log) {
230 +			if (!op.on) continue;
231 +			if (op.src) mergeInto(getFrame(op.src), f, only);
232 +			else if (overlaps(op.keys, only)) paintOpInto(f, op);
233 +		}
234 +	} finally {
235 +		paintClip = null;
236 +	}
237 +	requestDraw();
238 +	// a merged frame's pixels live on in the frame it went into
239 +	if (f.into) {
240 +		const g = getFrame(f.into);
241 +		if (g) rebuild(g, only);
242 +	}
243 +}
244 +
245 +// only the sealed ops at the front of the log can join base; anything
246 +// after a live op has to stay so the order is kept
247 +function fold(f) {
248 +	let n = 0;
249 +	while (n < f.log.length && !actions.has(f.log[n].a)) n++;
250 +	if (!n) return;
251 +	const done = f.log.splice(0, n);
252 +	const tmp = new Map();
253 +	for (const [k, cnv] of f.base) {
254 +		if (cnv) tmp.set(k, { canvas: cnv, ctx: cnv.getContext('2d') });
255 +	}
256 +	for (const op of done) {
257 +		if (!op.on) continue;
258 +		if (op.src) {
259 +			const src = limbo.get(op.src);
260 +			if (!src) continue;
261 +			mergeInto(src, tmp);
262 +			destroyFrame(src);
263 +		} else {
264 +			paintOpInto(tmp, op);
265 +		}
266 +	}
267 +	const live = new Set();
268 +	for (const op of f.log) {
269 +		for (const k of op.src ? srcKeys(getFrame(op.src)) : op.keys) live.add(k);
270 +	}
271 +	for (const k of [...f.base.keys()]) {
272 +		if (!live.has(k)) {
273 +			f.base.delete(k);
274 +			continue;
275 +		}
276 +		const t = tmp.get(k);
277 +		f.base.set(k, t ? t.canvas : null);
278 +	}
279 +}
280 +
281 +function destroyFrame(f) {
282 +	limbo.delete(f.id);
283 +	for (const op of f.log) {
284 +		if (!op.on || !op.src) continue;
285 +		const s = limbo.get(op.src);
286 +		if (s && s.limboBy === op.a) destroyFrame(s);
287 +	}
288 +}
289 +
290 +function seal(list) {
291 +	const touched = new Set();
292 +	for (const a of list) {
293 +		const rec = actions.get(a);
294 +		if (!rec) continue;
295 +		actions.delete(a);
296 +		const f = getFrame(rec.f);
297 +		if (rec.kind === 'stroke') {
298 +			if (f) touched.add(f);
299 +		} else if (rec.kind === 'merge') {
300 +			const into = getFrame(rec.into);
301 +			if (into) touched.add(into);
302 +		} else if (f && f.limboBy === a) {
303 +			destroyFrame(f);
304 +		}
305 +	}
306 +	for (const f of touched) fold(f);
307 +}
308 +
309 +function redrawAction(rec, f, keys, on, top) {
310 +	const snap = rec.redo;
311 +	rec.redo = null;
312 +	if (on) {
313 +		if (snap && !f.into && snap.f === f && unchanged(f, snap.after, keys)
314 +			&& (!top || unchanged(top, snap.top, keys))) restore(snap);
315 +		else rebuild(f, keys);
316 +		return;
317 +	}
318 +	const next = f.into ? null : snapshot(f, keys);
319 +	rebuild(f, keys);
320 +	if (next) {
321 +		next.after = versions(f, keys);
322 +		if (top) next.top = versions(top, keys);
323 +		rec.redo = next;
324 +	}
325 +}
326 +
327 +function toggle(a, on, mine) {
328 +	const rec = actions.get(a);
329 +	if (!rec) return;
330 +	if (rec.kind === 'stroke') {
331 +		const f = getFrame(rec.f);
332 +		if (!f) return;
333 +		for (const op of f.log) {
334 +			if (op.a === a) op.on = on;
335 +		}
336 +		redrawAction(rec, f, actionKeys(f, a), on, null);
337 +		if (mine && frames.includes(f)) setFrame(frames.indexOf(f));
338 +	} else if (rec.kind === 'add' || rec.kind === 'del') {
339 +		if (on === (rec.kind === 'add')) unbanish(rec, a, mine);
340 +		else banish(rec, a);
341 +	} else if (rec.kind === 'merge') {
342 +		const bottom = getFrame(rec.into);
343 +		const op = bottom && bottom.log.find((o) => o.a === a);
344 +		if (!op) return;
345 +		if (on) {
346 +			const top = frames.find((f) => f.id === rec.f);
347 +			if (!top || !banish(rec, a)) return;
348 +			op.on = true;
349 +			top.into = bottom.id;
350 +			redrawAction(rec, bottom, srcKeys(top), true, top);
351 +			if (mine && frames.includes(bottom)) setFrame(frames.indexOf(bottom));
352 +		} else {
353 +			const top = limbo.get(rec.f);
354 +			if (!top || !unbanish(rec, a, mine)) return;
355 +			op.on = false;
356 +			top.into = null;
357 +			redrawAction(rec, bottom, srcKeys(top), false, top);
358 +		}
359 +	}
360 +}
361 +
362 +function localActionId() {
363 +	return 'a' + (++actSeq);
364 +}
365 +
366 +function localAction(a) {
367 +	local.undo.push(a);
368 +	const done = local.redo.splice(0);
369 +	if (local.undo.length > UNDO_MAX) done.push(local.undo.shift());
370 +	seal(done);
371 +}
372 +
373 +function stepHistory(from, to, on, t) {
374 +	closeStroke();
375 +	// in a room the server keeps each painter's stacks
376 +	if (net.on) {
377 +		net.send({ t });
378 +		return;
379 +	}
380 +	const a = from.pop();
381 +	if (a === undefined) return;
382 +	to.push(a);
383 +	toggle(a, on, true);
384 +	dirty = true;
385 +}
386 +
387 +function undo() { stepHistory(local.undo, local.redo, false, 'undo'); }
388 +function redo() { stepHistory(local.redo, local.undo, true, 'redo'); }
resources/input.js
142  		requestDraw();
143  		return;
144  	}
145 +	closeStroke();
146  	painting = true;
147  	lastPaintX = curX;
148  	lastPaintY = curY;

270  			if (keys['c']) {
271  				picking = true;
272  			} else {
273 +				closeStroke();
274  				painting = true;
275  				lastPaintX = curX;
276  				lastPaintY = curY;

406  		fileInput.click();
407  		return;
408  	}
409 +	if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'z') {
410 +		e.preventDefault();
411 +		if (e.repeat) return;
412 +		if (e.shiftKey) redo();
413 +		else undo();
414 +		return;
415 +	}
416  	if ((e.metaKey || e.ctrlKey) && !e.altKey) {
417  		if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
418  		if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }

425  		if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
426  		if (lk === 'a') { addFrame(); return; }
427  		if (lk === 'd') { deleteFrame(); return; }
428 +		if (lk === 'm') { mergeDown(); return; }
429  		if (lk === 'o') {
430  			if (playing) { stopPlayback(); onionskin = true; }
431  			else onionskin = !onionskin;
resources/io.js
225  	const ox = -Math.floor(w / 2);
226  	const oy = -Math.floor(h / 2);
227  	const f = newFrameMap();
    -	drawIntoFrame(f, src, w, h, ox, oy);
    -	return { f, w, h, ox, oy };
    -}
    -
    -function drawIntoFrame(f, src, w, h, ox, oy) {
228  	const cx0 = Math.floor(ox / CHUNK);
229  	const cy0 = Math.floor(oy / CHUNK);
230  	const cx1 = Math.floor((ox + w - 1) / CHUNK);

234  			getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
235  		}
236  	}
237 +	return { f, w, h, ox, oy };
238  }
239  
240  function finishImport(newFrames, w, h, ox, oy) {
241  	frames = newFrames;
242 +	resetTimeline();
243  	dir = 1;
244  	setFrame(0);
245  	camX = ox - (cssW / zoom - w) / 2;

248  	requestDraw();
249  }
250  
    -function loadImage(src) {
    -	return new Promise((resolve, reject) => {
    -		const img = new Image();
    -		img.onload = () => resolve(img);
    -		img.onerror = reject;
    -		img.src = src;
    -	});
251 +function lzwDecode(minCode, data, out) {
252 +	const clear = 1 << minCode;
253 +	const end = clear + 1;
254 +	const prefix = new Int16Array(4096);
255 +	const suffix = new Uint8Array(4096);
256 +	const first = new Uint8Array(4096);
257 +	const len = new Uint16Array(4096);
258 +	for (let i = 0; i < clear; i++) {
259 +		prefix[i] = -1;
260 +		suffix[i] = i;
261 +		first[i] = i;
262 +		len[i] = 1;
263 +	}
264 +	let size = minCode + 1, next = end + 1, prev = -1;
265 +	let acc = 0, bits = 0, at = 0;
266 +	for (let i = 0; i < data.length; i++) {
267 +		acc |= data[i] << bits;
268 +		bits += 8;
269 +		while (bits >= size) {
270 +			const code = acc & ((1 << size) - 1);
271 +			acc >>>= size;
272 +			bits -= size;
273 +			if (code === clear) {
274 +				size = minCode + 1;
275 +				next = end + 1;
276 +				prev = -1;
277 +				continue;
278 +			}
279 +			if (code === end || code > next || (code === next && prev < 0)) return;
280 +			if (prev >= 0 && next < 4096) {
281 +				prefix[next] = prev;
282 +				suffix[next] = code < next ? first[code] : first[prev];
283 +				first[next] = first[prev];
284 +				len[next] = len[prev] + 1;
285 +				next++;
286 +				if (next === 1 << size && size < 12) size++;
287 +			}
288 +			let p = at + len[code] - 1;
289 +			for (let c = code; c >= 0; c = prefix[c], p--) {
290 +				if (p < out.length) out[p] = suffix[c];
291 +			}
292 +			at += len[code];
293 +			if (at >= out.length) return;
294 +			prev = code;
295 +		}
296 +	}
297 +}
298 +
299 +function decodeGIF(buf) {
300 +	const p = new Uint8Array(buf);
301 +	const w = p[6] | (p[7] << 8), h = p[8] | (p[9] << 8);
302 +	let at = 13;
303 +	let gct = null;
304 +	if (p[10] & 0x80) {
305 +		const n = 3 << ((p[10] & 7) + 1);
306 +		gct = p.subarray(at, at + n);
307 +		at += n;
308 +	}
309 +	const img = new ImageData(w, h);
310 +	const px = img.data;
311 +	const srcs = [];
312 +	let interval = 0;
313 +	let disposal = 0, delay = 0, trans = -1;
314 +	while (at < p.length) {
315 +		const block = p[at++];
316 +		if (block === 0x21) {
317 +			if (p[at] === 0xf9) {
318 +				const q = at + 2;
319 +				disposal = (p[q] >> 2) & 7;
320 +				delay = (p[q + 1] | (p[q + 2] << 8)) * 10;
321 +				trans = p[q] & 1 ? p[q + 3] : -1;
322 +			}
323 +			at++;
324 +			while (p[at]) at += p[at] + 1;
325 +			at++;
326 +			continue;
327 +		}
328 +		if (block !== 0x2c) break;
329 +		const fx = p[at] | (p[at + 1] << 8), fy = p[at + 2] | (p[at + 3] << 8);
330 +		const fw = p[at + 4] | (p[at + 5] << 8), fh = p[at + 6] | (p[at + 7] << 8);
331 +		const flags = p[at + 8];
332 +		at += 9;
333 +		let ct = gct;
334 +		if (flags & 0x80) {
335 +			const n = 3 << ((flags & 7) + 1);
336 +			ct = p.subarray(at, at + n);
337 +			at += n;
338 +		}
339 +		const minCode = p[at++];
340 +		const parts = [];
341 +		let total = 0;
342 +		while (p[at]) {
343 +			parts.push(p.subarray(at + 1, at + 1 + p[at]));
344 +			total += p[at];
345 +			at += p[at] + 1;
346 +		}
347 +		at++;
348 +		const data = new Uint8Array(total);
349 +		let o = 0;
350 +		for (const s of parts) {
351 +			data.set(s, o);
352 +			o += s.length;
353 +		}
354 +		const idx = new Uint8Array(fw * fh);
355 +		lzwDecode(minCode, data, idx);
356 +		const rows = [];
357 +		if (flags & 0x40) {
358 +			for (const [y0, step] of [[0, 8], [4, 8], [2, 4], [1, 2]]) {
359 +				for (let y = y0; y < fh; y += step) rows.push(y);
360 +			}
361 +		} else {
362 +			for (let y = 0; y < fh; y++) rows.push(y);
363 +		}
364 +		const saved = disposal === 3 ? px.slice() : null;
365 +		const x1 = Math.min(fw, w - fx);
366 +		for (let i = 0; i < fh; i++) {
367 +			const y = fy + rows[i];
368 +			if (y >= h) continue;
369 +			for (let x = 0; x < x1; x++) {
370 +				const c = idx[i * fw + x];
371 +				if (c === trans) continue;
372 +				const d = (y * w + fx + x) * 4;
373 +				px[d] = ct[c * 3];
374 +				px[d + 1] = ct[c * 3 + 1];
375 +				px[d + 2] = ct[c * 3 + 2];
376 +				px[d + 3] = 255;
377 +			}
378 +		}
379 +		const cnv = document.createElement('canvas');
380 +		cnv.width = w;
381 +		cnv.height = h;
382 +		cnv.getContext('2d').putImageData(img, 0, 0);
383 +		srcs.push({ src: cnv, w, h });
384 +		if (srcs.length === 1 && delay) interval = Math.max(20, delay);
385 +		if (disposal === 2) {
386 +			for (let y = fy; y < Math.min(h, fy + fh); y++) {
387 +				px.fill(0, (y * w + fx) * 4, (y * w + fx + x1) * 4);
388 +			}
389 +		} else if (saved) {
390 +			px.set(saved);
391 +		}
392 +		disposal = 0;
393 +		delay = 0;
394 +		trans = -1;
395 +	}
396 +	return { srcs, interval };
397  }
398  
399  // decoded frames of the file as {src, w, h, close}
400  async function decodeSources(file) {
    -	// ImageDecoder gives us every frame of an animated gif; fall back to
    -	// single-image import where unsupported
401 +	if (file.type === 'image/gif') return decodeGIF(await file.arrayBuffer());
402 +	// ImageDecoder gives us every frame of other animated formats, but only
403 +	// in secure contexts; fall back to single-image import where unsupported
404  	if (typeof ImageDecoder !== 'undefined') {
405  		try {
406  			const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });

417  		} catch (err) {}
418  	}
419  	const url = URL.createObjectURL(file);
420 +	const img = new Image();
421 +	img.src = url;
422  	try {
    -		const img = await loadImage(url);
423 +		await img.decode();
424  		return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 };
425  	} catch (err) {
426  		return null;

439  	if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy);
440  }
441  
    -// flat png per frame, positioned in world space, so an import can be handed
442 +// each frame's chunks, positioned in world space, so an import can be handed
443  // to everyone else in the room (and to whoever joins later)
    -function sourcesToBases(srcs) {
    -	const cnv = document.createElement('canvas');
    -	const cctx = cnv.getContext('2d');
    -	return srcs.map((s) => {
    -		cnv.width = s.w;
    -		cnv.height = s.h;
    -		cctx.drawImage(s.src, 0, 0);
    -		return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h };
    -	});
444 +async function sourcesToChunks(srcs) {
445 +	const out = [];
446 +	for (const s of srcs) out.push({ ch: await encodeFrame(imageToFrame(s.src, s.w, s.h).f) });
447 +	return out;
448  }
449  
450  async function importFile(file) {

453  	const dec = await decodeSources(file);
454  	if (!dec || !dec.srcs.length) return;
455  	if (dec.interval) frameInterval = dec.interval;
    -	if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval });
    -	else applyImport(dec.srcs);
456 +	if (net.on) {
457 +		const s = dec.srcs[0];
458 +		const view = { ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h };
459 +		net.send({ t: 'reset', frames: await sourcesToChunks(dec.srcs), interval: frameInterval, view });
460 +	} else {
461 +		applyImport(dec.srcs);
462 +	}
463  	for (const s of dec.srcs) if (s.close) s.close();
464  }
resources/net.js
  7  	ws: null,
  8  	peers: new Map(),
  9  	joined: false,	// has this page ever been in a room
    -	queue: null,	// messages held while a snapshot loads
 10 +	queue: null,	// messages held while frames load
 11  	lastCursor: '',
 12  	batch: null,	// segments painted this frame, not yet sent
 13  	key: 0,

 15  	overlays: new Map(),	// frame id -> chunk map of those unacked strokes
 16  };
 17  
 18 +// send a message to the room; does nothing when solo
 19  net.send = function (msg) {
 20  	if (!net.on) return;
 21  	try {

 24  };
 25  
 26  // a fast pen reports far more moves than anyone can see, so segments pile up
    -// into one message per animation frame. a new frame, color, or brush starts
    -// a new batch, since a batch carries a single style
 27 +// into one message per animation frame. a new stroke, frame, color, or brush
 28 +// starts a new batch, since a batch carries a single style
 29  net.paint = function (x0, y0, x1, y1) {
 30  	if (!net.on) return;
 31  	const cur = net.batch;
    -	if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness
 32 +	if (cur && cur.a === stroke.a && cur.f === chunks.id && cur.b === brush && cur.s === roundness
 33  		&& cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) {
 34  		cur.o.push(x0, y0, x1, y1);
 35  		return;
 36  	}
 37  	net.flush();
    -	net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness };
 38 +	net.batch = { t: 'p', k: ++net.key, a: stroke.a, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness };
 39  	// tracked from the first segment, so the overlay it painted onto is held
 40  	// until the echo commits it to the frame proper
 41  	net.pending.push({ k: net.batch.k, f: chunks.id });
 42  };
 43  
 44 +// send the batch being built, if any
 45  net.flush = function () {
 46  	const out = net.batch;
 47  	if (!out) return;

 68  	net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v });
 69  };
 70  
    -function frameSnapshot(f) {
    -	let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
    -	for (const k of f.keys()) {
    -		any = true;
    -		const [cx, cy] = k.split(',').map(Number);
    -		if (cx < minCx) minCx = cx;
    -		if (cy < minCy) minCy = cy;
    -		if (cx > maxCx) maxCx = cx;
    -		if (cy > maxCy) maxCy = cy;
    -	}
    -	if (!any) return { id: f.id, img: null };
    -	const w = (maxCx - minCx + 1) * CHUNK;
    -	const h = (maxCy - minCy + 1) * CHUNK;
    -	const cnv = document.createElement('canvas');
    -	cnv.width = w;
    -	cnv.height = h;
    -	const cctx = cnv.getContext('2d');
    -	cctx.fillStyle = '#000';
    -	cctx.fillRect(0, 0, w, h);
    -	for (const [k, c] of f) {
    -		const [cx, cy] = k.split(',').map(Number);
    -		cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK);
    -	}
    -	return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h };
 71 +// compress bytes to base64 for the wire
 72 +async function deflateB64(bytes) {
 73 +	const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'));
 74 +	const buf = new Uint8Array(await new Response(stream).arrayBuffer());
 75 +	let bin = '';
 76 +	for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
 77 +	return btoa(bin);
 78  }
 79  
    -// rebuild frames from flat images the server is holding
    -async function framesFromBases(list) {
 80 +// undo deflateB64
 81 +async function inflateB64(str) {
 82 +	const bin = atob(str);
 83 +	const bytes = new Uint8Array(bin.length);
 84 +	for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
 85 +	const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate'));
 86 +	return new Uint8Array(await new Response(stream).arrayBuffer());
 87 +}
 88 +
 89 +// a frame's tiles as compressed rgb, the way the server stores them
 90 +async function encodeFrame(f) {
 91  	const out = [];
    -	for (const e of list) {
    -		const f = newFrameMap(e.id);
    -		if (e.img) {
    -			try {
    -				const img = await loadImage(e.img);
    -				drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0);
    -			} catch (err) {}
 92 +	for (const [k, c] of f) {
 93 +		const d = c.ctx.getImageData(0, 0, CHUNK, CHUNK).data;
 94 +		const rgb = new Uint8Array(CHUNK * CHUNK * 3);
 95 +		for (let i = 0, j = 0; i < d.length; i += 4, j += 3) {
 96 +			rgb[j] = d[i];
 97 +			rgb[j + 1] = d[i + 1];
 98 +			rgb[j + 2] = d[i + 2];
 99  		}
    -		out.push(f);
100 +		out.push([k, await deflateB64(rgb)]);
101  	}
102  	return out;
103  }
104  
105 +// a frame and its log, rebuilt from what the server sent
106 +async function buildFrame(e) {
107 +	const f = newFrameMap(e.id);
108 +	for (const [k, data] of e.ch || []) {
109 +		const rgb = await inflateB64(data);
110 +		const img = new ImageData(CHUNK, CHUNK);
111 +		const d = img.data;
112 +		for (let i = 0, j = 0; j < rgb.length; i += 4, j += 3) {
113 +			d[i] = rgb[j];
114 +			d[i + 1] = rgb[j + 1];
115 +			d[i + 2] = rgb[j + 2];
116 +			d[i + 3] = 255;
117 +		}
118 +		const [cx, cy] = k.split(',').map(Number);
119 +		getOrCreateChunk(cx, cy, f).ctx.putImageData(img, 0, 0);
120 +	}
121 +	f.log = (e.log || []).map((op) => op.src
122 +		? { a: op.a, src: op.src, on: op.on }
123 +		: { a: op.a, o: op.o, c: op.c, b: op.b, s: op.s, on: op.on, keys: opKeys(op) });
124 +	f.limboBy = e.limboBy || null;
125 +	return f;
126 +}
127 +
128 +// move peers standing on a removed frame to where their own page sends them
129 +function movePeers(from, to) {
130 +	for (const p of net.peers.values()) {
131 +		if (p.f === from) p.f = to;
132 +	}
133 +}
134 +
135  // hold live messages until the frames they refer to exist
136  function netDefer(msg) {
137  	if (!net.queue) return false;

139  	return true;
140  }
141  
142 +// stop holding messages and handle the held ones in order
143  function netFlush() {
144  	const q = net.queue;
145  	net.queue = null;

147  	for (const msg of q) netHandle(msg);
148  }
149  
    -// swap in a server-supplied frame list, replaying anything that arrived
    -// while the images were decoding only after `after` has caught us up
    -async function netAdopt(list, after) {
150 +// swap in the server's frames and timeline, replaying anything that arrived
151 +// while they were decoding only after `after` has caught us up
152 +async function netAdopt(msg, after) {
153  	net.queue = [];
    -	const built = await framesFromBases(list);
154 +	const built = [];
155 +	for (const e of msg.frames) built.push(await buildFrame(e));
156 +	const parked = [];
157 +	for (const e of msg.limbo || []) parked.push(await buildFrame(e));
158  	frames = built;
159 +	resetTimeline();
160 +	for (const f of parked) limbo.set(f.id, f);
161 +	for (const [a, rec] of msg.acts || []) actions.set(a, rec);
162 +	for (const f of [...built, ...parked]) {
163 +		for (const op of f.log) {
164 +			const src = op.src && op.on ? getFrame(op.src) : null;
165 +			if (src) src.into = f.id;
166 +		}
167 +	}
168 +	for (const f of [...parked, ...built]) rebuild(f);
169  	setFrame(0);
170  	if (after) after();
171  	netFlush();
172  	requestDraw();
173  }
174  
175 +// act on one message from the room
176  function netHandle(msg) {
177  	if (msg.t === 'init') {
178  		net.id = msg.id;

180  		net.pending.length = 0;
181  		net.overlays.clear();
182  		if (msg.interval) frameInterval = msg.interval;
    -		netAdopt(msg.frames, () => {
    -			for (const e of msg.frames) {
    -				for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id });
    -			}
183 +		netAdopt(msg, () => {
184  			dirty = false;
185  		});
186  		return;

191  		// them - ours included. until now our own stroke existed only on the
192  		// overlay; this echo is what commits it, so every painter's frame is
193  		// the same ops in the same order
    -		applyPaintOp(msg);
194 +		const f = frames.find((fr) => fr.id === msg.f);
195 +		if (f) applyPaint(f, { a: msg.a, o: msg.o, c: msg.c, b: msg.b, s: msg.s }, !!msg.n);
196  		if (msg.by === net.id) {
197  			const i = net.pending.findIndex((p) => p.k === msg.k);
198  			if (i < 0) return;

221  		}
222  		requestDraw();
223  	} else if (msg.t === 'af') {
    -		applyAddFrame(msg.at, msg.id, msg.by === net.id);
224 +		applyAdd(msg.id, msg.at, msg.a, msg.by === net.id);
225  	} else if (msg.t === 'df') {
    -		// the server drops strokes aimed at a deleted frame, so their echoes
    -		// never come; the frame is going away, and its overlay with it
    -		net.pending = net.pending.filter((p) => p.f !== msg.id);
    -		net.overlays.delete(msg.id);
    -		applyDeleteFrame(msg.id);
226 +		applyDelete(msg.id, msg.a);
227 +	} else if (msg.t === 'mf') {
228 +		applyMerge(msg.id, msg.into, msg.a, msg.by === net.id);
229 +	} else if (msg.t === 'tog') {
230 +		toggle(msg.a, msg.on, msg.by === net.id);
231 +		if (msg.by === net.id) dirty = true;
232 +	} else if (msg.t === 'seal') {
233 +		seal(msg.a || []);
234  	} else if (msg.t === 'reset') {
235  		if (playing) stopPlayback();
236  		net.pending.length = 0;
237  		net.overlays.clear();
238  		if (msg.interval) frameInterval = msg.interval;
    -		netAdopt(msg.frames, () => {
    -			const first = msg.frames[0] || {};
239 +		netAdopt(msg, () => {
240 +			const v = msg.view || {};
241  			dir = 1;
    -			camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2;
    -			camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2;
242 +			camX = (v.ox || 0) - (cssW / zoom - (v.w || 0)) / 2;
243 +			camY = (v.oy || 0) - (cssH / zoom - (v.h || 0)) / 2;
244  			dirty = false;
245  		});
    -	} else if (msg.t === 'snap') {
    -		// the server's stroke log got long - hand it flat images instead so
    -		// the next person to join doesn't have to replay the whole session
    -		net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) });
246  	}
247  }
248  
249 +// join the room served from this page's host, and keep rejoining after a drop
250  function netConnect() {
251  	if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
252  	const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');

272  		net.batch = null;
273  		net.pending.length = 0;
274  		net.overlays.clear();
275 +		seal([...actions.keys()]);
276 +		resetTimeline();
277  		if (wasOn) requestDraw();
278  		// a page that was never in a room (opened from a file, or from a
279  		// static host) stays solo; one that was keeps reaching for the
resources/render.js
188  // that is exactly what it is - a brush on some other frame
189  function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) {
190  	if (alpha <= 0) return;
    -	const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]);
191 +	const shape = round === 0 ? null : brushShapeFor(n, round);
192  	vctx.save();
193  	vctx.globalAlpha = alpha;
194  	if (ghost) vctx.globalCompositeOperation = 'screen';

208  
209  	const drawOutline = () => {
210  		if (shape) {
    -			const runs = shape.outlineRuns;
211 +			const runs = brushOutline(shape);
212  			for (let i = 0; i < runs.length; i += 3) {
213  				vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
214  			}
serve.py
  9  import json
 10  import os
 11  import queue
 12 +import re
 13  import socket
 14  import struct
 15  import sys
 16  import threading
    -import time
 17 +import zlib
 18  from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
 19  
 20  ROOT = os.path.dirname(os.path.abspath(__file__))

 26  WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
 27  MAX_PAYLOAD = 64 * 1024 * 1024
 28  
    -# once the stored stroke log for the room passes this, ask a painter for flat
    -# images of the frames so joiners don't have to replay an entire session
    -SNAPSHOT_AFTER_OPS = 4000
    -SNAPSHOT_COOLDOWN = 15.0
 29 +CHUNK = 256
 30 +CHUNK_BYTES = CHUNK * CHUNK * 3
 31 +UNDO_MAX = 10
 32 +LOG_ACTIONS_MAX = 100
 33 +COLOR_RUNS = re.compile(rb'(?:\x00\x00\x00)+|((?:(?!\x00\x00\x00)[\x00-\xff]{3})+)')
 34  
 35  # the ways a connection ends when the other side simply walks off
 36  DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout)

130  	def __init__(self, sock):
131  		self.sock = sock
132  		self.alive = True
    -		self.joined = time.time()
133  		self.q = queue.Queue(maxsize=SEND_QUEUE_MAX)
134  		with Conn._seq_lock:
135  			Conn._seq += 1
136  			self.id = 'c%d' % Conn._seq
137 +		self.stroke = None
138  		self.writer = threading.Thread(target=self._pump, daemon=True)
139  		self.writer.start()
140  

180  		self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable)
181  
182  
183 +_shapes = {}
184 +INF = float('inf')
185 +
186 +
187 +def brush_rows(n, s):
188 +	key = (n, s)
189 +	rows = _shapes.get(key)
190 +	if rows is not None:
191 +		return rows
192 +	if len(_shapes) >= 1024:
193 +		_shapes.clear()
194 +	rad = s * (n / 2)
195 +	rad2 = rad * rad
196 +	lo = rad - 0.5
197 +	hi = n - 0.5 - rad
198 +	left = [INF] * n
199 +	right = [-INF] * n
200 +	for dy in range(n):
201 +		qy = lo - dy if dy < lo else (dy - hi if dy > hi else 0)
202 +		for dx in range(n):
203 +			qx = lo - dx if dx < lo else (dx - hi if dx > hi else 0)
204 +			if qx * qx + qy * qy <= rad2 + 1e-9:
205 +				if left[dy] == INF:
206 +					left[dy] = dx
207 +				right[dy] = dx + 1
208 +	rows = (left, right)
209 +	_shapes[key] = rows
210 +	return rows
211 +
212 +
213 +class Draft(dict):
214 +	def __init__(self, under):
215 +		super().__init__()
216 +		self.under = under
217 +
218 +	def __missing__(self, k):
219 +		buf = self.under.get(k)
220 +		buf = bytearray(CHUNK_BYTES) if buf is None else bytearray(buf)
221 +		self[k] = buf
222 +		return buf
223 +
224 +
225 +def add_span(spans, y, s, e):
226 +	row = spans.get(y)
227 +	if row is None:
228 +		spans[y] = [[s, e]]
229 +		return
230 +	last = row[-1]
231 +	if s <= last[1] and e >= last[0]:
232 +		if s < last[0]:
233 +			last[0] = s
234 +		if e > last[1]:
235 +			last[1] = e
236 +	else:
237 +		row.append([s, e])
238 +
239 +
240 +def chains_of(o):
241 +	chains = []
242 +	cur = None
243 +	way = 0
244 +	px = py = None
245 +	for i in range(0, len(o) - 3, 4):
246 +		x, y, x1, y1 = o[i], o[i + 1], o[i + 2], o[i + 3]
247 +		dx = abs(x1 - x)
248 +		sx = 1 if x < x1 else -1
249 +		dy = -abs(y1 - y)
250 +		sy = 1 if y < y1 else -1
251 +		err = dx + dy
252 +		while True:
253 +			if cur is not None:
254 +				if abs(x - px) > 1 or abs(y - py) > 1:
255 +					cur = None
256 +				else:
257 +					d = (y > py) - (y < py)
258 +					if d and way and d != way:
259 +						cur = None
260 +					elif d:
261 +						way = d
262 +			if cur is None:
263 +				cur = []
264 +				chains.append(cur)
265 +				way = 0
266 +			g = cur[-1] if cur else None
267 +			if g is not None and g[0] == y:
268 +				if x < g[1]:
269 +					g[1] = x
270 +				elif x > g[2]:
271 +					g[2] = x
272 +			else:
273 +				cur.append([y, x, x])
274 +			px, py = x, y
275 +			if x == x1 and y == y1:
276 +				break
277 +			e2 = 2 * err
278 +			if e2 >= dy:
279 +				err += dy
280 +				x += sx
281 +			if e2 <= dx:
282 +				err += dx
283 +				y += sy
284 +	return chains
285 +
286 +
287 +def render_op(base, op):
288 +	n = op['b']
289 +	left, right = brush_rows(n, op['s'])
290 +	off = n // 2
291 +	spans = {}
292 +	tiles = set()
293 +	for groups in chains_of(op['o']):
294 +		y0 = min(g[0] for g in groups) - off
295 +		h = max(g[0] for g in groups) - off - y0 + n
296 +		lo = [INF] * h
297 +		hi = [-INF] * h
298 +		for y, xa, xb in groups:
299 +			k = y - off - y0
300 +			lo[k:k + n] = map(min, lo[k:k + n], [xa - off + v for v in left])
301 +			hi[k:k + n] = map(max, hi[k:k + n], [xb - off + v for v in right])
302 +			for cy in range((y - off) // CHUNK, (y - off + n - 1) // CHUNK + 1):
303 +				for cx in range((xa - off) // CHUNK, (xb - off + n - 1) // CHUNK + 1):
304 +					tiles.add((cx, cy))
305 +		for k in range(h):
306 +			if lo[k] < hi[k]:
307 +				add_span(spans, y0 + k, lo[k], hi[k])
308 +	for k in tiles:
309 +		base[k]
310 +	line = bytes(op['c']) * CHUNK
311 +	for y, row in spans.items():
312 +		if len(row) > 1:
313 +			row.sort()
314 +			merged = [row[0]]
315 +			for s, e in row[1:]:
316 +				if s > merged[-1][1]:
317 +					merged.append([s, e])
318 +				elif e > merged[-1][1]:
319 +					merged[-1][1] = e
320 +			row = merged
321 +		cy = y // CHUNK
322 +		at = (y - cy * CHUNK) * CHUNK
323 +		for s, e in row:
324 +			x = s
325 +			while x < e:
326 +				cx = x // CHUNK
327 +				stop = min(e, (cx + 1) * CHUNK)
328 +				p = (at + x - cx * CHUNK) * 3
329 +				base[(cx, cy)][p:p + (stop - x) * 3] = line[:(stop - x) * 3]
330 +				x = stop
331 +
332 +
333 +def composite(dst, src):
334 +	for (cx, cy), sbuf in src.items():
335 +		dbuf = dst[(cx, cy)]
336 +		for m in COLOR_RUNS.finditer(sbuf):
337 +			if m.group(1) is not None:
338 +				dbuf[m.start():m.end()] = sbuf[m.start():m.end()]
339 +
340 +
341  class Room:
342  	def __init__(self):
343 +		"""one empty frame, no painters, and the flattening worker started"""
344  		self.lock = threading.RLock()
345  		self.clients = []
346  		self.frame_seq = 0
    -		self.op_seq = 0
    -		self.op_count = 0	# stored strokes, tracked rather than recounted
347 +		self.act_seq = 0
348  		self.frames = [self.blank_frame()]
    -		self.index = {f['id']: f for f in self.frames}
349 +		self.limbo = {}
350 +		self.actions = {}
351 +		self.stacks = {}
352  		self.interval = 250
    -		self.snap_pending = False
    -		self.snap_at = 0.0
353 +		self.jobs = queue.Queue()
354 +		self.busy = 0
355 +		self.idle = threading.Condition(self.lock)
356 +		threading.Thread(target=self.work, daemon=True).start()
357  
358  	def blank_frame(self):
359 +		"""a new empty frame with a fresh id"""
360  		self.frame_seq += 1
    -		return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []}
    -
    -	def base_frame(self, src):
    -		f = self.blank_frame()
    -		if isinstance(src, dict) and src.get('img'):
    -			f['img'] = src['img']
    -			f['ox'] = int(src.get('ox', 0))
    -			f['oy'] = int(src.get('oy', 0))
    -			f['w'] = int(src.get('w', 0))
    -			f['h'] = int(src.get('h', 0))
    -		return f
    -
    -	def frame(self, fid):
    -		return self.index.get(fid)
    -
    -	def wire_frames(self, with_ops=True):
    -		out = []
361 +		return {'id': 'f%d' % self.frame_seq, 'base': {}, 'log': [], 'acts': {}, 'limbo_by': None}
362 +
363 +	def listed(self, fid):
364 +		"""the frame with this id if it's in the frame list, else None"""
365  		for f in self.frames:
    -			e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']}
    -			if with_ops:
    -				e['ops'] = f['ops']
    -			out.append(e)
    -		return out
366 +			if f['id'] == fid:
367 +				return f
368 +		return None
369 +
370 +	def get_frame(self, fid):
371 +		"""the frame with this id, whether listed or in limbo"""
372 +		return self.listed(fid) or self.limbo.get(fid)
373 +
374 +	def wire_frame(self, f):
375 +		"""a frame as clients receive it: compressed base tiles plus its log"""
376 +		ch = [['%d,%d' % k, base64.b64encode(zlib.compress(bytes(buf), 6)).decode('ascii')] for k, buf in f['base'].items()]
377 +		return {'id': f['id'], 'ch': ch, 'log': f['log'], 'limboBy': f['limbo_by']}
378  
379  	def broadcast(self, msg, skip=None, droppable=False):
380 +		"""send a message to every painter except skip"""
381  		payload = json.dumps(msg, separators=(',', ':')).encode('utf-8')
382  		with self.lock:
383  			targets = [c for c in self.clients if c is not skip]

385  			c.raw(payload, droppable=droppable)
386  
387  	def join(self, conn):
388 +		"""add a painter and send them the whole room, once no flattening is pending"""
389  		with self.lock:
390 +			while self.busy:
391 +				self.idle.wait()
392  			self.clients.append(conn)
393  			conn.send({
394  				't': 'init',
395  				'id': conn.id,
    -				'frames': self.wire_frames(),
396 +				'frames': [self.wire_frame(f) for f in self.frames],
397 +				'limbo': [self.wire_frame(f) for f in self.limbo.values()],
398 +				'acts': [[a, rec] for a, rec in self.actions.items()],
399  				'interval': self.interval,
400  			})
401  			print('  + %s joined (%d painting)' % (conn.id, len(self.clients)))
402  
403  	def leave(self, conn):
404 +		"""remove a painter, sealing their undo and redo stacks"""
405  		with self.lock:
406  			if conn in self.clients:
407  				self.clients.remove(conn)
    -			# the painter we asked for a flat copy may be the one leaving; let
    -			# the next stroke ask someone else
    -			self.snap_pending = False
408 +			st = self.stacks.pop(conn.id, None)
409 +			if st:
410 +				self.seal(st['undo'] + st['redo'])
411  			print('  - %s left   (%d painting)' % (conn.id, len(self.clients)))
412  		self.broadcast({'t': 'bye', 'id': conn.id})
413  
    -	def maybe_snapshot(self):
    -		"""ask one painter to flatten the room so the stroke log stays bounded"""
    -		now = time.time()
    -		if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN:
414 +	def new_action(self, conn, a, rec):
415 +		"""push a painter's new action onto their undo stack; returns the actions that fell off, for sealing"""
416 +		rec['by'] = conn.id
417 +		self.actions[a] = rec
418 +		st = self.stacks.setdefault(conn.id, {'undo': [], 'redo': []})
419 +		st['undo'].append(a)
420 +		done = st['redo']
421 +		st['redo'] = []
422 +		if len(st['undo']) > UNDO_MAX:
423 +			done.append(st['undo'].pop(0))
424 +		return done
425 +
426 +	def append(self, f, op):
427 +		"""add a stroke op to a frame's log, extending the last op when it continues it"""
428 +		log = f['log']
429 +		last = log[-1] if log else None
430 +		if (last and last['a'] == op['a'] and 'src' not in last and last['on']
431 +				and last['c'] == op['c'] and last['b'] == op['b'] and last['s'] == op['s']):
432 +			last['o'].extend(op['o'])
433 +			return
434 +		log.append(op)
435 +		f['acts'][op['a']] = f['acts'].get(op['a'], 0) + 1
436 +
437 +	def banish(self, rec, a):
438 +		"""move a listed frame into limbo on behalf of action a"""
439 +		f = self.listed(rec['f'])
440 +		if f is None or len(self.frames) <= 1:
441 +			return False
442 +		rec['at'] = self.frames.index(f)
443 +		self.frames.remove(f)
444 +		f['limbo_by'] = a
445 +		self.limbo[f['id']] = f
446 +		return True
447 +
448 +	def unbanish(self, rec, a):
449 +		"""put a frame that action a banished back where it was"""
450 +		f = self.limbo.get(rec['f'])
451 +		if f is None or f['limbo_by'] != a:
452 +			return False
453 +		del self.limbo[f['id']]
454 +		f['limbo_by'] = None
455 +		self.frames.insert(max(0, min(rec['at'], len(self.frames))), f)
456 +		return True
457 +
458 +	def capture(self, op):
459 +		"""freeze an op for the worker; a merge takes its source frame out of limbo with it"""
460 +		if 'src' not in op:
461 +			return op
462 +		src = self.limbo.pop(op['src'], None)
463 +		if src is None:
464 +			return None
465 +		items = [self.capture(sop) for sop in src['log'] if sop['on']]
466 +		src['log'] = []
467 +		src['acts'] = {}
468 +		return (src, [it for it in items if it is not None])
469 +
470 +	def draw(self, base, item):
471 +		"""render a captured op into base, compositing merged frames over it"""
472 +		if isinstance(item, dict):
473 +			render_op(base, item)
474 +			return
475 +		src, items = item
476 +		draft = Draft(src['base'])
477 +		for it in items:
478 +			self.draw(draft, it)
479 +		composite(base, {**src['base'], **draft})
480 +
481 +	def work(self):
482 +		"""worker thread: flatten queued folds, then swap the results in under the lock"""
483 +		while True:
484 +			f, items = self.jobs.get()
485 +			draft = Draft(f['base'])
486 +			for it in items:
487 +				self.draw(draft, it)
488 +			with self.lock:
489 +				f['base'].update(draft)
490 +				self.busy -= 1
491 +				self.idle.notify_all()
492 +
493 +	def destroy(self, f):
494 +		"""drop a limbo frame and any frames merged into it"""
495 +		self.limbo.pop(f['id'], None)
496 +		for op in f['log']:
497 +			if not op['on'] or 'src' not in op:
498 +				continue
499 +			s = self.limbo.get(op['src'])
500 +			if s is not None and s['limbo_by'] == op['a']:
501 +				self.destroy(s)
502 +
503 +	def fold(self, f):
504 +		"""queue a frame's leading sealed ops to be flattened into its base"""
505 +		log = f['log']
506 +		n = 0
507 +		while n < len(log) and log[n]['a'] not in self.actions:
508 +			n += 1
509 +		if not n:
510  			return
    -		if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients:
511 +		done = log[:n]
512 +		del log[:n]
513 +		items = []
514 +		for op in done:
515 +			left = f['acts'].get(op['a'], 1) - 1
516 +			if left:
517 +				f['acts'][op['a']] = left
518 +			else:
519 +				f['acts'].pop(op['a'], None)
520 +			if op['on']:
521 +				it = self.capture(op)
522 +				if it is not None:
523 +					items.append(it)
524 +		if items:
525 +			self.busy += 1
526 +			self.jobs.put((f, items))
527 +
528 +	def seal(self, aids):
529 +		"""make actions permanent: drop them from the undo stacks and fold them into base"""
530 +		sealed = []
531 +		touched = []
532 +		for a in aids:
533 +			rec = self.actions.pop(a, None)
534 +			if rec is None:
535 +				continue
536 +			sealed.append(a)
537 +			st = self.stacks.get(rec['by'])
538 +			if st:
539 +				for side in ('undo', 'redo'):
540 +					if a in st[side]:
541 +						st[side].remove(a)
542 +			f = self.get_frame(rec['f'])
543 +			if rec['kind'] == 'stroke':
544 +				if f is not None:
545 +					touched.append(f)
546 +			elif rec['kind'] == 'merge':
547 +				into = self.get_frame(rec['into'])
548 +				if into is not None:
549 +					touched.append(into)
550 +			elif f is not None and f['limbo_by'] == a:
551 +				self.destroy(f)
552 +		if sealed:
553 +			self.broadcast({'t': 'seal', 'a': sealed})
554 +		for f in touched:
555 +			self.fold(f)
556 +
557 +	def cap(self, f):
558 +		"""seal a frame's oldest actions until its log holds at most LOG_ACTIONS_MAX"""
559 +		while len(f['acts']) > LOG_ACTIONS_MAX:
560 +			self.fold(f)
561 +			if len(f['acts']) <= LOG_ACTIONS_MAX:
562 +				break
563 +			self.seal([f['log'][0]['a']])
564 +
565 +	def toggle(self, a, on):
566 +		"""switch action a on (redo) or off (undo)"""
567 +		rec = self.actions.get(a)
568 +		if rec is None:
569  			return
    -		self.snap_pending = True
    -		self.snap_at = now
    -		self.clients[0].send({'t': 'snap', 'upto': self.op_seq})
570 +		kind = rec['kind']
571 +		if kind == 'stroke':
572 +			f = self.get_frame(rec['f'])
573 +			if f is not None:
574 +				for op in f['log']:
575 +					if op['a'] == a:
576 +						op['on'] = on
577 +		elif kind in ('add', 'del'):
578 +			if on == (kind == 'add'):
579 +				self.unbanish(rec, a)
580 +			else:
581 +				self.banish(rec, a)
582 +		elif kind == 'merge':
583 +			bottom = self.get_frame(rec['into'])
584 +			op = next((o for o in bottom['log'] if o['a'] == a), None) if bottom else None
585 +			if op is None:
586 +				return
587 +			if on:
588 +				if self.banish(rec, a):
589 +					op['on'] = True
590 +			elif self.unbanish(rec, a):
591 +				op['on'] = False
592  
    -	def set_frames(self, frames):
    -		self.frames = frames
    -		self.index = {f['id']: f for f in frames}
    -		self.op_count = sum(len(f['ops']) for f in frames)
593 +	def frame_action(self):
594 +		"""a fresh action id for a frame add, delete or merge"""
595 +		self.act_seq += 1
596 +		return 'x%d' % self.act_seq
597  
598  	def handle(self, conn, msg):
599 +		"""act on one message from a painter"""
600  		t = msg.get('t')
601  		if t == 'p':
602 +			op = {
603 +				'a': '%s.%s' % (conn.id, int(msg.get('a'))),
604 +				'o': [int(v) for v in msg.get('o')],
605 +				'c': [int(v) for v in msg.get('c')],
606 +				'b': int(msg.get('b')),
607 +				's': float(msg.get('s')),
608 +				'on': True,
609 +			}
610  			with self.lock:
    -				f = self.frame(msg.get('f'))
611 +				f = self.listed(msg.get('f'))
612  				if f is None:
613  					return
    -				self.op_seq += 1
    -				op = {
    -					'n': self.op_seq,
    -					'o': msg.get('o'),
    -					'c': msg.get('c'),
    -					'b': msg.get('b'),
    -					's': msg.get('s'),
    -				}
    -				f['ops'].append(op)
    -				self.op_count += 1
    -				out = dict(op)
    -				out['t'] = 'p'
    -				out['f'] = f['id']
    -				out['by'] = conn.id
614 +				new = op['a'] != conn.stroke
615 +				conn.stroke = op['a']
616 +				done = self.new_action(conn, op['a'], {'kind': 'stroke', 'f': f['id']}) if new else []
617 +				self.append(f, op)
618 +				out = {'t': 'p', 'f': f['id'], 'a': op['a'], 'o': op['o'], 'c': op['c'], 'b': op['b'], 's': op['s'], 'by': conn.id}
619 +				if new:
620 +					out['n'] = 1
621  				# the painter's own tag for this batch, so they can recognise it
622  				# coming back; not stored, it means nothing to anyone else
623  				if msg.get('k') is not None:
624  					out['k'] = msg['k']
    -				# broadcast under the lock so wire order matches sequence order;
    -				# sends only enqueue, so nothing slow happens in here. echoed to
    -				# the painter too: the ack is what commits their stroke
625 +				# broadcast under the lock so wire order matches the order the room
626 +				# applies them; sends only enqueue, so nothing slow happens in
627 +				# here. echoed to the painter too: the ack is what commits their
628 +				# stroke
629  				self.broadcast(out)
    -				# after the broadcast, so whoever we ask to flatten already has
    -				# this stroke queued ahead of the request
    -				self.maybe_snapshot()
630 +				self.seal(done)
631 +				self.cap(f)
632  		elif t == 'c':
633  			msg['id'] = conn.id
634  			self.broadcast(msg, skip=conn, droppable=True)

637  				at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames)))
638  				f = self.blank_frame()
639  				self.frames.insert(at, f)
    -				self.index[f['id']] = f
    -				self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id})
640 +				a = self.frame_action()
641 +				done = self.new_action(conn, a, {'kind': 'add', 'f': f['id'], 'at': at})
642 +				self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'a': a, 'by': conn.id})
643 +				self.seal(done)
644  		elif t == 'df':
645  			with self.lock:
    -				if len(self.frames) <= 1:
646 +				rec = {'kind': 'del', 'f': msg.get('id'), 'at': 0}
647 +				a = self.frame_action()
648 +				if not self.banish(rec, a):
649  					return
    -				f = self.frame(msg.get('id'))
    -				if f is None:
650 +				done = self.new_action(conn, a, rec)
651 +				self.broadcast({'t': 'df', 'id': rec['f'], 'a': a, 'by': conn.id})
652 +				self.seal(done)
653 +		elif t == 'mf':
654 +			with self.lock:
655 +				top = self.listed(msg.get('id'))
656 +				bottom = self.listed(msg.get('into'))
657 +				if top is None or bottom is None or top is bottom:
658  					return
    -				self.frames.remove(f)
    -				del self.index[f['id']]
    -				self.op_count -= len(f['ops'])
    -				self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id})
659 +				rec = {'kind': 'merge', 'f': top['id'], 'into': bottom['id'], 'at': 0}
660 +				a = self.frame_action()
661 +				if not self.banish(rec, a):
662 +					return
663 +				bottom['log'].append({'a': a, 'src': top['id'], 'on': True})
664 +				bottom['acts'][a] = 1
665 +				done = self.new_action(conn, a, rec)
666 +				self.broadcast({'t': 'mf', 'id': top['id'], 'into': bottom['id'], 'a': a, 'by': conn.id})
667 +				self.seal(done)
668 +				self.cap(bottom)
669 +		elif t == 'undo' or t == 'redo':
670 +			with self.lock:
671 +				st = self.stacks.get(conn.id)
672 +				if not st:
673 +					return
674 +				src, dst = (st['undo'], st['redo']) if t == 'undo' else (st['redo'], st['undo'])
675 +				if not src:
676 +					return
677 +				a = src.pop()
678 +				dst.append(a)
679 +				on = t == 'redo'
680 +				self.toggle(a, on)
681 +				self.broadcast({'t': 'tog', 'a': a, 'on': on, 'by': conn.id})
682  		elif t == 'reset':
    -			srcs = msg.get('frames') or []
    -			if not srcs:
683 +			bases = []
684 +			for src in msg.get('frames') or []:
685 +				base = {}
686 +				for k, data in src.get('ch') or []:
687 +					buf = zlib.decompress(base64.b64decode(data))
688 +					cx, cy = (int(v) for v in k.split(','))
689 +					base[(cx, cy)] = bytearray(buf)
690 +				bases.append(base)
691 +			if not bases:
692  				return
693  			with self.lock:
    -				self.set_frames([self.base_frame(s) for s in srcs])
694 +				frames = []
695 +				for base in bases:
696 +					f = self.blank_frame()
697 +					f['base'] = base
698 +					frames.append(f)
699 +				self.frames = frames
700 +				self.limbo = {}
701 +				self.actions = {}
702 +				self.stacks = {}
703 +				for c in self.clients:
704 +					c.stroke = None
705  				self.interval = int(msg.get('interval') or self.interval)
    -				self.snap_pending = False
706  				self.broadcast({
707  					't': 'reset',
    -					'frames': self.wire_frames(with_ops=False),
708 +					'frames': [self.wire_frame(f) for f in frames],
709 +					'view': msg.get('view'),
710  					'interval': self.interval,
711  					'by': conn.id,
712  				})
    -		elif t == 'snapshot':
    -			upto = int(msg.get('upto') or 0)
    -			with self.lock:
    -				self.snap_pending = False
    -				for src in msg.get('frames') or []:
    -					f = self.frame(src.get('id'))
    -					if f is None:
    -						continue
    -					f['img'] = src.get('img')
    -					f['ox'] = int(src.get('ox', 0))
    -					f['oy'] = int(src.get('oy', 0))
    -					f['w'] = int(src.get('w', 0))
    -					f['h'] = int(src.get('h', 0))
    -				# strokes newer than the snapshot are kept; a stroke that made it
    -				# into the image and also survives here just paints itself twice
    -				kept = 0
    -				for f in self.frames:
    -					f['ops'] = [o for o in f['ops'] if o['n'] > upto]
    -					kept += len(f['ops'])
    -				self.op_count = kept
713  		elif t == 'interval':
714  			with self.lock:
715  				self.interval = int(msg.get('v') or self.interval)