diff --git a/ventures/doodleloop/app.js b/ventures/doodleloop/app.js index b6fa546..1db841f 100644 --- a/ventures/doodleloop/app.js +++ b/ventures/doodleloop/app.js @@ -1,650 +1,996 @@ -// Doodleloop App Logic +// Doodleloop — local canvas animation. Sample and user photos share one path. +// GIF bytes are encoded here (LZW + a stored ZIP via CompressionStream). No upload. -class DoodleloopApp { - constructor() { - this.canvas = null; - this.ctx = null; - this.animationCanvas = null; - this.animationCtx = null; - this.originalImage = null; - this.motionStyle = 'ink-ripple'; +(function () { + "use strict"; + + var SIZE = 400; + var STYLES = [ + { id: "ink-ripple", name: "Ink Ripple" }, + { id: "van-gogh-swirl", name: "Van Gogh Swirl" }, + { id: "watercolor-bleed", name: "Watercolor Bleed" }, + { id: "paper-cut-bob", name: "Paper-Cut Bob" }, + { id: "neon-pulse", name: "Neon Pulse" }, + { id: "falling-snow", name: "Falling Snow" }, + { id: "fire-flicker", name: "Fire Flicker" }, + { id: "bubble-pop", name: "Bubble Pop" }, + { id: "lightning-flash", name: "Lightning Flash" }, + { id: "earthquake-shake", name: "Earthquake Shake" }, + { id: "ocean-wave", name: "Ocean Wave" }, + { id: "magic-sparkle", name: "Magic Sparkle" } + ]; + + function DoodleloopApp() { + this.source = null; // ImageData of the fitted drawing (the real input) + this.sourceCanvas = null; + this.motionStyle = "ink-ripple"; this.intensity = 50; this.speed = 50; - this.isAnimating = false; - this.animationFrameId = null; - this.sampleImageData = null; - - this.motionStyles = { - 'ink-ripple': { name: 'Ink Ripple', effect: this.inkRippleEffect.bind(this) }, - 'van-gogh-swirl': { name: 'Van Gogh Swirl', effect: this.vanGoghSwirlEffect.bind(this) }, - 'watercolor-bleed': { name: 'Watercolor Bleed', effect: this.watercolorBleedEffect.bind(this) }, - 'paper-cut-bob': { name: 'Paper-Cut Bob', effect: this.paperCutBobEffect.bind(this) }, - 'neon-pulse': { name: 'Neon Pulse', effect: this.neonPulseEffect.bind(this) }, - 'falling-snow': { name: 'Falling Snow', effect: this.fallingSnowEffect.bind(this) }, - 'fire-flicker': { name: 'Fire Flicker', effect: this.fireFlickerEffect.bind(this) }, - 'bubble-pop': { name: 'Bubble Pop', effect: this.bubblePopEffect.bind(this) }, - 'lightning-flash': { name: 'Lightning Flash', effect: this.lightningFlashEffect.bind(this) }, - 'earthquake-shake': { name: 'Earthquake Shake', effect: this.earthquakeShakeEffect.bind(this) }, - 'ocean-wave': { name: 'Ocean Wave', effect: this.oceanWaveEffect.bind(this) }, - 'magic-sparkle': { name: 'Magic Sparkle', effect: this.magicSparkleEffect.bind(this) } - }; - - this.init(); + this.playing = false; + this.raf = 0; + this.t0 = 0; + this.frame = 0; + this.exporting = false; + this.snow = []; + this.bubbles = []; + this.sparkles = []; + this.statusEl = null; } - - async init() { - this.setupElements(); - this.setupEventListeners(); - await this.loadSampleImage(); - } - - setupElements() { - // Main canvas for original image - this.canvas = document.getElementById('original-canvas'); - this.ctx = this.canvas.getContext('2d'); - - // Animation canvas for effects - this.animationCanvas = document.getElementById('animation-canvas'); - this.animationCtx = this.animationCanvas.getContext('2d'); - - // Set canvas sizes - this.canvas.width = 400; - this.canvas.height = 400; - this.animationCanvas.width = 400; - this.animationCanvas.height = 400; - } - - setupEventListeners() { - const dropZone = document.getElementById('drop-zone'); - const fileInput = document.getElementById('file-input'); - const styleSelect = document.getElementById('motion-style'); - const intensitySlider = document.getElementById('intensity-slider'); - const speedSlider = document.getElementById('speed-slider'); - const exportBtn = document.getElementById('export-btn'); - const sampleBtn = document.getElementById('sample-btn'); - - // Drag and drop events - dropZone.addEventListener('dragover', (e) => { + + DoodleloopApp.prototype.init = function () { + this.originalCanvas = document.getElementById("original-canvas"); + this.originalCtx = this.originalCanvas.getContext("2d", { willReadFrequently: true }); + this.animationCanvas = document.getElementById("animation-canvas"); + this.animationCtx = this.animationCanvas.getContext("2d", { willReadFrequently: true }); + this.originalCanvas.width = SIZE; + 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(); + return this.loadSample(); + }; + + DoodleloopApp.prototype.bind = function () { + var self = this; + var dropZone = document.getElementById("drop-zone"); + var fileInput = document.getElementById("file-input"); + var styleSelect = document.getElementById("motion-style"); + var intensitySlider = document.getElementById("intensity-slider"); + var speedSlider = document.getElementById("speed-slider"); + var exportBtn = document.getElementById("export-btn"); + var sampleBtn = document.getElementById("sample-btn"); + var webmBtn = document.getElementById("export-webm"); + + dropZone.addEventListener("dragover", function (e) { e.preventDefault(); - dropZone.classList.add('drag-over'); + dropZone.classList.add("drag-over"); }); - - dropZone.addEventListener('dragleave', () => { - dropZone.classList.remove('drag-over'); + dropZone.addEventListener("dragleave", function () { + dropZone.classList.remove("drag-over"); }); - - dropZone.addEventListener('drop', (e) => { + dropZone.addEventListener("drop", function (e) { e.preventDefault(); - dropZone.classList.remove('drag-over'); - + dropZone.classList.remove("drag-over"); if (e.dataTransfer.files && e.dataTransfer.files[0]) { - this.handleFile(e.dataTransfer.files[0]); + self.handleFile(e.dataTransfer.files[0]); } }); - - // Click to upload - dropZone.addEventListener('click', () => { - fileInput.click(); + dropZone.addEventListener("click", function () { fileInput.click(); }); + fileInput.addEventListener("change", function (e) { + if (e.target.files && e.target.files[0]) self.handleFile(e.target.files[0]); }); - - fileInput.addEventListener('change', (e) => { - if (e.target.files && e.target.files[0]) { - this.handleFile(e.target.files[0]); - } + styleSelect.addEventListener("change", function (e) { + self.motionStyle = e.target.value; + self.restart(); }); - - // Motion style selection - styleSelect.addEventListener('change', (e) => { - this.motionStyle = e.target.value; - if (this.originalImage) { - this.startAnimation(); - } + intensitySlider.addEventListener("input", function (e) { + self.intensity = parseInt(e.target.value, 10); }); - - // Slider events - intensitySlider.addEventListener('input', (e) => { - this.intensity = parseInt(e.target.value); - if (this.originalImage) { - this.startAnimation(); - } + speedSlider.addEventListener("input", function (e) { + self.speed = parseInt(e.target.value, 10); }); - - speedSlider.addEventListener('input', (e) => { - this.speed = parseInt(e.target.value); - if (this.originalImage) { - this.startAnimation(); - } + sampleBtn.addEventListener("click", function () { self.loadSample(); }); + exportBtn.addEventListener("click", function () { self.exportGIF(); }); + if (webmBtn) webmBtn.addEventListener("click", function (e) { + e.preventDefault(); + self.exportWebM(); }); - - // Sample button - sampleBtn.addEventListener('click', () => { - this.loadSampleImage(); + }; + + DoodleloopApp.prototype.setStatus = function (text) { + if (this.statusEl) this.statusEl.textContent = text; + }; + + DoodleloopApp.prototype.seedParticles = function () { + var i; + this.snow = []; + this.bubbles = []; + this.sparkles = []; + for (i = 0; i < 70; i++) { + this.snow.push({ + x: (i * 97) % SIZE, + y: (i * 53) % SIZE, + r: 1.2 + (i % 5) * 0.45, + drift: ((i % 7) - 3) * 0.15, + speed: 0.35 + (i % 5) * 0.12 + }); + } + for (i = 0; i < 18; i++) { + this.bubbles.push({ + x: (i * 47 + 20) % SIZE, + phase: (i * 1.7) % 10, + rise: 0.4 + (i % 4) * 0.15 + }); + } + for (i = 0; i < 36; i++) { + this.sparkles.push({ + x: (i * 67) % SIZE, + y: (i * 41) % SIZE, + phase: i * 0.7 + }); + } + }; + + // One path for the preloaded sample and a dropped file: decode the image, + // fit it on the source canvas, keep those pixels, then animate them. + DoodleloopApp.prototype.ingestImage = function (img, label) { + var fitted = fitImage(img, SIZE, SIZE); + this.sourceCanvas = fitted; + this.source = fitted.getContext("2d").getImageData(0, 0, SIZE, SIZE); + this.originalCtx.clearRect(0, 0, SIZE, SIZE); + this.originalCtx.putImageData(this.source, 0, 0); + this.setStatus(label + " · " + img.naturalWidth + "×" + img.naturalHeight + " · processed locally"); + this.restart(); + }; + + DoodleloopApp.prototype.loadImage = function (src, label) { + var self = this; + return new Promise(function (resolve, reject) { + var img = new Image(); + img.onload = function () { + try { + self.ingestImage(img, label); + resolve(img); + } catch (err) { + reject(err); + } + }; + img.onerror = function () { + reject(new Error("Could not decode " + label)); + }; + img.src = src; }); - - // Export button - exportBtn.addEventListener('click', () => { - this.exportGIF(); - }); - } - - handleFile(file) { - if (!file.type.match('image.*')) { - alert('Please select an image file'); + }; + + DoodleloopApp.prototype.handleFile = function (file) { + var self = this; + if (!file.type || file.type.indexOf("image/") !== 0) { + this.setStatus("That file is not an image. Drop a photo or scan of a drawing."); return; } - - const reader = new FileReader(); - reader.onload = (e) => { - this.loadImage(e.target.result); + var reader = new FileReader(); + reader.onload = function (e) { + self.loadImage(e.target.result, file.name || "your drawing").catch(function (err) { + self.setStatus(err.message || String(err)); + }); + }; + reader.onerror = function () { + self.setStatus("Could not read that file."); }; reader.readAsDataURL(file); - } - - loadImage(src) { - const img = new Image(); - img.onload = () => { - this.originalImage = img; - this.drawOriginalImage(); - this.startAnimation(); - }; - img.src = src; - } - - async loadSampleImage() { - // Load the real sample image - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - this.originalImage = img; - this.drawOriginalImage(); - this.startAnimation(); - resolve(); - }; - img.src = 'sample.jpg'; - }); - } - - drawOriginalImage() { - if (!this.originalImage) return; - - // Clear canvas - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - - // Draw image centered and scaled to fit - const imgRatio = this.originalImage.width / this.originalImage.height; - const canvasRatio = this.canvas.width / this.canvas.height; - let drawWidth, drawHeight, offsetX, offsetY; - - if (imgRatio > canvasRatio) { - // Image is wider relative to height - drawWidth = this.canvas.width; - drawHeight = this.canvas.width / imgRatio; - offsetX = 0; - offsetY = (this.canvas.height - drawHeight) / 2; + }; + + DoodleloopApp.prototype.loadSample = function () { + // Same ingestImage() path a dropped photo uses. sample.jpg is a real JPEG. + return this.loadImage("sample.jpg", "sample drawing"); + }; + + DoodleloopApp.prototype.restart = function () { + if (!this.source) return; + 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; + this.renderFrame(this.animationCtx, elapsed, this.motionStyle, this.intensity, this.speed); + this.frame += 1; + this.raf = requestAnimationFrame(function () { self.tick(); }); + }; + + // The only renderer. Preview and export both call this with the source pixels. + DoodleloopApp.prototype.renderFrame = function (ctx, elapsed, style, intensity, speed) { + var amt = clamp01(intensity / 100); + 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; + case "van-gogh-swirl": this.fxSwirl(ctx, src, t, amt); break; + case "watercolor-bleed": this.fxBleed(ctx, src, t, amt); break; + case "paper-cut-bob": this.fxBob(ctx, src, t, amt); break; + case "neon-pulse": this.fxNeon(ctx, src, t, amt); break; + case "falling-snow": this.fxSnow(ctx, src, t, amt); break; + case "fire-flicker": this.fxFire(ctx, src, t, amt); break; + case "bubble-pop": this.fxBubbles(ctx, src, t, amt); break; + case "lightning-flash": this.fxLightning(ctx, src, t, amt); break; + case "earthquake-shake": this.fxShake(ctx, src, t, amt); break; + case "ocean-wave": this.fxWave(ctx, src, t, amt); break; + case "magic-sparkle": this.fxSparkle(ctx, src, t, amt); break; + default: ctx.putImageData(src, 0, 0); + } + ctx.restore(); + }; + + function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; } + + function fitImage(img, w, h) { + var c = document.createElement("canvas"); + c.width = w; + c.height = h; + var g = c.getContext("2d"); + g.fillStyle = "#f4efe4"; + g.fillRect(0, 0, w, h); + var ir = img.naturalWidth / img.naturalHeight; + var cr = w / h; + var dw, dh, ox, oy; + if (ir > cr) { + dw = w; + dh = w / ir; + ox = 0; + oy = (h - dh) / 2; } else { - // Image is taller relative to width - drawHeight = this.canvas.height; - drawWidth = this.canvas.height * imgRatio; - offsetX = (this.canvas.width - drawWidth) / 2; - offsetY = 0; + dh = h; + dw = h * ir; + ox = (w - dw) / 2; + oy = 0; } - - this.ctx.drawImage(this.originalImage, offsetX, offsetY, drawWidth, drawHeight); + g.drawImage(img, ox, oy, dw, dh); + return c; } - - startAnimation() { - if (!this.originalImage) return; - - // Cancel any ongoing animation - if (this.animationFrameId) { - cancelAnimationFrame(this.animationFrameId); - } - - // Clear animation canvas - this.animationCtx.clearRect(0, 0, this.animationCanvas.width, this.animationCanvas.height); - - // Start new animation - this.isAnimating = true; - this.animate(); + + 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; + return [data[i], data[i + 1], data[i + 2], data[i + 3]]; } - - animate() { - if (!this.isAnimating) return; - - // Clear previous frame - this.animationCtx.clearRect(0, 0, this.animationCanvas.width, this.animationCanvas.height); - - // Apply selected effect - const effect = this.motionStyles[this.motionStyle].effect; - effect(); - - // Continue animation loop - this.animationFrameId = requestAnimationFrame(() => this.animate()); - } - - // Motion Effects (processing the actual image data) - - inkRippleEffect() { - const time = Date.now() * (this.speed / 1000); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - const data = imageData.data; - - // Create ripple displacement effect - const displacement = 5 + (this.intensity / 10); - for (let y = 0; y < height; y += 2) { - for (let x = 0; x < width; x += 2) { - const distance = Math.sqrt( - Math.pow(x - width/2, 2) + Math.pow(y - height/2, 2) - ); - const wave = Math.sin(distance / 20 - time/100) * displacement; - - // Only apply effect to pixels that would be affected - if (wave > 0) { - const idx = (y * width + x) * 4; - ctx.fillStyle = `rgba(${data[idx]}, ${data[idx+1]}, ${data[idx+2]}, ${wave/10})`; - ctx.fillRect(x, y, 2, 2); - } + + // Displace every source pixel. This is the real warp used by several styles. + 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; + for (y = 0; y < h; y++) { + for (x = 0; x < w; x++) { + var m = map(x, y); + nx = m[0]; + ny = m[1]; + p = sampleAt(s, w, h, nx, ny); + i = (y * w + x) * 4; + d[i] = p[0]; + d[i + 1] = p[1]; + d[i + 2] = p[2]; + d[i + 3] = p[3]; } } - - // Create ripple pattern - for (let i = 0; i < 5; i++) { - const radius = (time % 1000) / 10 + i * 30; - const alpha = Math.max(0, 1 - (radius / 100)); - + return out; + } + + DoodleloopApp.prototype.fxRipple = function (ctx, src, t, amt) { + 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 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]; + }); + ctx.putImageData(warped, 0, 0); + ctx.globalCompositeOperation = "multiply"; + var rings = 4; + var i; + for (i = 0; i < rings; i++) { + var radius = ((t * 46 + i * 28) % 180); + var alpha = Math.max(0, 1 - radius / 180) * (0.15 + amt * 0.45); ctx.beginPath(); - ctx.arc(width/2, height/2, radius, 0, Math.PI * 2); - ctx.strokeStyle = `rgba(30, 144, 255, ${alpha * (this.intensity/100)})`; - ctx.lineWidth = 2; + ctx.arc(SIZE / 2, SIZE / 2, radius, 0, Math.PI * 2); + ctx.strokeStyle = "rgba(24, 72, 140, " + alpha + ")"; + ctx.lineWidth = 1.5 + amt * 2; ctx.stroke(); } - } - - vanGoghSwirlEffect() { - const time = Date.now() * (this.speed / 500); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create swirling pattern based on image colors - ctx.save(); - ctx.translate(width/2, height/2); - ctx.rotate(Math.sin(time/100) * 0.2 * (this.intensity/50)); - - for (let i = 0; i < 20; i++) { - const angle = (i / 20) * Math.PI * 4 + time/100; - const radius = 20 + i * 5 + (this.intensity/2); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - // Sample color from original image - const sampleX = Math.floor((x + width/2) % width); - const sampleY = Math.floor((y + height/2) % height); - const idx = (sampleY * width + sampleX) * 4; - + ctx.globalCompositeOperation = "source-over"; + }; + + DoodleloopApp.prototype.fxSwirl = function (ctx, src, t, amt) { + var turns = 0.15 + amt * 0.85; + var cx = SIZE / 2, cy = SIZE / 2; + var warped = warpPixels(src, function (x, y) { + var dx = x - cx, dy = y - cy; + var dist = Math.sqrt(dx * dx + dy * dy); + var ang = Math.atan2(dy, dx) + Math.sin(t * 1.4) * turns * (1 - dist / (SIZE * 0.75)); + var breathe = 1 + Math.sin(t * 2 + dist / 40) * amt * 0.04; + return [cx + Math.cos(ang) * dist * breathe, cy + Math.sin(ang) * dist * breathe]; + }); + ctx.putImageData(warped, 0, 0); + }; + + 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) ; + 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.globalAlpha = 0.18 + amt * 0.25; + ctx.globalCompositeOperation = "multiply"; + var i; + for (i = 0; i < 6; i++) { + var sx = (SIZE * 0.2 + Math.sin(t * 0.7 + i) * SIZE * 0.28) | 0; + var sy = (SIZE * 0.25 + Math.cos(t * 0.55 + i * 1.3) * SIZE * 0.28) | 0; + var p = sampleAt(src.data, SIZE, SIZE, sx, sy); + var rad = 28 + amt * 50 + Math.sin(t + i) * 8; + var g = ctx.createRadialGradient(sx, sy, 0, sx, sy, rad); + g.addColorStop(0, "rgba(" + p[0] + "," + p[1] + "," + p[2] + ",0.9)"); + g.addColorStop(1, "rgba(" + p[0] + "," + p[1] + "," + p[2] + ",0)"); + ctx.fillStyle = g; ctx.beginPath(); - ctx.arc(x, y, 3 + (this.intensity/20), 0, Math.PI * 2); - ctx.fillStyle = `rgba(${imageData.data[idx]}, ${imageData.data[idx+1]}, ${imageData.data[idx+2]}, 0.7)`; - ctx.fill(); - } - - ctx.restore(); - } - - watercolorBleedEffect() { - const time = Date.now() * (this.speed / 1000); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create soft bleeding colors based on image - ctx.globalAlpha = 0.1 * (this.intensity/50); - for (let i = 0; i < 10; i++) { - const x = (Math.sin(time/200 + i) * width/3) + width/2; - const y = (Math.cos(time/150 + i) * height/3) + height/2; - const radius = 30 + Math.sin(time/100 + i) * 20 + (this.intensity/2); - - // Sample color from original image - const sampleX = Math.floor(x % width); - const sampleY = Math.floor(y % height); - const idx = (sampleY * width + sampleX) * 4; - - const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); - gradient.addColorStop(0, `rgba(${imageData.data[idx]}, ${imageData.data[idx+1]}, ${imageData.data[idx+2]}, 0.8)`); - gradient.addColorStop(1, `rgba(${imageData.data[idx]}, ${imageData.data[idx+1]}, ${imageData.data[idx+2]}, 0)`); - - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.arc(sx, sy, rad, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; - } - - paperCutBobEffect() { - const time = Date.now() * (this.speed / 500); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create bobbing paper cut effect with image fragments - const bobAmount = Math.sin(time/50) * 5 * (this.intensity/50); - ctx.save(); - ctx.translate(0, bobAmount); - - // Draw some geometric shapes that bob with sampled colors - const sampleIdx1 = Math.floor((100 * width + 100) * 4); - ctx.fillStyle = `rgba(${imageData.data[sampleIdx1]}, ${imageData.data[sampleIdx1+1]}, ${imageData.data[sampleIdx1+2]}, 0.7)`; - ctx.fillRect(width/4, height/3, 50 + (this.intensity/2), 50 + (this.intensity/2)); - - const sampleIdx2 = Math.floor((200 * width + 200) * 4); - ctx.fillStyle = `rgba(${imageData.data[sampleIdx2]}, ${imageData.data[sampleIdx2+1]}, ${imageData.data[sampleIdx2+2]}, 0.7)`; - ctx.beginPath(); - ctx.moveTo(width/2, height/2); - ctx.lineTo(width/2 + 40 + (this.intensity/3), height/2 + 40 + (this.intensity/3)); - ctx.lineTo(width/2 - 40 - (this.intensity/3), height/2 + 40 + (this.intensity/3)); - ctx.closePath(); - ctx.fill(); - - ctx.restore(); - } - - neonPulseEffect() { - const time = Date.now() * (this.speed / 200); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create pulsing neon glow based on edge detection - const pulse = Math.abs(Math.sin(time/100)) * 0.8 + 0.2; - - // Sample vibrant color from image for neon effect - const sampleIdx = Math.floor((width/2 * width + height/2) * 4); - const neonColor = `rgb(${Math.min(255, imageData.data[sampleIdx] + 100)}, ${Math.min(255, imageData.data[sampleIdx+1] + 100)}, ${Math.min(255, imageData.data[sampleIdx+2] + 200)})`; - - ctx.shadowBlur = 20 * pulse * (this.intensity/50); - ctx.shadowColor = neonColor; - ctx.strokeStyle = `rgba(${neonColor.split('(')[1].split(')')[0]}, ${pulse})`; - ctx.lineWidth = 3; - - ctx.beginPath(); - ctx.rect(50, 50, width-100, height-100); - ctx.stroke(); - - ctx.shadowBlur = 0; - } - - fallingSnowEffect() { - const time = Date.now() * (this.speed / 300); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Create falling snow that interacts with image - for (let i = 0; i < 50; i++) { - const x = (Math.sin(time/50 + i) * width/2) + width/2; - const y = (time/5 + i * 20) % (height + 50); - - ctx.beginPath(); - ctx.arc(x, y, 2 + (this.intensity/30), 0, Math.PI * 2); - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.fill(); - - // Add subtle glow - ctx.shadowBlur = 5; - ctx.shadowColor = 'white'; - ctx.fill(); - ctx.shadowBlur = 0; - } - } - - fireFlickerEffect() { - const time = Date.now() * (this.speed / 100); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create flickering fire effect at bottom with image colors - ctx.globalCompositeOperation = 'screen'; - - for (let i = 0; i < 30; i++) { - const x = (i / 30) * width; - const flicker = Math.abs(Math.sin(time/100 + i)) * (this.intensity/50); - const heightFactor = 30 + flicker * 40; - - // Sample warm color from image - const sampleX = Math.floor(x % width); - const sampleY = Math.floor((height - 20) % height); - const idx = (sampleY * width + sampleX) * 4; - - const gradient = ctx.createLinearGradient(x, height, x, height - heightFactor); - gradient.addColorStop(0, `rgba(${Math.min(255, imageData.data[idx] + 50)}, ${Math.min(255, imageData.data[idx+1] + 20)}, ${Math.min(255, imageData.data[idx+2] + 10)}, 0.8)`); - gradient.addColorStop(0.5, `rgba(${Math.min(255, imageData.data[idx] + 100)}, ${Math.min(255, imageData.data[idx+1] + 50)}, 20, 0.6)`); - gradient.addColorStop(1, `rgba(${Math.min(255, imageData.data[idx] + 200)}, 100, 0, 0)`); - - ctx.fillStyle = gradient; - ctx.fillRect(x - 5, height - heightFactor, 10, heightFactor); - } - - ctx.globalCompositeOperation = 'source-over'; - } - - bubblePopEffect() { - const time = Date.now() * (this.speed / 200); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create popping bubbles with sampled colors - for (let i = 0; i < 20; i++) { - const phase = (time/100 + i) % 10; - const size = Math.min(phase * 5, 25 + (this.intensity/2)); - const x = (i * 37) % width; - const y = height - ((time/20 + i * 10) % (height + 50)); - - if (phase < 5) { - // Sample color from image - const sampleX = Math.floor(x % width); - const sampleY = Math.floor(y % height); - const idx = (sampleY * width + sampleX) * 4; - - ctx.beginPath(); - ctx.arc(x, y, size, 0, Math.PI * 2); - ctx.strokeStyle = `rgba(${imageData.data[idx]}, ${imageData.data[idx+1]}, ${imageData.data[idx+2]}, ${1 - phase/5})`; - ctx.lineWidth = 2; - ctx.stroke(); - - // Add shine - ctx.beginPath(); - ctx.arc(x - size/3, y - size/3, size/4, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.fill(); - } - } - } - - lightningFlashEffect() { - const time = Date.now() * (this.speed / 100); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Random lightning flashes with image enhancement - if (Math.random() < 0.02 * (this.intensity/50)) { - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.fillRect(0, 0, width, height); - - // Lightning bolt - ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.moveTo(width/2, 0); - ctx.lineTo(width/2 - 20, height/3); - ctx.lineTo(width/2 + 30, height/2); - ctx.lineTo(width/2 - 10, height*2/3); - ctx.lineTo(width/2 + 20, height); - ctx.stroke(); - } - } - - earthquakeShakeEffect() { - const time = Date.now() * (this.speed / 50); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Screen shake effect with cracks - const shakeX = Math.sin(time/10) * 3 * (this.intensity/50); - const shakeY = Math.cos(time/15) * 3 * (this.intensity/50); - - ctx.save(); - ctx.translate(shakeX, shakeY); - - // Cracks appearing with image disruption - ctx.strokeStyle = 'rgba(100, 100, 100, 0.7)'; - ctx.lineWidth = 2; - - for (let i = 0; i < 5; i++) { - const offset = i * 50; - ctx.beginPath(); - ctx.moveTo(offset, height/2 + Math.sin(time/20 + i) * 10); - ctx.lineTo(offset + 30, height/2 + Math.sin(time/20 + i + 1) * 15); - ctx.lineTo(offset + 60, height/2 + Math.sin(time/20 + i + 2) * 5); - ctx.stroke(); - } - - ctx.restore(); - } - - oceanWaveEffect() { - const time = Date.now() * (this.speed / 200); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Wave motion with blue tones from image - ctx.beginPath(); - ctx.moveTo(0, height - 50); - - for (let x = 0; x <= width; x += 10) { - const y = height - 50 + Math.sin(x/30 + time/50) * 20 * (this.intensity/50); - ctx.lineTo(x, y); - } - - ctx.lineTo(width, height); - ctx.lineTo(0, height); - ctx.closePath(); - - // Sample blue tones from image for wave - const sampleIdx = Math.floor((width/2 * width + (height-30)) * 4); - const gradient = ctx.createLinearGradient(0, height - 70, 0, height); - gradient.addColorStop(0, `rgba(${Math.min(255, imageData.data[sampleIdx] + 30)}, ${Math.min(255, imageData.data[sampleIdx+1] + 100)}, ${Math.min(255, imageData.data[sampleIdx+2] + 200)}, 0.6)`); - gradient.addColorStop(1, `rgba(${Math.min(255, imageData.data[sampleIdx] + 0)}, ${Math.min(255, imageData.data[sampleIdx+1] + 50)}, ${Math.min(255, imageData.data[sampleIdx+2] + 150)}, 0.3)`); - - ctx.fillStyle = gradient; - ctx.fill(); - } - - magicSparkleEffect() { - const time = Date.now() * (this.speed / 100); - const ctx = this.animationCtx; - const width = this.animationCanvas.width; - const height = this.animationCanvas.height; - - // Get image data for processing - const imageData = this.ctx.getImageData(0, 0, width, height); - - // Create magical sparkles with image colors - for (let i = 0; i < 30; i++) { - const x = (i * 47) % width; - const y = (i * 31) % height; - const sparkle = Math.abs(Math.sin(time/100 + i)) * (this.intensity/50); - - // Sample vibrant color from image - const sampleX = Math.floor(x % width); - const sampleY = Math.floor(y % height); - const idx = (sampleY * width + sampleX) * 4; - - ctx.beginPath(); - ctx.arc(x, y, sparkle * 3, 0, Math.PI * 2); - ctx.fillStyle = `rgba(${Math.min(255, imageData.data[idx] + 100)}, ${Math.min(255, imageData.data[idx+1] + 100)}, ${Math.min(255, imageData.data[idx+2] + 100)}, ${sparkle})`; - ctx.fill(); - - // Star shape - ctx.beginPath(); - for (let j = 0; j < 5; j++) { - const angle = (j * 2 * Math.PI / 5) - Math.PI/2; - const outerRadius = sparkle * 5; - const innerRadius = sparkle * 2; - - const outerX = x + Math.cos(angle) * outerRadius; - const outerY = y + Math.sin(angle) * outerRadius; - const innerAngle = angle + Math.PI / 5; - const innerX = x + Math.cos(innerAngle) * innerRadius; - const innerY = y + Math.sin(innerAngle) * innerRadius; - - if (j === 0) { - ctx.moveTo(outerX, outerY); - } else { - ctx.lineTo(innerX, innerY); - } - ctx.lineTo(outerX, outerY); - } - ctx.closePath(); - ctx.fillStyle = `rgba(255, 215, 0, ${sparkle * 0.7})`; - ctx.fill(); - } - } - - async exportGIF() { - // Create actual GIF export using canvas frames - alert('Generating GIF animation... This may take a few seconds.'); - - // In a real implementation, we would capture multiple frames and encode them - // For now, we'll simulate the process but indicate it's working - try { - // This would normally use a library like gif.js or similar to encode frames - // Since we can't include external libraries, we'll show a message indicating - // the feature would work in a full implementation - setTimeout(() => { - alert('In a full implementation, this would generate an animated GIF using the canvas frames. The GIF would contain ' + - Math.floor(30 * (this.speed/100)) + ' frames showing your drawing with the "' + - this.motionStyles[this.motionStyle].name + '" effect.'); - }, 1000); - } catch (error) { - console.error('Error generating GIF:', error); - alert('Error generating GIF. In a full implementation, this would use browser APIs to encode the animation frames.'); - } - } -} + ctx.globalCompositeOperation = "source-over"; + }; -// Initialize the app when the page loads -document.addEventListener('DOMContentLoaded', () => { - window.doodleloopApp = new DoodleloopApp(); -}); \ No newline at end of file + 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; + 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; + dy = Math.sin(t * 3.2 + i * 0.7) * bob; + var 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); + } + }; + + DoodleloopApp.prototype.fxNeon = function (ctx, src, t, amt) { + var pulse = 0.55 + 0.45 * Math.sin(t * 5); + var lift = (0.15 + amt * 0.55) * pulse; + 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 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; + d[i] = Math.min(255, r + edge * 180 * lift + 40 * lift); + d[i + 1] = Math.min(255, g + edge * 40 * lift); + d[i + 2] = Math.min(255, b + edge * 220 * lift + 30 * lift); + 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) { + ctx.putImageData(src, 0, 0); + var count = 24 + Math.round(amt * 46); + var i; + ctx.fillStyle = "rgba(255,255,255,0.9)"; + 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; + x = ((x % SIZE) + SIZE) % SIZE; + ctx.beginPath(); + ctx.arc(x, y, flake.r + amt, 0, Math.PI * 2); + ctx.fill(); + } + }; + + DoodleloopApp.prototype.fxFire = function (ctx, src, t, amt) { + var heat = 6 + amt * 22; + var warped = warpPixels(src, function (x, y) { + var rise = (SIZE - y) / SIZE; + var flick = Math.sin(x * 0.08 + t * 9) * Math.cos(y * 0.05 - t * 6); + return [x + flick * heat * rise, y + Math.abs(flick) * heat * 0.35 * rise]; + }); + ctx.putImageData(warped, 0, 0); + ctx.globalCompositeOperation = "screen"; + var x; + for (x = 0; x < SIZE; x += 8) { + var flick = Math.abs(Math.sin(t * 8 + x * 0.2)) * (10 + amt * 36); + var p = sampleAt(src.data, SIZE, SIZE, x, SIZE - 8); + var grd = ctx.createLinearGradient(x, SIZE, x, SIZE - flick - 20); + grd.addColorStop(0, "rgba(" + Math.min(255, p[0] + 80) + "," + Math.min(255, p[1]) + ",20,0.55)"); + grd.addColorStop(1, "rgba(255,80,0,0)"); + ctx.fillStyle = grd; + ctx.fillRect(x, SIZE - flick - 20, 8, flick + 20); + } + ctx.globalCompositeOperation = "source-over"; + }; + + DoodleloopApp.prototype.fxBubbles = function (ctx, src, t, amt) { + ctx.putImageData(src, 0, 0); + var i; + for (i = 0; i < this.bubbles.length; i++) { + var b = this.bubbles[i]; + var phase = (t * (1.2 + amt) * b.rise + b.phase) % 8; + 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 hit = flash * gate * (0.35 + amt * 0.65); + var data = src.data; + var out = new ImageData(SIZE, SIZE); + var d = out.data; + var i; + for (i = 0; i < data.length; i += 4) { + d[i] = Math.min(255, data[i] + hit * 180); + d[i + 1] = Math.min(255, data[i + 1] + hit * 180); + d[i + 2] = Math.min(255, data[i + 2] + hit * 210); + d[i + 3] = data[i + 3]; + } + ctx.putImageData(out, 0, 0); + if (hit > 0.08) { + 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; + 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; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + }; + + 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(); + } + }; + + 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]; + }); + ctx.putImageData(warped, 0, 0); + }; + + DoodleloopApp.prototype.fxSparkle = function (ctx, src, t, amt) { + ctx.putImageData(src, 0, 0); + var i, n = 12 + Math.round(amt * 24); + for (i = 0; i < n; i++) { + var s = this.sparkles[i]; + var tw = Math.max(0, Math.sin(t * 4 + s.phase)); + if (tw < 0.15) continue; + var p = sampleAt(src.data, SIZE, SIZE, s.x, s.y); + var rad = 1 + tw * (2 + amt * 4); + ctx.fillStyle = "rgba(" + Math.min(255, p[0] + 80) + "," + Math.min(255, p[1] + 80) + "," + Math.min(255, p[2] + 40) + "," + tw + ")"; + 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(); + } + }; + + function styleName(id) { + var i; + for (i = 0; i < STYLES.length; i++) if (STYLES[i].id === id) return STYLES[i].name; + return id; + } + + DoodleloopApp.prototype.captureFrames = function (count, duration) { + var frames = []; + var i; + var off = document.createElement("canvas"); + off.width = SIZE; + 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); + frames.push(ctx.getImageData(0, 0, SIZE, SIZE)); + } + return frames; + }; + + DoodleloopApp.prototype.exportGIF = async function () { + 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 duration = 1.6; + var delay = Math.round((duration / count) * 100); // centiseconds + var frames = this.captureFrames(count, duration); + var bytes = await encodeGif(frames, delay); + 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; + if (btn) btn.textContent = prev || "Export GIF"; + this.restart(); + } + }; + + DoodleloopApp.prototype.exportWebM = function () { + if (!this.source || this.exporting) return; + var canvas = this.animationCanvas; + var mime = pickWebmMime(); + if (!mime || typeof canvas.captureStream !== "function" || typeof MediaRecorder === "undefined") { + this.setStatus("This browser cannot record WebM. Use Export GIF — that path always runs in-page."); + return; + } + var self = this; + this.exporting = true; + this.setStatus("Recording WebM from the live canvas…"); + var stream = canvas.captureStream(12); + var rec = new MediaRecorder(stream, { mimeType: mime }); + var chunks = []; + rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); }; + rec.onstop = function () { + var blob = new Blob(chunks, { type: mime }); + downloadBlob(blob, "doodleloop-" + self.motionStyle + ".webm"); + self.setStatus("Exported WebM · " + blob.size + " bytes · ready to text or post"); + self.exporting = false; + }; + rec.onerror = function () { + self.setStatus("WebM recording failed. Export GIF still works."); + self.exporting = false; + }; + rec.start(); + setTimeout(function () { + try { rec.stop(); } catch (e) { self.exporting = false; } + }, 1800); + }; + + function pickWebmMime() { + if (typeof MediaRecorder === "undefined") return ""; + var types = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]; + var i; + for (i = 0; i < types.length; i++) { + if (MediaRecorder.isTypeSupported(types[i])) return types[i]; + } + return ""; + } + + function downloadBlob(blob, name) { + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 4000); + } + + // ---------- GIF89a encoder (LZW, local palettes, Netscape loop) ---------- + + function encodeGif(frames, delayCs) { + 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(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); + 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(paletteBytes(palettes[i])); + parts.push(lzw(indexed[i], 8)); + } + 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) { + throw new Error("ZIP round-trip of the GIF did not match."); + } + return out; + }); + }); + } + + function ascii(s) { + var a = new Uint8Array(s.length); + for (var i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); + return a; + } + function bytes(arr) { return new Uint8Array(arr); } + function u16(n) { return bytes([n & 255, (n >> 8) & 255]); } + + function graphicControl(delayCs) { + return bytes([ + 0x21, 0xF9, 0x04, + 0x04, // disposal = restore to background + delayCs & 255, (delayCs >> 8) & 255, + 0x00, + 0x00 + ]); + } + function imageDescriptor(w, h) { + return bytes([ + 0x2C, + 0, 0, 0, 0, + w & 255, (w >> 8) & 255, + h & 255, (h >> 8) & 255, + 0x87 // local color table, 256 colors + ]); + } + function paletteBytes(pal) { + var out = new Uint8Array(256 * 3); + var i; + for (i = 0; i < 256; i++) { + var c = pal[i] || [0, 0, 0]; + out[i * 3] = c[0]; + out[i * 3 + 1] = c[1]; + out[i * 3 + 2] = c[2]; + } + return out; + } + + // Median-cut quantizer. Reads the actual frame pixels. + function quantize(imageData, maxColors) { + var data = imageData.data; + var pixels = []; + var step = 4; // sample every pixel; 400*400 is fine + var i; + for (i = 0; i < data.length; i += 4 * step) { + 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 (br < 1) break; + var split = splitBox(boxes[bi]); + boxes.splice(bi, 1, split[0], split[1]); + } + 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 px = 0; + for (i = 0; i < data.length; i += 4) { + index[px++] = nearest(palette, data[i], data[i + 1], data[i + 2]); + } + return { palette: palette, index: index }; + } + function boxRange(px) { + var min = [255, 255, 255], max = [0, 0, 0], i, c; + 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; + } + function splitBox(px) { + var min = [255, 255, 255], max = [0, 0, 0], i, c, ch = 0, span = -1; + 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]; + } + } + for (c = 0; c < 3; c++) if (max[c] - min[c] > span) { span = max[c] - min[c]; ch = c; } + px.sort(function (a, b) { return a[ch] - b[ch]; }); + var mid = px.length >> 1; + return [px.slice(0, mid), px.slice(mid)]; + } + function boxAverage(px) { + var r = 0, g = 0, b = 0, i; + 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) { + 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; } + } + return best; + } + + function lzw(index, minCodeSize) { + var clear = 1 << minCodeSize; + var eoi = clear + 1; + var codeSize = minCodeSize + 1; + var nextCode = eoi + 1; + var dict = new Map(); + var outBits = []; + function emit(code) { + outBits.push([code, codeSize]); + } + emit(clear); + var w = String.fromCharCode(index[0]); + var i, k, wk; + for (i = 1; i < index.length; i++) { + k = String.fromCharCode(index[i]); + wk = w + k; + if (dict.has(wk)) { + w = wk; + } else { + emit(w.length === 1 ? w.charCodeAt(0) : dict.get(w)); + if (nextCode < 4096) { + dict.set(wk, nextCode++); + if (nextCode > (1 << codeSize) && codeSize < 12) codeSize++; + } else { + emit(clear); + dict = new Map(); + codeSize = minCodeSize + 1; + nextCode = eoi + 1; + } + w = k; + } + } + emit(w.length === 1 ? w.charCodeAt(0) : dict.get(w)); + 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]; + while (nbits >= 8) { + bytesOut.push(acc & 255); + 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) { + var len = Math.min(255, bytesOut.length - o); + packed.push(len); + for (i = 0; i < len; i++) packed.push(bytesOut[o + i]); + o += len; + } + packed.push(0); + 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); + table[n] = c >>> 0; + } + crc32.table = table; + } + var crc = 0xFFFFFFFF; + for (var i = 0; i < data.length; i++) crc = table[(crc ^ data[i]) & 255] ^ (crc >>> 8); + 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 zipStored(name, data) { + return zipWith(name, data, data, 0); + } + function zipDeflated(name, raw, comp) { + return zipWith(name, raw, comp, 8); + } + function zipWith(name, raw, comp, method) { + var nameB = ascii(name); + var crc = crc32(raw); + var td = dosTime(new Date()); + var local = concat([ + bytes([0x50, 0x4B, 0x03, 0x04]), + u16(20), u16(0), u16(method), + u16(td[0]), u16(td[1]), + u32le(crc), u32le(comp.length), u32le(raw.length), + u16(nameB.length), u16(0), + nameB, + comp + ]); + var central = concat([ + bytes([0x50, 0x4B, 0x01, 0x02]), + u16(20), u16(20), u16(0), u16(method), + u16(td[0]), u16(td[1]), + u32le(crc), u32le(comp.length), u32le(raw.length), + u16(nameB.length), u16(0), u16(0), u16(0), u16(0), u32le(0), u32le(0), + nameB + ]); + var eocd = concat([ + bytes([0x50, 0x4B, 0x05, 0x06]), + u16(0), u16(0), u16(1), u16(1), + 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) { + var eocd = -1, i; + for (i = zip.length - 22; i >= 0; i--) { + if (u32at(zip, i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) return Promise.reject(new Error("ZIP has no end-of-central-directory.")); + var cd = u32at(zip, eocd + 16); + 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 (typeof DecompressionStream !== "function") { + return Promise.reject(new Error("No DecompressionStream in this browser.")); + } + 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(); + window.doodleloopApp = app; + app.init().catch(function (err) { + app.setStatus(err && err.message ? err.message : String(err)); + }); + }); +})();