import { RIFFFile } from "riff-file"; import { unpackArray, unpackString } from "byte-data"; type Chunk = { format: string; chunkId: string; chunkData: { start: number; end: number; }; subChunks: Chunk[]; }; // https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3 type AniMetadata = { cbSize: number; // Data structure size (in bytes) nFrames: number; // Number of images (also known as frames) stored in the file nSteps: number; // Number of frames to be displayed before the animation repeats iWidth: number; // Width of frame (in pixels) iHeight: number; // Height of frame (in pixels) iBitCount: number; // Number of bits per pixel nPlanes: number; // Number of color planes iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units) bfAttributes: number; // ANI attribute bit flags }; type ParsedAni = { rate: number[] | null; seq: number[] | null; images: Uint8Array[]; metadata: AniMetadata; artist: string | null; title: string | null; }; const DWORD = { bits: 32, be: false, signed: false, fp: false }; export function parseAni(arr: Uint8Array): ParsedAni { const riff = new RIFFFile(); riff.setSignature(arr); const signature = riff.signature as Chunk; if (signature.format !== "ACON") { throw new Error( `Expected format. Expected "ACON", got "${signature.format}"` ); } // Helper function to get a chunk by chunkId and transform it if it's non-null. function mapChunk(chunkId: string, mapper: (chunk: Chunk) => T): T | null { const chunk = riff.findChunk(chunkId) as Chunk | null; return chunk == null ? null : mapper(chunk); } function readImages(chunk: Chunk, frameCount: number): Uint8Array[] { return chunk.subChunks.slice(0, frameCount).map((c) => { if (c.chunkId !== "icon") { throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`); } return arr.slice(c.chunkData.start, c.chunkData.end); }); } const metadata = mapChunk("anih", (c) => { const words = unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); return { cbSize: words[0], nFrames: words[1], nSteps: words[2], iWidth: words[3], iHeight: words[4], iBitCount: words[5], nPlanes: words[6], iDispRate: words[7], bfAttributes: words[8], }; }); if (metadata == null) { throw new Error("Did not find anih"); } const rate = mapChunk("rate", (c) => { return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); }); // chunkIds are always four chars, hence the trailing space. const seq = mapChunk("seq ", (c) => { return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); }); const lists = riff.findChunk("LIST", true) as Chunk[] | null; const imageChunk = lists?.find((c) => c.format === "fram"); if (imageChunk == null) { throw new Error("Did not find fram LIST"); } let images = readImages(imageChunk, metadata.nFrames); let title = null; let artist = null; const infoChunk = lists?.find((c) => c.format === "INFO"); if (infoChunk != null) { infoChunk.subChunks.forEach((c) => { switch (c.chunkId) { case "INAM": title = unpackString(arr, c.chunkData.start, c.chunkData.end); break; case "IART": artist = unpackString(arr, c.chunkData.start, c.chunkData.end); break; case "LIST": // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason? if (c.format === "fram") { images = readImages(c, metadata.nFrames); } break; default: // Unexpected subchunk } }); } return { images, rate, seq, metadata, artist, title }; }