GIF: slow, but rendering GIFs

This commit is contained in:
Evan Hahn 2023-11-10 12:01:53 -06:00
parent 8f6bb74e17
commit 7f4c90178d
5 changed files with 449 additions and 50 deletions

View File

@ -35,6 +35,8 @@ export default (rootNode, getNodeUi) => {
let hoveringOver = null;
const rerender = () => {
console.time("rerender");
/**
* @callback IsActivatedFn
* @param {NodePath} path
@ -75,6 +77,8 @@ export default (rootNode, getNodeUi) => {
if (!path) continue;
treeEl.classList.toggle("activated", isTreeElActivated(path));
}
console.timeEnd("rerender");
};
outerBytesEl.addEventListener("click", (event) => {
@ -87,11 +91,9 @@ export default (rootNode, getNodeUi) => {
if (explorerEl.dataset.path) explorerEl.removeAttribute("open");
}
const leafEl = outerTreeEl.querySelector(
`details[data-path="${rawPath}"]`,
);
const leafEl = outerTreeEl.querySelector(`details[data-path="${rawPath}"]`);
let currentTreeEl = leafEl;
while (currentTreeEl && (currentTreeEl !== outerTreeEl)) {
while (currentTreeEl && currentTreeEl !== outerTreeEl) {
currentTreeEl.setAttribute("open", "open");
currentTreeEl = currentTreeEl.parentElement;
}
@ -124,7 +126,7 @@ export default (rootNode, getNodeUi) => {
if (event.target.classList.contains("children")) return;
/** @type {null | HTMLElement} */ let nodeToCheckForPath = event.target;
while (nodeToCheckForPath && (nodeToCheckForPath !== outerTreeEl)) {
while (nodeToCheckForPath && nodeToCheckForPath !== outerTreeEl) {
if (nodeToCheckForPath.dataset.path) break;
nodeToCheckForPath = nodeToCheckForPath.parentElement;
}
@ -137,10 +139,12 @@ export default (rootNode, getNodeUi) => {
/** @param {UIEvent} event */
const onBlurExplorerTree = (event) => {
if (
(event.target !== outerTreeEl) &&
(event.target !== outerTreeEl.children[0]) &&
(hoveringOver?.side !== "tree")
) return;
event.target !== outerTreeEl &&
event.target !== outerTreeEl.children[0] &&
hoveringOver?.side !== "tree"
) {
return;
}
hoveringOver = null;
rerender();
};
@ -153,7 +157,7 @@ export default (rootNode, getNodeUi) => {
if (!(event.target instanceof HTMLElement)) return;
/** @type {null | HTMLElement} */ let nodeToCheckForPath = event.target;
while (nodeToCheckForPath && (nodeToCheckForPath !== outerTreeEl)) {
while (nodeToCheckForPath && nodeToCheckForPath !== outerTreeEl) {
if (nodeToCheckForPath.dataset.path) break;
nodeToCheckForPath = nodeToCheckForPath.parentElement;
}
@ -184,10 +188,10 @@ const traverse = (node, path, getNodeUi) => {
crel(
"summary",
{},
crel("span", { "class": "title" }, title),
crel("span", { class: "title" }, title),
crel(
"span",
{ "class": "bytecount" },
{ class: "bytecount" },
pluralize(node.bytes.byteLength, "byte"),
),
),

View File

@ -39,6 +39,36 @@ export const GifNodeType = [
"extensionBlockIntroducer",
"extensionBlockSize",
"extensionBlockTerminator",
"applicationExtensionBlock",
"applicationIdentifier",
"applicationAuthenticationCode",
"applicationSubBlock",
"applicationSubBlockSize",
"applicationSubBlockData",
"commentSubBlock",
"commentSubBlockSize",
"commentSubBlockData",
"graphicControlExtensionBlock",
"graphicControlExtensionPackedFields",
"graphicControlExtensionDelayTime",
"graphicControlExtensionTransparentColorIndex",
"plainTextExtensionBlock",
"plainTextExtensionBlockTextGridLeftPosition",
"plainTextExtensionBlockTextGridTopPosition",
"plainTextExtensionBlockTextGridWidth",
"plainTextExtensionBlockTextGridHeight",
"plainTextExtensionBlockCharacterCellWidth",
"plainTextExtensionBlockCharacterCellHeight",
"plainTextExtensionBlockTextForegroundColorIndex",
"plainTextExtensionBlockTextBackgroundColorIndex",
"plainTextSubBlock",
"plainTextSubBlockSize",
"plainTextSubBlockData",
"unknownExtensionBlock",
"unknownExtensionBlockLabel",

View File

@ -56,7 +56,9 @@ const NODE_UI_FNS = {
[GifNodeType.header]: () => ({
title: "GIF header",
description: fragment(
p('GIFs start with a 6-byte header. This is typically the string "GIF87a" or "GIF89a", encoded as ASCII.'),
p(
'GIFs start with a 6-byte header. This is typically the string "GIF87a" or "GIF89a", encoded as ASCII.',
),
),
}),
[GifNodeType.headerSignature]: () => ({
@ -92,7 +94,10 @@ const NODE_UI_FNS = {
title: "Logical Screen Width",
description: p(
`The width of the image stored as a 16-bit little endian unsigned integer. In this case, the width is ${
pluralize(readGifUint(bytes), "byte")
pluralize(
readGifUint(bytes),
"byte",
)
}.`,
),
}),
@ -100,7 +105,10 @@ const NODE_UI_FNS = {
title: "Logical Screen Height",
description: p(
`The height of the image stored as a 16-bit little endian unsigned integer. In this case, the height is ${
pluralize(readGifUint(bytes), "byte")
pluralize(
readGifUint(bytes),
"byte",
)
}.`,
),
}),
@ -111,7 +119,9 @@ const NODE_UI_FNS = {
return {
title: "Logical Screen Descriptor packed fields",
description: fragment(
p("This byte contains several flags that control how the GIF is displayed. In binary, it looks like this:"),
p(
"This byte contains several flags that control how the GIF is displayed. In binary, it looks like this:",
),
p(bytes[0].toString(2).padStart(8, "0")),
crel(
"ul",
@ -123,7 +133,12 @@ const NODE_UI_FNS = {
),
li(
`The next three bits (${
packedField.colorResolution.toString(2).padStart(3, "0")
packedField.colorResolution
.toString(2)
.padStart(
3,
"0",
)
}) encode the color resolution. The bigger this number, the more colors this GIF can represent. This value is decoded as binary and then incremented by one, so the color resolution is ${packedField.colorResolution}.`,
),
li(
@ -135,9 +150,17 @@ const NODE_UI_FNS = {
),
li(
`The last three bits (${
(byte & 0b111).toString(2).padStart(3, "0")
(byte & 0b111)
.toString(2)
.padStart(
3,
"0",
)
}) encode the number of colors of the global color table. To decode this field, you add 1 and then raise that to the power of 2, so the size of the global color table is ${
pluralize(packedField.sizeOfGlobalColorTable, "color")
pluralize(
packedField.sizeOfGlobalColorTable,
"color",
)
}. `,
),
),
@ -159,7 +182,10 @@ const NODE_UI_FNS = {
title: "Global Color Table",
description: p(
`The Global Color Table is a list of colors. 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")
pluralize(
children?.length || 0,
"color",
)
}.`,
),
}),
@ -170,9 +196,11 @@ const NODE_UI_FNS = {
crel(
"span",
{
"style":
style:
`display:inline-block;width:1em;height:1em;vertical-align:middle;color:transparent;background:rgb(${
bytes.join(",")
bytes.join(
",",
)
})`,
},
`#${formatByte(bytes[0])}${formatByte(bytes[1])}${
@ -223,7 +251,10 @@ const NODE_UI_FNS = {
title: "Local Color Table",
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")
pluralize(
children?.length || 0,
"color",
)
}.`,
),
}),
@ -235,9 +266,11 @@ const NODE_UI_FNS = {
crel(
"span",
{
"style":
style:
`display:inline-block;width:1em;height:1em;vertical-align:middle;color:transparent;background:rgb(${
bytes.join(",")
bytes.join(
",",
)
})`,
},
`#${formatByte(bytes[0])}${formatByte(bytes[1])}${
@ -284,6 +317,135 @@ const NODE_UI_FNS = {
title: "Extension Block Size",
description: p("TODO"),
}),
[GifNodeType.extensionBlockTerminator]: () => ({
title: "Extension Block Terminator",
description: p("A zero byte indicating the end of this extension block."),
}),
// Application extension block
[GifNodeType.applicationExtensionBlock]: () => ({
title: "Application Extension Block",
description: p(
'A block for application-specific extensions. A very common extension is the "NETSCAPE2.0" extension which specifies the number of loops.',
),
}),
[GifNodeType.applicationIdentifier]: ({ bytes }) => ({
title: "Application Identifier",
description: p(
"An 8-byte string identifying the application. In this case, the identifier is ",
crel("code", {}, new TextDecoder().decode(bytes)),
),
}),
[GifNodeType.applicationAuthenticationCode]: () => ({
title: "Application Authentication Code",
description: p("Three bytes identifying the application."),
}),
[GifNodeType.applicationSubBlock]: ({ bytes }) => {
const isTerminator = areBytesEqual(bytes, new Uint8Array([0]));
return {
title: "Application Sub-Block",
description: p(
"A sub-block of data for this application." +
(isTerminator
? " This byte is empty, meaning it's the end of the sub-blocks and this application extension block."
: ""),
),
};
},
[GifNodeType.applicationSubBlockSize]: ({ bytes }) => ({
title: "Application Sub-Block Size",
description: p(`The size of this application sub-block: ${bytes[0]}.`),
}),
[GifNodeType.applicationSubBlockData]: () => ({
title: "Application Sub-Block Data",
description: p("The data for this application sub-block."),
}),
// Comment extension block
[GifNodeType.commentSubBlock]: () => ({
title: "Comment Sub-Block",
description: p("TODO"),
}),
[GifNodeType.commentSubBlockSize]: () => ({
title: "Comment Sub-Block Size",
description: p("TODO"),
}),
[GifNodeType.commentSubBlockData]: () => ({
title: "Comment Sub-Block Data",
description: p("TODO"),
}),
// Graphic control extension block
[GifNodeType.graphicControlExtensionBlock]: () => ({
title: "Graphic Control Extension Block",
description: p("TODO"),
}),
[GifNodeType.graphicControlExtensionPackedFields]: () => ({
title: "Graphic Control Extension packed fields",
description: p("TODO"),
}),
[GifNodeType.graphicControlExtensionDelayTime]: () => ({
title: "Graphic Control Extension Delay Time",
description: p("TODO"),
}),
[GifNodeType.graphicControlExtensionTransparentColorIndex]: () => ({
title: "Graphic Control Extension Transparent Color Index",
description: p("TODO"),
}),
// Plain text extension block
[GifNodeType.plainTextExtensionBlock]: () => ({
title: "Plain Text Extension Block",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockTextGridLeftPosition]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockTextGridTopPosition]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockTextGridWidth]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockTextGridHeight]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockCharacterCellWidth]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockCharacterCellHeight]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockTextForegroundColorIndex]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextExtensionBlockTextBackgroundColorIndex]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextSubBlock]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextSubBlockSize]: () => ({
title: "TODO",
description: p("TODO"),
}),
[GifNodeType.plainTextSubBlockData]: () => ({
title: "TODO",
description: p("TODO"),
}),
// Unknown extension block

View File

@ -9,6 +9,18 @@ const errorEl = document.getElementById("error");
const explorerEl = document.getElementById("explorer");
if (!errorEl || !explorerEl) throw new Error("HTML is not set up correctly");
/**
* @param {Element} element
* @returns {number}
*/
const totalElementChildren = (element) => {
let result = 1;
for (const child of element.children) {
result += totalElementChildren(child);
}
return result;
};
const main = () => {
// TODO: We may want a better UI here.
// TODO: Handle errors.
@ -19,16 +31,22 @@ const main = () => {
}
const bytes = base64.toByteArray(fileDataBase64);
console.time("parseGif");
const rootNode = parseGif(bytes);
console.timeEnd("parseGif");
if (!rootNode) {
// TODO: Is there better UI than this?
errorEl.removeAttribute("hidden");
return;
}
console.time("render");
explorerEl.innerHTML = "";
explorerEl.append(explorer(rootNode, getNodeUi));
explorerEl.removeAttribute("hidden");
console.timeEnd("render");
console.log(`Root node has ${totalElementChildren(explorerEl)} children`);
};
main();

View File

@ -7,6 +7,11 @@ const BLOCK_TYPE_IMAGE = 0x2c;
const BLOCK_TYPE_EXTENSION = 0x21;
const BLOCK_TYPE_TRAILER = 0x3b;
const EXTENSION_LABEL_FOR_APPLICATION = 0xff;
const EXTENSION_LABEL_FOR_COMMENT = 0xfe;
const EXTENSION_LABEL_FOR_GRAPHIC_CONTROL = 0xf9;
const EXTENSION_LABEL_FOR_PLAIN_TEXT = 0x01;
/**
* @typedef {object} LogicalScreenDescriptorPackedField
* @prop {boolean} globalColorTableFlag
@ -15,6 +20,14 @@ const BLOCK_TYPE_TRAILER = 0x3b;
* @prop {number} sizeOfGlobalColorTable
*/
/**
* @param {unknown} condition
* @param {string} [message]
*/
const assert = (condition, message = "Assertion failed") => {
if (!condition) throw new Error(message);
};
/**
* @param {number} byte
* @returns {LogicalScreenDescriptorPackedField}
@ -55,10 +68,12 @@ export const parseImageDescriptorPackedField = (byte) => ({
const readImageBlock = (bytes, startingOffset) => {
let offset = startingOffset;
/** @type {GifNode[]} */ const children = [{
type: GifNodeType.imageSeparator,
bytes: bytes.subarray(offset, offset + 1),
}];
/** @type {GifNode[]} */ const children = [
{
type: GifNodeType.imageSeparator,
bytes: bytes.subarray(offset, offset + 1),
},
];
offset++;
// Image descriptor
@ -102,17 +117,13 @@ const readImageBlock = (bytes, startingOffset) => {
for (let i = 0; i < packedField.sizeOfLocalColorTable; i++) {
colorNodes.push({
type: GifNodeType.localColorTableColor,
bytes: bytes.subarray(offset + (i * 3), offset + (i * 3) + 3),
bytes: bytes.subarray(offset + i * 3, offset + i * 3 + 3),
});
}
const sizeOfLocalColorTableInBytes = packedField.sizeOfLocalColorTable *
3;
const sizeOfLocalColorTableInBytes = packedField.sizeOfLocalColorTable * 3;
children.push({
type: GifNodeType.localColorTable,
bytes: bytes.subarray(
offset,
offset + sizeOfLocalColorTableInBytes,
),
bytes: bytes.subarray(offset, offset + sizeOfLocalColorTableInBytes),
children: colorNodes,
});
offset += sizeOfLocalColorTableInBytes;
@ -183,12 +194,49 @@ const readImageBlock = (bytes, startingOffset) => {
* @returns {{ node: GifNode, newOffset: number }}
*/
const readExtensionBlock = (bytes, startingOffset) => {
/** @type {GifNodeType} */ let type;
let offset = startingOffset;
/** @type {GifNode[]} */ const children = [{
type: GifNodeType.extensionBlockIntroducer,
bytes: bytes.subarray(offset, offset + 1),
}];
/**
* @param {GifNodeType} outerType
* @param {GifNodeType} sizeType
* @param {GifNodeType} dataType
*/
const readSubBlocks = (outerType, sizeType, dataType) => {
let subblockSize = -1;
do {
subblockSize = bytes[offset++];
children.push({
type: outerType,
bytes: bytes.subarray(offset, offset + subblockSize),
children: [
{
type: sizeType,
bytes: new Uint8Array([subblockSize]),
},
...(subblockSize
? [
{
type: dataType,
bytes: bytes.subarray(offset + 1, offset + 1 + subblockSize),
},
]
: []),
],
});
offset += subblockSize;
} while (subblockSize !== 0);
};
/** @type {GifNode[]} */ const children = [
{
type: GifNodeType.extensionBlockIntroducer,
bytes: bytes.subarray(offset, offset + 1),
},
];
offset++;
const extensionLabel = bytes[offset++];
@ -200,9 +248,142 @@ const readExtensionBlock = (bytes, startingOffset) => {
};
switch (extensionLabel) {
// TODO: handle various documented extensions
case EXTENSION_LABEL_FOR_APPLICATION: {
assert(extensionSize === 11);
type = GifNodeType.applicationExtensionBlock;
children.push({
type: GifNodeType.applicationIdentifier,
bytes: bytes.subarray(offset, offset + 8),
});
offset += 8;
children.push({
type: GifNodeType.applicationAuthenticationCode,
bytes: bytes.subarray(offset, offset + 3),
});
offset += 3;
readSubBlocks(
GifNodeType.applicationSubBlock,
GifNodeType.applicationSubBlockSize,
GifNodeType.applicationSubBlockData,
);
break;
}
case EXTENSION_LABEL_FOR_COMMENT: {
type = GifNodeType.commentExtensionBlock;
readSubBlocks(
GifNodeType.commentSubBlock,
GifNodeType.commentSubBlockSize,
GifNodeType.commentSubBlockData,
);
break;
}
case EXTENSION_LABEL_FOR_GRAPHIC_CONTROL: {
assert(extensionSize === 4);
type = GifNodeType.graphicControlExtensionBlock;
const packedField = bytes[offset++];
children.push({
type: GifNodeType.graphicControlExtensionPackedFields,
bytes: new Uint8Array([packedField]),
});
children.push({
type: GifNodeType.graphicControlExtensionDelayTime,
bytes: bytes.subarray(offset, offset + 2),
});
offset += 2;
const transparentColorIndex = bytes[offset++];
children.push({
type: GifNodeType.graphicControlExtensionTransparentColorIndex,
bytes: new Uint8Array([transparentColorIndex]),
});
const blockTerminator = bytes[offset++];
assert(blockTerminator === 0, "Expected block terminator");
children.push({
type: GifNodeType.extensionBlockTerminator,
bytes: new Uint8Array([blockTerminator]),
});
break;
}
case EXTENSION_LABEL_FOR_PLAIN_TEXT: {
assert(extensionSize === 12);
type = GifNodeType.plainTextExtensionBlock;
children.push({
type: GifNodeType.plainTextExtensionBlockTextGridLeftPosition,
bytes: bytes.subarray(offset, offset + 2),
});
offset += 2;
children.push({
type: GifNodeType.plainTextExtensionBlockTextGridTopPosition,
bytes: bytes.subarray(offset, offset + 2),
});
offset += 2;
children.push({
type: GifNodeType.plainTextExtensionBlockTextGridWidth,
bytes: bytes.subarray(offset, offset + 2),
});
offset += 2;
children.push({
type: GifNodeType.plainTextExtensionBlockTextGridHeight,
bytes: bytes.subarray(offset, offset + 2),
});
offset += 2;
const characterCellWidth = bytes[offset++];
children.push({
type: GifNodeType.plainTextExtensionBlockCharacterCellWidth,
bytes: new Uint8Array([characterCellWidth]),
});
const characterCellHeight = bytes[offset++];
children.push({
type: GifNodeType.plainTextExtensionBlockCharacterCellHeight,
bytes: new Uint8Array([characterCellHeight]),
});
const textForegroundColorIndex = bytes[offset++];
children.push({
type: GifNodeType.plainTextExtensionBlockTextForegroundColorIndex,
bytes: new Uint8Array([textForegroundColorIndex]),
});
const textBackgroundColorIndex = bytes[offset++];
children.push({
type: GifNodeType.plainTextExtensionBlockTextBackgroundColorIndex,
bytes: new Uint8Array([textBackgroundColorIndex]),
});
readSubBlocks(
GifNodeType.plainTextSubBlock,
GifNodeType.plainTextSubBlockSize,
GifNodeType.plainTextSubBlockData,
);
break;
}
default:
type = GifNodeType.unknownExtensionBlock;
children.push(
{
type: GifNodeType.unknownExtensionBlockLabel,
@ -214,16 +395,20 @@ const readExtensionBlock = (bytes, startingOffset) => {
bytes: bytes.subarray(offset, offset + extensionSize),
},
);
offset += extensionSize;
return {
newOffset: offset,
node: {
type: GifNodeType.unknownExtensionBlock,
bytes: bytes.subarray(startingOffset, offset),
children,
},
};
break;
}
return {
newOffset: offset,
node: {
type,
bytes: bytes.subarray(startingOffset, offset),
children,
},
};
};
/**
@ -302,7 +487,7 @@ export default (bytes) => {
for (let i = 0; i < packedField.sizeOfGlobalColorTable; i++) {
colorNodes.push({
type: GifNodeType.globalColorTableColor,
bytes: bytes.subarray(13 + (i * 3), 13 + (i * 3) + 3),
bytes: bytes.subarray(13 + i * 3, 13 + i * 3 + 3),
});
}
const sizeOfGlobalColorTableInBytes = packedField.sizeOfGlobalColorTable *