Compare commits

...

4 Commits

Author SHA1 Message Date
Zeilenschubser c495136131 feat: claude used binfmt.rs to infer this with anim.py 2 weeks ago
Zeilenschubser 8f72f59558 refactor: gif handling 2 weeks ago
Zeilenschubser bbd958baca refactor: cli + rewritten by claude 2 weeks ago
Zeilenschubser 1571d11322 animation converter v1 2 weeks ago
  1. 289
      anim.py
  2. 605
      binfmt.ts

@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
Convert BMP/GIF images to binary animation format.
written by me, refactored by claude sonnet 4.5
2025-12-28
"""
import argparse
from PIL import Image, ImageSequence
STATIC = 1
ANIMATION = 2
def bmp_to_bitmap_fn(im):
"""Create a function that converts image to bitmap data."""
def fn(img):
# Find bounding box of black pixels
x = [xc for xc in range(im.size[0]) for yc in range(im.size[1])
if sum(im.getpixel((xc, yc))) == 0]
y = [yc for xc in range(im.size[0]) for yc in range(im.size[1])
if sum(im.getpixel((xc, yc))) == 0]
if not x or not y:
return img, 0, 0
xmin, xmax = min(x), max(x)
ymin, ymax = min(y), max(y)
xsize = (xmax - xmin)
ysize = (ymax - ymin)
print(f"Image bounds: x={xmin}-{xmax}, y={ymin}-{ymax}, size={xsize}x{ysize}")
for y in range(ysize):
for x in range(xsize):
if im.size[0] >= x and sum(im.getpixel((xmin + x, ymin + y))) == 0:
pos = ((y * xsize) + x) // 8
bit = ((y * xsize) + x) % 8
if pos < len(img):
img[pos] |= 1 << bit
return img, xsize, ysize
return fn
def extract_frames_from_image(image_path):
"""
Extract all frames from an image file (GIF or static).
Returns a list of PIL Image objects.
"""
img = Image.open(image_path)
frames = []
# Convert to RGB if needed
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
if image_path.endswith(".gif"):
with Image.open(image_path) as im_gif:
index = 0
for img in ImageSequence.Iterator(im_gif):
frame_copy = img.copy()
if frame_copy.mode != 'RGB':
frame_copy = frame_copy.convert('RGB')
frames.append(frame_copy)
if len(frames) == 0:
try:
# Try to extract all frames (works for GIFs)
for frame_num in range(img.n_frames):
print("frame is ", frame_num)
img.seek(frame_num)
# Copy the frame to prevent issues with lazy loading
frame_copy = img.copy()
if frame_copy.mode != 'RGB':
frame_copy = frame_copy.convert('RGB')
frames.append(frame_copy)
print(f"Extracted frame {frame_num} from {image_path}")
except AttributeError:
# Not an animated image, just use the single frame
frames.append(img)
print(f"Loaded static image from {image_path}")
return frames
def extract_all_frames(image_paths, mode):
"""
Extract frames from all input images based on mode.
Args:
image_paths: List of image file paths
mode: 'frame' for sequential frames, 'differential' for alternating pattern
Returns:
List of PIL Image objects representing animation frames
"""
all_frames = []
if mode == 'differential':
# Differential mode: extract frames from each image, then create alternating pattern
if len(image_paths) != 2:
raise ValueError("Differential mode requires exactly 2 images (off and on states)")
off_frames = extract_frames_from_image(image_paths[0])
on_frames = extract_frames_from_image(image_paths[1])
# Use only the first frame from each for differential mode
off_frame = off_frames[0]
on_frame = on_frames[0]
# Create pattern: multiple off frames followed by on frame
all_frames = [off_frame] * 14 + [on_frame]
print(f"Created differential animation: 14 off frames + 1 on frame")
elif mode == 'frame':
# Frame mode: concatenate all frames from all images
for path in image_paths:
frames = extract_frames_from_image(path)
all_frames.extend(frames)
print(f"Total frames collected: {len(all_frames)}")
else:
raise ValueError(f"Unknown mode: {mode}. Use 'frame' or 'differential'")
return all_frames
def get_frame_dimensions(frames):
"""Get the maximum dimensions needed for all frames."""
max_width = 0
max_height = 0
for frame in frames:
# Find bounding box
x = [xc for xc in range(frame.size[0]) for yc in range(frame.size[1])
if sum(frame.getpixel((xc, yc))) == 0]
y = [yc for xc in range(frame.size[0]) for yc in range(frame.size[1])
if sum(frame.getpixel((xc, yc))) == 0]
if x and y:
width = max(x) - min(x)
height = max(y) - min(y)
max_width = max(max_width, width)
max_height = max(max_height, height)
return max_width, max_height
def create_animation_binary(image_paths, output_path, startx, starty,
update_interval, mode):
"""
Create binary animation file from BMP/GIF images.
Args:
image_paths: List of image file paths
output_path: Output binary file path
startx: X coordinate for animation start
starty: Y coordinate for animation start
update_interval: Frame update interval
mode: 'frame' for one image per frame, 'differential' for alternating states
"""
# Extract all frames from input images based on mode
frames = extract_all_frames(image_paths, mode)
if not frames:
raise ValueError("No frames extracted from input images")
# Determine dimensions
frame_width, frame_height = get_frame_dimensions(frames)
print(f"Using frame dimensions: {frame_width}x{frame_height}")
# Convert frames to bitmap functions
animation = [bmp_to_bitmap_fn(frame) for frame in frames]
# Single frame case: write as static object
if len(animation) == 1:
print("Single frame detected, creating static object")
with open(output_path, "wb") as fx:
# Write magic header
fx.write(bytearray([0x42, 0x4e, 0x17, 0xee]))
# Write number of objects
fx.write(bytearray([1]))
# Write static object
fx.write(bytearray([STATIC, startx, starty]))
fx.write(bytearray([frame_width, frame_height]))
img = bytearray([0] * ((frame_width * frame_height + 7) // 8))
img, xsize, ysize = animation[0](img)
fx.write(img)
file_size = fx.tell()
print(f"Total size: {file_size} bytes")
if file_size >= 50000:
print("WARNING: File size exceeds 50000 bytes!")
print(f"Static image created: {output_path}")
return
# Multiple frames: write as animation
with open(output_path, "wb") as fx:
# Write magic header
fx.write(bytearray([0x42, 0x4e, 0x17, 0xee]))
# Write number of objects
fx.write(bytearray([2]))
# Write static background object (empty)
fx.write(bytearray([STATIC, 0, 0]))
fx.write(bytearray([120, 60]))
fx.write(bytearray([0] * ((120 * 60 + 7) // 8)))
# Write animation object
fx.write(bytearray([ANIMATION, startx, starty]))
fx.write(bytearray([frame_width, frame_height, len(animation), update_interval]))
# Write animation frames
for i, bmp_fun in enumerate(animation):
img = bytearray([0] * ((frame_width * frame_height + 7) // 8))
img, xsize, ysize = bmp_fun(img)
print(f"Frame {i}: start={fx.tell()}")
fx.write(img)
file_size = fx.tell()
print(f"Total size: {file_size} bytes")
if file_size >= 50000:
print("WARNING: File size exceeds 50000 bytes!")
print(f"Animation binary created: {output_path}")
def main():
parser = argparse.ArgumentParser(
description='Convert BMP/GIF images to binary animation format',
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
'images',
nargs='+',
help='Image file(s) (BMP or GIF). GIF frames are extracted automatically. Single frame creates static object, multiple frames create animation.'
)
parser.add_argument(
'--output',
type=str,
default='animation.bin',
help='Output binary file path'
)
parser.add_argument(
'--start-x',
type=int,
default=0,
help='X coordinate for start position'
)
parser.add_argument(
'--start-y',
type=int,
default=0,
help='Y coordinate for start position'
)
parser.add_argument(
'--update-interval',
type=int,
default=3,
help='Update interval (0=every tick, 1=every second tick, etc.)'
)
parser.add_argument(
'--mode',
choices=['frame', 'differential'],
default='frame',
help='Animation mode: "frame" = each image/frame is a frame, "differential" = alternate between 2 images (requires exactly 2 images)'
)
args = parser.parse_args()
create_animation_binary(
args.images,
args.output,
args.start_x,
args.start_y,
args.update_interval,
args.mode
)
if __name__ == '__main__':
main()

@ -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<boolean[][]> {
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<boolean[][][]> {
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<boolean[][][]> {
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<boolean[][][] | boolean[][]> {
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<boolean[][]>;
async function createAnimationBinary(
images: string[],
output: string,
startX: number,
startY: number,
updateInterval: number,
mode: 'frame' | 'differential',
loadImage: LoadImageFunction,
): Promise<void> {
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();
}
Loading…
Cancel
Save