feat: add QOI codec, format encoders (BMP/ICO/JP2/QOI), fix JXL output bug

This commit is contained in:
SnapOtter
2026-05-08 00:09:55 +08:00
parent 5c9fa2de49
commit 390b5adb8c
7 changed files with 329 additions and 7 deletions
+10 -3
View File
@@ -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<Buffer> {
}
}
// ── QOI decoder (stub -- real codec comes in Task 4) ──
// ── QOI decoder ──
async function decodeQoi(_buffer: Buffer): Promise<Buffer> {
throw new Error("QOI decode not yet implemented");
async function decodeQoi(buffer: Buffer): Promise<Buffer> {
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();
}
+105
View File
@@ -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<string> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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);
}
+2 -2
View File
@@ -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.