← Files stark AI DeveloperARCHIVED FILE
skills/animated-readme-logo/scripts/lib/animated-image.mjs
29.1 KB · Oct 5, 2026 · 18:31 UTC
import {
closeSync,
constants as fsConstants,
fstatSync,
lstatSync,
openSync,
readSync,
} from "node:fs";
export const DEFAULT_ANIMATED_IMAGE_LIMITS = Object.freeze({
maxFileBytes: 20 * 1024 * 1024,
maxChunkBytes: 8 * 1024 * 1024,
maxFrames: 1_000,
maxDimension: 16_384,
maxPixels: 64_000_000,
maxChunks: 10_000,
});
export class AnimatedImageError extends Error {
constructor(code, message, category = "validation") {
super(message);
this.name = "AnimatedImageError";
this.code = code;
this.category = category;
}
}
function fail(code, message, category) {
throw new AnimatedImageError(code, message, category);
}
function normalizeLimits(options = {}) {
const limits = { ...DEFAULT_ANIMATED_IMAGE_LIMITS, ...options };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
fail("INVALID_LIMIT", `${name} must be a positive safe integer`, "usage");
}
}
return limits;
}
function requireBytes(buffer, offset, length, context) {
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || length < 0) {
fail("INTERNAL_BOUNDS_ERROR", `invalid parser bounds for ${context}`);
}
if (offset < 0 || offset + length > buffer.length) {
fail("TRUNCATED_FILE", `${context} is truncated at byte ${offset}`);
}
}
function assertCanvas(width, height, limits, context) {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
fail("INVALID_DIMENSIONS", `${context} dimensions must be positive integers`);
}
if (
width > limits.maxDimension ||
height > limits.maxDimension ||
width * height > limits.maxPixels
) {
fail("DIMENSION_LIMIT", `${context} dimensions exceed configured limits`);
}
}
function loopLabel(loopCount) {
if (loopCount === null) return "unspecified";
return loopCount === 0 ? "infinite" : loopCount;
}
function result(format, width, height, frameCount, loopCount, details = {}) {
return {
format,
width,
height,
frameCount,
animated: frameCount > 1,
loopCount,
loop: loopLabel(loopCount),
...details,
};
}
function inspectGif(buffer, limits) {
requireBytes(buffer, 0, 13, "GIF header");
const signature = buffer.toString("ascii", 0, 6);
if (signature !== "GIF87a" && signature !== "GIF89a") {
fail("INVALID_GIF_SIGNATURE", "GIF signature must be GIF87a or GIF89a");
}
const width = buffer.readUInt16LE(6);
const height = buffer.readUInt16LE(8);
assertCanvas(width, height, limits, "GIF canvas");
const logicalPacked = buffer[10];
const hasGlobalColorTable = Boolean(logicalPacked & 0x80);
let offset = 13;
if (hasGlobalColorTable) {
const colorBytes = 3 * 2 ** ((logicalPacked & 0x07) + 1);
if (colorBytes > limits.maxChunkBytes) {
fail("CHUNK_LIMIT", "GIF global color table exceeds the chunk limit");
}
requireBytes(buffer, offset, colorBytes, "GIF global color table");
offset += colorBytes;
}
let frameCount = 0;
let loopCount = null;
let chunkCount = 0;
let trailerSeen = false;
let pendingFrameDelay = null;
let graphicControlCount = 0;
const frameDelaysCentiseconds = [];
const countChunk = (context) => {
chunkCount += 1;
if (chunkCount > limits.maxChunks) {
fail("CHUNK_COUNT_LIMIT", `${context} exceeds the chunk-count limit`);
}
};
const subBlocks = (context, capture = false) => {
let total = 0;
const parts = [];
while (true) {
requireBytes(buffer, offset, 1, `${context} sub-block length`);
const length = buffer[offset];
offset += 1;
if (length === 0) break;
countChunk(context);
total += length;
if (total > limits.maxChunkBytes) {
fail("CHUNK_LIMIT", `${context} data exceeds the chunk limit`);
}
requireBytes(buffer, offset, length, `${context} sub-block`);
if (capture) parts.push(buffer.subarray(offset, offset + length));
offset += length;
}
return capture ? Buffer.concat(parts, total) : null;
};
while (offset < buffer.length) {
const marker = buffer[offset];
offset += 1;
if (marker === 0x3b) {
trailerSeen = true;
break;
}
if (marker === 0x2c) {
if (frameCount >= limits.maxFrames) {
fail("FRAME_LIMIT", "GIF frame count exceeds the configured limit");
}
countChunk("GIF image descriptor");
requireBytes(buffer, offset, 9, "GIF image descriptor");
const left = buffer.readUInt16LE(offset);
const top = buffer.readUInt16LE(offset + 2);
const frameWidth = buffer.readUInt16LE(offset + 4);
const frameHeight = buffer.readUInt16LE(offset + 6);
const packed = buffer[offset + 8];
offset += 9;
if (packed & 0x18) {
fail("INVALID_GIF_DESCRIPTOR", "GIF image descriptor reserved bits must be zero");
}
assertCanvas(frameWidth, frameHeight, limits, "GIF frame");
if (left + frameWidth > width || top + frameHeight > height) {
fail("FRAME_OUT_OF_BOUNDS", "GIF frame exceeds the logical canvas");
}
const hasLocalColorTable = Boolean(packed & 0x80);
if (hasLocalColorTable) {
const colorBytes = 3 * 2 ** ((packed & 0x07) + 1);
if (colorBytes > limits.maxChunkBytes) {
fail("CHUNK_LIMIT", "GIF local color table exceeds the chunk limit");
}
requireBytes(buffer, offset, colorBytes, "GIF local color table");
offset += colorBytes;
} else if (!hasGlobalColorTable) {
fail("MISSING_COLOR_TABLE", "GIF frame has no global or local color table");
}
requireBytes(buffer, offset, 1, "GIF LZW minimum code size");
const minimumCodeSize = buffer[offset];
offset += 1;
if (minimumCodeSize < 2 || minimumCodeSize > 8) {
fail("INVALID_GIF_LZW", "GIF LZW minimum code size must be between 2 and 8");
}
subBlocks("GIF image data");
frameDelaysCentiseconds.push(pendingFrameDelay ?? 0);
pendingFrameDelay = null;
frameCount += 1;
continue;
}
if (marker !== 0x21) {
fail("INVALID_GIF_BLOCK", `unexpected GIF block marker 0x${marker.toString(16)}`);
}
requireBytes(buffer, offset, 1, "GIF extension label");
const label = buffer[offset];
offset += 1;
countChunk("GIF extension");
if (label === 0xf9) {
requireBytes(buffer, offset, 6, "GIF graphic-control extension");
if (buffer[offset] !== 4 || buffer[offset + 5] !== 0) {
fail("INVALID_GIF_EXTENSION", "GIF graphic-control extension has invalid framing");
}
const controlPacked = buffer[offset + 1];
if ((controlPacked & 0xe0) !== 0 || ((controlPacked >>> 2) & 0x07) > 3) {
fail("INVALID_GIF_EXTENSION", "GIF graphic-control flags are invalid");
}
if (4 > limits.maxChunkBytes) {
fail("CHUNK_LIMIT", "GIF graphic-control extension exceeds the chunk limit");
}
if (pendingFrameDelay !== null) {
fail("CONFLICTING_GIF_CONTROL", "GIF frame has multiple graphic-control extensions");
}
pendingFrameDelay = buffer.readUInt16LE(offset + 2);
graphicControlCount += 1;
offset += 6;
continue;
}
if (label === 0x01) {
fail("UNSUPPORTED_GIF_EXTENSION", "GIF plain-text extensions are not allowed");
}
if (label === 0xff) {
requireBytes(buffer, offset, 1, "GIF fixed extension length");
const fixedLength = buffer[offset];
offset += 1;
const expected = 11;
if (fixedLength !== expected) {
fail(
"INVALID_GIF_EXTENSION",
`GIF extension 0x${label.toString(16)} must be ${expected} bytes`,
);
}
if (fixedLength > limits.maxChunkBytes) {
fail("CHUNK_LIMIT", "GIF fixed extension data exceeds the chunk limit");
}
requireBytes(buffer, offset, fixedLength, "GIF fixed extension data");
const identifier = buffer.toString("ascii", offset, offset + 11);
offset += fixedLength;
if (identifier !== "NETSCAPE2.0" && identifier !== "ANIMEXTS1.0") {
fail(
"HIDDEN_METADATA",
`GIF application extension ${JSON.stringify(identifier)} is not allowed`,
);
}
const extensionData = subBlocks("GIF extension data", true);
if (identifier === "NETSCAPE2.0" || identifier === "ANIMEXTS1.0") {
if (extensionData.length !== 3 || extensionData[0] !== 1) {
fail("INVALID_GIF_LOOP", "GIF loop extension payload is malformed");
}
const parsedLoop = extensionData.readUInt16LE(1);
if (loopCount !== null && loopCount !== parsedLoop) {
fail("CONFLICTING_LOOP_METADATA", "GIF contains conflicting loop metadata");
}
loopCount = parsedLoop;
}
continue;
}
if (label === 0xfe) {
fail("HIDDEN_METADATA", "GIF comment extensions are not allowed");
}
fail("INVALID_GIF_EXTENSION", `unsupported GIF extension label 0x${label.toString(16)}`);
}
if (!trailerSeen) fail("MISSING_GIF_TRAILER", "GIF trailer is missing");
if (offset !== buffer.length) fail("TRAILING_DATA", "GIF contains data after its trailer");
if (frameCount === 0) fail("MISSING_FRAMES", "GIF contains no image frames");
if (pendingFrameDelay !== null) {
fail("DANGLING_GIF_CONTROL", "GIF has a graphic-control extension without a frame");
}
return result("gif", width, height, frameCount, loopCount, {
graphicControlCount,
frameDelaysCentiseconds,
durationCentiseconds: frameDelaysCentiseconds.reduce((total, delay) => total + delay, 0),
});
}
let crcTable;
function crc32(buffer) {
if (!crcTable) {
crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
crcTable[index] = value >>> 0;
}
}
let crc = 0xffffffff;
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
const PNG_BIT_DEPTHS = new Map([
[0, new Set([1, 2, 4, 8, 16])],
[2, new Set([8, 16])],
[3, new Set([1, 2, 4, 8])],
[4, new Set([8, 16])],
[6, new Set([8, 16])],
]);
function inspectPng(buffer, limits) {
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
requireBytes(buffer, 0, signature.length, "PNG signature");
if (!buffer.subarray(0, signature.length).equals(signature)) {
fail("INVALID_PNG_SIGNATURE", "PNG signature is invalid");
}
let offset = 8;
let chunkCount = 0;
let width;
let height;
let bitDepth;
let colorType;
let seenIhdr = false;
let seenPlte = false;
let seenTrns = false;
let paletteEntries = 0;
let seenIdat = false;
let idatClosed = false;
let seenIend = false;
let seenActl = false;
let animationFrameTotal = null;
let loopCount = null;
let frameCount = 0;
let expectedSequence = 0;
let currentFrameHasData = false;
let currentFrameDataKind = null;
while (offset < buffer.length) {
requireBytes(buffer, offset, 12, "PNG chunk");
const length = buffer.readUInt32BE(offset);
const type = buffer.toString("ascii", offset + 4, offset + 8);
if (!/^[A-Za-z]{4}$/.test(type) || (type.charCodeAt(2) & 0x20) !== 0) {
fail("INVALID_PNG_CHUNK_TYPE", `PNG chunk type ${JSON.stringify(type)} is invalid`);
}
if (length > limits.maxChunkBytes) {
fail("CHUNK_LIMIT", `PNG ${type} chunk exceeds the configured limit`);
}
requireBytes(buffer, offset + 8, length + 4, `PNG ${type} chunk`);
const data = buffer.subarray(offset + 8, offset + 8 + length);
const expectedCrc = buffer.readUInt32BE(offset + 8 + length);
const actualCrc = crc32(buffer.subarray(offset + 4, offset + 8 + length));
if (actualCrc !== expectedCrc) {
fail("PNG_CRC_MISMATCH", `PNG ${type} chunk CRC is invalid`);
}
offset += 12 + length;
chunkCount += 1;
if (chunkCount > limits.maxChunks) {
fail("CHUNK_COUNT_LIMIT", "PNG chunk count exceeds the configured limit");
}
if (!seenIhdr && type !== "IHDR") fail("PNG_ORDER", "IHDR must be the first PNG chunk");
if (seenIend) fail("TRAILING_DATA", "PNG contains chunks after IEND");
if (seenIdat && type !== "IDAT") idatClosed = true;
if (type === "IHDR") {
if (seenIhdr || length !== 13) fail("INVALID_IHDR", "PNG must contain one 13-byte IHDR");
seenIhdr = true;
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
assertCanvas(width, height, limits, "PNG canvas");
bitDepth = data[8];
colorType = data[9];
if (!PNG_BIT_DEPTHS.get(colorType)?.has(bitDepth)) {
fail("INVALID_IHDR", "PNG bit-depth and color-type combination is invalid");
}
if (data[10] !== 0 || data[11] !== 0 || ![0, 1].includes(data[12])) {
fail("INVALID_IHDR", "PNG compression, filter, or interlace method is invalid");
}
continue;
}
if (type === "PLTE") {
if (seenPlte || seenIdat || length === 0 || length % 3 !== 0 || length > 768) {
fail("INVALID_PLTE", "PNG PLTE must appear once before IDAT with 1 to 256 RGB entries");
}
if ([0, 4].includes(colorType)) {
fail("INVALID_PLTE", "PNG grayscale color types must not contain PLTE");
}
if (colorType === 3 && length / 3 > 2 ** bitDepth) {
fail("INVALID_PLTE", "indexed PNG PLTE has more entries than its bit depth permits");
}
paletteEntries = length / 3;
seenPlte = true;
continue;
}
if (type === "tRNS") {
if (seenTrns || seenIdat) {
fail("INVALID_TRNS", "PNG tRNS must appear at most once before IDAT");
}
if (colorType === 0 && length !== 2) {
fail("INVALID_TRNS", "grayscale PNG tRNS must contain one sample");
}
if (colorType === 2 && length !== 6) {
fail("INVALID_TRNS", "truecolor PNG tRNS must contain three samples");
}
if (colorType === 3 && (!seenPlte || length === 0 || length > paletteEntries)) {
fail("INVALID_TRNS", "indexed PNG tRNS must follow PLTE and fit its palette");
}
if ([4, 6].includes(colorType)) {
fail("INVALID_TRNS", "PNG color types with alpha must not contain tRNS");
}
seenTrns = true;
continue;
}
if (type === "acTL") {
if (seenActl || seenIdat || length !== 8) {
fail("INVALID_ACTL", "APNG acTL must appear once before IDAT and be 8 bytes");
}
seenActl = true;
animationFrameTotal = data.readUInt32BE(0);
loopCount = data.readUInt32BE(4);
if (animationFrameTotal === 0) fail("INVALID_ACTL", "APNG frame count must be positive");
if (animationFrameTotal > limits.maxFrames) {
fail("FRAME_LIMIT", "APNG declared frame count exceeds the configured limit");
}
continue;
}
if (type === "fcTL") {
if (!seenActl || length !== 26) {
fail("INVALID_FCTL", "APNG fcTL requires acTL and must be 26 bytes");
}
if (frameCount > 0 && !currentFrameHasData) {
fail("MISSING_FRAME_DATA", "APNG frame has no IDAT or fdAT data");
}
if (frameCount >= limits.maxFrames) fail("FRAME_LIMIT", "APNG frame count exceeds the limit");
const sequence = data.readUInt32BE(0);
if (sequence !== expectedSequence)
fail("APNG_SEQUENCE", "APNG sequence numbers are not contiguous");
expectedSequence += 1;
const frameWidth = data.readUInt32BE(4);
const frameHeight = data.readUInt32BE(8);
const x = data.readUInt32BE(12);
const y = data.readUInt32BE(16);
assertCanvas(frameWidth, frameHeight, limits, "APNG frame");
if (
frameCount === 0 &&
!seenIdat &&
(frameWidth !== width || frameHeight !== height || x !== 0 || y !== 0)
) {
fail(
"INVALID_FCTL",
"APNG first frame control before IDAT must match the full default-image canvas",
);
}
if (x + frameWidth > width || y + frameHeight > height) {
fail("FRAME_OUT_OF_BOUNDS", "APNG frame exceeds the PNG canvas");
}
if (data[24] > 2 || data[25] > 1) {
fail("INVALID_FCTL", "APNG dispose or blend operation is invalid");
}
frameCount += 1;
currentFrameHasData = false;
currentFrameDataKind = seenIdat ? "fdat" : "idat";
continue;
}
if (type === "IDAT") {
if (length === 0) fail("INVALID_IDAT", "PNG IDAT chunk must not be empty");
if (idatClosed) fail("PNG_ORDER", "PNG IDAT chunks must be consecutive");
if (colorType === 3 && !seenPlte) {
fail("MISSING_PLTE", "indexed PNG requires PLTE before IDAT");
}
if (frameCount > 0 && currentFrameDataKind !== "idat") {
fail("PNG_ORDER", "APNG IDAT data is only valid for the controlled default frame");
}
seenIdat = true;
if (currentFrameDataKind === "idat") currentFrameHasData = true;
continue;
}
if (type === "fdAT") {
if (
!seenActl ||
!seenIdat ||
frameCount === 0 ||
currentFrameDataKind !== "fdat" ||
length < 5
) {
fail("INVALID_FDAT", "APNG fdAT requires prior IDAT data and a subsequent frame control");
}
const sequence = data.readUInt32BE(0);
if (sequence !== expectedSequence)
fail("APNG_SEQUENCE", "APNG sequence numbers are not contiguous");
expectedSequence += 1;
currentFrameHasData = true;
continue;
}
if (type === "IEND") {
if (length !== 0 || !seenIdat) fail("INVALID_IEND", "PNG IEND is invalid or IDAT is missing");
if (frameCount > 0 && !currentFrameHasData) {
fail("MISSING_FRAME_DATA", "APNG final frame has no IDAT or fdAT data");
}
seenIend = true;
if (offset !== buffer.length) fail("TRAILING_DATA", "PNG contains data after IEND");
break;
}
if (type[0] === type[0].toUpperCase()) {
fail("UNKNOWN_CRITICAL_CHUNK", `unsupported critical PNG chunk ${type}`);
}
if (["eXIf", "iCCP", "iTXt", "tEXt", "tIME", "zTXt"].includes(type)) {
fail("HIDDEN_METADATA", `PNG metadata chunk ${type} is not allowed`);
}
if (!["cHRM", "gAMA", "sBIT", "sRGB"].includes(type)) {
fail("UNSUPPORTED_ANCILLARY_CHUNK", `unsupported PNG ancillary chunk ${type}`);
}
}
if (!seenIhdr || !seenIend) fail("INCOMPLETE_PNG", "PNG is missing IHDR or IEND");
if (seenActl) {
if (frameCount === 0 || frameCount !== animationFrameTotal) {
fail("APNG_FRAME_COUNT", "APNG fcTL count does not match acTL");
}
return result("apng", width, height, frameCount, loopCount);
}
if (frameCount > 0) fail("APNG_CONTROL_WITHOUT_ACTL", "APNG frame control appears without acTL");
return result("png", width, height, 1, null);
}
function uint24Le(buffer, offset) {
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16);
}
function parseVp8Dimensions(data, context) {
if (data.length < 10 || (data[0] & 1) !== 0) {
fail("INVALID_VP8", `${context} VP8 key-frame header is invalid`);
}
if (data[3] !== 0x9d || data[4] !== 0x01 || data[5] !== 0x2a) {
fail("INVALID_VP8", `${context} VP8 start code is invalid`);
}
return {
width: data.readUInt16LE(6) & 0x3fff,
height: data.readUInt16LE(8) & 0x3fff,
};
}
function parseVp8lDimensions(data, context) {
if (data.length < 5 || data[0] !== 0x2f) {
fail("INVALID_VP8L", `${context} VP8L header is invalid`);
}
const bits = data.readUInt32LE(1);
if (bits >>> 29 !== 0) fail("INVALID_VP8L", `${context} VP8L version must be zero`);
return {
width: (bits & 0x3fff) + 1,
height: ((bits >>> 14) & 0x3fff) + 1,
};
}
function parseWebpFramePayload(data, frameWidth, frameHeight, limits, countChunk) {
let offset = 16;
let imageDimensions = null;
let alphaSeen = false;
while (offset < data.length) {
requireBytes(data, offset, 8, "WebP ANMF child chunk");
const type = data.toString("ascii", offset, offset + 4);
const length = data.readUInt32LE(offset + 4);
if (length > limits.maxChunkBytes)
fail("CHUNK_LIMIT", `WebP ${type} child chunk exceeds the limit`);
const paddedLength = length + (length & 1);
requireBytes(data, offset + 8, paddedLength, `WebP ${type} child chunk`);
if (length & 1 && data[offset + 8 + length] !== 0) {
fail("INVALID_WEBP_PADDING", `WebP ${type} child padding byte must be zero`);
}
countChunk("WebP ANMF child chunk");
const payload = data.subarray(offset + 8, offset + 8 + length);
if (type === "ALPH") {
if (alphaSeen || imageDimensions)
fail("WEBP_FRAME_ORDER", "WebP ALPH must precede one frame image");
alphaSeen = true;
} else if (type === "VP8 ") {
if (imageDimensions)
fail("MULTIPLE_FRAME_IMAGES", "WebP frame contains multiple image chunks");
imageDimensions = parseVp8Dimensions(payload, "WebP frame");
} else if (type === "VP8L") {
if (imageDimensions || alphaSeen) {
fail("WEBP_FRAME_ORDER", "WebP lossless frame cannot follow ALPH or another image");
}
imageDimensions = parseVp8lDimensions(payload, "WebP frame");
} else {
fail(
"INVALID_WEBP_FRAME_CHUNK",
`unsupported WebP frame child chunk ${JSON.stringify(type)}`,
);
}
offset += 8 + paddedLength;
}
if (!imageDimensions) fail("MISSING_FRAME_IMAGE", "WebP ANMF contains no VP8 or VP8L image");
assertCanvas(imageDimensions.width, imageDimensions.height, limits, "WebP frame bitstream");
if (imageDimensions.width !== frameWidth || imageDimensions.height !== frameHeight) {
fail("FRAME_DIMENSION_MISMATCH", "WebP ANMF and frame bitstream dimensions differ");
}
}
function inspectWebp(buffer, limits) {
requireBytes(buffer, 0, 12, "WebP RIFF header");
if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WEBP") {
fail("INVALID_WEBP_SIGNATURE", "WebP RIFF signature is invalid");
}
const declaredSize = buffer.readUInt32LE(4) + 8;
if (declaredSize !== buffer.length)
fail("WEBP_SIZE_MISMATCH", "WebP RIFF size does not match the file");
let offset = 12;
let chunkCount = 0;
let width;
let height;
let extended = false;
let animationFlag = false;
let animSeen = false;
let loopCount = null;
let frameCount = 0;
let staticDimensions = null;
let topLevelAlpha = false;
const countChunk = (context) => {
chunkCount += 1;
if (chunkCount > limits.maxChunks)
fail("CHUNK_COUNT_LIMIT", `${context} exceeds the chunk limit`);
};
while (offset < buffer.length) {
requireBytes(buffer, offset, 8, "WebP chunk");
const type = buffer.toString("ascii", offset, offset + 4);
const length = buffer.readUInt32LE(offset + 4);
if (!/^[A-Z0-9 ]{4}$/.test(type))
fail("INVALID_WEBP_CHUNK", `invalid WebP chunk type ${JSON.stringify(type)}`);
if (length > limits.maxChunkBytes) fail("CHUNK_LIMIT", `WebP ${type} chunk exceeds the limit`);
const paddedLength = length + (length & 1);
requireBytes(buffer, offset + 8, paddedLength, `WebP ${type} chunk`);
if (length & 1 && buffer[offset + 8 + length] !== 0) {
fail("INVALID_WEBP_PADDING", `WebP ${type} padding byte must be zero`);
}
const data = buffer.subarray(offset + 8, offset + 8 + length);
offset += 8 + paddedLength;
countChunk("WebP");
if (type === "VP8X") {
if (extended || chunkCount !== 1 || length !== 10) {
fail("INVALID_VP8X", "WebP VP8X must be the first chunk and exactly 10 bytes");
}
if ((data[0] & 0xc1) !== 0 || data[1] !== 0 || data[2] !== 0 || data[3] !== 0) {
fail("INVALID_VP8X", "WebP VP8X reserved bits and bytes must be zero");
}
if (data[0] & 0x2c) {
fail("HIDDEN_METADATA", "WebP ICC, EXIF, and XMP metadata flags are not allowed");
}
extended = true;
animationFlag = Boolean(data[0] & 0x02);
width = uint24Le(data, 4) + 1;
height = uint24Le(data, 7) + 1;
assertCanvas(width, height, limits, "WebP canvas");
continue;
}
if (type === "ANIM") {
if (!extended || !animationFlag || animSeen || frameCount > 0 || length !== 6) {
fail("INVALID_ANIM", "WebP ANIM requires one animation VP8X and must precede frames");
}
animSeen = true;
loopCount = data.readUInt16LE(4);
continue;
}
if (type === "ANMF") {
if (!animSeen || length < 24) fail("INVALID_ANMF", "WebP ANMF requires ANIM and frame data");
if (frameCount >= limits.maxFrames) fail("FRAME_LIMIT", "WebP frame count exceeds the limit");
const x = uint24Le(data, 0) * 2;
const y = uint24Le(data, 3) * 2;
const frameWidth = uint24Le(data, 6) + 1;
const frameHeight = uint24Le(data, 9) + 1;
if (data[15] & 0xfc) fail("INVALID_ANMF", "WebP ANMF reserved flag bits must be zero");
assertCanvas(frameWidth, frameHeight, limits, "WebP frame");
if (x + frameWidth > width || y + frameHeight > height) {
fail("FRAME_OUT_OF_BOUNDS", "WebP frame exceeds its canvas");
}
parseWebpFramePayload(data, frameWidth, frameHeight, limits, countChunk);
frameCount += 1;
continue;
}
if (type === "VP8 " || type === "VP8L") {
if (staticDimensions || animSeen)
fail("MULTIPLE_WEBP_IMAGES", "WebP contains unexpected image chunks");
staticDimensions =
type === "VP8 " ? parseVp8Dimensions(data, "WebP") : parseVp8lDimensions(data, "WebP");
if (type === "VP8L" && topLevelAlpha) {
fail("WEBP_IMAGE_ORDER", "WebP ALPH cannot precede a lossless VP8L image");
}
assertCanvas(staticDimensions.width, staticDimensions.height, limits, "WebP image");
continue;
}
if (type === "ALPH") {
if (!extended || animationFlag || topLevelAlpha || staticDimensions) {
fail("WEBP_IMAGE_ORDER", "WebP ALPH must precede one extended static VP8 image");
}
topLevelAlpha = true;
continue;
}
if (["ICCP", "EXIF", "XMP "].includes(type)) {
fail("HIDDEN_METADATA", `WebP metadata chunk ${type.trim()} is not allowed`);
}
fail("UNKNOWN_WEBP_CHUNK", `unsupported WebP chunk ${JSON.stringify(type)}`);
}
if (animationFlag) {
if (!animSeen || frameCount === 0)
fail("INCOMPLETE_ANIMATION", "animated WebP lacks ANIM or ANMF chunks");
if (staticDimensions)
fail("INVALID_ANIMATED_WEBP", "animated WebP contains a top-level image chunk");
return result("webp", width, height, frameCount, loopCount);
}
if (animSeen || frameCount > 0)
fail("UNDECLARED_ANIMATION", "WebP animation chunks lack the VP8X flag");
if (!staticDimensions) fail("MISSING_WEBP_IMAGE", "static WebP contains no VP8 or VP8L image");
if (extended) {
if (staticDimensions.width !== width || staticDimensions.height !== height) {
fail("CANVAS_DIMENSION_MISMATCH", "WebP VP8X and image dimensions differ");
}
} else {
({ width, height } = staticDimensions);
}
return result("webp", width, height, 1, null);
}
export function inspectAnimatedImage(input, options = {}) {
const limits = normalizeLimits(options);
const buffer = Buffer.isBuffer(input)
? input
: input instanceof Uint8Array
? Buffer.from(input.buffer, input.byteOffset, input.byteLength)
: null;
if (!buffer) fail("INVALID_INPUT", "input must be a Buffer or Uint8Array", "usage");
if (buffer.length === 0) fail("EMPTY_FILE", "image file is empty");
if (buffer.length > limits.maxFileBytes) {
fail("FILE_LIMIT", "image file exceeds the configured byte limit");
}
if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.toString("ascii", 0, 6))) {
return inspectGif(buffer, limits);
}
if (
buffer.length >= 8 &&
buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) {
return inspectPng(buffer, limits);
}
if (
buffer.length >= 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return inspectWebp(buffer, limits);
}
fail("UNSUPPORTED_FORMAT", "file is not a GIF, PNG/APNG, or WebP image");
}
export function inspectAnimatedImageFile(filePath, options = {}) {
const limits = normalizeLimits(options);
let descriptor;
try {
const pathStat = lstatSync(filePath);
if (!pathStat.isFile()) {
fail("NOT_REGULAR_FILE", "input path is not a regular file", "io");
}
const safeReadFlags =
fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOFOLLOW ?? 0);
descriptor = openSync(filePath, safeReadFlags);
const stat = fstatSync(descriptor);
if (!stat.isFile()) fail("NOT_REGULAR_FILE", "input path is not a regular file", "io");
if (stat.size > limits.maxFileBytes) {
fail("FILE_LIMIT", "image file exceeds the configured byte limit");
}
const buffer = Buffer.alloc(Math.min(stat.size, limits.maxFileBytes) + 1);
let total = 0;
while (total < buffer.length) {
const read = readSync(descriptor, buffer, total, buffer.length - total, null);
if (read === 0) break;
total += read;
}
if (total > limits.maxFileBytes)
fail("FILE_LIMIT", "image file exceeds the configured byte limit");
return inspectAnimatedImage(buffer.subarray(0, total), limits);
} catch (error) {
if (error instanceof AnimatedImageError) throw error;
fail("READ_ERROR", `unable to read local image: ${error.message}`, "io");
} finally {
if (descriptor !== undefined) closeSync(descriptor);
}
}
SHA-256: 6e0ed88a4cf068ec09038d6c0a8886f138d1c0ab8e12f1fdb87d74ecdf6832ef