Merge branch 'feat/format-support-expansion'

# Conflicts:
#	README.md
#	apps/web/src/components/layout/app-layout.tsx
This commit is contained in:
SnapOtter
2026-05-08 16:21:11 +08:00
145 changed files with 27402 additions and 100 deletions
+5 -1
View File
@@ -333,7 +333,7 @@ const settingsSchema = z.object({
cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z.string().default("#FFFFFF"),
aspectRatio: z.string().default("free"),
outputFormat: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
outputFormat: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
quality: z.number().min(1).max(100).default(90),
});
@@ -642,6 +642,10 @@ export function registerCollage(app: FastifyInstance) {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
outputExt = "avif";
break;
case "jxl":
pipeline = pipeline.jxl({ quality: settings.quality });
outputExt = "jxl";
break;
default:
pipeline = pipeline.png();
outputExt = "png";
+42 -1
View File
@@ -3,6 +3,7 @@ import { convert } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -16,10 +17,36 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
gif: "image/gif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
bmp: "image/bmp",
ico: "image/x-icon",
jp2: "image/jp2",
qoi: "image/x-qoi",
};
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
bmp: encodeBmp,
ico: encodeIco,
jp2: encodeJp2,
qoi: encodeQoi,
};
const settingsSchema = z.object({
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]),
format: z.enum([
"jpg",
"png",
"webp",
"avif",
"tiff",
"gif",
"heic",
"heif",
"jxl",
"bmp",
"ico",
"jp2",
"qoi",
]),
quality: z.number().min(1).max(100).optional(),
});
@@ -28,6 +55,20 @@ export function registerConvert(app: FastifyInstance) {
toolId: "convert",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
// CLI-encoded formats bypass Sharp entirely
const cliEncoder = CLI_ENCODERS[settings.format];
if (cliEncoder) {
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return {
buffer: outputBuffer,
filename: `${baseName}.${settings.format}`,
contentType,
};
}
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
const image = sharp(inputBuffer, sharpOpts);
+6 -2
View File
@@ -26,13 +26,14 @@ const EXT_MAP: Record<string, string> = {
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
const settingsSchema = z.object({
format: z
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"])
.default("auto"),
quality: z.number().int().min(1).max(100).default(95),
});
@@ -203,7 +204,7 @@ export function registerEraseObject(app: FastifyInstance) {
);
// Convert to the requested output format using Sharp
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
@@ -211,6 +212,9 @@ export function registerEraseObject(app: FastifyInstance) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
+5 -1
View File
@@ -6,7 +6,7 @@ import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"),
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif", "jxl"]).default("original"),
quality: z.number().int().min(1).max(100).default(80),
maxWidth: z.number().int().min(0).default(0),
maxHeight: z.number().int().min(0).default(0),
@@ -145,6 +145,10 @@ export function registerImageToBase64(app: FastifyInstance) {
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
mimeType = "image/avif";
break;
case "jxl":
outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer();
mimeType = "image/jxl";
break;
default:
outputBuffer = await pipeline.toBuffer();
mimeType = detectMimeType(ext);
+2 -2
View File
@@ -21,7 +21,7 @@ const settingsSchema = z.object({
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
});
@@ -198,7 +198,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
}),
process: async (inputBuffer, settings, filename) => {
@@ -17,6 +17,7 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpeg: "image/jpeg",
avif: "image/avif",
png: "image/png",
jxl: "image/jxl",
};
const FORMAT_EXTENSIONS: Record<string, string> = {
@@ -24,10 +25,11 @@ const FORMAT_EXTENSIONS: Record<string, string> = {
jpeg: "jpg",
avif: "avif",
png: "png",
jxl: "jxl",
};
const settingsSchema = z.object({
format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"),
format: z.enum(["webp", "jpeg", "avif", "png", "jxl"]).default("webp"),
quality: z.number().min(1).max(100).default(80),
maxWidth: z.number().positive().optional(),
maxHeight: z.number().positive().optional(),
+6 -1
View File
@@ -14,7 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js";
// ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({
format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"),
format: z
.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"])
.default("png"),
dpi: z.number().min(36).max(2400).default(150),
quality: z.number().min(1).max(100).default(85),
colorMode: z.enum(["color", "grayscale", "bw"]).default("color"),
@@ -86,6 +88,7 @@ const FORMAT_EXT: Record<string, string> = {
gif: ".gif",
heic: ".heic",
heif: ".heif",
jxl: ".jxl",
};
async function convertWithSharp(
@@ -114,6 +117,8 @@ async function convertWithSharp(
return s.tiff().toBuffer();
case "gif":
return s.gif().toBuffer();
case "jxl":
return s.jxl({ quality }).toBuffer();
case "heic":
case "heif": {
const pngBuf = await s.png().toBuffer();
+2 -1
View File
@@ -15,7 +15,7 @@ const settingsSchema = z.object({
rows: z.number().min(1).max(100).default(3),
tileWidth: z.number().min(10).optional(),
tileHeight: z.number().min(10).optional(),
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif"]).default("original"),
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif", "jxl"]).default("original"),
quality: z.number().min(1).max(100).default(90),
});
@@ -31,6 +31,7 @@ function resolveOutputFormat(
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" },
avif: { sharpFormat: "avif", ext: ".avif" },
jxl: { sharpFormat: "jxl", ext: ".jxl" },
};
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
}
+5 -1
View File
@@ -24,7 +24,7 @@ const settingsSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
format: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
quality: z.number().min(1).max(100).default(90),
});
@@ -203,6 +203,8 @@ export function registerStitch(app: FastifyInstance) {
pipeline = pipeline.webp({ quality: settings.quality });
} else if (settings.format === "avif") {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
} else if (settings.format === "jxl") {
pipeline = pipeline.jxl({ quality: settings.quality });
} else {
pipeline = pipeline.png();
}
@@ -235,6 +237,8 @@ export function registerStitch(app: FastifyInstance) {
result = await sharp(result).webp({ quality: settings.quality }).toBuffer();
} else if (settings.format === "avif") {
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
} else if (settings.format === "jxl") {
result = await sharp(result).jxl({ quality: settings.quality }).toBuffer();
}
}
+5 -1
View File
@@ -26,7 +26,7 @@ const settingsSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000000"),
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"),
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"]).default("png"),
});
interface ParsedSvgFile {
@@ -77,6 +77,10 @@ async function convertSvg(
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "jxl":
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
ext = "jxl";
break;
case "heif": {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
+5 -1
View File
@@ -158,7 +158,7 @@ export function registerUpscale(app: FastifyInstance) {
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
const pythonFormat = needsNodeConversion ? "png" : format;
const onProgress = (percent: number, stage: string) => {
@@ -185,6 +185,9 @@ export function registerUpscale(app: FastifyInstance) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
finalFormat = "avif";
@@ -201,6 +204,7 @@ export function registerUpscale(app: FastifyInstance) {
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;