commit 7b6f2be8bf27c9bf712e2aba95d298c9922e84f3
parent e7c536c212e2825443bcc597c28fd667cb9cc1b6
Author: Hunter
Date: Sat, 11 Jul 2026 21:40:01 -0400
a n i m a t i o n !
Diffstat:
3 files changed, 314 insertions(+), 66 deletions(-)
diff --git a/index.html b/index.html
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
-<title>paint</title>
+<title>animus</title>
<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">
<style>
html, body {
@@ -33,13 +33,21 @@
const vctx = view.getContext('2d', { alpha: false });
const CHUNK = 256;
- const chunks = new Map();
+ let frames = [new Map()];
+ let frameIdx = 0;
+ let chunks = frames[0];
+ let dir = 1; // playback direction: 1 forward, -1 reverse
+ let onionskin = false;
+ let playing = false;
+ let playTimer = null;
+ let frameInterval = 250;
+ let lastTap = 0;
function chunkKey(cx, cy) { return cx + ',' + cy; }
- function getOrCreateChunk(cx, cy) {
+ function getOrCreateChunk(cx, cy, map = chunks) {
const k = chunkKey(cx, cy);
- let c = chunks.get(k);
+ let c = map.get(k);
if (c) return c;
const cnv = document.createElement('canvas');
cnv.width = CHUNK;
@@ -48,7 +56,7 @@
cctx.fillStyle = '#000';
cctx.fillRect(0, 0, CHUNK, CHUNK);
c = { canvas: cnv, ctx: cctx };
- chunks.set(k, c);
+ map.set(k, c);
return c;
}
@@ -83,6 +91,49 @@
return [d[0], d[1], d[2]];
}
+ function setFrame(i) {
+ frameIdx = ((i % frames.length) + frames.length) % frames.length;
+ chunks = frames[frameIdx];
+ requestDraw();
+ }
+
+ function addFrame() {
+ const at = dir === 1 ? frameIdx + 1 : frameIdx;
+ frames.splice(at, 0, new Map());
+ setFrame(at);
+ }
+
+ function deleteFrame() {
+ if (frames.length === 1) return;
+ frames.splice(frameIdx, 1);
+ if (playing && frames.length === 1) stopPlayback();
+ setFrame(Math.min(frameIdx, frames.length - 1));
+ }
+
+ function stopPlayback() {
+ playing = false;
+ clearTimeout(playTimer);
+ requestDraw();
+ }
+
+ function startPlayback() {
+ if (frames.length === 1) return;
+ playing = true;
+ playTimer = setTimeout(function step() {
+ setFrame(frameIdx + dir);
+ playTimer = setTimeout(step, frameInterval);
+ }, frameInterval);
+ }
+
+ function tapArrow(d) {
+ dir = d;
+ const now = performance.now();
+ if (lastTap && now - lastTap <= 10000) frameInterval = now - lastTap;
+ lastTap = now;
+ if (playing) { stopPlayback(); return; }
+ setFrame(frameIdx + d);
+ }
+
let dpr = window.devicePixelRatio || 1;
let cssW = 0, cssH = 0;
@@ -383,6 +434,25 @@
}
}
+ // onionskin: ghost the frame behind us in playback order, screen-blended
+ // so black contributes nothing over the opaque chunks
+ if (onionskin && !playing && frames.length > 1) {
+ const ghost = frames[(frameIdx - dir + frames.length) % frames.length];
+ vctx.save();
+ vctx.globalAlpha = 0.35;
+ vctx.globalCompositeOperation = 'screen';
+ for (let cy = cy0; cy <= cy1; cy++) {
+ for (let cx = cx0; cx <= cx1; cx++) {
+ const c = ghost.get(chunkKey(cx, cy));
+ if (!c) continue;
+ const x0 = destX(cx * CHUNK);
+ const y0 = destY(cy * CHUNK);
+ vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
+ }
+ }
+ vctx.restore();
+ }
+
if (mouseInside) {
const tl = brushTopLeft(curX, curY);
// round to integer device pixels - camX/camY are fractional (smooth
@@ -454,7 +524,7 @@
document.body.appendChild(fileInput);
fileInput.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0];
- if (f) importImage(f);
+ if (f) importFile(f);
fileInput.value = '';
});
@@ -463,69 +533,211 @@
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());
}
- 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;
+ // union of painted chunk bounds across all frames, so every exported
+ // frame shares the canvas of the biggest one
+ function frameBounds() {
+ let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
+ for (const f of frames) {
+ 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;
+ }
}
- const w = (maxCx - minCx + 1) * CHUNK;
- const h = (maxCy - minCy + 1) * CHUNK;
+ return any ? { minCx, minCy, maxCx, maxCy } : null;
+ }
+
+ function renderFrame(f, b, w, h) {
const out = document.createElement('canvas');
out.width = w;
out.height = h;
const octx = out.getContext('2d');
octx.fillStyle = '#000';
octx.fillRect(0, 0, w, h);
- for (const [k, c] of chunks) {
+ for (const [k, c] of f) {
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');
+ octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
+ }
+ return octx.getImageData(0, 0, w, h);
+ }
+
+ function lzwEncode(minCode, data, lookup, out) {
+ const clear = 1 << minCode, eoi = clear + 1;
+ let codeSize = minCode + 1, next = eoi + 1;
+ let dict = new Map();
+ let acc = 0, accBits = 0;
+ let block = [];
+ const flushBlock = () => {
+ if (block.length) { out.push(block.length, ...block); block = []; }
+ };
+ const emit = (code) => {
+ acc |= code << accBits;
+ accBits += codeSize;
+ while (accBits >= 8) {
+ block.push(acc & 255);
+ acc >>= 8;
+ accBits -= 8;
+ if (block.length === 255) flushBlock();
+ }
+ };
+ const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
+ emit(clear);
+ let prev = idx(0);
+ for (let i = 4; i < data.length; i += 4) {
+ const k = idx(i);
+ const key = prev * 256 + k;
+ if (dict.has(key)) { prev = dict.get(key); continue; }
+ emit(prev);
+ if (next === 4096) {
+ emit(clear);
+ dict = new Map();
+ next = eoi + 1;
+ codeSize = minCode + 1;
+ } else {
+ if (next >= (1 << codeSize)) codeSize++;
+ dict.set(key, next++);
+ }
+ prev = k;
+ }
+ emit(prev);
+ emit(eoi);
+ if (accBits) block.push(acc & 255);
+ flushBlock();
+ }
+
+ // minimal GIF89a encoder: exact global palette when <=256 colors,
+ // else uniform 6x6x6 quantization
+ function encodeGIF(images, w, h, delayMs) {
+ const colorIdx = new Map();
+ let over = false;
+ for (const img of images) {
+ const d = img.data;
+ for (let i = 0; i < d.length && !over; i += 4) {
+ const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
+ if (!colorIdx.has(c)) {
+ if (colorIdx.size === 256) over = true;
+ else colorIdx.set(c, colorIdx.size);
+ }
+ }
+ if (over) break;
+ }
+ if (over) {
+ colorIdx.clear();
+ for (let i = 0; i < 216; i++) {
+ colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
+ }
+ }
+ const lookup = (c) => over
+ ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
+ : colorIdx.get(c);
+ let bits = 2;
+ while ((1 << bits) < colorIdx.size) bits++;
+ const out = [];
+ const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
+ out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
+ u16(w); u16(h);
+ out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
+ const pal = [...colorIdx.keys()];
+ for (let i = 0; i < (1 << bits); i++) {
+ const c = pal[i] || 0;
+ out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
+ }
+ const animated = images.length > 1;
+ if (animated) {
+ // NETSCAPE2.0 loop forever
+ out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
+ }
+ const delay = clamp(Math.round(delayMs / 10), 2, 65535);
+ for (const img of images) {
+ if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
+ out.push(0x2c);
+ u16(0); u16(0); u16(w); u16(h);
+ out.push(0);
+ const minCode = Math.max(2, bits);
+ out.push(minCode);
+ lzwEncode(minCode, img.data, lookup, out);
+ out.push(0);
+ }
+ out.push(0x3b);
+ return new Uint8Array(out);
+ }
+
+ function exportGIF() {
+ const b = frameBounds();
+ if (!b) return;
+ const w = (b.maxCx - b.minCx + 1) * CHUNK;
+ const h = (b.maxCy - b.minCy + 1) * CHUNK;
+ const imgs = frames.map((f) => renderFrame(f, b, w, h));
+ if (dir === -1) imgs.reverse();
+ const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ dirty = false;
+ }
+
+ function imageToFrame(src, w, h) {
+ const f = new Map();
+ 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++) {
+ getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
+ }
+ }
+ return { f, w, h, ox, oy };
+ }
+
+ function finishImport(newFrames, w, h, ox, oy) {
+ frames = newFrames;
+ dir = 1;
+ setFrame(0);
+ camX = ox - (cssW / zoom - w) / 2;
+ camY = oy - (cssH / zoom - h) / 2;
dirty = false;
+ requestDraw();
}
- function importImage(file) {
- if (dirty && !confirm('Importing will discard the current painting. Continue?')) return;
+ async function importFile(file) {
+ if (dirty && !confirm('Importing will discard the current animation. Continue?')) return;
+ if (playing) stopPlayback();
+ // ImageDecoder gives us every frame of an animated gif; fall back to
+ // single-image import where unsupported
+ if (typeof ImageDecoder !== 'undefined') {
+ try {
+ const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
+ await dec.tracks.ready;
+ const count = dec.tracks.selectedTrack.frameCount;
+ const newFrames = [];
+ let r = null;
+ for (let i = 0; i < count; i++) {
+ const { image } = await dec.decode({ frameIndex: i });
+ if (i === 0 && image.duration) frameInterval = Math.max(20, image.duration / 1000);
+ r = imageToFrame(image, image.displayWidth, image.displayHeight);
+ newFrames.push(r.f);
+ image.close();
+ }
+ finishImport(newFrames, r.w, r.h, r.ox, r.oy);
+ return;
+ } catch (err) {}
+ }
const img = new Image();
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;
+ const r = imageToFrame(img, img.width, img.height);
+ finishImport([r.f], r.w, r.h, r.ox, r.oy);
URL.revokeObjectURL(img.src);
- requestDraw();
};
img.onerror = () => URL.revokeObjectURL(img.src);
img.src = URL.createObjectURL(file);
@@ -662,7 +874,7 @@
window.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
e.preventDefault();
- exportPNG();
+ exportGIF();
return;
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
@@ -670,6 +882,20 @@
fileInput.click();
return;
}
+ if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
+ const lk = e.key.toLowerCase();
+ if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
+ if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
+ if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
+ if (lk === 'a') { addFrame(); return; }
+ if (lk === 'd') { deleteFrame(); return; }
+ if (lk === 'o') {
+ if (playing) { stopPlayback(); onionskin = true; }
+ else onionskin = !onionskin;
+ requestDraw();
+ return;
+ }
+ }
const k = e.key.toLowerCase();
const wasDown = keys[k];
keys[k] = true;
diff --git a/readme.md b/readme.md
@@ -1,20 +1,42 @@
-# paint
+# animus
-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>.
+an infinite canvas for radical digital painting.
-## demo
+<!-- > inspired by KidPix, MS Paint, Mario Paint, and Flipnote Studio. -->
+
+<p align="center">
+ <img src="readme_images/doodle.gif" width=235>
+</p>
-<a href="https://hunterirving.github.io/paint/">click here</a>
+## demo
+<p align="center">
+<br>
+ <a href="https://hunterirving.github.io/animus/">click here</a>
+<br><br>
+</p>
## controls
+
+### mark-making
+
- click and drag to make marks
- hold `shift` and move your cursor up/down to resize the brush
- hold `⌘` and move your cursor up/down to change the shape of the brush
- 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
+- hold `option` and click anywhere to pick up the color underneath your brush
+- pinch with two fingers to zoom in/out
+- drag with two fingers to pan around the canvas
+
+### animation
+- press `A` to add a new frame
+- press `D` to delete the current frame
+- press `O` to toggle onionskin
+- press `←` / `→` to step between frames
+ - tap out a rhythm using the arrow keys to set the playback speed
+ - the last-tapped direction sets the playback direction
+- press `space` to toggle playback
+ - you can continue painting while the canvas animates
+
+### importing / exporting
+- hold `⌘` and press `S` to save your painting as a .GIF file
- hold `⌘` and press `O` to open an existing painting from your filesystem (will clear the current canvas)
\ No newline at end of file
diff --git a/readme_images/doodle.gif b/readme_images/doodle.gif
Binary files differ.