refactor: improve tool processing, dropzone, seam carving, and format encoding

- Refactor use-tool-processor and use-pipeline-processor hooks
- Enhance dropzone component with improved UX
- Improve seam carving with better error handling and tests
- Add JXL format encoding support to format-encoders
- Update tool routes for consistent format handling
- Add dropzone unit tests
This commit is contained in:
SnapOtter
2026-05-11 21:57:40 +08:00
parent 656a8d3d44
commit b06906025c
25 changed files with 1368 additions and 436 deletions
+5 -3
View File
@@ -8,6 +8,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -643,7 +644,7 @@ export function registerCollage(app: FastifyInstance) {
outputExt = "avif";
break;
case "jxl":
pipeline = pipeline.jxl({ quality: settings.quality });
pipeline = pipeline.png();
outputExt = "jxl";
break;
default:
@@ -653,18 +654,19 @@ export function registerCollage(app: FastifyInstance) {
}
const result = await pipeline.toBuffer();
const finalBuffer = outputExt === "jxl" ? await encodeJxl(result, settings.quality) : result;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = `collage.${outputExt}`;
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
await writeFile(outputPath, finalBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: result.length,
processedSize: finalBuffer.length,
});
} catch (err) {
return reply.status(422).send({
+8 -1
View File
@@ -8,7 +8,13 @@ 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 {
encodeBmp,
encodeIco,
encodeJp2,
encodeJxl,
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";
@@ -56,6 +62,7 @@ const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Bu
bmp: encodeBmp,
ico: encodeIco,
jp2: encodeJp2,
jxl: encodeJxl,
qoi: encodeQoi,
};
+2 -1
View File
@@ -11,6 +11,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -213,7 +214,7 @@ export function registerEraseObject(app: FastifyInstance) {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
outputBuffer = await encodeJxl(resultBuffer, quality);
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
+5 -2
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -145,10 +146,12 @@ 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();
case "jxl": {
const pngBuf = await pipeline.png().toBuffer();
outputBuffer = await encodeJxl(pngBuf, opts.quality);
mimeType = "image/jxl";
break;
}
default:
outputBuffer = await pipeline.toBuffer();
mimeType = detectMimeType(ext);
+10 -2
View File
@@ -8,6 +8,7 @@ import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -40,9 +41,16 @@ const settingsSchema = z.object({
type Settings = z.infer<typeof settingsSchema>;
async function processImage(inputBuffer: Buffer, settings: Settings, filename: string) {
const isJxl = settings.format === "jxl";
const engineSettings = isJxl ? { ...settings, format: "png" as const } : settings;
const image = sharp(inputBuffer);
const result = await optimizeForWeb(image, settings);
const buffer = await result.toBuffer();
const result = await optimizeForWeb(image, engineSettings);
let buffer = await result.toBuffer();
if (isJxl) {
buffer = await encodeJxl(buffer, settings.quality);
}
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
+5 -2
View File
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -117,8 +118,10 @@ async function convertWithSharp(
return s.tiff().toBuffer();
case "gif":
return s.gif().toBuffer();
case "jxl":
return s.jxl({ quality }).toBuffer();
case "jxl": {
const pngBuf = await s.png().toBuffer();
return encodeJxl(pngBuf, quality);
}
case "heic":
case "heif": {
const pngBuf = await s.png().toBuffer();
+12 -3
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -31,7 +32,7 @@ function resolveOutputFormat(
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" },
avif: { sharpFormat: "avif", ext: ".avif" },
jxl: { sharpFormat: "jxl", ext: ".jxl" },
jxl: { sharpFormat: "png", ext: ".jxl" },
};
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
}
@@ -150,7 +151,11 @@ export function registerSplit(app: FastifyInstance) {
}
const partBuffer = await pipeline.toBuffer();
archive.append(partBuffer, {
const finalBuffer =
settings.outputFormat === "jxl"
? await encodeJxl(partBuffer, settings.quality)
: partBuffer;
archive.append(finalBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`,
});
}
@@ -236,7 +241,11 @@ export function registerSplit(app: FastifyInstance) {
}
const partBuffer = await pipeline.toBuffer();
archive.append(partBuffer, {
const finalBuffer =
settings.outputFormat === "jxl"
? await encodeJxl(partBuffer, settings.quality)
: partBuffer;
archive.append(finalBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`,
});
}
+7 -2
View File
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -204,13 +205,17 @@ export function registerStitch(app: FastifyInstance) {
} else if (settings.format === "avif") {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
} else if (settings.format === "jxl") {
pipeline = pipeline.jxl({ quality: settings.quality });
pipeline = pipeline.png();
} else {
pipeline = pipeline.png();
}
let result = await pipeline.toBuffer();
if (settings.format === "jxl") {
result = await encodeJxl(result, settings.quality);
}
if (settings.cornerRadius > 0) {
const meta = await sharp(result).metadata();
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
@@ -238,7 +243,7 @@ export function registerStitch(app: FastifyInstance) {
} 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();
result = await encodeJxl(result, settings.quality);
}
}
+5 -2
View File
@@ -10,6 +10,7 @@ import { env } from "../../config.js";
import { resolveConcurrency } from "../../lib/env.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -77,10 +78,12 @@ async function convertSvg(
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "jxl":
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
case "jxl": {
const pngBuf = await image.png().toBuffer();
buffer = await encodeJxl(pngBuf, settings.quality);
ext = "jxl";
break;
}
case "heif": {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
+2 -1
View File
@@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -186,7 +187,7 @@ export function registerUpscale(app: FastifyInstance) {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
outputBuffer = await encodeJxl(result.buffer, outputQuality);
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();