a n i m a t i o n !
Hunter
Saturday, July 11th, 2026 at 9:40 PM
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;
-# 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 , , and/or  keys and move your cursor up/down to change the , , and/or  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)
Binary files differ.