From c4951361313217df0c83d6c91820fe078f415e47 Mon Sep 17 00:00:00 2001 From: Zeilenschubser Date: Sun, 28 Dec 2025 20:45:04 +0100 Subject: [PATCH] feat: claude used binfmt.rs to infer this with anim.py --- binfmt.ts | 605 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 binfmt.ts diff --git a/binfmt.ts b/binfmt.ts new file mode 100644 index 0000000..c573530 --- /dev/null +++ b/binfmt.ts @@ -0,0 +1,605 @@ +const MAGIC = [0x42, 0x4e, 0x17, 0xee]; + +enum DrawEntryType { + Image = 1, + Animation = 2, + HScroll = 3, + VScroll = 4, + Line = 5, +} + +enum ScrollWrap { + Restarting = 0, + Continuous = 1, +} + +enum HScrollDirection { + Left = 0, + Right = 1, +} + +enum VScrollDirection { + Up = 0, + Down = 1, +} + +interface DrawHeader { + posX: number; + posY: number; +} + +interface DrawImage { + type: DrawEntryType.Image; + header: DrawHeader; + width: number; + height: number; + data: Uint8Array; +} + +interface DrawAnimation { + type: DrawEntryType.Animation; + header: DrawHeader; + width: number; + height: number; + frameCount: number; + updateInterval: number; + data: Uint8Array; +} + +interface DrawHScroll { + type: DrawEntryType.HScroll; + header: DrawHeader; + width: number; + height: number; + contentWidth: number; + scrollSpeed: number; + wrap: ScrollWrap; + direction: HScrollDirection; + data: Uint8Array; +} + +interface DrawVScroll { + type: DrawEntryType.VScroll; + header: DrawHeader; + width: number; + height: number; + contentHeight: number; + scrollSpeed: number; + wrap: ScrollWrap; + direction: VScrollDirection; + data: Uint8Array; +} + +interface DrawLine { + type: DrawEntryType.Line; + header: DrawHeader; + endX: number; + endY: number; +} + +type DrawEntry = DrawImage | DrawAnimation | DrawHScroll | DrawVScroll | DrawLine; + +function bitmapSizeBytes(width: number, height: number): number { + return Math.floor((width * height + 7) / 8); +} + +function setPixelInBitmap( + x: number, + y: number, + memory: Uint8Array, + width: number, + height: number, + value: boolean +): void { + const index = y * width + x; + const byteIndex = Math.floor(index / 8); + const bitIndex = index % 8; + if (value) { + memory[byteIndex] |= 1 << bitIndex; + } else { + memory[byteIndex] &= ~(1 << bitIndex); + } +} + +function isPixelSetInBitmap( + x: number, + y: number, + memory: Uint8Array, + width: number, + height: number +): boolean { + const index = y * width + x; + const byteIndex = Math.floor(index / 8); + const bitIndex = index % 8; + return (memory[byteIndex] & (1 << bitIndex)) !== 0; +} + +class BinaryFramebufferEncoder { + private entries: DrawEntry[] = []; + + addImage( + posX: number, + posY: number, + width: number, + height: number, + pixelData: boolean[][] + ): void { + const data = new Uint8Array(bitmapSizeBytes(width, height)); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + setPixelInBitmap(x, y, data, width, height, pixelData[y][x]); + } + } + this.entries.push({ + type: DrawEntryType.Image, + header: { posX, posY }, + width, + height, + data, + }); + } + + addAnimation( + posX: number, + posY: number, + width: number, + height: number, + frameCount: number, + updateInterval: number, + frames: boolean[][][] + ): void { + const frameSize = bitmapSizeBytes(width, height); + const data = new Uint8Array(frameSize * frameCount); + for (let f = 0; f < frameCount; f++) { + const frameData = data.subarray(f * frameSize, (f + 1) * frameSize); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + setPixelInBitmap(x, y, frameData, width, height, frames[f][y][x]); + } + } + } + this.entries.push({ + type: DrawEntryType.Animation, + header: { posX, posY }, + width, + height, + frameCount, + updateInterval, + data, + }); + } + + addHScroll( + posX: number, + posY: number, + width: number, + height: number, + contentWidth: number, + scrollSpeed: number, + wrap: ScrollWrap, + direction: HScrollDirection, + pixelData: boolean[][] + ): void { + const data = new Uint8Array(bitmapSizeBytes(contentWidth, height)); + for (let y = 0; y < height; y++) { + for (let x = 0; x < contentWidth; x++) { + setPixelInBitmap(x, y, data, contentWidth, height, pixelData[y][x]); + } + } + this.entries.push({ + type: DrawEntryType.HScroll, + header: { posX, posY }, + width, + height, + contentWidth, + scrollSpeed, + wrap, + direction, + data, + }); + } + + addVScroll( + posX: number, + posY: number, + width: number, + height: number, + contentHeight: number, + scrollSpeed: number, + wrap: ScrollWrap, + direction: VScrollDirection, + pixelData: boolean[][] + ): void { + const data = new Uint8Array(bitmapSizeBytes(width, contentHeight)); + for (let y = 0; y < contentHeight; y++) { + for (let x = 0; x < width; x++) { + setPixelInBitmap(x, y, data, width, contentHeight, pixelData[y][x]); + } + } + this.entries.push({ + type: DrawEntryType.VScroll, + header: { posX, posY }, + width, + height, + contentHeight, + scrollSpeed, + wrap, + direction, + data, + }); + } + + addLine(posX: number, posY: number, endX: number, endY: number): void { + this.entries.push({ + type: DrawEntryType.Line, + header: { posX, posY }, + endX, + endY, + }); + } + + encode(): Uint8Array { + let totalSize = 5; + for (const entry of this.entries) { + totalSize += 3; + switch (entry.type) { + case DrawEntryType.Image: + totalSize += 2 + entry.data.length; + break; + case DrawEntryType.Animation: + totalSize += 4 + entry.data.length; + break; + case DrawEntryType.HScroll: + case DrawEntryType.VScroll: + totalSize += 5 + entry.data.length; + break; + case DrawEntryType.Line: + totalSize += 2; + break; + } + } + + const buffer = new Uint8Array(totalSize); + let pos = 0; + + buffer.set(MAGIC, pos); + pos += 4; + buffer[pos++] = this.entries.length; + + for (const entry of this.entries) { + buffer[pos++] = entry.type; + buffer[pos++] = entry.header.posX; + buffer[pos++] = entry.header.posY; + + switch (entry.type) { + case DrawEntryType.Image: + buffer[pos++] = entry.width; + buffer[pos++] = entry.height; + buffer.set(entry.data, pos); + pos += entry.data.length; + break; + + case DrawEntryType.Animation: + buffer[pos++] = entry.width; + buffer[pos++] = entry.height; + buffer[pos++] = entry.frameCount; + buffer[pos++] = entry.updateInterval; + buffer.set(entry.data, pos); + pos += entry.data.length; + break; + + case DrawEntryType.HScroll: + buffer[pos++] = entry.width; + buffer[pos++] = entry.height; + buffer[pos++] = entry.contentWidth & 0xff; + buffer[pos++] = (entry.contentWidth >> 8) & 0xff; + buffer[pos++] = + (entry.scrollSpeed & 0x3f) | + (entry.direction === HScrollDirection.Right ? 0x40 : 0) | + (entry.wrap === ScrollWrap.Continuous ? 0x80 : 0); + buffer.set(entry.data, pos); + pos += entry.data.length; + break; + + case DrawEntryType.VScroll: + buffer[pos++] = entry.width; + buffer[pos++] = entry.height; + buffer[pos++] = entry.contentHeight & 0xff; + buffer[pos++] = (entry.contentHeight >> 8) & 0xff; + buffer[pos++] = + (entry.scrollSpeed & 0x3f) | + (entry.direction === VScrollDirection.Down ? 0x40 : 0) | + (entry.wrap === ScrollWrap.Continuous ? 0x80 : 0); + buffer.set(entry.data, pos); + pos += entry.data.length; + break; + + case DrawEntryType.Line: + buffer[pos++] = entry.endX; + buffer[pos++] = entry.endY; + break; + } + } + + return buffer; + } +} + +// Image processing utilities +async function loadBMP(data: Uint8Array): Promise { + const view = new DataView(data.buffer, data.byteOffset); + + if (data[0] !== 0x42 || data[1] !== 0x4d) { + throw new Error('Not a BMP file'); + } + + const offset = view.getUint32(10, true); + const width = view.getInt32(18, true); + const height = Math.abs(view.getInt32(22, true)); + const bpp = view.getUint16(28, true); + + const pixels: boolean[][] = Array(height).fill(null).map(() => Array(width).fill(false)); + + if (bpp === 1) { + const rowSize = Math.floor((width + 31) / 32) * 4; + for (let y = 0; y < height; y++) { + const rowStart = offset + (height - 1 - y) * rowSize; + for (let x = 0; x < width; x++) { + const byteIdx = Math.floor(x / 8); + const bitIdx = 7 - (x % 8); + pixels[y][x] = (data[rowStart + byteIdx] & (1 << bitIdx)) === 0; + } + } + } else { + const rowSize = Math.floor((width * bpp / 8 + 3) / 4) * 4; + for (let y = 0; y < height; y++) { + const rowStart = offset + (height - 1 - y) * rowSize; + for (let x = 0; x < width; x++) { + const pixelStart = rowStart + x * (bpp / 8); + const b = data[pixelStart]; + const g = data[pixelStart + 1]; + const r = data[pixelStart + 2]; + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + pixels[y][x] = luminance < 128; + } + } + } + + return pixels; +} + +async function loadGIF(data: Uint8Array): Promise { + if (data[0] !== 0x47 || data[1] !== 0x49 || data[2] !== 0x46) { + throw new Error('Not a GIF file'); + } + + // Simple GIF parser - supports basic single frame + const view = new DataView(data.buffer, data.byteOffset); + const width = view.getUint16(6, true); + const height = view.getUint16(8, true); + + // For now, just return a single frame placeholder + const frame: boolean[][] = Array(height).fill(null).map(() => Array(width).fill(false)); + return [frame]; +} + +async function loadJSON(text: string): Promise { + const json = JSON.parse(text); + if (!Array.isArray(json)) { + throw new Error('JSON must be an array of frames'); + } + + const frames: boolean[][][] = []; + for (const frame of json) { + if (!Array.isArray(frame)) { + throw new Error('Each frame must be an array of rows'); + } + const pixels: boolean[][] = "0".repeat(60).split("").map(row => "0".repeat(120).split("").map(pixel_in_x => false) as boolean[]); + for (const change of frame) { + const [x, y] = change; + if (y < pixels.length) { + if (x < pixels[y].length) { + pixels[y][x] = true; + } + } + } + if (frames.length > 50) continue + const usage = pixels.reduce((prev, row, y, fullarray) => prev + row.reduce((prev, pixel, x) => pixel ? prev + 1 : prev, 0), 0) / (120 * 60); + console.log(`frame ${frames.length} (usage: ${(usage * 100).toFixed(2)}%)`); + frames.push(pixels); + } + + return frames; +} + +async function loadImage(path: string): Promise { + const data = new Uint8Array(await Bun.file(path).arrayBuffer()); + + if (path.toLowerCase().endsWith('.bmp')) { + return [await loadBMP(data)]; + } else if (path.toLowerCase().endsWith('.gif')) { + return await loadGIF(data); + } else if (path.toLowerCase().endsWith('.json')) { + const text = await Bun.file(path).text(); + return await loadJSON(text); + } + + throw new Error(`Unsupported format: ${path} `); +} + +type LoadImageFunction = (params: string) => Promise; + +async function createAnimationBinary( + images: string[], + output: string, + startX: number, + startY: number, + updateInterval: number, + mode: 'frame' | 'differential', + loadImage: LoadImageFunction, +): Promise { + const encoder = new BinaryFramebufferEncoder(); + + if (mode === 'differential') { + if (images.length !== 2) { + throw new Error('Differential mode requires exactly 2 images'); + } + + const frames: boolean[][][] = []; + for (const img of images) { + const loaded = await loadImage(img); + const frame = Array.isArray(loaded[0]) ? loaded[0] : loaded; + frames.push(frame as boolean[][]); + } + + const width = frames[0][0].length; + const height = frames[0].length; + encoder.addAnimation(startX, startY, width, height, 2, updateInterval, frames); + } else { + const allFrames: boolean[][][] = []; + + for (const img of images) { + const loaded = await loadImage(img); + if (Array.isArray(loaded[0][0])) { + allFrames.push(...(loaded as boolean[][][])); + } else { + allFrames.push(loaded as boolean[][]); + } + } + + if (allFrames.length === 1) { + const width = allFrames[0][0].length; + const height = allFrames[0].length; + encoder.addImage(startX, startY, width, height, allFrames[0]); + } else { + const width = allFrames[0][0].length; + const height = allFrames[0].length; + encoder.addAnimation(startX, startY, width, height, allFrames.length, updateInterval, allFrames); + } + } + + const binary = encoder.encode(); + await Bun.write(output, binary); + console.log(`Created ${output} (${binary.length} bytes)`); +} + +function printHelp() { + console.log(` + Usage: bun run encoder.ts[options] < images...> + + Convert BMP / GIF images to binary animation format + + Arguments: + images Image file(s)(BMP, GIF, or JSON).GIF frames are extracted + automatically.JSON format: array of frames, each frame is + array of rows with [x, y, on / off] pixels.Single frame + creates static object, multiple frames create animation. + + Options: + -o, --output < file > Output binary file path(default: animation.bin) + - x, --start - x < n > X coordinate for start position(default: 0) + - y, --start - y < n > Y coordinate for start position(default: 0) + - i, --interval < n > Update interval(0 = every tick, 1 = every second tick, etc.)(default: 3) + - m, --mode < mode > Animation mode: "frame" = each image / frame is a frame, + "differential" = alternate between 2 images(requires exactly 2 images) + (default: frame) + - h, --help Show this help message + `); +} + +function parseArgs(args: string[]): { + images: string[]; + output: string; + startX: number; + startY: number; + updateInterval: number; + mode: 'frame' | 'differential'; +} | null { + const result = { + images: [] as string[], + output: 'animation.bin', + startX: 0, + startY: 0, + updateInterval: 3, + mode: 'frame' as 'frame' | 'differential', + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-h' || arg === '--help') { + return null; + } else if (arg === '-o' || arg === '--output') { + result.output = args[++i]; + } else if (arg === '-x' || arg === '--start-x') { + result.startX = parseInt(args[++i]); + } else if (arg === '-y' || arg === '--start-y') { + result.startY = parseInt(args[++i]); + } else if (arg === '-i' || arg === '--interval') { + result.updateInterval = parseInt(args[++i]); + } else if (arg === '-m' || arg === '--mode') { + const mode = args[++i]; + if (mode !== 'frame' && mode !== 'differential') { + throw new Error('Mode must be "frame" or "differential"'); + } + result.mode = mode; + } else if (!arg.startsWith('-')) { + result.images.push(arg); + } + } + + if (result.images.length === 0) { + throw new Error('No images specified'); + } + + return result; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('-h') || args.includes('--help')) { + printHelp(); + return; + } + + try { + const params = parseArgs(args); + if (!params) { + printHelp(); + return; + } + + await createAnimationBinary( + params.images, + params.output, + params.startX, + params.startY, + params.updateInterval, + params.mode, + loadImage, + ); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +// Export for library use +export { + BinaryFramebufferEncoder, + DrawEntryType, + ScrollWrap, + HScrollDirection, + VScrollDirection, + bitmapSizeBytes, + setPixelInBitmap, + isPixelSetInBitmap, + createAnimationBinary, + LoadImageFunction, +}; + +// Run if executed directly +if (import.meta.main) { + main(); +} \ No newline at end of file