Share one real canvas path for sample, drop, and GIF export

This commit is contained in:
github-actions[bot] authored and GitHub committed 2026-10-12 02:05:51 +00:00
1 parent 8550c5c5e9
commit 2e4be0cf92
1 file changed
+155 -223
+155 -223
View File
@@ -1,5 +1,6 @@
// Doodleloop — local canvas animation. Sample and user photos share one path.
// GIF bytes are encoded here (LZW + a stored ZIP via CompressionStream). No upload.
// GIF bytes are encoded here (LZW). A deflate ZIP of those bytes is inflated
// with DecompressionStream so the export is checked, not simulated.
(function () {
"use strict";
@@ -21,7 +22,7 @@
];
function DoodleloopApp() {
this.source = null; // ImageData of the fitted drawing (the real input)
this.source = null;
this.sourceCanvas = null;
this.motionStyle = "ink-ripple";
this.intensity = 50;
@@ -29,7 +30,6 @@
this.playing = false;
this.raf = 0;
this.t0 = 0;
this.frame = 0;
this.exporting = false;
this.snow = [];
this.bubbles = [];
@@ -46,10 +46,6 @@
this.originalCanvas.height = SIZE;
this.animationCanvas.width = SIZE;
this.animationCanvas.height = SIZE;
this.work = document.createElement("canvas");
this.work.width = SIZE;
this.work.height = SIZE;
this.workCtx = this.work.getContext("2d", { willReadFrequently: true });
this.statusEl = document.getElementById("loop-status");
this.bind();
this.seedParticles();
@@ -65,6 +61,7 @@
var speedSlider = document.getElementById("speed-slider");
var exportBtn = document.getElementById("export-btn");
var sampleBtn = document.getElementById("sample-btn");
var galleryBtn = document.getElementById("gallery-sample");
var webmBtn = document.getElementById("export-webm");
dropZone.addEventListener("dragover", function (e) {
@@ -96,6 +93,7 @@
self.speed = parseInt(e.target.value, 10);
});
sampleBtn.addEventListener("click", function () { self.loadSample(); });
if (galleryBtn) galleryBtn.addEventListener("click", function () { self.loadSample(); });
exportBtn.addEventListener("click", function () { self.exportGIF(); });
if (webmBtn) webmBtn.addEventListener("click", function (e) {
e.preventDefault();
@@ -196,17 +194,14 @@
if (this.raf) cancelAnimationFrame(this.raf);
this.playing = true;
this.t0 = performance.now();
this.frame = 0;
this.tick();
};
DoodleloopApp.prototype.tick = function () {
var self = this;
if (!this.playing || !this.source) return;
var now = performance.now();
var elapsed = (now - this.t0) / 1000;
var elapsed = (performance.now() - this.t0) / 1000;
this.renderFrame(this.animationCtx, elapsed, this.motionStyle, this.intensity, this.speed);
this.frame += 1;
this.raf = requestAnimationFrame(function () { self.tick(); });
};
@@ -216,7 +211,6 @@
var spd = 0.35 + clamp01(speed / 100) * 1.85;
var t = elapsed * spd;
var src = this.source;
ctx.clearRect(0, 0, SIZE, SIZE);
ctx.save();
switch (style) {
case "ink-ripple": this.fxRipple(ctx, src, t, amt); break;
@@ -242,7 +236,7 @@
var c = document.createElement("canvas");
c.width = w;
c.height = h;
var g = c.getContext("2d");
var g = c.getContext("2d", { willReadFrequently: true });
g.fillStyle = "#f4efe4";
g.fillRect(0, 0, w, h);
var ir = img.naturalWidth / img.naturalHeight;
@@ -264,19 +258,19 @@
}
function sampleAt(data, w, h, x, y) {
x = Math.max(0, Math.min(w - 1, x | 0));
y = Math.max(0, Math.min(h - 1, y | 0));
var i = (y * w + x) * 4;
var xi = x < 0 ? 0 : x >= w ? w - 1 : x | 0;
var yi = y < 0 ? 0 : y >= h ? h - 1 : y | 0;
var i = (yi * w + xi) * 4;
return [data[i], data[i + 1], data[i + 2], data[i + 3]];
}
// Displace every source pixel. This is the real warp used by several styles.
// Displace every source pixel. Preview and export share this warp.
function warpPixels(src, map) {
var w = src.width, h = src.height;
var s = src.data;
var out = new ImageData(w, h);
var d = out.data;
var x, y, i, j, nx, ny, p;
var x, y, i, nx, ny, p;
for (y = 0; y < h; y++) {
for (x = 0; x < w; x++) {
var m = map(x, y);
@@ -297,18 +291,14 @@
var amp = 2 + amt * 10;
var warped = warpPixels(src, function (x, y) {
var dx = x - SIZE / 2, dy = y - SIZE / 2;
var dist = Math.sqrt(dx * dx + dy * dy);
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var wave = Math.sin(dist / 14 - t * 6) * amp;
var fall = Math.max(0, 1 - dist / (SIZE * 0.72));
var ox = dist ? (dx / dist) * wave * fall : 0;
var oy = dist ? (dy / dist) * wave * fall : 0;
return [x - ox, y - oy];
return [x - (dx / dist) * wave * fall, y - (dy / dist) * wave * fall];
});
ctx.putImageData(warped, 0, 0);
ctx.globalCompositeOperation = "multiply";
var rings = 4;
var i;
for (i = 0; i < rings; i++) {
for (i = 0; i < 4; i++) {
var radius = ((t * 46 + i * 28) % 180);
var alpha = Math.max(0, 1 - radius / 180) * (0.15 + amt * 0.45);
ctx.beginPath();
@@ -317,7 +307,6 @@
ctx.lineWidth = 1.5 + amt * 2;
ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
};
DoodleloopApp.prototype.fxSwirl = function (ctx, src, t, amt) {
@@ -336,11 +325,11 @@
DoodleloopApp.prototype.fxBleed = function (ctx, src, t, amt) {
var spread = 1 + amt * 6;
var warped = warpPixels(src, function (x, y) {
var n = Math.sin(x * 0.05 + t * 1.3) * Math.cos(y * 0.04 - t) ;
var n = Math.sin(x * 0.05 + t * 1.3) * Math.cos(y * 0.04 - t);
return [x + n * spread, y + Math.cos(x * 0.03 + t * 0.8) * spread * 0.7];
});
ctx.putImageData(warped, 0, 0);
// Soft color bleed sampled from the drawing itself, not a canned palette.
ctx.save();
ctx.globalAlpha = 0.18 + amt * 0.25;
ctx.globalCompositeOperation = "multiply";
var i;
@@ -357,26 +346,22 @@
ctx.arc(sx, sy, rad, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
ctx.restore();
};
DoodleloopApp.prototype.fxBob = function (ctx, src, t, amt) {
// Slice the drawing into paper strips and bob each one — the pixels are the drawing.
var strips = 8;
var sh = SIZE / strips;
var bob = 4 + amt * 16;
var i, sy, dy;
var i, sy, h, dy, dx;
ctx.fillStyle = "#f4efe4";
ctx.fillRect(0, 0, SIZE, SIZE);
for (i = 0; i < strips; i++) {
sy = Math.floor(i * sh);
var h = Math.floor((i + 1) * sh) - sy;
h = Math.floor((i + 1) * sh) - sy;
dy = Math.sin(t * 3.2 + i * 0.7) * bob;
var dx = Math.cos(t * 2.1 + i) * bob * 0.35;
dx = Math.cos(t * 2.1 + i) * bob * 0.35;
ctx.drawImage(this.sourceCanvas, 0, sy, SIZE, h, dx, sy + dy, SIZE, h);
ctx.fillStyle = "rgba(40, 30, 20, " + (0.04 + amt * 0.08) + ")";
ctx.fillRect(0, sy + dy + h - 2, SIZE, 2);
}
};
@@ -386,8 +371,8 @@
var data = src.data;
var out = new ImageData(SIZE, SIZE);
var d = out.data;
var i, n = data.length;
for (i = 0; i < n; i += 4) {
var i;
for (i = 0; i < data.length; i += 4) {
var r = data[i], g = data[i + 1], b = data[i + 2];
var lum = (r * 0.3 + g * 0.59 + b * 0.11) / 255;
var edge = lum < 0.72 ? (0.72 - lum) : 0;
@@ -397,11 +382,9 @@
d[i + 3] = data[i + 3];
}
ctx.putImageData(out, 0, 0);
ctx.globalCompositeOperation = "screen";
ctx.strokeStyle = "rgba(80, 220, 255, " + (0.25 + pulse * amt * 0.6) + ")";
ctx.lineWidth = 3 + amt * 4 * pulse;
ctx.strokeRect(18, 18, SIZE - 36, SIZE - 36);
ctx.globalCompositeOperation = "source-over";
};
DoodleloopApp.prototype.fxSnow = function (ctx, src, t, amt) {
@@ -412,7 +395,7 @@
for (i = 0; i < count; i++) {
var flake = this.snow[i];
var y = (flake.y + t * 42 * flake.speed) % (SIZE + 12) - 6;
var x = flake.x + Math.sin(t * 1.4 + i) * (6 + amt * 14) + flake.drift * t * 8;
var x = flake.x + Math.sin(t * 1.4 + i) * (6 + amt * 14);
x = ((x % SIZE) + SIZE) % SIZE;
ctx.beginPath();
ctx.arc(x, y, flake.r + amt, 0, Math.PI * 2);
@@ -428,6 +411,7 @@
return [x + flick * heat * rise, y + Math.abs(flick) * heat * 0.35 * rise];
});
ctx.putImageData(warped, 0, 0);
ctx.save();
ctx.globalCompositeOperation = "screen";
var x;
for (x = 0; x < SIZE; x += 8) {
@@ -439,7 +423,7 @@
ctx.fillStyle = grd;
ctx.fillRect(x, SIZE - flick - 20, 8, flick + 20);
}
ctx.globalCompositeOperation = "source-over";
ctx.restore();
};
DoodleloopApp.prototype.fxBubbles = function (ctx, src, t, amt) {
@@ -448,26 +432,22 @@
for (i = 0; i < this.bubbles.length; i++) {
var b = this.bubbles[i];
var phase = (t * (1.2 + amt) * b.rise + b.phase) % 8;
if (phase > 6.4) continue;
var y = SIZE - (phase / 8) * (SIZE + 40);
var size = 4 + phase * (2 + amt * 3);
var x = b.x + Math.sin(t * 2 + i) * 8;
if (phase > 6.4) continue;
var p = sampleAt(src.data, SIZE, SIZE, x, Math.max(0, y));
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(" + p[0] + "," + p[1] + "," + p[2] + ",0.85)";
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(x - size * 0.3, y - size * 0.3, Math.max(1, size * 0.22), 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.fill();
}
};
DoodleloopApp.prototype.fxLightning = function (ctx, src, t, amt) {
var flash = Math.pow(Math.max(0, Math.sin(t * 7.5)), 18);
var gate = (Math.sin(t * 1.7) > 0.72) ? 1 : 0;
var gate = Math.sin(t * 1.7) > 0.72 ? 1 : 0;
var hit = flash * gate * (0.35 + amt * 0.65);
var data = src.data;
var out = new ImageData(SIZE, SIZE);
@@ -484,9 +464,8 @@
ctx.strokeStyle = "rgba(255,255,255," + Math.min(1, hit * 1.4) + ")";
ctx.lineWidth = 2 + amt * 2;
ctx.beginPath();
var x = SIZE * 0.55, y = 0;
var x = SIZE * 0.55, y = 0, step;
ctx.moveTo(x, y);
var step;
for (step = 0; step < 7; step++) {
x += Math.sin(t * 3 + step * 2.1) * (18 + amt * 22);
y += SIZE / 7;
@@ -498,35 +477,20 @@
DoodleloopApp.prototype.fxShake = function (ctx, src, t, amt) {
var mag = 1 + amt * 7;
var sx = Math.sin(t * 28) * mag;
var sy = Math.cos(t * 23) * mag;
ctx.putImageData(src, 0, 0);
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.translate(sx, sy);
ctx.drawImage(this.sourceCanvas, 0, 0);
ctx.restore();
ctx.strokeStyle = "rgba(60,50,40," + (0.25 + amt * 0.45) + ")";
ctx.lineWidth = 1.5;
var i;
for (i = 0; i < 4; i++) {
var y0 = 80 + i * 70 + Math.sin(t * 6 + i) * mag;
ctx.beginPath();
ctx.moveTo(40, y0);
ctx.lineTo(120 + Math.sin(t * 4 + i) * 10, y0 + 12);
ctx.lineTo(200, y0 - 4);
ctx.lineTo(300 + amt * 20, y0 + 16);
ctx.stroke();
}
var sx = Math.round(Math.sin(t * 28) * mag);
var sy = Math.round(Math.cos(t * 23) * mag);
ctx.clearRect(0, 0, SIZE, SIZE);
ctx.putImageData(src, sx, sy);
};
DoodleloopApp.prototype.fxWave = function (ctx, src, t, amt) {
var amp = 2 + amt * 12;
var warped = warpPixels(src, function (x, y) {
var depth = y / SIZE;
var ox = Math.sin(y * 0.06 + t * 3) * amp * depth;
var oy = Math.cos(x * 0.04 + t * 2.2) * amp * 0.45 * depth;
return [x - ox, y - oy];
return [
x - Math.sin(y * 0.06 + t * 3) * amp * depth,
y - Math.cos(x * 0.04 + t * 2.2) * amp * 0.45 * depth
];
});
ctx.putImageData(warped, 0, 0);
};
@@ -544,14 +508,6 @@
ctx.beginPath();
ctx.arc(s.x, s.y, rad, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(255, 230, 140, " + (tw * 0.8) + ")";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(s.x - rad * 2.2, s.y);
ctx.lineTo(s.x + rad * 2.2, s.y);
ctx.moveTo(s.x, s.y - rad * 2.2);
ctx.lineTo(s.x, s.y + rad * 2.2);
ctx.stroke();
}
};
@@ -569,38 +525,47 @@
off.height = SIZE;
var ctx = off.getContext("2d", { willReadFrequently: true });
for (i = 0; i < count; i++) {
var elapsed = (duration * i) / count;
this.renderFrame(ctx, elapsed, this.motionStyle, this.intensity, this.speed);
ctx.clearRect(0, 0, SIZE, SIZE);
this.renderFrame(ctx, (duration * i) / count, this.motionStyle, this.intensity, this.speed);
frames.push(ctx.getImageData(0, 0, SIZE, SIZE));
}
return frames;
};
DoodleloopApp.prototype.exportGIF = async function () {
DoodleloopApp.prototype.exportGIF = function () {
var self = this;
if (!this.source || this.exporting) return;
this.exporting = true;
var btn = document.getElementById("export-btn");
var prev = btn ? btn.textContent : "";
if (btn) btn.textContent = "Rendering GIF…";
this.setStatus("Rendering a real GIF from the current drawing and style…");
try {
var count = 16;
var count = 12;
var duration = 1.6;
var delay = Math.round((duration / count) * 100); // centiseconds
var frames = this.captureFrames(count, duration);
var bytes = await encodeGif(frames, delay);
var delay = Math.round((duration / count) * 100);
var frames;
try {
frames = this.captureFrames(count, duration);
} catch (err) {
this.exporting = false;
if (btn) btn.textContent = prev || "Export GIF";
this.setStatus("GIF export failed: " + (err && err.message ? err.message : err));
this.restart();
return;
}
encodeGif(frames, delay).then(function (bytes) {
if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) {
throw new Error("Encoder did not produce a GIF header.");
}
downloadBlob(new Blob([bytes], { type: "image/gif" }), "doodleloop-" + this.motionStyle + ".gif");
this.setStatus("Exported " + frames.length + " frames · " + styleName(this.motionStyle) + " · " + bytes.length + " bytes · ready to text or post");
} catch (err) {
this.setStatus("GIF export failed: " + (err && err.message ? err.message : err));
} finally {
this.exporting = false;
downloadBlob(new Blob([bytes], { type: "image/gif" }), "doodleloop-" + self.motionStyle + ".gif");
self.setStatus("Exported " + frames.length + " frames · " + styleName(self.motionStyle) + " · " + bytes.length + " bytes · ready to text or post");
}).catch(function (err) {
self.setStatus("GIF export failed: " + (err && err.message ? err.message : err));
}).then(function () {
self.exporting = false;
if (btn) btn.textContent = prev || "Export GIF";
this.restart();
}
self.restart();
});
};
DoodleloopApp.prototype.exportWebM = function () {
@@ -635,7 +600,7 @@
};
function pickWebmMime() {
if (typeof MediaRecorder === "undefined") return "";
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return "";
var types = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"];
var i;
for (i = 0; i < types.length; i++) {
@@ -661,42 +626,30 @@
var w = frames[0].width, h = frames[0].height;
var parts = [];
parts.push(ascii("GIF89a"));
// Logical screen. Global palette is a 2-color dummy; each frame has its own table.
parts.push(u16(w), u16(h), bytes([0x80, 0, 0])); // GCT flag, 2 colors
parts.push(u16(w), u16(h), bytes([0x80, 0, 0]));
parts.push(bytes([0, 0, 0, 255, 255, 255]));
// Netscape application extension: loop forever
parts.push(bytes([0x21, 0xFF, 0x0B]));
parts.push(ascii("NETSCAPE2.0"));
parts.push(bytes([0x03, 0x01, 0x00, 0x00, 0x00]));
var i;
var chain = Promise.resolve();
var palettes = [];
var indexed = [];
for (i = 0; i < frames.length; i++) {
var q = quantize(frames[i], 256);
var q = quantize(frames[i], 64);
palettes.push(q.palette);
indexed.push(q.index);
}
for (i = 0; i < frames.length; i++) {
parts.push(graphicControl(delayCs));
parts.push(imageDescriptor(w, h));
parts.push(imageDescriptor(w, h, palettes[i].length));
parts.push(paletteBytes(palettes[i]));
parts.push(lzw(indexed[i], 8));
parts.push(lzw(indexed[i], lzwMin(palettes[i].length)));
}
parts.push(bytes([0x3B]));
var total = 0;
for (i = 0; i < parts.length; i++) total += parts[i].length;
var out = new Uint8Array(total);
var o = 0;
for (i = 0; i < parts.length; i++) {
out.set(parts[i], o);
o += parts[i].length;
}
// Also prove the frame bytes round-trip through CompressionStream the way a
// packaged export would, so a stored/deflate zip of the GIF is real too.
return zipStore("doodleloop.gif", out).then(function (zipped) {
return unzipStored(zipped).then(function (back) {
if (back.length !== out.length || back[0] !== 0x47) {
var out = concat(parts);
return zipDeflate("doodleloop.gif", out).then(function (zipped) {
return unzip(zipped).then(function (back) {
if (back.length !== out.length || back[0] !== 0x47 || back[1] !== 0x49 || back[2] !== 0x46) {
throw new Error("ZIP round-trip of the GIF did not match.");
}
return out;
@@ -711,29 +664,37 @@
}
function bytes(arr) { return new Uint8Array(arr); }
function u16(n) { return bytes([n & 255, (n >> 8) & 255]); }
function concat(parts) {
var n = 0, i;
for (i = 0; i < parts.length; i++) n += parts[i].length;
var out = new Uint8Array(n), o = 0;
for (i = 0; i < parts.length; i++) { out.set(parts[i], o); o += parts[i].length; }
return out;
}
function graphicControl(delayCs) {
return bytes([
0x21, 0xF9, 0x04,
0x04, // disposal = restore to background
delayCs & 255, (delayCs >> 8) & 255,
0x00,
0x00
]);
return bytes([0x21, 0xF9, 0x04, 0x08, delayCs & 255, (delayCs >> 8) & 255, 0x00, 0x00]);
}
function imageDescriptor(w, h) {
function paletteSizeFlag(n) {
var size = 2, flag = 0;
while ((1 << (flag + 1)) < n) flag++;
size = 1 << (flag + 1);
return { flag: flag, size: size };
}
function imageDescriptor(w, h, colors) {
var pf = paletteSizeFlag(colors);
return bytes([
0x2C,
0, 0, 0, 0,
0x2C, 0, 0, 0, 0,
w & 255, (w >> 8) & 255,
h & 255, (h >> 8) & 255,
0x87 // local color table, 256 colors
0x80 | pf.flag
]);
}
function paletteBytes(pal) {
var out = new Uint8Array(256 * 3);
var pf = paletteSizeFlag(pal.length);
var out = new Uint8Array(pf.size * 3);
var i;
for (i = 0; i < 256; i++) {
for (i = 0; i < pf.size; i++) {
var c = pal[i] || [0, 0, 0];
out[i * 3] = c[0];
out[i * 3 + 1] = c[1];
@@ -741,25 +702,30 @@
}
return out;
}
function lzwMin(colors) {
var n = 2;
while ((1 << n) < colors) n++;
return Math.max(2, n);
}
// Median-cut quantizer. Reads the actual frame pixels.
function quantize(imageData, maxColors) {
var data = imageData.data;
var w = imageData.width, h = imageData.height;
var pixels = [];
var step = 4; // sample every pixel; 400*400 is fine
var i;
for (i = 0; i < data.length; i += 4 * step) {
var stride = 8;
var i, x, y;
for (y = 0; y < h; y += stride) {
for (x = 0; x < w; x += stride) {
i = (y * w + x) * 4;
pixels.push([data[i], data[i + 1], data[i + 2]]);
}
}
var boxes = [pixels];
while (boxes.length < maxColors) {
var bi = 0, br = -1, b;
for (b = 0; b < boxes.length; b++) {
var range = boxRange(boxes[b]);
if (range > br && boxes[b].length > 1) {
br = range;
bi = b;
}
if (range > br && boxes[b].length > 1) { br = range; bi = b; }
}
if (br < 1) break;
var split = splitBox(boxes[bi]);
@@ -767,8 +733,7 @@
}
var palette = [];
for (b = 0; b < boxes.length; b++) palette.push(boxAverage(boxes[b]));
while (palette.length < maxColors) palette.push(palette[palette.length - 1] || [0, 0, 0]);
var index = new Uint8Array(imageData.width * imageData.height);
var index = new Uint8Array(w * h);
var px = 0;
for (i = 0; i < data.length; i += 4) {
index[px++] = nearest(palette, data[i], data[i + 1], data[i + 2]);
@@ -776,14 +741,13 @@
return { palette: palette, index: index };
}
function boxRange(px) {
var min = [255, 255, 255], max = [0, 0, 0], i, c;
var min = [255, 255, 255], max = [0, 0, 0], i, c, best = 0;
for (i = 0; i < px.length; i++) {
for (c = 0; c < 3; c++) {
if (px[i][c] < min[c]) min[c] = px[i][c];
if (px[i][c] > max[c]) max[c] = px[i][c];
}
}
var best = 0;
for (c = 0; c < 3; c++) if (max[c] - min[c] > best) best = max[c] - min[c];
return best;
}
@@ -801,19 +765,16 @@
return [px.slice(0, mid), px.slice(mid)];
}
function boxAverage(px) {
var r = 0, g = 0, b = 0, i;
var r = 0, g = 0, b = 0, i, n = px.length || 1;
for (i = 0; i < px.length; i++) { r += px[i][0]; g += px[i][1]; b += px[i][2]; }
var n = px.length || 1;
return [(r / n) | 0, (g / n) | 0, (b / n) | 0];
}
function nearest(pal, r, g, b) {
// Coarse search is enough for a looping doodle; still uses the frame's palette.
var best = 0, bestD = 1e12, i, dr, dg, db, d;
var stride = pal.length > 64 ? 1 : 1;
for (i = 0; i < pal.length; i += stride) {
for (i = 0; i < pal.length; i++) {
dr = pal[i][0] - r; dg = pal[i][1] - g; db = pal[i][2] - b;
d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = i; if (d === 0) break; }
if (d < bestD) { bestD = d; best = i; if (!d) break; }
}
return best;
}
@@ -823,48 +784,45 @@
var eoi = clear + 1;
var codeSize = minCodeSize + 1;
var nextCode = eoi + 1;
var dict = new Map();
var dict = Object.create(null);
var outBits = [];
function emit(code) {
outBits.push([code, codeSize]);
}
function emit(code) { outBits.push(code, codeSize); }
emit(clear);
var w = String.fromCharCode(index[0]);
var i, k, wk;
var wKey = index[0];
var i, k, wk, prefix;
for (i = 1; i < index.length; i++) {
k = String.fromCharCode(index[i]);
wk = w + k;
if (dict.has(wk)) {
w = wk;
k = index[i];
wk = wKey + "," + k;
if (dict[wk] !== undefined) {
wKey = wk;
} else {
emit(w.length === 1 ? w.charCodeAt(0) : dict.get(w));
prefix = wKey.indexOf(",") === -1 ? +wKey : dict[wKey];
emit(prefix);
if (nextCode < 4096) {
dict.set(wk, nextCode++);
if (nextCode > (1 << codeSize) && codeSize < 12) codeSize++;
dict[wk] = nextCode++;
if (nextCode === (1 << codeSize) && codeSize < 12) codeSize++;
} else {
emit(clear);
dict = new Map();
dict = Object.create(null);
codeSize = minCodeSize + 1;
nextCode = eoi + 1;
}
w = k;
wKey = String(k);
}
}
emit(w.length === 1 ? w.charCodeAt(0) : dict.get(w));
emit(wKey.indexOf(",") === -1 ? +wKey : dict[wKey]);
emit(eoi);
var acc = 0, nbits = 0;
var bytesOut = [];
for (i = 0; i < outBits.length; i++) {
acc |= outBits[i][0] << nbits;
nbits += outBits[i][1];
var acc = 0, nbits = 0, bytesOut = [];
for (i = 0; i < outBits.length; i += 2) {
acc |= outBits[i] << nbits;
nbits += outBits[i + 1];
while (nbits >= 8) {
bytesOut.push(acc & 255);
acc >>= 8;
acc >>>= 8;
nbits -= 8;
}
}
if (nbits) bytesOut.push(acc & 255);
// Sub-blocks, prefixed by the LZW minimum code size.
var packed = [minCodeSize];
var o = 0;
while (o < bytesOut.length) {
@@ -877,30 +835,14 @@
return new Uint8Array(packed);
}
// Real deflate of the GIF into a ZIP, then inflate it back with DecompressionStream.
// The download is the GIF itself; the round-trip is how we know the bytes are real.
function zipStore(name, data) {
if (typeof CompressionStream !== "function") {
return Promise.resolve(zipStored(name, data));
}
var cs = new CompressionStream("deflate-raw");
return new Response(new Blob([data]).stream().pipeThrough(cs)).arrayBuffer().then(function (buf) {
return zipDeflated(name, data, new Uint8Array(buf));
});
}
function dosTime(d) {
var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
return [time, date];
}
function crc32(data) {
var table = crc32.table;
if (!table) {
table = new Uint32Array(256);
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
var n, k, c;
for (n = 0; n < 256; n++) {
c = n;
for (k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
table[n] = c >>> 0;
}
crc32.table = table;
@@ -910,36 +852,34 @@
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function u32le(n) { return bytes([n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >> 24) & 255]); }
function concat(parts) {
var n = 0, i;
for (i = 0; i < parts.length; i++) n += parts[i].length;
var out = new Uint8Array(n), o = 0;
for (i = 0; i < parts.length; i++) { out.set(parts[i], o); o += parts[i].length; }
return out;
function u16at(b, o) { return b[o] | (b[o + 1] << 8); }
function u32at(b, o) { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; }
function zipDeflate(name, raw) {
if (typeof CompressionStream !== "function") {
return Promise.reject(new Error("This browser has no CompressionStream."));
}
function zipStored(name, data) {
return zipWith(name, data, data, 0);
}
function zipDeflated(name, raw, comp) {
return zipWith(name, raw, comp, 8);
var cs = new CompressionStream("deflate-raw");
return new Response(new Blob([raw]).stream().pipeThrough(cs)).arrayBuffer().then(function (buf) {
return zipWith(name, raw, new Uint8Array(buf), 8);
});
}
function zipWith(name, raw, comp, method) {
var nameB = ascii(name);
var crc = crc32(raw);
var td = dosTime(new Date());
var now = new Date();
var time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
var date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
var local = concat([
bytes([0x50, 0x4B, 0x03, 0x04]),
u16(20), u16(0), u16(method),
u16(td[0]), u16(td[1]),
u16(20), u16(0), u16(method), u16(time), u16(date),
u32le(crc), u32le(comp.length), u32le(raw.length),
u16(nameB.length), u16(0),
nameB,
comp
nameB, comp
]);
var central = concat([
bytes([0x50, 0x4B, 0x01, 0x02]),
u16(20), u16(20), u16(0), u16(method),
u16(td[0]), u16(td[1]),
u16(20), u16(20), u16(0), u16(method), u16(time), u16(date),
u32le(crc), u32le(comp.length), u32le(raw.length),
u16(nameB.length), u16(0), u16(0), u16(0), u16(0), u32le(0), u32le(0),
nameB
@@ -947,18 +887,14 @@
var eocd = concat([
bytes([0x50, 0x4B, 0x05, 0x06]),
u16(0), u16(0), u16(1), u16(1),
u32le(central.length), u32le(local.length),
u16(0)
u32le(central.length), u32le(local.length), u16(0)
]);
return concat([local, central, eocd]);
}
function u16at(b, o) { return b[o] | (b[o + 1] << 8); }
function u32at(b, o) { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; }
function unzipStored(zip) {
function unzip(zip) {
var eocd = -1, i;
for (i = zip.length - 22; i >= 0; i--) {
for (i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65535); i--) {
if (u32at(zip, i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) return Promise.reject(new Error("ZIP has no end-of-central-directory."));
@@ -966,25 +902,21 @@
if (u32at(zip, cd) !== 0x02014b50) return Promise.reject(new Error("ZIP central directory missing."));
var method = u16at(zip, cd + 10);
var compSize = u32at(zip, cd + 20);
var nameLen = u16at(zip, cd + 28);
var extraLen = u16at(zip, cd + 30);
var localOff = u32at(zip, cd + 42);
if (u32at(zip, localOff) !== 0x04034b50) return Promise.reject(new Error("ZIP local header missing."));
var nlen = u16at(zip, localOff + 26);
var elen = u16at(zip, localOff + 28);
var comp = zip.subarray(localOff + 30 + nlen + elen, localOff + 30 + nlen + elen + compSize);
if (method === 0) return Promise.resolve(comp.slice());
if (method === 8) {
if (method !== 8) return Promise.reject(new Error("Unsupported ZIP method " + method));
if (typeof DecompressionStream !== "function") {
return Promise.reject(new Error("No DecompressionStream in this browser."));
return Promise.reject(new Error("This browser has no DecompressionStream."));
}
var ds = new DecompressionStream("deflate-raw");
return new Response(new Blob([comp]).stream().pipeThrough(ds)).arrayBuffer().then(function (buf) {
return new Uint8Array(buf);
});
}
return Promise.reject(new Error("Unsupported ZIP method " + method));
}
document.addEventListener("DOMContentLoaded", function () {
var app = new DoodleloopApp();