Skip to content

Instantly share code, notes, and snippets.

@lifeart
Created July 5, 2023 08:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lifeart/8015568b3ccccbbddfefcd13a671454c to your computer and use it in GitHub Desktop.
Save lifeart/8015568b3ccccbbddfefcd13a671454c to your computer and use it in GitHub Desktop.
QOI - The "Quite OK Image" format for fast, lossless image compression
// https://raw.githubusercontent.com/phoboslab/qoi/master/qoi.h
class QoiRGBA {
constructor(r = 0, g = 0, b = 0, a = 255) {
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
}
const QOI_MAGIC = 0x716f6966;
const QOI_HEADER_SIZE = 14;
const QOI_PIXELS_MAX = 400000000;
const QOI_OP_INDEX = 0x00;
const QOI_OP_DIFF = 0x40;
const QOI_OP_LUMA = 0x80;
const QOI_OP_RUN = 0xc0;
const QOI_OP_RGB = 0xfe;
const QOI_OP_RGBA = 0xff;
const QOI_MASK_2 = 0xc0;
function qoiColorHash(color) {
return (color.r * 3 + color.g * 5 + color.b * 7 + color.a * 11) % 64;
}
function qoiWrite32(arr, p, v) {
arr[p++] = (v >> 24) & 0xff;
arr[p++] = (v >> 16) & 0xff;
arr[p++] = (v >> 8) & 0xff;
arr[p++] = v & 0xff;
return p;
}
function qoiRead32(arr, p) {
const v =
(arr[p++] << 24) | (arr[p++] << 16) | (arr[p++] << 8) | arr[p++];
return { value: v, position: p };
}
function qoiEncode(data, desc) {
if (
data === null ||
desc === null ||
desc.width === 0 ||
desc.height === 0 ||
desc.channels < 3 ||
desc.channels > 4 ||
desc.colorspace > 1 ||
desc.height >= QOI_PIXELS_MAX / desc.width
) {
return null;
}
const max_size =
desc.width * desc.height * (desc.channels + 1) +
QOI_HEADER_SIZE +
8;
let p = 0;
const bytes = new Uint8Array(max_size);
p = qoiWrite32(bytes, p, QOI_MAGIC);
p = qoiWrite32(bytes, p, desc.width);
p = qoiWrite32(bytes, p, desc.height);
bytes[p++] = desc.channels;
bytes[p++] = desc.colorspace;
const pixels = new Uint8Array(data);
const index = Array(64).fill(new QoiRGBA());
let run = 0;
let px_prev = new QoiRGBA();
let px = new QoiRGBA();
const px_len = desc.width * desc.height * desc.channels;
const px_end = px_len - desc.channels;
const channels = desc.channels;
for (let px_pos = 0; px_pos < px_len; px_pos += channels) {
px.r = pixels[px_pos + 0];
px.g = pixels[px_pos + 1];
px.b = pixels[px_pos + 2];
if (channels === 4) {
px.a = pixels[px_pos + 3];
}
if (px.r === px_prev.r && px.g === px_prev.g && px.b === px_prev.b && px.a === px_prev.a) {
run++;
if (run === 62 || px_pos === px_end) {
bytes[p++] = QOI_OP_RUN | (run - 1);
run = 0;
}
} else {
if (run > 0) {
bytes[p++] = QOI_OP_RUN | (run - 1);
run = 0;
}
const index_pos = qoiColorHash(px) % 64;
if (
index[index_pos].r === px.r &&
index[index_pos].g === px.g &&
index[index_pos].b === px.b &&
index[index_pos].a === px.a
) {
bytes[p++] = QOI_OP_INDEX | index_pos;
} else {
index[index_pos] = new QoiRGBA(px.r, px.g, px.b, px.a);
if (px.a === px_prev.a) {
const vr = px.r - px_prev.r;
const vg = px.g - px_prev.g;
const vb = px.b - px_prev.b;
const vg_r = vr - vg;
const vg_b = vb - vg;
if (
vr > -3 && vr < 2 &&
vg > -3 && vg < 2 &&
vb > -3 && vb < 2
) {
bytes[p++] = QOI_OP_DIFF | (vr + 2) << 4 | (vg + 2) << 2 | (vb + 2);
} else if (
vg_r > -9 && vg_r < 8 &&
vg > -33 && vg < 32 &&
vg_b > -9 && vg_b < 8
) {
bytes[p++] = QOI_OP_LUMA | (vg + 32);
bytes[p++] = (vg_r + 8) << 4 | (vg_b + 8);
} else {
bytes[p++] = QOI_OP_RGB;
bytes[p++] = px.r;
bytes[p++] = px.g;
bytes[p++] = px.b;
}
} else {
bytes[p++] = QOI_OP_RGBA;
bytes[p++] = px.r;
bytes[p++] = px.g;
bytes[p++] = px.b;
bytes[p++] = px.a;
}
}
}
px_prev = new QoiRGBA(px.r, px.g, px.b, px.a);
}
for (let i = 0; i < 8; i++) {
bytes[p++] = i === 7 ? 1 : 0;
}
return bytes.slice(0, p);
}
function qoiDecode(data, desc, channels) {
if (
data === null ||
desc === null ||
(channels !== 0 && channels !== 3 && channels !== 4) ||
data.length < QOI_HEADER_SIZE + 8
) {
return null;
}
const bytes = new Uint8Array(data);
let p = 0;
const header_magic = qoiRead32(bytes, p);
p = header_magic.position;
const header_width = qoiRead32(bytes, p);
p = header_width.position;
const header_height = qoiRead32(bytes, p);
p = header_height.position;
const header_channels = bytes[p++];
const header_colorspace = bytes[p++];
if (
header_width.value === 0 ||
header_height.value === 0 ||
header_channels < 3 ||
header_channels > 4 ||
header_colorspace > 1 ||
header_magic.value !== QOI_MAGIC ||
header_height.value >= QOI_PIXELS_MAX / header_width.value
) {
return null;
}
desc.width = header_width.value;
desc.height = header_height.value;
desc.channels = header_channels;
desc.colorspace = header_colorspace;
if (channels === 0) {
channels = desc.channels;
}
const px_len = desc.width * desc.height * channels;
const pixels = new Uint8Array(px_len);
const index = Array(64).fill(new QoiRGBA());
let px = new QoiRGBA();
let run = 0;
const chunks_len = data.length - 8;
for (let px_pos = 0; px_pos < px_len; px_pos += channels) {
if (run > 0) {
run--;
} else if (p < chunks_len) {
const b1 = bytes[p++];
if (b1 === QOI_OP_RGB) {
px.r = bytes[p++];
px.g = bytes[p++];
px.b = bytes[p++];
} else if (b1 === QOI_OP_RGBA) {
px.r = bytes[p++];
px.g = bytes[p++];
px.b = bytes[p++];
px.a = bytes[p++];
} else if ((b1 & QOI_MASK_2) === QOI_OP_INDEX) {
px = index[b1];
} else if ((b1 & QOI_MASK_2) === QOI_OP_DIFF) {
px.r += ((b1 >> 4) & 0x03) - 2;
px.g += ((b1 >> 2) & 0x03) - 2;
px.b += (b1 & 0x03) - 2;
} else if ((b1 & QOI_MASK_2) === QOI_OP_LUMA) {
const b2 = bytes[p++];
const vg = (b1 & 0x3f) - 32;
px.r += vg - 8 + ((b2 >> 4) & 0x0f);
px.g += vg;
px.b += vg - 8 + (b2 & 0x0f);
} else if ((b1 & QOI_MASK_2) === QOI_OP_RUN) {
run = b1 & 0x3f;
}
index[qoiColorHash(px) % 64] = new QoiRGBA(px.r, px.g, px.b, px.a);
}
pixels[px_pos + 0] = px.r;
pixels[px_pos + 1] = px.g;
pixels[px_pos + 2] = px.b;
if (channels === 4) {
pixels[px_pos + 3] = px.a;
}
}
return pixels;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment