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
+28
View File
@@ -103,3 +103,31 @@ export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4);
return Buffer.from(encoded);
}
export async function encodeJxl(inputBuffer: Buffer, quality?: number): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jxl-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `jxl-enc-out-${id}.jxl`);
try {
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
try {
const q = String(quality ?? 75);
await execFileAsync("cjxl", [inputPath, outputPath, "-q", q], {
timeout: 120_000,
});
return await readFile(outputPath);
} catch {
/* cjxl not available, fall back to ImageMagick */
}
const cmd = await findMagickCmd();
const q = quality ? ["-quality", String(quality)] : [];
await execFileAsync(cmd, magickArgs(cmd, [inputPath, ...q, `jxl:${outputPath}`]), {
timeout: 120_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
+20 -2
View File
@@ -30,7 +30,7 @@ import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { createWorkspace } from "../lib/workspace.js";
import { hasEffectivePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
import { type JobProgress, updateJobProgress } from "./progress.js";
import { type JobProgress, updateJobProgress, updateSingleFileProgress } from "./progress.js";
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
/** Schema for a single pipeline step. */
@@ -75,6 +75,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
let fileBuffer: Buffer | null = null;
let filename = "image";
let pipelineRaw: string | null = null;
let clientJobId: string | null = null;
// Parse multipart
try {
@@ -89,6 +90,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
}
} catch (err) {
@@ -215,10 +218,23 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
let currentBuffer = fileBuffer;
let currentFilename = filename;
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
const totalSteps = pipeline.steps.length;
const reportProgress = (percent: number, stage?: string) => {
if (!clientJobId) return;
updateSingleFileProgress({
jobId: clientJobId,
phase: "processing",
percent,
stage,
});
};
try {
for (let i = 0; i < pipeline.steps.length; i++) {
for (let i = 0; i < totalSteps; i++) {
const step = pipeline.steps[i];
const stepPercent = Math.round((i / totalSteps) * 90);
reportProgress(stepPercent, `Step ${i + 1}/${totalSteps}: ${step.toolId}`);
// Route content-aware resize to its dedicated tool
const resolvedToolId =
@@ -250,6 +266,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
throw new Error(`Step ${i + 1} (${step.toolId}): ${msg}`);
}
}
reportProgress(95, "Saving...");
} catch (err) {
const message = err instanceof Error ? err.message : "Pipeline processing failed";
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
+43 -2
View File
@@ -20,6 +20,7 @@ import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
import { computeTimeout } from "../lib/timeout.js";
import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js";
import { updateSingleFileProgress } from "./progress.js";
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
@@ -112,6 +113,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
let filename = "image";
let settingsRaw: string | null = null;
let fileId: string | null = null;
let clientJobId: string | null = null;
let fileCount = 0;
// Parse multipart parts
@@ -143,6 +145,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
if (part.fieldname === "fileId") {
fileId = part.value as string;
}
if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
}
}
} catch (err) {
@@ -167,6 +172,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// mutates fileBuffer into a larger intermediate PNG.
const uploadedSize = fileBuffer.length;
const reportProgress = (percent: number, stage?: string) => {
if (!clientJobId) return;
updateSingleFileProgress({
jobId: clientJobId,
phase: "processing",
percent,
stage,
});
};
reportProgress(5, "Validating...");
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
@@ -178,6 +195,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// The decoded buffer is PNG, so update the filename extension to match.
const isHeif = validation.format === "heif";
if (isHeif) {
reportProgress(10, "Decoding HEIC...");
try {
fileBuffer = await decodeHeic(fileBuffer);
const ext = filename.match(/\.[^.]+$/)?.[0];
@@ -195,6 +213,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// Pass the original file extension so RAW decoder can use the correct
// temp file suffix (e.g. .cr3, .nef) for format identification.
if (needsCliDecode(validation.format)) {
reportProgress(10, "Decoding...");
try {
const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
@@ -225,6 +244,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
}
}
reportProgress(15, "Preparing...");
// Parse and validate settings
let settings: T;
try {
@@ -259,6 +280,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
try {
let result: { buffer: Buffer; filename: string; contentType: string };
reportProgress(20, "Processing...");
// Offload to worker thread for non-AI tools.
// Falls back to main-thread processing on any worker error.
// Disabled in test environments where worker_threads can't load .ts files.
@@ -299,6 +322,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
result = await config.process(processBuffer, settings, filename);
}
reportProgress(75, "Saving...");
// Add a tool-specific suffix to the filename so the download
// doesn't silently overwrite the user's original file.
// Skip if the tool already changed the filename (e.g. convert, split).
@@ -362,6 +387,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
]);
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
reportProgress(85, "Generating preview...");
try {
let previewInput = result.buffer;
// Sharp can't decode HEIC - use system decoder first
@@ -372,8 +398,21 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend will show the success card fallback
} catch (previewErr) {
request.log.warn(
{ previewErr, contentType: result.contentType, toolId: config.toolId },
"Failed to generate preview thumbnail, falling back to input buffer",
);
// Retry with the original input buffer (pre-processing) which
// was already validated and decoded during the intake phase.
try {
const fallbackBuffer = await sharp(fileBuffer).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, fallbackBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Both attempts failed - frontend will use the upload preview as fallback
}
}
}
@@ -381,6 +420,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
reportProgress(95, "Finishing...");
// Auto-save to persistent file store when a fileId is provided
let savedFileId: string | undefined;
if (fileId) {
+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();