a n i m a t i o n !

Hunter
Saturday, July 11th, 2026 at 9:40 PM

index.html+282-56
readme.md+32-10
readme_images/doodle.gifnew

index.html
  2  <html lang="en">
  3  <head>
  4  <meta charset="UTF-8">
    -<title>paint</title>
  5 +<title>animus</title>
  6  <link id="favicon" rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='white'/%3E%3C/svg%3E">
  7  <style>
  8  	html, body {

 33  	const vctx = view.getContext('2d', { alpha: false });
 34  
 35  	const CHUNK = 256;
    -	const chunks = new Map();
 36 +	let frames = [new Map()];
 37 +	let frameIdx = 0;
 38 +	let chunks = frames[0];
 39 +	let dir = 1; // playback direction: 1 forward, -1 reverse
 40 +	let onionskin = false;
 41 +	let playing = false;
 42 +	let playTimer = null;
 43 +	let frameInterval = 250;
 44 +	let lastTap = 0;
 45  
 46  	function chunkKey(cx, cy) { return cx + ',' + cy; }
 47  
    -	function getOrCreateChunk(cx, cy) {
 48 +	function getOrCreateChunk(cx, cy, map = chunks) {
 49  		const k = chunkKey(cx, cy);
    -		let c = chunks.get(k);
 50 +		let c = map.get(k);
 51  		if (c) return c;
 52  		const cnv = document.createElement('canvas');
 53  		cnv.width = CHUNK;

 56  		cctx.fillStyle = '#000';
 57  		cctx.fillRect(0, 0, CHUNK, CHUNK);
 58  		c = { canvas: cnv, ctx: cctx };
    -		chunks.set(k, c);
 59 +		map.set(k, c);
 60  		return c;
 61  	}
 62  

 91  		return [d[0], d[1], d[2]];
 92  	}
 93  
 94 +	function setFrame(i) {
 95 +		frameIdx = ((i % frames.length) + frames.length) % frames.length;
 96 +		chunks = frames[frameIdx];
 97 +		requestDraw();
 98 +	}
 99 +
100 +	function addFrame() {
101 +		const at = dir === 1 ? frameIdx + 1 : frameIdx;
102 +		frames.splice(at, 0, new Map());
103 +		setFrame(at);
104 +	}
105 +
106 +	function deleteFrame() {
107 +		if (frames.length === 1) return;
108 +		frames.splice(frameIdx, 1);
109 +		if (playing && frames.length === 1) stopPlayback();
110 +		setFrame(Math.min(frameIdx, frames.length - 1));
111 +	}
112 +
113 +	function stopPlayback() {
114 +		playing = false;
115 +		clearTimeout(playTimer);
116 +		requestDraw();
117 +	}
118 +
119 +	function startPlayback() {
120 +		if (frames.length === 1) return;
121 +		playing = true;
122 +		playTimer = setTimeout(function step() {
123 +			setFrame(frameIdx + dir);
124 +			playTimer = setTimeout(step, frameInterval);
125 +		}, frameInterval);
126 +	}
127 +
128 +	function tapArrow(d) {
129 +		dir = d;
130 +		const now = performance.now();
131 +		if (lastTap && now - lastTap <= 10000) frameInterval = now - lastTap;
132 +		lastTap = now;
133 +		if (playing) { stopPlayback(); return; }
134 +		setFrame(frameIdx + d);
135 +	}
136 +
137  	let dpr = window.devicePixelRatio || 1;
138  	let cssW = 0, cssH = 0;
139  

434  			}
435  		}
436  
437 +		// onionskin: ghost the frame behind us in playback order, screen-blended
438 +		// so black contributes nothing over the opaque chunks
439 +		if (onionskin && !playing && frames.length > 1) {
440 +			const ghost = frames[(frameIdx - dir + frames.length) % frames.length];
441 +			vctx.save();
442 +			vctx.globalAlpha = 0.35;
443 +			vctx.globalCompositeOperation = 'screen';
444 +			for (let cy = cy0; cy <= cy1; cy++) {
445 +				for (let cx = cx0; cx <= cx1; cx++) {
446 +					const c = ghost.get(chunkKey(cx, cy));
447 +					if (!c) continue;
448 +					const x0 = destX(cx * CHUNK);
449 +					const y0 = destY(cy * CHUNK);
450 +					vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
451 +				}
452 +			}
453 +			vctx.restore();
454 +		}
455 +
456  		if (mouseInside) {
457  			const tl = brushTopLeft(curX, curY);
458  			// round to integer device pixels - camX/camY are fractional (smooth

524  	document.body.appendChild(fileInput);
525  	fileInput.addEventListener('change', (e) => {
526  		const f = e.target.files && e.target.files[0];
    -		if (f) importImage(f);
527 +		if (f) importFile(f);
528  		fileInput.value = '';
529  	});
530  

533  		return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds());
534  	}
535  
    -	function exportPNG() {
    -		if (chunks.size === 0) return;
    -		let minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
    -		for (const k of chunks.keys()) {
    -			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;
536 +	// union of painted chunk bounds across all frames, so every exported
537 +	// frame shares the canvas of the biggest one
538 +	function frameBounds() {
539 +		let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
540 +		for (const f of frames) {
541 +			for (const k of f.keys()) {
542 +				any = true;
543 +				const [cx, cy] = k.split(',').map(Number);
544 +				if (cx < minCx) minCx = cx;
545 +				if (cy < minCy) minCy = cy;
546 +				if (cx > maxCx) maxCx = cx;
547 +				if (cy > maxCy) maxCy = cy;
548 +			}
549  		}
    -		const w = (maxCx - minCx + 1) * CHUNK;
    -		const h = (maxCy - minCy + 1) * CHUNK;
550 +		return any ? { minCx, minCy, maxCx, maxCy } : null;
551 +	}
552 +
553 +	function renderFrame(f, b, w, h) {
554  		const out = document.createElement('canvas');
555  		out.width = w;
556  		out.height = h;
557  		const octx = out.getContext('2d');
558  		octx.fillStyle = '#000';
559  		octx.fillRect(0, 0, w, h);
    -		for (const [k, c] of chunks) {
560 +		for (const [k, c] of f) {
561  			const [cx, cy] = k.split(',').map(Number);
    -			octx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK);
    -		}
    -		out.toBlob((blob) => {
    -			const url = URL.createObjectURL(blob);
    -			const a = document.createElement('a');
    -			a.href = url;
    -			a.download = 'paint \u00b7 ' + formatTimestamp(new Date()) + '.png';
    -			document.body.appendChild(a);
    -			a.click();
    -			document.body.removeChild(a);
    -			URL.revokeObjectURL(url);
    -		}, 'image/png');
562 +			octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
563 +		}
564 +		return octx.getImageData(0, 0, w, h);
565 +	}
566 +
567 +	function lzwEncode(minCode, data, lookup, out) {
568 +		const clear = 1 << minCode, eoi = clear + 1;
569 +		let codeSize = minCode + 1, next = eoi + 1;
570 +		let dict = new Map();
571 +		let acc = 0, accBits = 0;
572 +		let block = [];
573 +		const flushBlock = () => {
574 +			if (block.length) { out.push(block.length, ...block); block = []; }
575 +		};
576 +		const emit = (code) => {
577 +			acc |= code << accBits;
578 +			accBits += codeSize;
579 +			while (accBits >= 8) {
580 +				block.push(acc & 255);
581 +				acc >>= 8;
582 +				accBits -= 8;
583 +				if (block.length === 255) flushBlock();
584 +			}
585 +		};
586 +		const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
587 +		emit(clear);
588 +		let prev = idx(0);
589 +		for (let i = 4; i < data.length; i += 4) {
590 +			const k = idx(i);
591 +			const key = prev * 256 + k;
592 +			if (dict.has(key)) { prev = dict.get(key); continue; }
593 +			emit(prev);
594 +			if (next === 4096) {
595 +				emit(clear);
596 +				dict = new Map();
597 +				next = eoi + 1;
598 +				codeSize = minCode + 1;
599 +			} else {
600 +				if (next >= (1 << codeSize)) codeSize++;
601 +				dict.set(key, next++);
602 +			}
603 +			prev = k;
604 +		}
605 +		emit(prev);
606 +		emit(eoi);
607 +		if (accBits) block.push(acc & 255);
608 +		flushBlock();
609 +	}
610 +
611 +	// minimal GIF89a encoder: exact global palette when <=256 colors,
612 +	// else uniform 6x6x6 quantization
613 +	function encodeGIF(images, w, h, delayMs) {
614 +		const colorIdx = new Map();
615 +		let over = false;
616 +		for (const img of images) {
617 +			const d = img.data;
618 +			for (let i = 0; i < d.length && !over; i += 4) {
619 +				const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
620 +				if (!colorIdx.has(c)) {
621 +					if (colorIdx.size === 256) over = true;
622 +					else colorIdx.set(c, colorIdx.size);
623 +				}
624 +			}
625 +			if (over) break;
626 +		}
627 +		if (over) {
628 +			colorIdx.clear();
629 +			for (let i = 0; i < 216; i++) {
630 +				colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
631 +			}
632 +		}
633 +		const lookup = (c) => over
634 +			? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
635 +			: colorIdx.get(c);
636 +		let bits = 2;
637 +		while ((1 << bits) < colorIdx.size) bits++;
638 +		const out = [];
639 +		const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
640 +		out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
641 +		u16(w); u16(h);
642 +		out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
643 +		const pal = [...colorIdx.keys()];
644 +		for (let i = 0; i < (1 << bits); i++) {
645 +			const c = pal[i] || 0;
646 +			out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
647 +		}
648 +		const animated = images.length > 1;
649 +		if (animated) {
650 +			// NETSCAPE2.0 loop forever
651 +			out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
652 +		}
653 +		const delay = clamp(Math.round(delayMs / 10), 2, 65535);
654 +		for (const img of images) {
655 +			if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
656 +			out.push(0x2c);
657 +			u16(0); u16(0); u16(w); u16(h);
658 +			out.push(0);
659 +			const minCode = Math.max(2, bits);
660 +			out.push(minCode);
661 +			lzwEncode(minCode, img.data, lookup, out);
662 +			out.push(0);
663 +		}
664 +		out.push(0x3b);
665 +		return new Uint8Array(out);
666 +	}
667 +
668 +	function exportGIF() {
669 +		const b = frameBounds();
670 +		if (!b) return;
671 +		const w = (b.maxCx - b.minCx + 1) * CHUNK;
672 +		const h = (b.maxCy - b.minCy + 1) * CHUNK;
673 +		const imgs = frames.map((f) => renderFrame(f, b, w, h));
674 +		if (dir === -1) imgs.reverse();
675 +		const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
676 +		const url = URL.createObjectURL(blob);
677 +		const a = document.createElement('a');
678 +		a.href = url;
679 +		a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
680 +		document.body.appendChild(a);
681 +		a.click();
682 +		document.body.removeChild(a);
683 +		URL.revokeObjectURL(url);
684 +		dirty = false;
685 +	}
686 +
687 +	function imageToFrame(src, w, h) {
688 +		const f = new Map();
689 +		const ox = -Math.floor(w / 2);
690 +		const oy = -Math.floor(h / 2);
691 +		const cx0 = Math.floor(ox / CHUNK);
692 +		const cy0 = Math.floor(oy / CHUNK);
693 +		const cx1 = Math.floor((ox + w - 1) / CHUNK);
694 +		const cy1 = Math.floor((oy + h - 1) / CHUNK);
695 +		for (let cy = cy0; cy <= cy1; cy++) {
696 +			for (let cx = cx0; cx <= cx1; cx++) {
697 +				getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
698 +			}
699 +		}
700 +		return { f, w, h, ox, oy };
701 +	}
702 +
703 +	function finishImport(newFrames, w, h, ox, oy) {
704 +		frames = newFrames;
705 +		dir = 1;
706 +		setFrame(0);
707 +		camX = ox - (cssW / zoom - w) / 2;
708 +		camY = oy - (cssH / zoom - h) / 2;
709  		dirty = false;
710 +		requestDraw();
711  	}
712  
    -	function importImage(file) {
    -		if (dirty && !confirm('Importing will discard the current painting. Continue?')) return;
713 +	async function importFile(file) {
714 +		if (dirty && !confirm('Importing will discard the current animation. Continue?')) return;
715 +		if (playing) stopPlayback();
716 +		// ImageDecoder gives us every frame of an animated gif; fall back to
717 +		// single-image import where unsupported
718 +		if (typeof ImageDecoder !== 'undefined') {
719 +			try {
720 +				const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
721 +				await dec.tracks.ready;
722 +				const count = dec.tracks.selectedTrack.frameCount;
723 +				const newFrames = [];
724 +				let r = null;
725 +				for (let i = 0; i < count; i++) {
726 +					const { image } = await dec.decode({ frameIndex: i });
727 +					if (i === 0 && image.duration) frameInterval = Math.max(20, image.duration / 1000);
728 +					r = imageToFrame(image, image.displayWidth, image.displayHeight);
729 +					newFrames.push(r.f);
730 +					image.close();
731 +				}
732 +				finishImport(newFrames, r.w, r.h, r.ox, r.oy);
733 +				return;
734 +			} catch (err) {}
735 +		}
736  		const img = new Image();
737  		img.onload = () => {
    -			chunks.clear();
    -			const w = img.width, h = img.height;
    -			const tmp = document.createElement('canvas');
    -			tmp.width = w;
    -			tmp.height = h;
    -			const tctx = tmp.getContext('2d');
    -			tctx.drawImage(img, 0, 0);
    -			const ox = -Math.floor(w / 2);
    -			const oy = -Math.floor(h / 2);
    -			const cx0 = Math.floor(ox / CHUNK);
    -			const cy0 = Math.floor(oy / CHUNK);
    -			const cx1 = Math.floor((ox + w - 1) / CHUNK);
    -			const cy1 = Math.floor((oy + h - 1) / CHUNK);
    -			for (let cy = cy0; cy <= cy1; cy++) {
    -				for (let cx = cx0; cx <= cx1; cx++) {
    -					const c = getOrCreateChunk(cx, cy);
    -					c.ctx.drawImage(tmp, ox - cx * CHUNK, oy - cy * CHUNK);
    -				}
    -			}
    -			camX = ox - (cssW / zoom - w) / 2;
    -			camY = oy - (cssH / zoom - h) / 2;
    -			dirty = false;
738 +			const r = imageToFrame(img, img.width, img.height);
739 +			finishImport([r.f], r.w, r.h, r.ox, r.oy);
740  			URL.revokeObjectURL(img.src);
    -			requestDraw();
741  		};
742  		img.onerror = () => URL.revokeObjectURL(img.src);
743  		img.src = URL.createObjectURL(file);

874  	window.addEventListener('keydown', (e) => {
875  		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
876  			e.preventDefault();
    -			exportPNG();
877 +			exportGIF();
878  			return;
879  		}
880  		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {

882  			fileInput.click();
883  			return;
884  		}
885 +		if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
886 +			const lk = e.key.toLowerCase();
887 +			if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
888 +			if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
889 +			if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
890 +			if (lk === 'a') { addFrame(); return; }
891 +			if (lk === 'd') { deleteFrame(); return; }
892 +			if (lk === 'o') {
893 +				if (playing) { stopPlayback(); onionskin = true; }
894 +				else onionskin = !onionskin;
895 +				requestDraw();
896 +				return;
897 +			}
898 +		}
899  		const k = e.key.toLowerCase();
900  		const wasDown = keys[k];
901  		keys[k] = true;
readme.md
   -# paint
 1 +# animus
 2  
   -an infinite canvas for radical digital painting, inspired by the works of <a href="https://dl.acm.org/doi/epdf/10.1145/2212877.2212896">Larry Tesler</a>, <a href="https://www.youtube.com/watch?v=ndz-co7Xpn8">Jeffrey Alan Scudder</a>, <a href="https://www.youtube.com/watch?v=csalhuSixQU">Craig Hickman</a>, and <a href="https://www.youtube.com/watch?v=5TFDG-y-EHs">Tom7</a>.
 3 +an infinite canvas for radical digital painting.
 4  
   -## demo
 5 +<!-- > inspired by KidPix, MS Paint, Mario Paint, and Flipnote Studio. -->
 6 +
 7 +<p align="center">
 8 +	<img src="readme_images/doodle.gif" width=235>
 9 +</p>
10  
   -<a href="https://hunterirving.github.io/paint/">click here</a>
11 +## demo
12 +<p align="center">
13 +<br>
14 +	<a href="https://hunterirving.github.io/animus/">click here</a>
15 +<br><br>
16 +</p>
17  
18  ## controls
19 +
20 +### mark-making
21 +
22  - click and drag to make marks
23  - hold `shift` and move your cursor up/down to resize the brush
24  - hold `⌘` and move your cursor up/down to change the shape of the brush
25  - hold any of the ![R](readme_images/R.svg), ![G](readme_images/G.svg), and/or ![B](readme_images/B.svg) keys and move your cursor up/down to change the ![Redness](readme_images/Redness.svg), ![Greenness](readme_images/Greenness.svg), and/or ![Blueness](readme_images/Blueness.svg) of the active color
   -- hold `option` and click anywhere to pick up the color underneath the brush
   -	- you can also hold `option` then click and drag to scrub for colors
   -- pinch with two fingers to zoom in or out
   -- drag with two fingers to pan the viewport
   ----
   -- hold `⌘` and press `S` to save your painting as a PNG file
26 +- hold `option` and click anywhere to pick up the color underneath your brush
27 +- pinch with two fingers to zoom in/out
28 +- drag with two fingers to pan around the canvas
29 +
30 +### animation
31 +- press `A` to add a new frame
32 +- press `D` to delete the current frame
33 +- press `O` to toggle onionskin
34 +- press `←` / `→` to step between frames
35 +	- tap out a rhythm using the arrow keys to set the playback speed
36 +	- the last-tapped direction sets the playback direction
37 +- press `space` to toggle playback
38 +	- you can continue painting while the canvas animates
39 +
40 +### importing / exporting
41 +- hold `⌘` and press `S` to save your painting as a .GIF file
42  - hold `⌘` and press `O` to open an existing painting from your filesystem (will clear the current canvas)
readme_images/doodle.gif
Binary files differ.