parent
8f72f59558
commit
c495136131
@ -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…
Reference in new issue