From 390b5adb8c33e8a314e888c6769a7343e85dc095 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 00:09:55 +0800 Subject: [PATCH] feat: add QOI codec, format encoders (BMP/ICO/JP2/QOI), fix JXL output bug --- apps/api/src/lib/format-decoders.ts | 13 +- apps/api/src/lib/format-encoders.ts | 105 +++++++++ apps/api/src/lib/output-format.ts | 4 +- packages/image-engine/src/formats/qoi.ts | 203 ++++++++++++++++++ packages/image-engine/src/index.ts | 2 + .../image-engine/src/operations/compress.ts | 3 +- packages/image-engine/src/types.ts | 6 +- 7 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/lib/format-encoders.ts create mode 100644 packages/image-engine/src/formats/qoi.ts diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index 1da71cf5..91c05f00 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -4,6 +4,7 @@ import { readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import sharp from "sharp"; const execFileAsync = promisify(execFile); @@ -440,8 +441,14 @@ async function decodeFits(buffer: Buffer): Promise { } } -// ── QOI decoder (stub -- real codec comes in Task 4) ── +// ── QOI decoder ── -async function decodeQoi(_buffer: Buffer): Promise { - throw new Error("QOI decode not yet implemented"); +async function decodeQoi(buffer: Buffer): Promise { + const { qoiDecode } = await import("@snapotter/image-engine"); + const { header, pixels } = qoiDecode(new Uint8Array(buffer)); + return sharp(Buffer.from(pixels), { + raw: { width: header.width, height: header.height, channels: 4 }, + }) + .png() + .toBuffer(); } diff --git a/apps/api/src/lib/format-encoders.ts b/apps/api/src/lib/format-encoders.ts new file mode 100644 index 00000000..5e5c5e92 --- /dev/null +++ b/apps/api/src/lib/format-encoders.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { qoiEncode } from "@snapotter/image-engine"; +import sharp from "sharp"; + +const execFileAsync = promisify(execFile); + +let cachedMagickCmd: string | null = null; + +async function findMagickCmd(): Promise { + if (cachedMagickCmd) return cachedMagickCmd; + for (const cmd of ["magick", "convert"]) { + try { + await execFileAsync(cmd, ["--version"], { timeout: 5_000 }); + cachedMagickCmd = cmd; + return cmd; + } catch { + /* try next */ + } + } + throw new Error("No ImageMagick found."); +} + +function magickArgs(cmd: string, args: string[]): string[] { + return cmd === "magick" ? ["convert", ...args] : args; +} + +export async function encodeBmp(inputBuffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `bmp-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `bmp-enc-out-${id}.bmp`); + try { + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `bmp3:${outputPath}`]), { + timeout: 60_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +export async function encodeIco(inputBuffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `ico-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `ico-enc-out-${id}.ico`); + try { + const pngBuffer = await sharp(inputBuffer) + .resize(256, 256, { fit: "inside", withoutEnlargement: true }) + .png() + .toBuffer(); + await writeFile(inputPath, pngBuffer); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `ico:${outputPath}`]), { + timeout: 60_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +export async function encodeJp2(inputBuffer: Buffer, quality?: number): Promise { + const id = randomUUID(); + const inputPath = join(tmpdir(), `jp2-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `jp2-enc-out-${id}.jp2`); + try { + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); + try { + const rate = quality ? String(Math.max(1, Math.round(quality / 10))) : "5"; + await execFileAsync("opj_compress", ["-i", inputPath, "-o", outputPath, "-r", rate], { + timeout: 60_000, + }); + return await readFile(outputPath); + } catch { + /* fall back to ImageMagick */ + } + const cmd = await findMagickCmd(); + const q = quality ? ["-quality", String(quality)] : []; + await execFileAsync(cmd, magickArgs(cmd, [inputPath, ...q, `jp2:${outputPath}`]), { + timeout: 60_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +export async function encodeQoi(inputBuffer: Buffer): Promise { + const { data, info } = await sharp(inputBuffer).ensureAlpha().raw().toBuffer({ + resolveWithObject: true, + }); + const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4); + return Buffer.from(encoded); +} diff --git a/apps/api/src/lib/output-format.ts b/apps/api/src/lib/output-format.ts index 81ecbb08..64e55361 100644 --- a/apps/api/src/lib/output-format.ts +++ b/apps/api/src/lib/output-format.ts @@ -18,14 +18,14 @@ const FORMAT_MAP: Record< tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" }, avif: { format: "avif", extension: "avif", contentType: "image/avif" }, heif: { format: "avif", extension: "avif", contentType: "image/avif" }, - jxl: { format: "png", extension: "png", contentType: "image/png" }, + jxl: { format: "jxl" as keyof sharp.FormatEnum, extension: "jxl", contentType: "image/jxl" }, }; const DEFAULT_QUALITY = 95; const PNG_FALLBACK = FORMAT_MAP.png; /** Formats that have no Sharp output encoder — fall back to PNG. */ -const PNG_FALLBACK_FORMATS = new Set(["svg", "bmp", "raw", "tga", "psd", "exr", "hdr", "ico"]); +const PNG_FALLBACK_FORMATS = new Set(["svg", "raw", "tga", "psd", "exr", "hdr"]); /** * Detect the input image format and return matching output config. diff --git a/packages/image-engine/src/formats/qoi.ts b/packages/image-engine/src/formats/qoi.ts new file mode 100644 index 00000000..1e744006 --- /dev/null +++ b/packages/image-engine/src/formats/qoi.ts @@ -0,0 +1,203 @@ +const QOI_MAGIC = 0x716f6966; // "qoif" +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_END_MARKER = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 1]); + +function hash(r: number, g: number, b: number, a: number): number { + return (r * 3 + g * 5 + b * 7 + a * 11) % 64; +} + +export interface QoiHeader { + width: number; + height: number; + channels: 3 | 4; + colorspace: 0 | 1; +} + +export function qoiDecode(data: Uint8Array): { header: QoiHeader; pixels: Uint8Array } { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + if (view.getUint32(0) !== QOI_MAGIC) throw new Error("Not a QOI file"); + + const width = view.getUint32(4); + const height = view.getUint32(8); + const channels = data[12] as 3 | 4; + const colorspace = data[13] as 0 | 1; + + if (width === 0 || height === 0) throw new Error("Invalid QOI dimensions"); + if (channels !== 3 && channels !== 4) throw new Error("Invalid QOI channels"); + + const totalPixels = width * height; + const pixels = new Uint8Array(totalPixels * 4); + const index = new Uint8Array(64 * 4); + + let r = 0, + g = 0, + b = 0, + a = 255; + let pos = 14; + let px = 0; + + while (px < totalPixels) { + const byte = data[pos++]; + + if (byte === QOI_OP_RGB) { + r = data[pos++]; + g = data[pos++]; + b = data[pos++]; + } else if (byte === QOI_OP_RGBA) { + r = data[pos++]; + g = data[pos++]; + b = data[pos++]; + a = data[pos++]; + } else { + const op = byte & 0xc0; + if (op === QOI_OP_INDEX) { + const idx = (byte & 0x3f) * 4; + r = index[idx]; + g = index[idx + 1]; + b = index[idx + 2]; + a = index[idx + 3]; + } else if (op === QOI_OP_DIFF) { + r = (r + ((byte >> 4) & 0x03) - 2) & 0xff; + g = (g + ((byte >> 2) & 0x03) - 2) & 0xff; + b = (b + (byte & 0x03) - 2) & 0xff; + } else if (op === QOI_OP_LUMA) { + const b2 = data[pos++]; + const dg = (byte & 0x3f) - 32; + r = (r + dg + ((b2 >> 4) & 0x0f) - 8) & 0xff; + g = (g + dg) & 0xff; + b = (b + dg + (b2 & 0x0f) - 8) & 0xff; + } else { + // QOI_OP_RUN + let run = (byte & 0x3f) + 1; + while (run-- > 0 && px < totalPixels) { + const off = px * 4; + pixels[off] = r; + pixels[off + 1] = g; + pixels[off + 2] = b; + pixels[off + 3] = a; + px++; + } + const h = hash(r, g, b, a) * 4; + index[h] = r; + index[h + 1] = g; + index[h + 2] = b; + index[h + 3] = a; + continue; + } + } + + const h = hash(r, g, b, a) * 4; + index[h] = r; + index[h + 1] = g; + index[h + 2] = b; + index[h + 3] = a; + const off = px * 4; + pixels[off] = r; + pixels[off + 1] = g; + pixels[off + 2] = b; + pixels[off + 3] = a; + px++; + } + + return { header: { width, height, channels, colorspace }, pixels }; +} + +export function qoiEncode( + pixels: Uint8Array, + width: number, + height: number, + channels: 3 | 4 = 4, +): Uint8Array { + const maxSize = 14 + width * height * (channels + 1) + QOI_END_MARKER.length; + const out = new Uint8Array(maxSize); + const view = new DataView(out.buffer); + + view.setUint32(0, QOI_MAGIC); + view.setUint32(4, width); + view.setUint32(8, height); + out[12] = channels; + out[13] = 0; // sRGB + + const index = new Uint8Array(64 * 4); + let pos = 14; + let prevR = 0, + prevG = 0, + prevB = 0, + prevA = 255; + let run = 0; + const totalPixels = width * height; + + for (let px = 0; px < totalPixels; px++) { + const off = px * channels; + const r = pixels[off]; + const g = pixels[off + 1]; + const b = pixels[off + 2]; + const a = channels === 4 ? pixels[off + 3] : 255; + + if (r === prevR && g === prevG && b === prevB && a === prevA) { + run++; + if (run === 62 || px === totalPixels - 1) { + out[pos++] = QOI_OP_RUN | (run - 1); + run = 0; + } + continue; + } + + if (run > 0) { + out[pos++] = QOI_OP_RUN | (run - 1); + run = 0; + } + + const h = hash(r, g, b, a); + const idx = h * 4; + + if (index[idx] === r && index[idx + 1] === g && index[idx + 2] === b && index[idx + 3] === a) { + out[pos++] = QOI_OP_INDEX | h; + } else { + index[idx] = r; + index[idx + 1] = g; + index[idx + 2] = b; + index[idx + 3] = a; + + if (a !== prevA) { + out[pos++] = QOI_OP_RGBA; + out[pos++] = r; + out[pos++] = g; + out[pos++] = b; + out[pos++] = a; + } else { + const dr = r - prevR; + const dg = g - prevG; + const db = b - prevB; + const drDg = dr - dg; + const dbDg = db - dg; + + if (dr > -3 && dr < 2 && dg > -3 && dg < 2 && db > -3 && db < 2) { + out[pos++] = QOI_OP_DIFF | ((dr + 2) << 4) | ((dg + 2) << 2) | (db + 2); + } else if (dg > -33 && dg < 32 && drDg > -9 && drDg < 8 && dbDg > -9 && dbDg < 8) { + out[pos++] = QOI_OP_LUMA | (dg + 32); + out[pos++] = ((drDg + 8) << 4) | (dbDg + 8); + } else { + out[pos++] = QOI_OP_RGB; + out[pos++] = r; + out[pos++] = g; + out[pos++] = b; + } + } + } + + prevR = r; + prevG = g; + prevB = b; + prevA = a; + } + + out.set(QOI_END_MARKER, pos); + pos += QOI_END_MARKER.length; + return out.slice(0, pos); +} diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 155e1dba..e92610ef 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -1,5 +1,7 @@ export * from "./engine.js"; export * from "./formats/detect.js"; +export type { QoiHeader } from "./formats/qoi.js"; +export { qoiDecode, qoiEncode } from "./formats/qoi.js"; export { analyzeImage, applyCorrections, scaleCorrections } from "./operations/auto-enhance.js"; export { brightness } from "./operations/brightness.js"; export { colorChannels } from "./operations/color-channels.js"; diff --git a/packages/image-engine/src/operations/compress.ts b/packages/image-engine/src/operations/compress.ts index 9248ae85..7b103aaa 100644 --- a/packages/image-engine/src/operations/compress.ts +++ b/packages/image-engine/src/operations/compress.ts @@ -10,10 +10,11 @@ const FORMAT_MAP: Record = { heif: "avif", tiff: "tiff", gif: "gif", + jxl: "jxl", }; /** Formats that Sharp cannot encode — fall back to PNG for output. */ -const NO_ENCODER = new Set(["svg", "bmp", "raw", "tga", "psd", "exr", "hdr", "ico"]); +const NO_ENCODER = new Set(["svg", "raw", "tga", "psd", "exr", "hdr"]); function formatOpts(format: string, quality: number): Record { const opts: Record = { quality }; diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 0515e359..546d3b4f 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -26,7 +26,11 @@ export type OutputFormat = | "gif" | "heic" | "heif" - | "jxl"; + | "jxl" + | "bmp" + | "ico" + | "jp2" + | "qoi"; export interface ResizeOptions { width?: number;