Skip to content

Commit 0b631b8

Browse files
committed
Some fixes + BW format
1 parent 4da64ce commit 0b631b8

6 files changed

Lines changed: 236 additions & 117 deletions

File tree

BitmapArrayTools/bitmap_array.js

Lines changed: 154 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,85 @@
1+
function readBits(view, bitOffset, count) {
2+
let value = 0;
3+
let done = 0;
4+
5+
while (done < count) {
6+
const bitInByte = (bitOffset + done) & 7;
7+
const take = Math.min(8 - bitInByte, count - done);
8+
const byte = view.getUint8((bitOffset + done) >> 3);
9+
const chunk = (byte >> (8 - bitInByte - take)) & ((1 << take) - 1);
10+
11+
value = (value * (1 << take)) + chunk;
12+
done += take;
13+
}
14+
return value;
15+
}
16+
17+
function writeBits(bytes, bitOffset, count, value) {
18+
let done = 0;
19+
20+
while (done < count) {
21+
const bitInByte = (bitOffset + done) & 7;
22+
const take = Math.min(8 - bitInByte, count - done);
23+
const idx = (bitOffset + done) >> 3;
24+
const shift = 8 - bitInByte - take;
25+
const left = count - done - take;
26+
const chunk = Math.floor(value / (2 ** left)) & ((1 << take) - 1);
27+
const mask = ((1 << take) - 1) << shift;
28+
29+
bytes[idx] = (bytes[idx] & ~mask) | (chunk << shift);
30+
done += take;
31+
}
32+
}
33+
34+
const BNA_TYPES = {
35+
bw: {
36+
bits: 8,
37+
name: "8-bit BW",
38+
toRGB(pixel) { return [pixel, pixel, pixel] },
39+
fromRGB: (r, g, b) => ( Math.max(r, g, b))
40+
},
41+
b1: {
42+
bits: 8,
43+
name: "8-bit RGB332",
44+
toRGB(pixel) {
45+
let r = pixel & 0xe0;
46+
r |= (r >> 3);
47+
r |= (r >> 3);
48+
let g = pixel & 0x1c;
49+
g |= (g << 3) | (g >> 3);
50+
let b = pixel & 0x03;
51+
b |= b << 2;
52+
b |= b << 4;
53+
return [r, g, b];
54+
},
55+
fromRGB: (r, g, b) => (r & 0xE0) | ((g & 0xE0) >> 3) | ((b & 0xC0) >> 6)
56+
},
57+
b2: {
58+
bits: 16,
59+
name: "16-bit RGB565",
60+
toRGB(pixel) {
61+
let r = (pixel & 0xF800) >> 8;
62+
r |= r >> 5;
63+
let g = (pixel & 0x07E0) >> 3;
64+
g |= g >> 6;
65+
let b = (pixel & 0x001F) << 3;
66+
b |= b >> 5;
67+
return [r, g, b];
68+
},
69+
fromRGB: (r, g, b) => ((r & 0xf8) << 8) | ((g & 0xFC) << 3) | ((b & 0xF8) >> 3)
70+
},
71+
b3: {
72+
bits: 24,
73+
name: "24-bit RGB888",
74+
toRGB(pixel) { return [(pixel >> 16) & 0xff, (pixel >> 8) & 0xff, pixel & 0xff] },
75+
fromRGB: (r, g, b) => (r << 16) | (g << 8) | b
76+
}
77+
};
78+
79+
function frameBytes(width, height, bits) {
80+
return Math.ceil((width * height * bits) / 8);
81+
}
82+
183
class BnaPlayer {
284
constructor(canvas) {
385
this.canvas = canvas;
@@ -9,21 +91,33 @@ class BnaPlayer {
991
}
1092

1193
async load(fileOrBuffer) {
94+
this.stop();
95+
this.meta = null;
96+
1297
const buffer = fileOrBuffer instanceof ArrayBuffer
1398
? fileOrBuffer
1499
: await fileOrBuffer.arrayBuffer();
100+
101+
if (buffer.byteLength < 8) throw new Error("File is too small to hold a header");
102+
15103
const view = new DataView(buffer);
16104
const signature = String.fromCharCode(view.getUint8(0), view.getUint8(1));
17105
const type = String.fromCharCode(view.getUint8(2), view.getUint8(3));
18106

19107
if (signature !== 'bA') throw new Error("Invalid file signature");
20108

21-
const bpp = type === 'b3' ? 3 : type === 'b2' ? 2 : type === 'b1' ? 1 : -1;
109+
const codec = BNA_TYPES[type];
110+
if (!codec) throw new Error(`Unsupported type "${type}"`);
111+
22112
const w = view.getUint16(4, true);
23113
const h = view.getUint16(6, true);
24-
const frames = Math.floor((buffer.byteLength - 8) / (w * h * bpp));
114+
if (w === 0 || h === 0) throw new Error("Width and height must be non-zero");
25115

26-
this.meta = { buffer, view, w, h, bpp, frames, headerSize: 8 };
116+
const frameSize = frameBytes(w, h, codec.bits);
117+
const frames = Math.floor((buffer.byteLength - 8) / frameSize);
118+
if (frames < 1) throw new Error(`File holds no complete ${w}x${h} frame`);
119+
120+
this.meta = { buffer, view, w, h, type, codec, frameSize, frames, headerSize: 8 };
27121
this.canvas.width = w;
28122
this.canvas.height = h;
29123
this.frameIdx = 0;
@@ -33,37 +127,15 @@ class BnaPlayer {
33127

34128
renderFrame(idx) {
35129
if (!this.meta) return;
36-
const { headerSize, w, h, bpp, view, frames } = this.meta;
130+
const { headerSize, w, h, codec, view, frames, frameSize } = this.meta;
37131
const imageData = this.ctx.createImageData(w, h);
38132
const pixelsPerFrame = w * h;
39-
const frameOffset = headerSize + ((idx % frames) * pixelsPerFrame * bpp);
133+
const frameBit = (headerSize + ((idx % frames) * frameSize)) * 8;
40134

41135
for (let i = 0; i < pixelsPerFrame; i++) {
42-
let r, g, b;
136+
const [r, g, b] = codec.toRGB(readBits(view, frameBit + (i * codec.bits), codec.bits));
43137
const pxIdx = i * 4;
44138

45-
if (bpp === 1) { // RGB332
46-
const byte = view.getUint8(frameOffset + i);
47-
r = byte & 0xe0; // mask out the 3 bits of red at the start of the byte
48-
r |= (r >> 3); // extend limited 0-224 range to 0-252
49-
r |= (r >> 3); // extend limited 0-252 range to 0-255
50-
g = byte & 0x1c; // mask out the 3 bits of green in the middle of the byte
51-
g |= (g << 3) | (r >> 3); // extend limited 0-34 range to 0-255
52-
b = byte & 0x03; // mask out the 2 bits of blue at the end of the byte
53-
b |= b << 2; // extend 0-3 range to 0-15
54-
b |= b << 4;
55-
} else if (bpp === 2) { // RGB565
56-
const word = view.getUint8(frameOffset + (i * 2)) << 8
57-
| view.getUint8(frameOffset + (i * 2) + 1);
58-
r = (word & 0xF800) >> 8;
59-
g = (word & 0x07E0) >> 3;
60-
b = (word & 0x001F) << 3;
61-
} else { // RGB888
62-
r = view.getUint8(frameOffset + (i * 3));
63-
g = view.getUint8(frameOffset + (i * 3) + 1);
64-
b = view.getUint8(frameOffset + (i * 3) + 2);
65-
}
66-
67139
imageData.data[pxIdx] = r;
68140
imageData.data[pxIdx + 1] = g;
69141
imageData.data[pxIdx + 2] = b;
@@ -74,7 +146,11 @@ class BnaPlayer {
74146

75147
play(fps = this.fps) {
76148
this.stop();
77-
this.fps = fps;
149+
if (!this.meta) return;
150+
151+
const rate = Number(fps);
152+
if (rate > 0) this.fps = rate;
153+
78154
this.timer = setInterval(() => {
79155
this.renderFrame(this.frameIdx);
80156
this.frameIdx = (this.frameIdx + 1) % this.meta.frames;
@@ -83,6 +159,7 @@ class BnaPlayer {
83159

84160
stop() {
85161
if (this.timer) clearInterval(this.timer);
162+
this.timer = null;
86163
}
87164
}
88165

@@ -92,64 +169,58 @@ class BnaEncoder {
92169
* @param {File} file - The GIF file
93170
* @param {number} width - Target width
94171
* @param {number} height - Target height
95-
* @param {string} type - type: 'b1' (RGB332), 'b2' (RGB565), 'b3' (RGB888)
172+
* @param {string} type - type code from BNA_TYPES, for example 'b1' (RGB332)
96173
*/
97174
static async fromGif(file, width, height, type) {
98-
const decoder = new ImageDecoder({ data: file.stream(), type: "image/gif" });
99-
await decoder.completed;
100-
const totalFrames = decoder.tracks.selectedTrack.frameCount;
175+
const codec = BNA_TYPES[type];
176+
if (!codec) throw new Error(`Unsupported type "${type}"`);
177+
if (!Number.isInteger(width) || !Number.isInteger(height)
178+
|| width < 1 || height < 1 || width > 65535 || height > 65535) {
179+
throw new Error("Width and height must be 1-65535");
180+
}
181+
if (typeof ImageDecoder === 'undefined') throw new Error("This browser has no ImageDecoder");
101182

102-
const bpp = (type === 'b1') ? 1 : (type === 'b2') ? 2 : (type === 'b3') ? 3 : -1;
103-
const bytesPerFrame = width * height * bpp;
104-
const totalSize = 8 + (bytesPerFrame * totalFrames);
105-
const buffer = new ArrayBuffer(totalSize);
106-
const view = new DataView(buffer);
107-
const uint8 = new Uint8Array(buffer);
108-
109-
// Header: "bA" + type + width + height
110-
uint8[0] = 0x62; // b
111-
uint8[1] = 0x41; // A
112-
uint8[2] = type.charCodeAt(0);
113-
uint8[3] = type.charCodeAt(1);
114-
115-
view.setUint16(4, width, true);
116-
view.setUint16(6, height, true);
117-
118-
const canvas = document.createElement('canvas');
119-
const ctx = canvas.getContext('2d', { willReadFrequently: true });
120-
canvas.width = width;
121-
canvas.height = height;
122-
123-
for (let i = 0; i < totalFrames; i++) {
124-
const { image } = await decoder.decode({ frameIndex: i });
125-
ctx.clearRect(0, 0, width, height);
126-
ctx.drawImage(image, 0, 0, width, height);
127-
const rgba = ctx.getImageData(0, 0, width, height).data;
128-
const frameOffset = 8 + (i * bytesPerFrame);
129-
130-
for (let j = 0; j < width * height; j++) {
131-
const r = rgba[j * 4];
132-
const g = rgba[j * 4 + 1];
133-
const b = rgba[j * 4 + 2];
134-
const pixelPos = frameOffset + (j * bpp);
135-
136-
if (bpp === 1) {
137-
// RGB332
138-
uint8[pixelPos] = (r & 0xE0) | ((g & 0xE0) >> 3) | ((b & 0xC0) >> 6);
139-
} else if (bpp === 2) {
140-
// RGB565
141-
uint8[pixelPos] = (r & 0xf8) | ((g & 0xE0) >> 5);
142-
uint8[pixelPos + 1] = ((g & 0x1C) << 5) | ((b & 0xF8) >> 3);
143-
} else {
144-
// RGB888
145-
uint8[pixelPos] = r;
146-
uint8[pixelPos + 1] = g;
147-
uint8[pixelPos + 2] = b;
183+
const decoder = new ImageDecoder({ data: file.stream(), type: "image/gif" });
184+
try {
185+
await decoder.completed;
186+
const totalFrames = decoder.tracks.selectedTrack.frameCount;
187+
188+
const frameSize = frameBytes(width, height, codec.bits);
189+
const buffer = new ArrayBuffer(8 + (frameSize * totalFrames));
190+
const view = new DataView(buffer);
191+
const uint8 = new Uint8Array(buffer);
192+
193+
// Header: "bA" + type + width + height
194+
uint8[0] = 0x62; // b
195+
uint8[1] = 0x41; // A
196+
uint8[2] = type.charCodeAt(0);
197+
uint8[3] = type.charCodeAt(1);
198+
199+
view.setUint16(4, width, true);
200+
view.setUint16(6, height, true);
201+
202+
const canvas = document.createElement('canvas');
203+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
204+
canvas.width = width;
205+
canvas.height = height;
206+
207+
for (let i = 0; i < totalFrames; i++) {
208+
const { image } = await decoder.decode({ frameIndex: i });
209+
ctx.clearRect(0, 0, width, height); // no alpha in the format, transparency goes black
210+
ctx.drawImage(image, 0, 0, width, height);
211+
image.close();
212+
const rgba = ctx.getImageData(0, 0, width, height).data;
213+
const frameBit = (8 + (i * frameSize)) * 8;
214+
215+
for (let j = 0; j < width * height; j++) {
216+
const pixel = codec.fromRGB(rgba[j * 4], rgba[j * 4 + 1], rgba[j * 4 + 2]);
217+
writeBits(uint8, frameBit + (j * codec.bits), codec.bits, pixel);
148218
}
149219
}
150-
image.close();
151-
}
152220

153-
return new Blob([buffer], { type: 'application/octet-stream' });
221+
return new Blob([buffer], { type: 'application/octet-stream' });
222+
} finally {
223+
decoder.close();
224+
}
154225
}
155-
}
226+
}

BitmapArrayTools/converter.html

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<!DOCTYPE html>
2-
<html lang="uk">
2+
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<title>GIF to .BNA (v1.1)</title>
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>GIF to .BNA converter</title>
67
<link rel="stylesheet" href="styles.css">
78
<script src="bitmap_array.js"></script>
89
</head>
@@ -14,46 +15,67 @@ <h2>.GIF to .BNA Converter</h2>
1415
<label>Choose GIF:</label>
1516
<input type="file" id="inputGif" accept="image/gif">
1617
</div>
17-
<button id="btnConvert">Generate & download .BNA</button>
18+
<button id="btnConvert">Generate and download .BNA</button>
1819
<div class="control-group" style="align-items: flex-end; display: flex;">
1920
<div style="flex: 1;">
2021
<label>Out width:</label>
21-
<input type="number" id="w" value="16" min="1" title="Width">
22+
<input type="number" id="w" value="16" min="1" max="65535" step="1" title="Width">
2223
</div>
2324
<div style="flex: 1;">
2425
<label>Out height:</label>
25-
<input type="number" id="h" value="16" min="1" title="Height">
26+
<input type="number" id="h" value="16" min="1" max="65535" step="1" title="Height">
2627
</div>
2728
<div style="flex: 1;">
28-
<label>Bits per pixel:</label>
29-
<input type="number" id="bpp" value="1" min="1" max="3" title="Bits Per Pixel">
29+
<label>Pixel format:</label>
30+
<select id="type" title="Pixel format"></select>
3031
</div>
3132
</div>
3233

3334
<div class="info" id="status">Waiting for file...</div>
3435
</div>
3536

3637
<script>
37-
document.getElementById('btnConvert').onclick = async () => {
38-
const fileInput = document.getElementById('inputGif');
39-
const file = fileInput.files[0];
38+
const statusEl = document.getElementById('status');
39+
const btnConvert = document.getElementById('btnConvert');
40+
const typeSelect = document.getElementById('type');
41+
42+
// every type in the table shows up here on its own
43+
for (const [code, codec] of Object.entries(BNA_TYPES)) {
44+
typeSelect.add(new Option(codec.name, code));
45+
}
46+
47+
function setStatus(text, isError = false) {
48+
statusEl.innerText = text;
49+
statusEl.classList.toggle('error', isError);
50+
}
51+
52+
btnConvert.onclick = async () => {
53+
const file = document.getElementById('inputGif').files[0];
4054
if (!file) return alert("Choose a file!");
4155

42-
const bpp = parseInt(document.getElementById('bpp').value);
56+
const type = typeSelect.value;
4357
const w = parseInt(document.getElementById('w').value);
4458
const h = parseInt(document.getElementById('h').value);
4559

46-
const type = bpp === 1 ? 'b1' : bpp === 2 ? 'b2' : bpp === 3 ? 'b3' : null;
47-
if (!type) return alert("Bits per pixel must be 1, 2, or 3!");
48-
const blob = await BnaEncoder.fromGif(file, w, h, type);
49-
status.innerText = `Encoding...`;
60+
btnConvert.disabled = true;
61+
setStatus("Encoding...");
62+
try {
63+
const blob = await BnaEncoder.fromGif(file, w, h, type);
64+
65+
const url = URL.createObjectURL(blob);
66+
const a = document.createElement('a');
67+
a.href = url;
68+
a.download = file.name.replace(/\.[^/.]+$/, "") + ".bna";
69+
a.click();
70+
setTimeout(() => URL.revokeObjectURL(url), 1000);
5071

51-
const a = document.createElement('a');
52-
a.href = URL.createObjectURL(blob);
53-
a.download = file.name.replace(/\.[^/.]+$/, "") + ".bna";
54-
a.click();
55-
status.innerText = "Done!";
72+
setStatus(`Done! ${w}x${h}, ${blob.size} bytes`);
73+
} catch (err) {
74+
setStatus(`Cannot convert ${file.name}: ${err.message}`, true);
75+
} finally {
76+
btnConvert.disabled = false;
77+
}
5678
};
5779
</script>
5880
</body>
59-
</html>
81+
</html>

0 commit comments

Comments
 (0)