io.js (8.1 KB)


  1 const fileInput = document.createElement('input');
  2 fileInput.type = 'file';
  3 fileInput.accept = 'image/*';
  4 fileInput.style.display = 'none';
  5 document.body.appendChild(fileInput);
  6 fileInput.addEventListener('change', (e) => {
  7 	const f = e.target.files && e.target.files[0];
  8 	if (f) importFile(f);
  9 	fileInput.value = '';
 10 });
 11 
 12 function formatTimestamp(d) {
 13 	const p = (n) => String(n).padStart(2, '0');
 14 	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());
 15 }
 16 
 17 // union of painted chunk bounds across all frames, so every exported
 18 // frame shares the canvas of the biggest one
 19 function frameBounds() {
 20 	let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
 21 	for (const f of frames) {
 22 		for (const k of f.keys()) {
 23 			any = true;
 24 			const [cx, cy] = k.split(',').map(Number);
 25 			if (cx < minCx) minCx = cx;
 26 			if (cy < minCy) minCy = cy;
 27 			if (cx > maxCx) maxCx = cx;
 28 			if (cy > maxCy) maxCy = cy;
 29 		}
 30 	}
 31 	return any ? { minCx, minCy, maxCx, maxCy } : null;
 32 }
 33 
 34 function renderFrame(f, b, w, h) {
 35 	const out = document.createElement('canvas');
 36 	out.width = w;
 37 	out.height = h;
 38 	const octx = out.getContext('2d');
 39 	octx.fillStyle = '#000';
 40 	octx.fillRect(0, 0, w, h);
 41 	for (const [k, c] of f) {
 42 		const [cx, cy] = k.split(',').map(Number);
 43 		octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
 44 	}
 45 	return octx.getImageData(0, 0, w, h);
 46 }
 47 
 48 function lzwEncode(minCode, data, lookup, out) {
 49 	const clear = 1 << minCode, eoi = clear + 1;
 50 	let codeSize = minCode + 1, next = eoi + 1;
 51 	let dict = new Map();
 52 	let acc = 0, accBits = 0;
 53 	let block = [];
 54 	const flushBlock = () => {
 55 		if (block.length) { out.push(block.length, ...block); block = []; }
 56 	};
 57 	const emit = (code) => {
 58 		acc |= code << accBits;
 59 		accBits += codeSize;
 60 		while (accBits >= 8) {
 61 			block.push(acc & 255);
 62 			acc >>= 8;
 63 			accBits -= 8;
 64 			if (block.length === 255) flushBlock();
 65 		}
 66 	};
 67 	const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
 68 	emit(clear);
 69 	let prev = idx(0);
 70 	for (let i = 4; i < data.length; i += 4) {
 71 		const k = idx(i);
 72 		const key = prev * 256 + k;
 73 		if (dict.has(key)) { prev = dict.get(key); continue; }
 74 		emit(prev);
 75 		if (next === 4096) {
 76 			emit(clear);
 77 			dict = new Map();
 78 			next = eoi + 1;
 79 			codeSize = minCode + 1;
 80 		} else {
 81 			if (next >= (1 << codeSize)) codeSize++;
 82 			dict.set(key, next++);
 83 		}
 84 		prev = k;
 85 	}
 86 	emit(prev);
 87 	emit(eoi);
 88 	if (accBits) block.push(acc & 255);
 89 	flushBlock();
 90 }
 91 
 92 // minimal GIF89a encoder: exact global palette when <=256 colors,
 93 // else uniform 6x6x6 quantization
 94 function encodeGIF(images, w, h, delayMs) {
 95 	const colorIdx = new Map();
 96 	let over = false;
 97 	for (const img of images) {
 98 		const d = img.data;
 99 		for (let i = 0; i < d.length && !over; i += 4) {
100 			const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
101 			if (!colorIdx.has(c)) {
102 				if (colorIdx.size === 256) over = true;
103 				else colorIdx.set(c, colorIdx.size);
104 			}
105 		}
106 		if (over) break;
107 	}
108 	if (over) {
109 		colorIdx.clear();
110 		for (let i = 0; i < 216; i++) {
111 			colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
112 		}
113 	}
114 	const lookup = (c) => over
115 		? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
116 		: colorIdx.get(c);
117 	let bits = 2;
118 	while ((1 << bits) < colorIdx.size) bits++;
119 	const out = [];
120 	const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
121 	out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
122 	u16(w); u16(h);
123 	out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
124 	const pal = [...colorIdx.keys()];
125 	for (let i = 0; i < (1 << bits); i++) {
126 		const c = pal[i] || 0;
127 		out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
128 	}
129 	const animated = images.length > 1;
130 	if (animated) {
131 		// NETSCAPE2.0 loop forever
132 		out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
133 	}
134 	const delay = clamp(Math.round(delayMs / 10), 2, 65535);
135 	for (const img of images) {
136 		if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
137 		out.push(0x2c);
138 		u16(0); u16(0); u16(w); u16(h);
139 		out.push(0);
140 		const minCode = Math.max(2, bits);
141 		out.push(minCode);
142 		lzwEncode(minCode, img.data, lookup, out);
143 		out.push(0);
144 	}
145 	out.push(0x3b);
146 	return new Uint8Array(out);
147 }
148 
149 function exportGIF() {
150 	const b = frameBounds();
151 	if (!b) return;
152 	const w = (b.maxCx - b.minCx + 1) * CHUNK;
153 	const h = (b.maxCy - b.minCy + 1) * CHUNK;
154 	const imgs = frames.map((f) => renderFrame(f, b, w, h));
155 	if (dir === -1) imgs.reverse();
156 	const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
157 	const url = URL.createObjectURL(blob);
158 	const a = document.createElement('a');
159 	a.href = url;
160 	a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
161 	document.body.appendChild(a);
162 	a.click();
163 	document.body.removeChild(a);
164 	URL.revokeObjectURL(url);
165 	dirty = false;
166 }
167 
168 function imageToFrame(src, w, h) {
169 	const ox = -Math.floor(w / 2);
170 	const oy = -Math.floor(h / 2);
171 	const f = newFrameMap();
172 	drawIntoFrame(f, src, w, h, ox, oy);
173 	return { f, w, h, ox, oy };
174 }
175 
176 function drawIntoFrame(f, src, w, h, ox, oy) {
177 	const cx0 = Math.floor(ox / CHUNK);
178 	const cy0 = Math.floor(oy / CHUNK);
179 	const cx1 = Math.floor((ox + w - 1) / CHUNK);
180 	const cy1 = Math.floor((oy + h - 1) / CHUNK);
181 	for (let cy = cy0; cy <= cy1; cy++) {
182 		for (let cx = cx0; cx <= cx1; cx++) {
183 			getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
184 		}
185 	}
186 }
187 
188 function finishImport(newFrames, w, h, ox, oy) {
189 	frames = newFrames;
190 	dir = 1;
191 	setFrame(0);
192 	camX = ox - (cssW / zoom - w) / 2;
193 	camY = oy - (cssH / zoom - h) / 2;
194 	dirty = false;
195 	requestDraw();
196 }
197 
198 function loadImage(src) {
199 	return new Promise((resolve, reject) => {
200 		const img = new Image();
201 		img.onload = () => resolve(img);
202 		img.onerror = reject;
203 		img.src = src;
204 	});
205 }
206 
207 // decoded frames of the file as {src, w, h, close}
208 async function decodeSources(file) {
209 	// ImageDecoder gives us every frame of an animated gif; fall back to
210 	// single-image import where unsupported
211 	if (typeof ImageDecoder !== 'undefined') {
212 		try {
213 			const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
214 			await dec.tracks.ready;
215 			const count = dec.tracks.selectedTrack.frameCount;
216 			const srcs = [];
217 			let interval = 0;
218 			for (let i = 0; i < count; i++) {
219 				const { image } = await dec.decode({ frameIndex: i });
220 				if (i === 0 && image.duration) interval = Math.max(20, image.duration / 1000);
221 				srcs.push({ src: image, w: image.displayWidth, h: image.displayHeight, close: () => image.close() });
222 			}
223 			return { srcs, interval };
224 		} catch (err) {}
225 	}
226 	const url = URL.createObjectURL(file);
227 	try {
228 		const img = await loadImage(url);
229 		return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 };
230 	} catch (err) {
231 		return null;
232 	} finally {
233 		URL.revokeObjectURL(url);
234 	}
235 }
236 
237 function applyImport(srcs) {
238 	const newFrames = [];
239 	let last = null;
240 	for (const s of srcs) {
241 		last = imageToFrame(s.src, s.w, s.h);
242 		newFrames.push(last.f);
243 	}
244 	if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy);
245 }
246 
247 // flat png per frame, positioned in world space, so an import can be handed
248 // to everyone else in the room (and to whoever joins later)
249 function sourcesToBases(srcs) {
250 	const cnv = document.createElement('canvas');
251 	const cctx = cnv.getContext('2d');
252 	return srcs.map((s) => {
253 		cnv.width = s.w;
254 		cnv.height = s.h;
255 		cctx.drawImage(s.src, 0, 0);
256 		return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h };
257 	});
258 }
259 
260 async function importFile(file) {
261 	if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?' + (net.on ? ' (for everyone)' : ''))) return;
262 	if (playing) stopPlayback();
263 	const dec = await decodeSources(file);
264 	if (!dec || !dec.srcs.length) return;
265 	if (dec.interval) frameInterval = dec.interval;
266 	if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval });
267 	else applyImport(dec.srcs);
268 	for (const s of dec.srcs) if (s.close) s.close();
269 }