diff --git a/.vscode/settings.json b/.vscode/settings.json index fe50924..a2b0ccb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,19 @@ "peacock.remoteColor": "aa11aa", "deno.enable": true, "deno.unstable": true, - "liveServer.settings.port": 5501 + "liveServer.settings.port": 5501, + "cSpell.words": [ + "BGRA", + "blitting", + "dpng", + "idat", + "iend", + "ihdr", + "imgscr", + "Namee", + "NMAX", + "omggif's", + "plte", + "trns" + ] } \ No newline at end of file diff --git a/animation/gif.ts b/animation/gif.ts new file mode 100644 index 0000000..9684e4e --- /dev/null +++ b/animation/gif.ts @@ -0,0 +1,63 @@ +import { Vector } from "../geometry/vector.ts"; +import { Frame, handleGIF } from "../processing/gif.ts"; + +type frame = { canvas: HTMLCanvasElement } & Frame; +export class GIFAnimation { + frames: frame[] = []; + canvas: HTMLCanvasElement; + ctx!: CanvasRenderingContext2D; + ready = false; + + constructor( + url: string, + private origin: Vector, + private scale = 1, + ) { + this.canvas = document.createElement("canvas"); + + this.init(url); + } + + async init(url: string) { + const res = await fetch(url); + const buf = new Uint8Array(await res.arrayBuffer()); + const gif = handleGIF(buf); + this.frames = gif.frames; + this.frameTimes = this.frames.map((f) => f.delay); + this.totalAnimationTime = this.frameTimes.reduce( + (a, b) => a + b, + 0, + ); + + this.canvas.width = gif.w; + this.canvas.height = gif.h; + this.ctx = this.canvas.getContext("2d")!; + this.ready = true; + } + + frameTimes!: number[]; + totalAnimationTime = 0; + _frameCounter = 0; + currentFrameIndex = 0; + + draw(timeSinceLastFrame: number) { + if (!this.ready) return; + this._frameCounter += timeSinceLastFrame; + + const currentFrameDelay = this.frames[this.currentFrameIndex].delay * 10; + + while (this._frameCounter >= currentFrameDelay) { + this._frameCounter -= currentFrameDelay; + this.currentFrameIndex = (this.currentFrameIndex + 1) % + this.frames.length; + } + + const currentFrame = this.frames[this.currentFrameIndex]; + doodler.drawImage( + currentFrame.canvas, + this.origin, + this.canvas.width * this.scale, + this.canvas.height * this.scale, + ); + } +} diff --git a/animation/sprite.ts b/animation/sprite.ts index 79e65ad..3595004 100644 --- a/animation/sprite.ts +++ b/animation/sprite.ts @@ -9,8 +9,8 @@ export class SpriteAnimation { private cellHeight: number, private cellCountX: number, private cellCountY: number, - private timing = 1, - private scale = 1, + public timing = 1, + public scale = 1, ) { this.image = new Image(); this.image.src = this.imageUrl; diff --git a/bundle.js b/bundle.js index 89ed87e..9e3505b 100644 --- a/bundle.js +++ b/bundle.js @@ -2,6 +2,404 @@ // deno-lint-ignore-file // This code was bundled using `deno bundle` and it's not recommended to edit it manually +class GifReader { + buf; + p; + width; + height; + globalPaletteOffset; + globalPaletteSize; + frames; + loopCountValue; + constructor(buf){ + this.buf = buf; + this.p = 0; + this.width = 0; + this.height = 0; + this.globalPaletteOffset = null; + this.globalPaletteSize = null; + this.frames = []; + this.loopCountValue = null; + this.parseHeader(); + this.parseFrames(); + } + numFrames() { + return this.frames.length; + } + loopCount() { + return this.loopCountValue; + } + frameInfo(frameNum) { + if (frameNum < 0 || frameNum >= this.frames.length) { + throw new Error("Frame index out of range."); + } + return this.frames[frameNum]; + } + decodeAndBlitFrameBGRA(frameNum, pixels) { + const frame = this.frameInfo(frameNum); + const numPixels = frame.width * frame.height; + const indexStream = new Uint8Array(numPixels); + GifReaderLZWOutputIndexStream(this.buf, frame.dataOffset, indexStream, numPixels); + const paletteOffset = frame.paletteOffset; + let trans = frame.transparentIndex; + if (trans === null) trans = 256; + const frameWidth = frame.width; + const frameStride = this.width - frameWidth; + let xLeft = frameWidth; + const opBeg = (frame.y * this.width + frame.x) * 4; + const opEnd = ((frame.y + frame.height) * this.width + frame.x) * 4; + let op = opBeg; + let scanStride = frameStride * 4; + if (frame.interlaced === true) { + scanStride += this.width * 4 * 7; + } + let interlaceSkip = 8; + for(let i = 0, il = indexStream.length; i < il; ++i){ + const index = indexStream[i]; + if (xLeft === 0) { + op += scanStride; + xLeft = frameWidth; + if (op >= opEnd) { + scanStride = frameStride * 4 + this.width * 4 * (interlaceSkip - 1); + op = opBeg + (frameWidth + frameStride) * (interlaceSkip << 1); + interlaceSkip >>= 1; + } + } + if (index === trans) { + op += 4; + } else { + const r = this.buf[(paletteOffset || 0) + index * 3]; + const g = this.buf[(paletteOffset || 0) + index * 3 + 1]; + const b = this.buf[(paletteOffset || 0) + index * 3 + 2]; + pixels[op++] = b; + pixels[op++] = g; + pixels[op++] = r; + pixels[op++] = 255; + } + --xLeft; + } + } + decodeAndBlitFrameRGBA(frameNum, pixels) { + const frame = this.frameInfo(frameNum); + const numPixels = frame.width * frame.height; + const indexStream = new Uint8Array(numPixels); + GifReaderLZWOutputIndexStream(this.buf, frame.dataOffset, indexStream, numPixels); + const paletteOffset = frame.paletteOffset; + let trans = frame.transparentIndex; + if (trans === null) trans = 256; + const frameWidth = frame.width; + const frameStride = this.width - frameWidth; + let xLeft = frameWidth; + const opBeg = (frame.y * this.width + frame.x) * 4; + const opEnd = ((frame.y + frame.height) * this.width + frame.x) * 4; + let op = opBeg; + let scanStride = frameStride * 4; + if (frame.interlaced === true) { + scanStride += this.width * 4 * 7; + } + let interlaceSkip = 8; + for(let i = 0, il = indexStream.length; i < il; ++i){ + const index = indexStream[i]; + if (xLeft === 0) { + op += scanStride; + xLeft = frameWidth; + if (op >= opEnd) { + scanStride = frameStride * 4 + this.width * 4 * (interlaceSkip - 1); + op = opBeg + (frameWidth + frameStride) * (interlaceSkip << 1); + interlaceSkip >>= 1; + } + } + if (index === trans) { + op += 4; + } else { + const rI = (paletteOffset || 0) + index * 3; + const r = this.buf[rI]; + const g = this.buf[rI + 1]; + const b = this.buf[rI + 2]; + pixels[op++] = r; + pixels[op++] = g; + pixels[op++] = b; + pixels[op++] = 255; + } + --xLeft; + } + } + parseHeader() { + if (this.buf[this.p++] !== 0x47 || this.buf[this.p++] !== 0x49 || this.buf[this.p++] !== 0x46 || this.buf[this.p++] !== 0x38 || (this.buf[this.p++] + 1 & 0xfd) !== 0x38 || this.buf[this.p++] !== 0x61) { + throw new Error("Invalid GIF 87a/89a header."); + } + } + parseLogicalScreenDescriptor() {} + parseGlobalColorTable() {} + parseFrames() { + const width = this.buf[this.p++] | this.buf[this.p++] << 8; + const height = this.buf[this.p++] | this.buf[this.p++] << 8; + const pf0 = this.buf[this.p++]; + const global_palette_flag = pf0 >> 7; + const num_global_colors_pow2 = pf0 & 0x7; + const num_global_colors = 1 << num_global_colors_pow2 + 1; + this.buf[this.p++]; + this.buf[this.p++]; + let global_palette_offset = null; + let global_palette_size = null; + if (global_palette_flag) { + global_palette_offset = this.p; + global_palette_size = num_global_colors; + this.p += num_global_colors * 3; + } + let no_eof = true; + let delay = 0; + let transparentIndex = null; + let disposal = 0; + let loopCount = null; + this.width = width; + this.height = height; + while(no_eof && this.p < this.buf.length){ + switch(this.buf[this.p++]){ + case 0x21: + switch(this.buf[this.p++]){ + case 0xff: + if (this.buf[this.p] !== 0x0b || this.buf[this.p + 1] == 0x4e && this.buf[this.p + 2] == 0x45 && this.buf[this.p + 3] == 0x54 && this.buf[this.p + 4] == 0x53 && this.buf[this.p + 5] == 0x43 && this.buf[this.p + 6] == 0x41 && this.buf[this.p + 7] == 0x50 && this.buf[this.p + 8] == 0x45 && this.buf[this.p + 9] == 0x32 && this.buf[this.p + 10] == 0x2e && this.buf[this.p + 11] == 0x30 && this.buf[this.p + 12] == 0x03 && this.buf[this.p + 13] == 0x01 && this.buf[this.p + 16] == 0) { + this.p += 14; + loopCount = this.buf[this.p++] | this.buf[this.p++] << 8; + this.p++; + } else { + this.p += 12; + while(true){ + const block_size = this.buf[this.p++]; + if (!(block_size >= 0)) throw Error("Invalid block size"); + if (block_size === 0) break; + this.p += block_size; + } + } + break; + case 0xf9: + { + if (this.buf[this.p++] !== 0x4 || this.buf[this.p + 4] !== 0) { + throw new Error("Invalid graphics extension block."); + } + const pf1 = this.buf[this.p++]; + delay = this.buf[this.p++] | this.buf[this.p++] << 8; + transparentIndex = this.buf[this.p++]; + if ((pf1 & 1) === 0) transparentIndex = null; + disposal = pf1 >> 2 & 0x7; + this.p++; + break; + } + case 0x01: + case 0xfe: + while(true){ + const block_size = this.buf[this.p++]; + if (!(block_size >= 0)) throw Error("Invalid block size"); + if (block_size === 0) break; + this.p += block_size; + } + break; + default: + throw new Error("Unknown graphic control label: 0x" + this.buf[this.p - 1].toString(16)); + } + break; + case 0x2c: + { + const x = this.buf[this.p++] | this.buf[this.p++] << 8; + const y = this.buf[this.p++] | this.buf[this.p++] << 8; + const w = this.buf[this.p++] | this.buf[this.p++] << 8; + const h = this.buf[this.p++] | this.buf[this.p++] << 8; + const pf2 = this.buf[this.p++]; + const local_palette_flag = pf2 >> 7; + const interlace_flag = pf2 >> 6 & 1; + const num_local_colors_pow2 = pf2 & 0x7; + const num_local_colors = 1 << num_local_colors_pow2 + 1; + let palette_offset = global_palette_offset; + let palette_size = global_palette_size; + let has_local_palette = false; + if (local_palette_flag) { + has_local_palette = true; + palette_offset = this.p; + palette_size = num_local_colors; + this.p += num_local_colors * 3; + } + const data_offset = this.p; + this.p++; + while(true){ + const block_size = this.buf[this.p++]; + if (!(block_size >= 0)) throw Error("Invalid block size"); + if (block_size === 0) break; + this.p += block_size; + } + this.frames.push({ + x, + y, + width: w, + height: h, + hasLocalPalette: has_local_palette, + paletteOffset: palette_offset, + paletteSize: palette_size, + dataOffset: data_offset, + dataLength: this.p - data_offset, + transparentIndex: transparentIndex, + interlaced: !!interlace_flag, + delay: delay, + disposal: disposal + }); + break; + } + case 0x3b: + no_eof = false; + break; + default: + throw new Error("Unknown gif block: 0x" + this.buf[this.p - 1].toString(16)); + } + } + } +} +function GifReaderLZWOutputIndexStream(codeStream, p, output, outputLength) { + const minCodeSize = codeStream[p++]; + const clear_code = 1 << minCodeSize; + const eoi_code = clear_code + 1; + let nextCode = eoi_code + 1; + let curCodeSize = minCodeSize + 1; + let codeMask = (1 << curCodeSize) - 1; + let curShift = 0; + let cur = 0; + let op = 0; + let subBlockSize = codeStream[p++]; + const codeTable = new Int32Array(4096); + let prevCode = null; + while(true){ + while(curShift < 16){ + if (subBlockSize === 0) break; + cur |= codeStream[p++] << curShift; + curShift += 8; + if (subBlockSize === 1) { + subBlockSize = codeStream[p++]; + } else { + --subBlockSize; + } + } + if (curShift < curCodeSize) { + break; + } + const code = cur & codeMask; + cur >>= curCodeSize; + curShift -= curCodeSize; + if (code === clear_code) { + nextCode = eoi_code + 1; + curCodeSize = minCodeSize + 1; + codeMask = (1 << curCodeSize) - 1; + prevCode = null; + continue; + } else if (code === eoi_code) { + break; + } + const chaseCode = code < nextCode ? code : prevCode; + let chaseLength = 0; + let chase = chaseCode; + while(chase > clear_code){ + chase = codeTable[chase] >> 8; + ++chaseLength; + } + const k = chase; + const op_end = op + chaseLength + (chaseCode !== code ? 1 : 0); + if (op_end > outputLength) { + console.log("Warning, gif stream longer than expected."); + return; + } + output[op++] = k; + op += chaseLength; + let b = op; + if (chaseCode !== code) { + output[op++] = k; + } + chase = chaseCode; + while(chaseLength--){ + chase = codeTable[chase]; + output[--b] = chase & 0xff; + chase >>= 8; + } + if (prevCode !== null && nextCode < 4096) { + codeTable[nextCode++] = prevCode << 8 | k; + if (nextCode >= codeMask + 1 && curCodeSize < 12) { + ++curCodeSize; + codeMask = codeMask << 1 | 1; + } + } + prevCode = code; + } + if (op !== outputLength) { + console.log("Warning, gif stream shorter than expected."); + } + return output; +} +function handleGIF(data) { + const framesBase64 = []; + const reader = new GifReader(data); + for(let i = 0; i < reader.numFrames(); i++){ + const frameData = reader.frameInfo(i); + const canvas = document.createElement("canvas"); + canvas.width = reader.width; + canvas.height = reader.height; + const ctx = canvas.getContext("2d"); + const imageData = ctx.createImageData(reader.width, reader.height); + reader.decodeAndBlitFrameRGBA(i, imageData.data); + ctx.putImageData(imageData, 0, 0, frameData.x, frameData.y, frameData.width, frameData.height); + framesBase64.push({ + ...frameData, + canvas + }); + } + return { + w: reader.width, + h: reader.height, + frames: framesBase64 + }; +} +class GIFAnimation { + origin; + scale; + frames; + canvas; + ctx; + ready; + constructor(url, origin, scale = 1){ + this.origin = origin; + this.scale = scale; + this.frames = []; + this.ready = false; + this.totalAnimationTime = 0; + this._frameCounter = 0; + this.currentFrameIndex = 0; + this.canvas = document.createElement("canvas"); + this.init(url); + } + async init(url) { + const res = await fetch(url); + const buf = new Uint8Array(await res.arrayBuffer()); + const gif = handleGIF(buf); + this.frames = gif.frames; + this.frameTimes = this.frames.map((f)=>f.delay); + this.totalAnimationTime = this.frameTimes.reduce((a, b)=>a + b, 0); + this.canvas.width = gif.w; + this.canvas.height = gif.h; + this.ctx = this.canvas.getContext("2d"); + this.ready = true; + } + frameTimes; + totalAnimationTime; + _frameCounter; + currentFrameIndex; + draw(timeSinceLastFrame) { + if (!this.ready) return; + this._frameCounter += timeSinceLastFrame; + const currentFrameDelay = this.frames[this.currentFrameIndex].delay * 10; + while(this._frameCounter >= currentFrameDelay){ + this._frameCounter -= currentFrameDelay; + this.currentFrameIndex = (this.currentFrameIndex + 1) % this.frames.length; + } + const currentFrame = this.frames[this.currentFrameIndex]; + doodler.drawImage(currentFrame.canvas, this.origin, this.canvas.width * this.scale, this.canvas.height * this.scale); + } +} const Constants = { TWO_PI: Math.PI * 2 }; @@ -259,47 +657,6 @@ class OriginVector extends Vector { return new OriginVector(origin, v); } } -class SpriteAnimation { - imageUrl; - cellWidth; - cellHeight; - cellCountX; - cellCountY; - timing; - scale; - image; - origin; - constructor(imageUrl, cellWidth, cellHeight, cellCountX, cellCountY, timing = 1, scale = 1){ - this.imageUrl = imageUrl; - this.cellWidth = cellWidth; - this.cellHeight = cellHeight; - this.cellCountX = cellCountX; - this.cellCountY = cellCountY; - this.timing = timing; - this.scale = scale; - this._frameCount = 0; - this.image = new Image(); - this.image.src = this.imageUrl; - this.origin = new Vector(); - } - _frameCount; - get frameCount() { - return this._frameCount += this.timing; - } - getCell() { - const time = Math.floor(this.frameCount); - const x = time % this.cellCountX * this.cellWidth; - const y = Math.floor(time / this.cellCountX) % this.cellCountY * this.cellHeight; - return { - x, - y - }; - } - draw() { - const { x, y } = this.getCell(); - doodler.drawSprite(this.image, new Vector(x, y), this.cellWidth, this.cellHeight, this.origin, this.cellWidth * this.scale, this.cellHeight * this.scale); - } -} const easeInOut = (x)=>x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; const map = (value, x1, y1, x2, y2)=>(value - x1) * (y2 - x2) / (y1 - x1) + x2; class Doodler { @@ -339,18 +696,23 @@ class Doodler { this.startDrawLoop(); } timer; + lastFrameAt = 0; startDrawLoop() { + this.lastFrameAt = Date.now(); this.timer = setInterval(()=>this.draw(), 1000 / this.framerate); } draw() { + const time = Date.now(); + const frameTime = time - this.lastFrameAt; this.ctx.clearRect(0, 0, this.width, this.height); this.ctx.fillStyle = this.bg; this.ctx.fillRect(0, 0, this.width, this.height); for (const [i, l] of (this.layers || []).entries()){ - l(this.ctx, i); + l(this.ctx, i, frameTime); this.drawDeferred(); } this.drawUI(); + this.lastFrameAt = time; } createLayer(layer) { this.layers.push(layer); @@ -743,7 +1105,6 @@ class ZoomableDoodler extends Doodler { setTimeout(()=>this.hasDoubleTapped = false, 300); return false; } - console.log(this.mouse); if (this.scale > 1) { this.frameCounter = map(this.scale, this.maxScale, 1, 0, 59); this.zoomDirection = -1; @@ -874,25 +1235,16 @@ const init = (opt, zoomable, postInit)=>{ }; init({ width: 400, - height: 400 + height: 400, + framerate: 90 }, true, (ctx)=>{ ctx.imageSmoothingEnabled = false; }); -new Vector(100, 300); -const v = new Vector(30, 30); -doodler.registerDraggable(v, 20); const img = new Image(); -img.src = "./skeleton.png"; -img.hidden; -document.body.append(img); -new Vector(200, 200); -const animSprite = new SpriteAnimation("./EngineSprites.png", 100, 20, 1, 5, .02, 5); -doodler.createLayer(()=>{ - animSprite.draw(); -}); -document.addEventListener("keyup", (e)=>{ - e.preventDefault(); - if (e.key === " ") { - doodler.unregisterDraggable(v); - } +img.src = "./pixel fire.gif"; +const p = new Vector(); +const gif = new GIFAnimation("./fire-joypixels.gif", p, .5); +doodler.createLayer((c, i, t)=>{ + gif.draw(t); }); +requestAnimationFrame; diff --git a/canvas.ts b/canvas.ts index 828ecff..3ff90a5 100644 --- a/canvas.ts +++ b/canvas.ts @@ -27,7 +27,11 @@ export interface IDoodlerOptions { framerate?: number; } -type layer = (ctx: CanvasRenderingContext2D, index: number) => void; +type layer = ( + ctx: CanvasRenderingContext2D, + index: number, + frameTime: number, +) => void; export class Doodler { protected ctx: CanvasRenderingContext2D; @@ -85,11 +89,15 @@ export class Doodler { } private timer?: number; + private lastFrameAt = 0; private startDrawLoop() { + this.lastFrameAt = Date.now(); this.timer = setInterval(() => this.draw(), 1000 / this.framerate); } protected draw() { + const time = Date.now(); + const frameTime = time - this.lastFrameAt; this.ctx.clearRect(0, 0, this.width, this.height); this.ctx.fillStyle = this.bg; this.ctx.fillRect(0, 0, this.width, this.height); @@ -97,10 +105,12 @@ export class Doodler { // d.point.set(this.mouseX,this.mouseY); // } for (const [i, l] of (this.layers || []).entries()) { - l(this.ctx, i); + l(this.ctx, i, frameTime); this.drawDeferred(); } this.drawUI(); + + this.lastFrameAt = time; } // Layer management @@ -215,9 +225,9 @@ export class Doodler { this.ctx.restore(); } - drawImage(img: HTMLImageElement, at: Vector): void; - drawImage(img: HTMLImageElement, at: Vector, w: number, h: number): void; - drawImage(img: HTMLImageElement, at: Vector, w?: number, h?: number) { + drawImage(img: CanvasImageSource, at: Vector): void; + drawImage(img: CanvasImageSource, at: Vector, w: number, h: number): void; + drawImage(img: CanvasImageSource, at: Vector, w?: number, h?: number) { w && h ? this.ctx.drawImage(img, at.x, at.y, w, h) : this.ctx.drawImage(img, at.x, at.y); diff --git a/cartoon fire.png b/cartoon fire.png new file mode 100644 index 0000000..a95aebd Binary files /dev/null and b/cartoon fire.png differ diff --git a/deno.jsonc b/deno.jsonc index c24fed6..ba6983a 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -9,6 +9,11 @@ ] }, "tasks": { - "dev" : "deno bundle --watch main.ts bundle.js" + "dev": "deno bundle --watch main.ts bundle.js" + }, + "imports": { + "std": "https://deno.land/std@0.205.0/mod.ts", + "std/": "https://deno.land/std@0.205.0/", + "dpng": "https://deno.land/x/dpng@0.7.5/mod.ts" } } \ No newline at end of file diff --git a/fire-joypixels.gif b/fire-joypixels.gif new file mode 100644 index 0000000..a70405a Binary files /dev/null and b/fire-joypixels.gif differ diff --git a/index.html b/index.html index b9cdf36..095b092 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,26 @@ + Doodler + + + \ No newline at end of file diff --git a/main.ts b/main.ts index 43d3e97..176c06e 100644 --- a/main.ts +++ b/main.ts @@ -1,13 +1,16 @@ /// +import { GIFAnimation } from "./animation/gif.ts"; import { SpriteAnimation } from "./animation/sprite.ts"; import { initializeDoodler, Vector } from "./mod.ts"; +import { handleGIF } from "./processing/gif.ts"; import { ZoomableDoodler } from "./zoomableCanvas.ts"; initializeDoodler( { width: 400, height: 400, + framerate: 90, }, true, (ctx) => { @@ -15,94 +18,15 @@ initializeDoodler( }, ); -const movingVector = new Vector(100, 300); -let angleMultiplier = 0; -const v = new Vector(30, 30); -doodler.registerDraggable(v, 20); const img = new Image(); -img.src = "./skeleton.png"; -img.hidden; -document.body.append(img); +img.src = "./pixel fire.gif"; -const p = new Vector(200, 200); +const p = new Vector(); +const gif = new GIFAnimation("./fire-joypixels.gif", p, .5); -const animSprite = new SpriteAnimation( - "./EngineSprites.png", - 100, - 20, - 1, - 5, - .02, - 5, -); - -doodler.createLayer(() => { - animSprite.draw(); - // const [gamepad] = navigator.getGamepads(); - // const deadzone = 0.04; - // if (gamepad) { - // const leftX = gamepad.axes[0]; - // const leftY = gamepad.axes[1]; - // p.add( - // Math.min(Math.max(leftX - deadzone, 0), leftX + deadzone), - // Math.min(Math.max(leftY - deadzone, 0), leftY + deadzone), - // ); - - // const rigthX = gamepad.axes[2]; - // const rigthY = gamepad.axes[3]; - // (doodler as ZoomableDoodler).moveOrigin({ x: -rigthX * 5, y: -rigthY * 5 }); - - // if (gamepad.buttons[7].value) { - // (doodler as ZoomableDoodler).scaleAt( - // { x: 200, y: 200 }, - // 1 + (gamepad.buttons[7].value / 5), - // ); - // } - // if (gamepad.buttons[6].value) { - // (doodler as ZoomableDoodler).scaleAt( - // { x: 200, y: 200 }, - // 1 - (gamepad.buttons[6].value / 5), - // ); - // } - // } - // doodler.drawImageWithOutline(img, p); - // doodler.line(new Vector(100, 100), new Vector(200, 200)) - // doodler.dot(new Vector(300, 300)) - // doodler.fillCircle(movingVector, 6, { color: 'red' }); - // doodler.drawRect(new Vector(50, 50), movingVector.x, movingVector.y); - // doodler.fillRect(new Vector(200, 250), 30, 10) - - // doodler.drawCenteredSquare(new Vector(200, 200), 40, { color: 'purple', weight: 5 }) - // doodler.drawBezier(new Vector(100, 150), movingVector, new Vector(150, 300), new Vector(100, 250)) - - // let rotatedOrigin = new Vector(200, 200) - // doodler.drawRotated(rotatedOrigin, Math.PI * angleMultiplier, () => { - // doodler.drawCenteredSquare(rotatedOrigin, 30) - // doodler.drawSprite(img, new Vector(0, 40), 80, 20, new Vector(160, 300), 80, 20) - // }) - - // movingVector.set((movingVector.x + 1) % 400, movingVector.y); - // angleMultiplier += .001; - - // doodler.drawSprite(img, new Vector(0, 40), 80, 20, new Vector(100, 300), 80, 20) - - // doodler.drawScaled(1.5, () => { - // doodler.line(p.copy().add(-8, 10), p.copy().add(8, 10), { - // color: "grey", - // weight: 2, - // }); - // doodler.line(p.copy().add(-8, -10), p.copy().add(8, -10), { - // color: "grey", - // weight: 2, - // }); - // doodler.line(p, p.copy().add(0, 12), { color: "brown", weight: 4 }); - // doodler.line(p, p.copy().add(0, -12), { color: "brown", weight: 4 }); - // }); +doodler.createLayer((c, i, t) => { + gif.draw(t); + // c.drawImage(img, 0, 0); }); -document.addEventListener("keyup", (e) => { - e.preventDefault(); - if (e.key === " ") { - doodler.unregisterDraggable(v); - } -}); +requestAnimationFrame; diff --git a/pixel fire.gif b/pixel fire.gif new file mode 100644 index 0000000..15b8807 Binary files /dev/null and b/pixel fire.gif differ diff --git a/processing/gif.ts b/processing/gif.ts new file mode 100644 index 0000000..a80b8f9 --- /dev/null +++ b/processing/gif.ts @@ -0,0 +1,573 @@ +export type Frame = { + x: number; + y: number; + width: number; + height: number; + hasLocalPalette: boolean; + paletteOffset: number | null; + paletteSize: number | null; + dataOffset: number; + dataLength: number; + transparentIndex: number | null; + interlaced: boolean; + delay: number; + disposal: number; +}; + +/** + * @classdesc This class is a TS refactoring of 'omggif's GifReader constructor, I simply copy-pasta'd it to be able to include using a deno bundler since they currently do not work properly with npm packages. Due to this, if anything doesn't work, do NOT contact the original author for issues with this class + * @author original - Dean McNamee + * @author refactor - Emma Short + */ + +export class GifReader { + private buf: Uint8Array; + private p: number; + public width: number; + public height: number; + private globalPaletteOffset: number | null; + private globalPaletteSize: number | null; + private frames: Frame[]; + private loopCountValue: number | null; + + constructor(buf: Uint8Array) { + this.buf = buf; + this.p = 0; + this.width = 0; + this.height = 0; + this.globalPaletteOffset = null; + this.globalPaletteSize = null; + this.frames = []; + this.loopCountValue = null; + + this.parseHeader(); + this.parseFrames(); + } + + public numFrames(): number { + return this.frames.length; + } + + public loopCount(): number | null { + return this.loopCountValue; + } + + public frameInfo(frameNum: number): Frame { + if (frameNum < 0 || frameNum >= this.frames.length) { + throw new Error("Frame index out of range."); + } + return this.frames[frameNum]; + } + + public decodeAndBlitFrameBGRA( + frameNum: number, + pixels: Uint8ClampedArray, + ): void { + const frame = this.frameInfo(frameNum); + const numPixels = frame.width * frame.height; + const indexStream = new Uint8Array(numPixels); // At most 8-bit indices. + GifReaderLZWOutputIndexStream( + this.buf, + frame.dataOffset, + indexStream, + numPixels, + ); + const paletteOffset = frame.paletteOffset; + + let trans = frame.transparentIndex; + if (trans === null) trans = 256; + + // We are possibly just blitting to a portion of the entire frame. + // That is a subRect within the frameRect, so the additional pixels + // must be skipped over after we finished a scanline. + const frameWidth = frame.width; + const frameStride = this.width - frameWidth; + let xLeft = frameWidth; // Number of subRect pixels left in scanline. + + // Output index of the top left corner of the subRect. + const opBeg = ((frame.y * this.width) + frame.x) * 4; + // Output index of what would be the left edge of the subRect, one row + // below it, i.e. the index at which an interlace pass should wrap. + const opEnd = ((frame.y + frame.height) * this.width + frame.x) * 4; + let op = opBeg; + + let scanStride = frameStride * 4; + + // Use scanStride to skip past the rows when interlacing. This is skipping + // 7 rows for the first two passes, then 3 then 1. + if (frame.interlaced === true) { + scanStride += this.width * 4 * 7; // Pass 1. + } + + let interlaceSkip = 8; // Tracking the row interval in the current pass. + + for (let i = 0, il = indexStream.length; i < il; ++i) { + const index = indexStream[i]; + + if (xLeft === 0) { // Beginning of new scan line + op += scanStride; + xLeft = frameWidth; + if (op >= opEnd) { // Catch the wrap to switch passes when interlacing. + scanStride = frameStride * 4 + this.width * 4 * (interlaceSkip - 1); + // interlaceSkip / 2 * 4 is interlaceSkip << 1. + op = opBeg + (frameWidth + frameStride) * (interlaceSkip << 1); + interlaceSkip >>= 1; + } + } + + if (index === trans) { + op += 4; + } else { + const r = this.buf[(paletteOffset || 0) + index * 3]; + const g = this.buf[(paletteOffset || 0) + index * 3 + 1]; + const b = this.buf[(paletteOffset || 0) + index * 3 + 2]; + pixels[op++] = b; + pixels[op++] = g; + pixels[op++] = r; + pixels[op++] = 255; + } + --xLeft; + } + } + + public decodeAndBlitFrameRGBA( + frameNum: number, + pixels: Uint8ClampedArray, + ): void { + const frame = this.frameInfo(frameNum); + const numPixels = frame.width * frame.height; + const indexStream = new Uint8Array(numPixels); // At most 8-bit indices. + GifReaderLZWOutputIndexStream( + this.buf, + frame.dataOffset, + indexStream, + numPixels, + ); + // debugger; + const paletteOffset = frame.paletteOffset; + + let trans = frame.transparentIndex; + if (trans === null) trans = 256; + + // We are possibly just blitting to a portion of the entire frame. + // That is a subRect within the frameRect, so the additional pixels + // must be skipped over after we finished a scanline. + const frameWidth = frame.width; + const frameStride = this.width - frameWidth; + let xLeft = frameWidth; // Number of subRect pixels left in scanline. + + // Output index of the top left corner of the subRect. + const opBeg = ((frame.y * this.width) + frame.x) * 4; + // Output index of what would be the left edge of the subRect, one row + // below it, i.e. the index at which an interlace pass should wrap. + const opEnd = ((frame.y + frame.height) * this.width + frame.x) * 4; + let op = opBeg; + + let scanStride = frameStride * 4; + + // Use scanStride to skip past the rows when interlacing. This is skipping + // 7 rows for the first two passes, then 3 then 1. + if (frame.interlaced === true) { + scanStride += this.width * 4 * 7; // Pass 1. + } + + let interlaceSkip = 8; // Tracking the row interval in the current pass. + + for (let i = 0, il = indexStream.length; i < il; ++i) { + const index = indexStream[i]; + + if (xLeft === 0) { // Beginning of new scan line + op += scanStride; + xLeft = frameWidth; + if (op >= opEnd) { // Catch the wrap to switch passes when interlacing. + scanStride = frameStride * 4 + this.width * 4 * (interlaceSkip - 1); + // interlaceSkip / 2 * 4 is interlaceSkip << 1. + op = opBeg + (frameWidth + frameStride) * (interlaceSkip << 1); + interlaceSkip >>= 1; + } + } + + if (index === trans) { + op += 4; + } else { + const rI = (paletteOffset || 0) + index * 3; + const r = this.buf[rI]; + const g = this.buf[rI + 1]; + const b = this.buf[rI + 2]; + pixels[op++] = r; + pixels[op++] = g; + pixels[op++] = b; + pixels[op++] = 255; + } + --xLeft; + } + } + + // Additional private or public methods should be implemented below + + private parseHeader(): void { + // Parse the GIF file header + if ( + this.buf[this.p++] !== 0x47 || this.buf[this.p++] !== 0x49 || + this.buf[this.p++] !== 0x46 || + this.buf[this.p++] !== 0x38 || (this.buf[this.p++] + 1 & 0xfd) !== 0x38 || + this.buf[this.p++] !== 0x61 + ) { + throw new Error("Invalid GIF 87a/89a header."); + } + } + + private parseLogicalScreenDescriptor(): void { + // Parse the Logical Screen Descriptor block + } + + private parseGlobalColorTable(): void { + // Parse the Global Color Table block if it exists + } + + private parseFrames(): void { + const width = this.buf[this.p++] | this.buf[this.p++] << 8; + const height = this.buf[this.p++] | this.buf[this.p++] << 8; + const pf0 = this.buf[this.p++]; // . + const global_palette_flag = pf0 >> 7; + const num_global_colors_pow2 = pf0 & 0x7; + const num_global_colors = 1 << (num_global_colors_pow2 + 1); + const background = this.buf[this.p++]; + this.buf[this.p++]; // Pixel aspect ratio (unused?). + + let global_palette_offset = null; + let global_palette_size = null; + + if (global_palette_flag) { + global_palette_offset = this.p; + global_palette_size = num_global_colors; + this.p += num_global_colors * 3; // Seek past palette. + } + + let no_eof = true; + + const frames = []; + + let delay = 0; + let transparentIndex = null; + let disposal = 0; // 0 - No disposal specified. + let loopCount = null; + + this.width = width; + this.height = height; + + while (no_eof && this.p < this.buf.length) { + switch (this.buf[this.p++]) { + case 0x21: // Graphics Control Extension Block + switch (this.buf[this.p++]) { + case 0xff: // Application specific block + // Try if it's a Netscape block (with animation loop counter). + if ( + this.buf[this.p] !== 0x0b || // 21 FF already read, check block size. + // NETSCAPE2.0 + this.buf[this.p + 1] == 0x4e && this.buf[this.p + 2] == 0x45 && + this.buf[this.p + 3] == 0x54 && + this.buf[this.p + 4] == 0x53 && + this.buf[this.p + 5] == 0x43 && + this.buf[this.p + 6] == 0x41 && + this.buf[this.p + 7] == 0x50 && + this.buf[this.p + 8] == 0x45 && + this.buf[this.p + 9] == 0x32 && + this.buf[this.p + 10] == 0x2e && + this.buf[this.p + 11] == 0x30 && + // Sub-block + this.buf[this.p + 12] == 0x03 && + this.buf[this.p + 13] == 0x01 && this.buf[this.p + 16] == 0 + ) { + this.p += 14; + loopCount = this.buf[this.p++] | this.buf[this.p++] << 8; + this.p++; // Skip terminator. + } else { // We don't know what it is, just try to get past it. + this.p += 12; + while (true) { // Seek through subblocks. + const block_size = this.buf[this.p++]; + // Bad block size (ex: undefined from an out of bounds read). + if (!(block_size >= 0)) throw Error("Invalid block size"); + if (block_size === 0) break; // 0 size is terminator + this.p += block_size; + } + } + break; + + case 0xf9: { // Graphics Control Extension + if (this.buf[this.p++] !== 0x4 || this.buf[this.p + 4] !== 0) { + throw new Error("Invalid graphics extension block."); + } + const pf1 = this.buf[this.p++]; + delay = this.buf[this.p++] | this.buf[this.p++] << 8; + transparentIndex = this.buf[this.p++]; + if ((pf1 & 1) === 0) transparentIndex = null; + disposal = pf1 >> 2 & 0x7; + this.p++; // Skip terminator. + break; + } + + // Plain Text Extension could be present and we just want to be able + // to parse past it. It follows the block structure of the comment + // extension enough to reuse the path to skip through the blocks. + case 0x01: // Plain Text Extension (fallthrough to Comment Extension) + case 0xfe: // Comment Extension. + while (true) { // Seek through subblocks. + const block_size = this.buf[this.p++]; + // Bad block size (ex: undefined from an out of bounds read). + if (!(block_size >= 0)) throw Error("Invalid block size"); + if (block_size === 0) break; // 0 size is terminator + this.p += block_size; + } + break; + + default: + throw new Error( + "Unknown graphic control label: 0x" + + this.buf[this.p - 1].toString(16), + ); + } + break; + + case 0x2c: { // Image Descriptor. + const x = this.buf[this.p++] | this.buf[this.p++] << 8; + const y = this.buf[this.p++] | this.buf[this.p++] << 8; + const w = this.buf[this.p++] | this.buf[this.p++] << 8; + const h = this.buf[this.p++] | this.buf[this.p++] << 8; + const pf2 = this.buf[this.p++]; + const local_palette_flag = pf2 >> 7; + const interlace_flag = pf2 >> 6 & 1; + const num_local_colors_pow2 = pf2 & 0x7; + const num_local_colors = 1 << (num_local_colors_pow2 + 1); + let palette_offset = global_palette_offset; + let palette_size = global_palette_size; + let has_local_palette = false; + if (local_palette_flag) { + has_local_palette = true; + palette_offset = this.p; // Override with local palette. + palette_size = num_local_colors; + this.p += num_local_colors * 3; // Seek past palette. + } + + const data_offset = this.p; + + this.p++; // codeSize + while (true) { + const block_size = this.buf[this.p++]; + // Bad block size (ex: undefined from an out of bounds read). + if (!(block_size >= 0)) throw Error("Invalid block size"); + if (block_size === 0) break; // 0 size is terminator + this.p += block_size; + } + + this.frames.push({ + x, + y, + width: w, + height: h, + hasLocalPalette: has_local_palette, + paletteOffset: palette_offset, + paletteSize: palette_size, + dataOffset: data_offset, + dataLength: this.p - data_offset, + transparentIndex: transparentIndex, + interlaced: !!interlace_flag, + delay: delay, + disposal: disposal, + }); + break; + } + case 0x3b: // Trailer Marker (end of file). + no_eof = false; + break; + + default: + throw new Error( + "Unknown gif block: 0x" + this.buf[this.p - 1].toString(16), + ); + } + } + } + + // private readSubBlocks(): string { + // // Read a series of sub-blocks + // return ""; + // } + + // private readBlockTerminator(): void { + // // Read a block terminator if necessary + // } +} + +function GifReaderLZWOutputIndexStream( + codeStream: Uint8Array, + p: number, + output: Uint8Array, + outputLength: number, +) { + const minCodeSize = codeStream[p++]; + + const clear_code = 1 << minCodeSize; + const eoi_code = clear_code + 1; + let nextCode = eoi_code + 1; + + let curCodeSize = minCodeSize + 1; // Number of bits per code. + // NOTE: This shares the same name as the encoder, but has a different + // meaning here. Here this masks each code coming from the code stream. + let codeMask = (1 << curCodeSize) - 1; + let curShift = 0; + let cur = 0; + + let op = 0; // Output pointer. + + let subBlockSize = codeStream[p++]; + + const codeTable = new Int32Array(4096); // Can be signed, we only use 20 bits. + + let prevCode = null; // Track code-1. + + while (true) { + // Read up to two bytes, making sure we always 12-bits for max sized code. + while (curShift < 16) { + if (subBlockSize === 0) break; // No more data to be read. + + cur |= codeStream[p++] << curShift; + curShift += 8; + + if (subBlockSize === 1) { // Never let it get to 0 to hold logic above. + subBlockSize = codeStream[p++]; // Next subBlock. + } else { + --subBlockSize; + } + } + + if (curShift < curCodeSize) { + break; + } + + const code = cur & codeMask; + cur >>= curCodeSize; + curShift -= curCodeSize; + + if (code === clear_code) { + // We don't actually have to clear the table. This could be a good idea + // for greater error checking, but we don't really do any anyway. We + // will just track it with next_code and overwrite old entries. + + nextCode = eoi_code + 1; + curCodeSize = minCodeSize + 1; + codeMask = (1 << curCodeSize) - 1; + + // Don't update prev_code ? + prevCode = null; + continue; + } else if (code === eoi_code) { + break; + } + + // We have a similar situation as the decoder, where we want to store + // variable length entries (code table entries), but we want to do in a + // faster manner than an array of arrays. The code below stores sort of a + // linked list within the code table, and then "chases" through it to + // construct the dictionary entries. When a new entry is created, just the + // last byte is stored, and the rest (prefix) of the entry is only + // referenced by its table entry. Then the code chases through the + // prefixes until it reaches a single byte code. We have to chase twice, + // first to compute the length, and then to actually copy the data to the + // output (backwards, since we know the length). The alternative would be + // storing something in an intermediate stack, but that doesn't make any + // more sense. I implemented an approach where it also stored the length + // in the code table, although it's a bit tricky because you run out of + // bits (12 + 12 + 8), but I didn't measure much improvements (the table + // entries are generally not the long). Even when I created benchmarks for + // very long table entries the complexity did not seem worth it. + // The code table stores the prefix entry in 12 bits and then the suffix + // byte in 8 bits, so each entry is 20 bits. + + const chaseCode: number = code < nextCode ? code : prevCode as number; + + // Chase what we will output, either {CODE} or {CODE-1}. + let chaseLength = 0; + let chase = chaseCode as number; + while (chase > clear_code) { + chase = codeTable[chase] >> 8; + ++chaseLength; + } + + const k = chase; + + const op_end = op + chaseLength + (chaseCode !== code ? 1 : 0); + if (op_end > outputLength) { + console.log("Warning, gif stream longer than expected."); + return; + } + + // Already have the first byte from the chase, might as well write it fast. + output[op++] = k; + + op += chaseLength; + let b = op; // Track pointer, writing backwards. + + if (chaseCode !== code) { // The case of emitting {CODE-1} + k. + output[op++] = k; + } + + chase = chaseCode; + while (chaseLength--) { + chase = codeTable[chase]; + output[--b] = chase & 0xff; // Write backwards. + chase >>= 8; // Pull down to the prefix code. + } + + if (prevCode !== null && nextCode < 4096) { + codeTable[nextCode++] = prevCode << 8 | k; + + if (nextCode >= codeMask + 1 && curCodeSize < 12) { + ++curCodeSize; + codeMask = codeMask << 1 | 1; + } + } + + prevCode = code; + } + + if (op !== outputLength) { + console.log("Warning, gif stream shorter than expected."); + } + + return output; +} + +export function handleGIF( + data: Uint8Array, +) { + const framesBase64: ({ canvas: HTMLCanvasElement } & Frame)[] = []; + const reader = new GifReader(data); + + for (let i = 0; i < reader.numFrames(); i++) { + const frameData = reader.frameInfo(i); + // const buf = new Uint8Array(frameData.width * frameData.height * 4); + const canvas = document.createElement("canvas"); + canvas.width = reader.width; + canvas.height = reader.height; + const ctx = canvas.getContext("2d")!; + const imageData = ctx.createImageData(reader.width, reader.height); + reader.decodeAndBlitFrameRGBA(i, imageData.data); + + ctx.putImageData( + imageData, + 0, + 0, + frameData.x, + frameData.y, + frameData.width, + frameData.height, + ); + framesBase64.push({ ...frameData, canvas }); + } + return { + w: reader.width, + h: reader.height, + frames: framesBase64, + }; +} diff --git a/zoomableCanvas.ts b/zoomableCanvas.ts index 35a01ce..04e2ba1 100644 --- a/zoomableCanvas.ts +++ b/zoomableCanvas.ts @@ -157,8 +157,6 @@ export class ZoomableDoodler extends Doodler { // this.origin.x = 0; // this.origin.y = 0; - console.log(this.mouse); - if (this.scale > 1) { this.frameCounter = map(this.scale, this.maxScale, 1, 0, 59); this.zoomDirection = -1;