Compare commits
No commits in common. 'c4951361313217df0c83d6c91820fe078f415e47' and '0dbaf6b6e68fdd36d7d1391e47bef238d2299abe' have entirely different histories.
c495136131
...
0dbaf6b6e6
@ -1,289 +0,0 @@ |
|||||||
#!/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() |
|
||||||
@ -1,605 +0,0 @@ |
|||||||
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…
Reference in new issue