From c755f0bb6f76097f8044127e477d10008636fe92 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Sat, 16 Sep 2023 14:06:58 -0500 Subject: [PATCH] GIF: basic parsing of image sections and skipping extensions --- public/gif/constants.js | 39 ++++++- public/gif/getNodeUi.js | 117 +++++++++++++++++++++ public/gif/parseGif.js | 227 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+), 1 deletion(-) diff --git a/public/gif/constants.js b/public/gif/constants.js index 7475d96..7912516 100644 --- a/public/gif/constants.js +++ b/public/gif/constants.js @@ -1,7 +1,7 @@ // @ts-check /** @enum {string} */ -export const GifNodeType = [ +const types = [ "root", "header", @@ -17,7 +17,44 @@ export const GifNodeType = [ "globalColorTable", "globalColorTableColor", + + "imageSection", + "imageDescriptor", + "imageSeparator", + "imageDescriptorLeftPosition", + "imageDescriptorTopPosition", + "imageDescriptorWidth", + "imageDescriptorHeight", + "imageDescriptorPackedFields", + + "localColorTable", + "localColorTableColor", + + "imageData", + "imageDataLzwMinimumCodeSize", + "imageDataSubBlock", + "imageDataSubBlockSize", + "imageDataSubBlockData", + "imageDataTerminator", + + "extensionBlockIntroducer", + "extensionBlockSize", + + "unknownExtensionBlock", + "unknownExtensionBlockLabel", + "unknownExtensionBlockData", ].reduce((result, id) => { result[id] = id; return result; }, Object.create(null)); + +// TODO: Revert this and just export the thing +export const GifNodeType = new Proxy(types, { + get(target, prop) { + const result = target[prop]; + if (!result) { + throw new Error(`cannot get ${String(prop)}`); + } + return result; + }, +}); diff --git a/public/gif/getNodeUi.js b/public/gif/getNodeUi.js index 377ef96..5287812 100644 --- a/public/gif/getNodeUi.js +++ b/public/gif/getNodeUi.js @@ -181,6 +181,123 @@ const NODE_UI_FNS = { ), ), }), + + // Image descriptor + + [GifNodeType.imageSection]: () => ({ + title: "Image", + description: p("A single image in the GIF."), + }), + [GifNodeType.imageDescriptor]: () => ({ + title: "Image Descriptor", + description: p("TODO"), + }), + [GifNodeType.imageSeparator]: () => ({ + title: "Image Separator", + description: p("TODO"), + }), + [GifNodeType.imageDescriptorLeftPosition]: () => ({ + title: "Image Left Position", + description: p("The X coordinate of the top left corner of this frame."), + }), + [GifNodeType.imageDescriptorTopPosition]: () => ({ + title: "Image Top Position", + description: p("The Y coordinate of the top left corner of this frame."), + }), + [GifNodeType.imageDescriptorWidth]: () => ({ + title: "Image Width", + description: p("The width of this frame."), + }), + [GifNodeType.imageDescriptorHeight]: () => ({ + title: "Image Height", + description: p("The height of this frame."), + }), + [GifNodeType.imageDescriptorPackedFields]: () => ({ + title: "Image Descriptor packed fields", + description: p("TODO"), + }), + + // Local color table + + [GifNodeType.localColorTable]: ({ children }) => ({ + description: p( + `The Local Color Table is a list of colors for this frame. Each color is encoded as three bytes: one for red, one for green, and one for blue. The number of colors in the table is ${ + pluralize(children?.length || 0, "color") + }.`, + ), + }), + [GifNodeType.localColorTableColor]: ({ bytes }) => ({ + // TODO: DRY this out with the global color above? Or maybe just use a single node type? + title: "Local Color Table color", + description: p( + "A color. It looks like this: ", + crel( + "span", + { + "style": + `display:inline-block;width:1em;height:1em;vertical-align:middle;color:transparent;background:rgb(${ + bytes.join(",") + })`, + }, + `#${formatByte(bytes[0])}${formatByte(bytes[1])}${ + formatByte(bytes[2]) + }`, + ), + ), + }), + + // Image data + + [GifNodeType.imageData]: () => ({ + title: "Image Data", + description: p("TODO"), + }), + [GifNodeType.imageDataLzwMinimumCodeSize]: () => ({ + title: "LZW Minimum Code Size", + description: p("TODO"), + }), + [GifNodeType.imageDataSubBlock]: () => ({ + title: "Image Data Sub-Block", + description: p("TODO"), + }), + [GifNodeType.imageDataSubBlockSize]: () => ({ + title: "Image Data Sub-Block Size", + description: p("TODO"), + }), + [GifNodeType.imageDataSubBlockData]: () => ({ + title: "Image Data Sub-Block Data", + description: p("TODO"), + }), + [GifNodeType.imageDataTerminator]: () => ({ + title: "Image Data Terminator", + description: p("TODO"), + }), + + // Generic extension stuff + + [GifNodeType.extensionBlockIntroducer]: () => ({ + title: "Extension Block Introducer", + description: p("TODO"), + }), + [GifNodeType.extensionBlockSize]: () => ({ + title: "Extension Block Size", + description: p("TODO"), + }), + + // Unknown extension block + + [GifNodeType.unknownExtensionBlock]: () => ({ + title: "Unknown Extension Block", + description: p("TODO"), + }), + [GifNodeType.unknownExtensionBlockLabel]: () => ({ + title: "Unknown Extension Block Label", + description: p("TODO"), + }), + [GifNodeType.unknownExtensionBlockData]: () => ({ + title: "Unknown Extension Block Data", + description: p("TODO"), + }), }; /** diff --git a/public/gif/parseGif.js b/public/gif/parseGif.js index d197bf7..3c173ac 100644 --- a/public/gif/parseGif.js +++ b/public/gif/parseGif.js @@ -3,6 +3,10 @@ import { GifNodeType } from "./constants.js"; /** @typedef {import("../../types/gif.d.ts").GifNode} GifNode */ +const BLOCK_TYPE_IMAGE = 0x2c; +const BLOCK_TYPE_EXTENSION = 0x21; +const BLOCK_TYPE_TRAILER = 0x3b; + /** * @typedef {object} LogicalScreenDescriptorPackedField * @prop {boolean} globalColorTableFlag @@ -22,6 +26,206 @@ export const parseLogicalScreenDescriptorPackedField = (byte) => ({ sizeOfGlobalColorTable: 2 ** ((byte & 0b00000111) + 1), }); +/** + * @typedef {object} ImageDescriptorPackedField + * @prop {boolean} localColorTableFlag + * @prop {boolean} interlaceFlag + * @prop {boolean} sortFlag + * @prop {number} reservedBits + * @prop {number} sizeOfLocalColorTable + */ + +/** + * @param {number} byte + * @returns {ImageDescriptorPackedField} + */ +export const parseImageDescriptorPackedField = (byte) => ({ + localColorTableFlag: (byte & 0b10000000) !== 0, + interlaceFlag: (byte & 0b1000000) !== 0, + sortFlag: (byte & 0b100000) !== 0, + reservedBits: (byte & 0b11000) >> 2, + sizeOfLocalColorTable: 2 ** ((byte & 0b111) + 1), +}); + +/** + * @param {Uint8Array} bytes + * @param {number} startingOffset + * @returns {{ node: GifNode, newOffset: number }} + */ +const readImageBlock = (bytes, startingOffset) => { + let offset = startingOffset; + + /** @type {GifNode[]} */ const children = [{ + type: GifNodeType.imageSeparator, + bytes: bytes.subarray(offset, offset + 1), + }]; + offset++; + + // Image descriptor + // TODO: remove these commented-out lines? + // const imageLeftPosition = dataView.getUint16(offset, true); + // const imageTopPosition = dataView.getUint16(offset + 2, true); + // const imageWidth = dataView.getUint16(offset + 4, true); + // const imageHeight = dataView.getUint16(offset + 6, true); + const packedField = parseImageDescriptorPackedField(bytes[offset + 8]); + children.push({ + type: GifNodeType.imageDescriptor, + bytes: bytes.subarray(offset, offset + 9), + children: [ + { + type: GifNodeType.imageDescriptorLeftPosition, + bytes: bytes.subarray(offset, offset + 2), + }, + { + type: GifNodeType.imageDescriptorTopPosition, + bytes: bytes.subarray(offset + 2, offset + 4), + }, + { + type: GifNodeType.imageDescriptorWidth, + bytes: bytes.subarray(offset + 4, offset + 6), + }, + { + type: GifNodeType.imageDescriptorHeight, + bytes: bytes.subarray(offset + 6, offset + 8), + }, + { + type: GifNodeType.imageDescriptorPackedFields, + bytes: bytes.subarray(offset + 8, offset + 9), + }, + ], + }); + offset += 9; + + // Local color table + if (packedField.localColorTableFlag) { + /** @type {GifNode[]} */ const colorNodes = []; + for (let i = 0; i < packedField.sizeOfLocalColorTable; i++) { + colorNodes.push({ + type: GifNodeType.localColorTableColor, + bytes: bytes.subarray(offset + (i * 3), offset + (i * 3) + 3), + }); + } + const sizeOfLocalColorTableInBytes = packedField.sizeOfLocalColorTable * + 3; + children.push({ + type: GifNodeType.localColorTable, + bytes: bytes.subarray( + offset, + offset + sizeOfLocalColorTableInBytes, + ), + children: colorNodes, + }); + offset += sizeOfLocalColorTableInBytes; + } + + const imageDataStartOffset = offset; + + // TODO: remove this? + // const lzwMinimumCodeSize = bytes[offset++]; + offset++; + + /** @type {GifNode[]} */ const imageDataSubBlocks = []; + while (true) { + const subBlockSize = bytes[offset]; + const subBlockSizeBytes = bytes.subarray(offset, offset + 1); + + if (subBlockSize === 0) { + imageDataSubBlocks.push({ + type: GifNodeType.imageDataTerminator, + bytes: subBlockSizeBytes, + }); + offset++; + break; + } + + imageDataSubBlocks.push({ + type: GifNodeType.imageDataSubBlock, + bytes: bytes.subarray(offset, offset + 1 + subBlockSize), + children: [ + { + type: GifNodeType.imageDataSubBlockSize, + bytes: subBlockSizeBytes, + }, + { + type: GifNodeType.imageDataSubBlockData, + bytes: bytes.subarray(offset + 1, offset + 1 + subBlockSize), + }, + ], + }); + offset += 1 + subBlockSize; + } + + children.push({ + type: GifNodeType.imageData, + bytes: bytes.subarray(imageDataStartOffset, offset), + children: [ + { + type: GifNodeType.imageDataLzwMinimumCodeSize, + bytes: bytes.subarray(imageDataStartOffset, imageDataStartOffset + 1), + }, + ...imageDataSubBlocks, + ], + }); + + return { + newOffset: offset, + node: { + type: GifNodeType.imageSection, + bytes: bytes.subarray(startingOffset, offset), + children, + }, + }; +}; + +/** + * @param {Uint8Array} bytes + * @param {number} startingOffset + * @returns {{ node: GifNode, newOffset: number }} + */ +const readExtensionBlock = (bytes, startingOffset) => { + let offset = startingOffset; + + /** @type {GifNode[]} */ const children = [{ + type: GifNodeType.extensionBlockIntroducer, + bytes: bytes.subarray(offset, offset + 1), + }]; + offset++; + + const extensionLabel = bytes[offset++]; + + const extensionSize = bytes[offset++]; + const extensionSizeNode = { + type: GifNodeType.extensionBlockSize, + bytes: new Uint8Array([extensionSize]), + }; + + switch (extensionLabel) { + // TODO: handle various documented extensions + + default: + children.push( + { + type: GifNodeType.unknownExtensionBlockLabel, + bytes: new Uint8Array([extensionLabel]), + }, + extensionSizeNode, + { + type: GifNodeType.unknownExtensionBlockData, + bytes: bytes.subarray(offset, offset + extensionSize), + }, + ); + offset += extensionSize; + return { + newOffset: offset, + node: { + type: GifNodeType.unknownExtensionBlock, + bytes: bytes.subarray(startingOffset, offset), + children, + }, + }; + } +}; + /** * @param {Uint8Array} bytes * @returns {null | GifNode} The root node of the GIF tree, or null if the GIF is invalid. @@ -98,5 +302,28 @@ export default (bytes) => { offset += sizeOfGlobalColorTableInBytes; } + readingBlocks: + while (true) { + const blockType = bytes[offset]; + + let readerFn; + switch (blockType) { + case BLOCK_TYPE_IMAGE: + readerFn = readImageBlock; + break; + case BLOCK_TYPE_EXTENSION: + readerFn = readExtensionBlock; + break; + case BLOCK_TYPE_TRAILER: + break readingBlocks; + default: + throw new Error(`Unknown GIF block type: ${blockType}`); + } + + const { node, newOffset } = readerFn(bytes, offset); + children.push(node); + offset = newOffset; + } + return { type: GifNodeType.root, bytes, children }; };