You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
605 lines
16 KiB
605 lines
16 KiB
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();
|
|
} |