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 }