From 5d8556254f0684de7c3a1a177a28f8d1e0818cd3 Mon Sep 17 00:00:00 2001
From: stirling-image
Date: Mon, 6 Apr 2026 12:53:53 +0800
Subject: [PATCH] fix: batch file ordering and format preservation for image
tools (#20)
* feat: add resolveOutputFormat utility for input format preservation
* fix: preserve file order in batch processing with X-File-Results header
Collect all results before streaming the ZIP to guarantee upload order.
Replace X-File-Order with index-based X-File-Results header that maps
each upload index to its processed filename, handling failures and
duplicate filenames correctly.
Closes #13
* fix: use X-File-Results for index-based batch file matching
The frontend now matches processed files to entries by upload index
instead of fragile name/position matching.
* feat: preserve input format in smart-crop with quality control
Smart crop now outputs in the same format as the input (JPG in, JPG out)
instead of always converting to PNG. Adds an optional quality setting
(default 95) for lossy formats.
Closes #14
* feat: add output quality slider to smart crop settings UI
* feat: preserve input format in crop tool
* feat: preserve input format in color adjustment tools
Applies to brightness-contrast, saturation, color-channels, and
color-effects tool routes.
* refactor: avoid double encode in smart-crop content mode
For the simple trim path (no pad-to-square), chain .toFormat() on the
trim pipeline directly instead of creating a second Sharp instance.
This eliminates a redundant intermediate encode that degraded quality
for lossy formats. Also use trimmed.info dimensions instead of a
separate metadata() call for the pad-to-square path.
---------
Co-authored-by: Siddharth Kumar Sah
---
apps/api/src/lib/output-format.ts | 48 +++
apps/api/src/routes/batch.ts | 128 ++++----
.../api/src/routes/tools/color-adjustments.ts | 14 +-
apps/api/src/routes/tools/crop.ts | 8 +-
apps/api/src/routes/tools/smart-crop.ts | 41 +--
.../components/tools/smart-crop-settings.tsx | 34 ++-
apps/web/src/hooks/use-tool-processor.ts | 22 +-
tests/integration/api.test.ts | 284 +++++++++++++++++-
tests/unit/api/output-format.test.ts | 54 ++++
9 files changed, 536 insertions(+), 97 deletions(-)
create mode 100644 apps/api/src/lib/output-format.ts
create mode 100644 tests/unit/api/output-format.test.ts
diff --git a/apps/api/src/lib/output-format.ts b/apps/api/src/lib/output-format.ts
new file mode 100644
index 00000000..2132cb8f
--- /dev/null
+++ b/apps/api/src/lib/output-format.ts
@@ -0,0 +1,48 @@
+import sharp from "sharp";
+
+export interface OutputFormat {
+ format: keyof sharp.FormatEnum;
+ extension: string;
+ contentType: string;
+ quality: number;
+}
+
+const FORMAT_MAP: Record<
+ string,
+ { format: keyof sharp.FormatEnum; extension: string; contentType: string }
+> = {
+ jpeg: { format: "jpeg", extension: "jpg", contentType: "image/jpeg" },
+ png: { format: "png", extension: "png", contentType: "image/png" },
+ webp: { format: "webp", extension: "webp", contentType: "image/webp" },
+ gif: { format: "gif", extension: "gif", contentType: "image/gif" },
+ tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
+ avif: { format: "avif", extension: "avif", contentType: "image/avif" },
+};
+
+const DEFAULT_QUALITY = 95;
+const PNG_FALLBACK = FORMAT_MAP.png;
+
+/**
+ * Detect the input image format and return matching output config.
+ * Falls back to PNG for undetectable or unsupported output formats
+ * (SVG, BMP, raw camera formats like CR2/NEF).
+ */
+export async function resolveOutputFormat(
+ inputBuffer: Buffer,
+ _filename: string,
+ qualityOverride?: number,
+): Promise {
+ let detected: string | undefined;
+ try {
+ const meta = await sharp(inputBuffer).metadata();
+ detected = meta.format;
+ } catch {
+ // format detection failed
+ }
+
+ const mapped = detected ? FORMAT_MAP[detected] : undefined;
+ const config = mapped ?? PNG_FALLBACK;
+ const quality = qualityOverride ?? DEFAULT_QUALITY;
+
+ return { ...config, quality };
+}
diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts
index fb073d5a..7c6bebf0 100644
--- a/apps/api/src/routes/batch.ts
+++ b/apps/api/src/routes/batch.ts
@@ -114,62 +114,19 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise {
};
updateJobProgress({ ...progress });
- // Tell Fastify we're taking over the response — without this,
- // Fastify's lifecycle hooks conflict with reply.raw.writeHead()
- // and can throw unhandled errors that crash the process.
- reply.hijack();
-
- // Set up response headers for ZIP streaming.
- // X-File-Order must be URI-encoded because filenames can contain
- // spaces/special chars that are invalid in HTTP header values.
- reply.raw.writeHead(200, {
- "Content-Type": "application/zip",
- "Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
- "Transfer-Encoding": "chunked",
- "X-Job-Id": jobId,
- "X-File-Order": files.map((f) => encodeURIComponent(f.filename)).join(","),
- });
-
- // Create ZIP archive that pipes directly to the response
- const archive = archiver("zip", { zlib: { level: 5 } });
-
- // Handle archive-level errors to prevent unhandled exceptions
- // that would crash the server process.
- archive.on("error", (err) => {
- request.log.error({ err }, "Archiver error during batch processing");
- if (!reply.raw.writableEnded) {
- reply.raw.end();
- }
- });
-
- archive.pipe(reply.raw);
-
// Use p-queue for concurrency control
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
- // Track unique filenames to avoid collisions in the ZIP
- const usedNames = new Set();
- function getUniqueName(name: string): string {
- if (!usedNames.has(name)) {
- usedNames.add(name);
- return name;
- }
- const dotIdx = name.lastIndexOf(".");
- const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
- const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
- let counter = 1;
- let candidate = `${base}_${counter}${ext}`;
- while (usedNames.has(candidate)) {
- counter++;
- candidate = `${base}_${counter}${ext}`;
- }
- usedNames.add(candidate);
- return candidate;
- }
+ // All processed buffers are held in memory until ZIP streaming begins.
+ // Peak memory scales with files.length * avg output size. MAX_BATCH_SIZE bounds this.
+ // Collect results in indexed array to preserve upload order
+ const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
+ null,
+ );
// Process all files through the queue
try {
- const tasks = files.map((file) =>
+ const tasks = files.map((file, index) =>
queue.add(async () => {
progress.currentFile = file.filename;
updateJobProgress({ ...progress });
@@ -195,8 +152,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise {
processBuffer = await autoOrient(processBuffer);
const result = await toolConfig.process(processBuffer, settings, file.filename);
- const zipFilename = getUniqueName(result.filename);
- archive.append(result.buffer, { name: zipFilename });
+ results[index] = { buffer: result.buffer, filename: result.filename };
progress.completedFiles++;
updateJobProgress({ ...progress });
@@ -212,7 +168,6 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise {
}),
);
- // Wait for all tasks to complete
await Promise.all(tasks);
} catch (err) {
request.log.error({ err }, "Unexpected error in batch queue");
@@ -223,7 +178,72 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise {
progress.currentFile = undefined;
updateJobProgress({ ...progress });
- // Finalize the ZIP archive (flushes remaining data and ends the stream)
+ // Deduplicate filenames in original order and build X-File-Results header
+ const usedNames = new Set();
+ function getUniqueName(name: string): string {
+ if (!usedNames.has(name)) {
+ usedNames.add(name);
+ return name;
+ }
+ const dotIdx = name.lastIndexOf(".");
+ const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
+ const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
+ let counter = 1;
+ let candidate = `${base}_${counter}${ext}`;
+ while (usedNames.has(candidate)) {
+ counter++;
+ candidate = `${base}_${counter}${ext}`;
+ }
+ usedNames.add(candidate);
+ return candidate;
+ }
+
+ const fileResultsMap: Record = {};
+ for (let i = 0; i < results.length; i++) {
+ const entry = results[i];
+ if (entry) {
+ const uniqueName = getUniqueName(entry.filename);
+ entry.filename = uniqueName;
+ fileResultsMap[String(i)] = uniqueName;
+ }
+ }
+
+ // If every file failed, return an error instead of an empty ZIP
+ if (progress.status === "failed") {
+ return reply.status(422).send({
+ error: "All files failed processing",
+ errors: progress.errors,
+ });
+ }
+
+ // Hijack and stream the ZIP response after all processing
+ reply.hijack();
+ reply.raw.writeHead(200, {
+ "Content-Type": "application/zip",
+ "Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
+ "Transfer-Encoding": "chunked",
+ "X-Job-Id": jobId,
+ "X-File-Results": JSON.stringify(fileResultsMap),
+ });
+
+ const archive = archiver("zip", { zlib: { level: 5 } });
+
+ archive.on("error", (err) => {
+ request.log.error({ err }, "Archiver error during batch processing");
+ if (!reply.raw.writableEnded) {
+ reply.raw.end();
+ }
+ });
+
+ archive.pipe(reply.raw);
+
+ // Append results in original upload order
+ for (const result of results) {
+ if (result) {
+ archive.append(result.buffer, { name: result.filename });
+ }
+ }
+
await archive.finalize();
},
);
diff --git a/apps/api/src/routes/tools/color-adjustments.ts b/apps/api/src/routes/tools/color-adjustments.ts
index 8b22f45d..9611c8f7 100644
--- a/apps/api/src/routes/tools/color-adjustments.ts
+++ b/apps/api/src/routes/tools/color-adjustments.ts
@@ -10,6 +10,7 @@ import {
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
+import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -29,7 +30,6 @@ const settingsSchema = z.object({
* Serves tool IDs: brightness-contrast, saturation, color-channels, color-effects
*/
export function registerColorAdjustments(app: FastifyInstance) {
- // Register the same handler under all four color-related tool IDs
const toolIds = ["brightness-contrast", "saturation", "color-channels", "color-effects"];
for (const toolId of toolIds) {
@@ -37,28 +37,25 @@ export function registerColorAdjustments(app: FastifyInstance) {
toolId,
settingsSchema,
process: async (inputBuffer, settings, filename) => {
+ const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let image = sharp(inputBuffer);
- // Apply brightness
if (settings.brightness !== 0) {
image = await adjustBrightness(image, {
value: settings.brightness,
});
}
- // Apply contrast
if (settings.contrast !== 0) {
image = await adjustContrast(image, { value: settings.contrast });
}
- // Apply saturation
if (settings.saturation !== 0) {
image = await adjustSaturation(image, {
value: settings.saturation,
});
}
- // Apply color channels (only if not default 100/100/100)
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
image = await colorChannels(image, {
red: settings.red,
@@ -67,7 +64,6 @@ export function registerColorAdjustments(app: FastifyInstance) {
});
}
- // Apply effect
switch (settings.effect) {
case "grayscale":
image = await grayscale(image);
@@ -80,8 +76,10 @@ export function registerColorAdjustments(app: FastifyInstance) {
break;
}
- const buffer = await image.toBuffer();
- return { buffer, filename, contentType: "image/png" };
+ const buffer = await image
+ .toFormat(outputFormat.format, { quality: outputFormat.quality })
+ .toBuffer();
+ return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
diff --git a/apps/api/src/routes/tools/crop.ts b/apps/api/src/routes/tools/crop.ts
index df508346..d89acf71 100644
--- a/apps/api/src/routes/tools/crop.ts
+++ b/apps/api/src/routes/tools/crop.ts
@@ -2,6 +2,7 @@ import { crop } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
+import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -16,10 +17,13 @@ export function registerCrop(app: FastifyInstance) {
toolId: "crop",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
+ const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const image = sharp(inputBuffer);
const result = await crop(image, settings);
- const buffer = await result.toBuffer();
- return { buffer, filename, contentType: "image/png" };
+ const buffer = await result
+ .toFormat(outputFormat.format, { quality: outputFormat.quality })
+ .toBuffer();
+ return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
diff --git a/apps/api/src/routes/tools/smart-crop.ts b/apps/api/src/routes/tools/smart-crop.ts
index b5534da9..54814026 100644
--- a/apps/api/src/routes/tools/smart-crop.ts
+++ b/apps/api/src/routes/tools/smart-crop.ts
@@ -1,18 +1,18 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
+import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
mode: z.enum(["attention", "content"]).default("attention"),
- // Attention mode: resize to target dimensions using subject detection
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
- // Content mode: trim uniform borders, optionally pad to square
threshold: z.number().int().min(0).max(255).default(30),
padToSquare: z.boolean().default(false),
padColor: z.string().default("#ffffff"),
targetSize: z.number().int().positive().optional(),
+ quality: z.number().int().min(1).max(100).optional(),
});
/**
@@ -26,35 +26,41 @@ export function registerSmartCrop(app: FastifyInstance) {
toolId: "smart-crop",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
+ const outputFormat = await resolveOutputFormat(inputBuffer, filename, settings.quality);
let result: Buffer;
if (settings.mode === "content") {
- // Crop to content: trim uniform borders
- const pipeline = sharp(inputBuffer).trim({ threshold: settings.threshold });
- let trimmed = await pipeline.toBuffer({ resolveWithObject: true });
-
if (settings.padToSquare || settings.targetSize) {
- const meta = await sharp(trimmed.data).metadata();
- const w = meta.width ?? 1;
- const h = meta.height ?? 1;
+ // Trim first to get dimensions, then pad to square
+ const trimmed = await sharp(inputBuffer)
+ .trim({ threshold: settings.threshold })
+ .toBuffer({ resolveWithObject: true });
+
+ const w = trimmed.info.width;
+ const h = trimmed.info.height;
const target = settings.targetSize || Math.max(w, h);
const padR = Math.round(parseInt(settings.padColor.slice(1, 3), 16));
const padG = Math.round(parseInt(settings.padColor.slice(3, 5), 16));
const padB = Math.round(parseInt(settings.padColor.slice(5, 7), 16));
- trimmed = await sharp(trimmed.data)
+ const padded = await sharp(trimmed.data)
.resize({
width: target,
height: target,
fit: "contain",
background: { r: padR, g: padG, b: padB, alpha: 1 },
})
- .toBuffer({ resolveWithObject: true });
+ .toFormat(outputFormat.format, { quality: outputFormat.quality })
+ .toBuffer();
+ result = padded;
+ } else {
+ // Simple trim + format in one pass (no intermediate encode)
+ result = await sharp(inputBuffer)
+ .trim({ threshold: settings.threshold })
+ .toFormat(outputFormat.format, { quality: outputFormat.quality })
+ .toBuffer();
}
-
- result = trimmed.data;
} else {
- // Attention mode: resize to target using subject detection
const w = settings.width ?? 1080;
const h = settings.height ?? 1080;
result = await sharp(inputBuffer)
@@ -62,12 +68,13 @@ export function registerSmartCrop(app: FastifyInstance) {
fit: "cover",
position: sharp.strategy.attention,
})
- .png()
+ .toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
- const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_smartcrop.png`;
- return { buffer: result, filename: outputFilename, contentType: "image/png" };
+ const stem = filename.replace(/\.[^.]+$/, "");
+ const outputFilename = `${stem}_smartcrop.${outputFormat.extension}`;
+ return { buffer: result, filename: outputFilename, contentType: outputFormat.contentType };
},
});
}
diff --git a/apps/web/src/components/tools/smart-crop-settings.tsx b/apps/web/src/components/tools/smart-crop-settings.tsx
index 1a9b7834..fa5e5aba 100644
--- a/apps/web/src/components/tools/smart-crop-settings.tsx
+++ b/apps/web/src/components/tools/smart-crop-settings.tsx
@@ -31,6 +31,7 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
const [padToSquare, setPadToSquare] = useState(false);
const [padColor, setPadColor] = useState("#ffffff");
const [targetSize, setTargetSize] = useState("1000");
+ const [quality, setQuality] = useState(95);
const emit = (overrides: Record = {}) => {
if (mode === "content") {
@@ -39,6 +40,7 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
threshold,
padToSquare,
padColor,
+ quality,
...(padToSquare ? { targetSize: Number(targetSize) } : {}),
...overrides,
});
@@ -47,6 +49,7 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
mode: "attention",
width: Number(width),
height: Number(height),
+ quality,
...overrides,
});
}
@@ -55,9 +58,9 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
const handleModeChange = (m: Mode) => {
setMode(m);
if (m === "content") {
- onChange?.({ mode: "content", threshold, padToSquare, padColor });
+ onChange?.({ mode: "content", threshold, padToSquare, padColor, quality });
} else {
- onChange?.({ mode: "attention", width: Number(width), height: Number(height) });
+ onChange?.({ mode: "attention", width: Number(width), height: Number(height), quality });
}
};
@@ -257,6 +260,32 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
>
)}
+
+ {/* Quality slider */}
+
+
+
+ {quality}%
+
+
{
+ const v = Number(e.target.value);
+ setQuality(v);
+ emit({ quality: v });
+ }}
+ className="w-full mt-1"
+ />
+
+ For JPEG and WebP outputs. PNG is always lossless.
+
+
);
}
@@ -271,6 +300,7 @@ export function SmartCropSettings() {
threshold: 30,
padToSquare: false,
padColor: "#ffffff",
+ quality: 95,
});
const handleProcess = () => {
diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts
index 7311ade8..21de8ce9 100644
--- a/apps/web/src/hooks/use-tool-processor.ts
+++ b/apps/web/src/hooks/use-tool-processor.ts
@@ -323,25 +323,23 @@ export function useToolProcessor(toolId: string) {
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
const extracted = unzipSync(zipBuffer);
- const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(
- decodeURIComponent,
- );
const entries = useFileStore.getState().entries;
- const extractedNames = Object.keys(extracted);
+ let fileResults: Record = {};
+ try {
+ fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
+ } catch {
+ // Malformed header - fall back to empty mapping, all entries marked failed
+ }
for (let i = 0; i < entries.length; i++) {
- let zipName: string | undefined;
- if (fileOrder[i] && extracted[fileOrder[i]]) {
- zipName = fileOrder[i];
- } else {
- zipName = extractedNames.find((n) => n === entries[i].file.name) ?? extractedNames[i];
- }
- if (zipName && extracted[zipName]) {
- const blob = new Blob([extracted[zipName] as BlobPart]);
+ const processedName = fileResults[String(i)];
+ if (processedName && extracted[processedName]) {
+ const blob = new Blob([extracted[processedName] as BlobPart]);
updateEntry(i, {
processedUrl: URL.createObjectURL(blob),
processedSize: blob.size,
status: "completed",
+ error: null,
});
} else {
updateEntry(i, { status: "failed", error: "File not found in batch results" });
diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts
index fde1d5c7..d0faa88c 100644
--- a/tests/integration/api.test.ts
+++ b/tests/integration/api.test.ts
@@ -2706,7 +2706,7 @@ describe("Batch processing", () => {
expect(res.headers["content-type"]).toBe("application/zip");
});
- it("batch includes X-File-Order header", async () => {
+ it("batch includes X-File-Results header", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "first.png", contentType: "image/png", content: PNG_1x1 },
{ name: "file", filename: "second.png", contentType: "image/png", content: PNG_200x150 },
@@ -2723,7 +2723,10 @@ describe("Batch processing", () => {
payload,
});
expect(res.statusCode).toBe(200);
- expect(res.headers["x-file-order"]).toBeDefined();
+ expect(res.headers["x-file-results"]).toBeDefined();
+ const parsed = JSON.parse(res.headers["x-file-results"] as string);
+ expect(parsed["0"]).toBeDefined();
+ expect(parsed["1"]).toBeDefined();
});
it("returns ZIP with content-disposition attachment header", async () => {
@@ -2787,6 +2790,59 @@ describe("Batch processing", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
+
+ it("batch returns X-File-Results header with index-to-filename mapping", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "first.png", contentType: "image/png", content: PNG_1x1 },
+ { name: "file", filename: "second.png", contentType: "image/png", content: PNG_200x150 },
+ { name: "settings", content: JSON.stringify({ width: 50 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/resize/batch",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+
+ const fileResults = res.headers["x-file-results"];
+ expect(fileResults).toBeDefined();
+ const parsed = JSON.parse(fileResults as string);
+ expect(parsed["0"]).toBeDefined();
+ expect(parsed["1"]).toBeDefined();
+ expect(typeof parsed["0"]).toBe("string");
+ expect(typeof parsed["1"]).toBe("string");
+ });
+
+ it("batch X-File-Results entries contain original filename stems in order", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "aaa.png", contentType: "image/png", content: PNG_1x1 },
+ { name: "file", filename: "bbb.png", contentType: "image/png", content: PNG_200x150 },
+ { name: "file", filename: "ccc.jpg", contentType: "image/jpeg", content: JPG_100x100 },
+ { name: "settings", content: JSON.stringify({ width: 50 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/resize/batch",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+
+ const fileResults = JSON.parse(res.headers["x-file-results"] as string);
+ const names = [fileResults["0"], fileResults["1"], fileResults["2"]];
+ expect(names[0]).toContain("aaa");
+ expect(names[1]).toContain("bbb");
+ expect(names[2]).toContain("ccc");
+ });
});
describe("Batch rejects incompatible tools", () => {
@@ -2815,6 +2871,230 @@ describe("Batch processing", () => {
});
});
+// ═══════════════════════════════════════════════════════════════════════════
+// SMART CROP FORMAT PRESERVATION
+// ═══════════════════════════════════════════════════════════════════════════
+describe("Smart crop format preservation", () => {
+ it("preserves JPEG format for JPEG input in content mode", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG_100x100 },
+ { name: "settings", content: JSON.stringify({ mode: "content", threshold: 30 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/smart-crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.downloadUrl).toMatch(/_smartcrop\.jpg/);
+ });
+
+ it("preserves PNG format for PNG input", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "image.png", contentType: "image/png", content: PNG_200x150 },
+ { name: "settings", content: JSON.stringify({ mode: "content", threshold: 30 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/smart-crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.downloadUrl).toMatch(/_smartcrop\.png/);
+ });
+
+ it("preserves WebP format for WebP input", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "image.webp", contentType: "image/webp", content: WEBP_50x50 },
+ { name: "settings", content: JSON.stringify({ mode: "content", threshold: 30 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/smart-crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.downloadUrl).toMatch(/_smartcrop\.webp/);
+ });
+
+ it("preserves JPEG format in attention mode", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG_100x100 },
+ { name: "settings", content: JSON.stringify({ mode: "attention", width: 50, height: 50 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/smart-crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.downloadUrl).toMatch(/_smartcrop\.jpg/);
+ });
+
+ it("accepts quality setting without error", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG_100x100 },
+ {
+ name: "settings",
+ content: JSON.stringify({ mode: "content", threshold: 30, quality: 50 }),
+ },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/smart-crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CROP FORMAT PRESERVATION
+// ═══════════════════════════════════════════════════════════════════════════
+describe("Crop format preservation", () => {
+ it("preserves JPEG format for JPEG input", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG_100x100 },
+ { name: "settings", content: JSON.stringify({ left: 0, top: 0, width: 50, height: 50 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ const dlRes = await app.inject({
+ method: "GET",
+ url: body.downloadUrl,
+ headers: { authorization: `Bearer ${adminToken}` },
+ });
+ const sharp = (await import("sharp")).default;
+ const meta = await sharp(dlRes.rawPayload).metadata();
+ expect(meta.format).toBe("jpeg");
+ });
+
+ it("preserves PNG format for PNG input", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "image.png", contentType: "image/png", content: PNG_200x150 },
+ { name: "settings", content: JSON.stringify({ left: 0, top: 0, width: 50, height: 50 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/crop",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ const dlRes = await app.inject({
+ method: "GET",
+ url: body.downloadUrl,
+ headers: { authorization: `Bearer ${adminToken}` },
+ });
+ const sharp = (await import("sharp")).default;
+ const meta = await sharp(dlRes.rawPayload).metadata();
+ expect(meta.format).toBe("png");
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// COLOR ADJUSTMENTS FORMAT PRESERVATION
+// ═══════════════════════════════════════════════════════════════════════════
+describe("Color adjustments format preservation", () => {
+ it("preserves JPEG format for JPEG input via brightness-contrast", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG_100x100 },
+ { name: "settings", content: JSON.stringify({ brightness: 10 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/brightness-contrast",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ const dlRes = await app.inject({
+ method: "GET",
+ url: body.downloadUrl,
+ headers: { authorization: `Bearer ${adminToken}` },
+ });
+ const sharp = (await import("sharp")).default;
+ const meta = await sharp(dlRes.rawPayload).metadata();
+ expect(meta.format).toBe("jpeg");
+ });
+
+ it("preserves PNG format for PNG input via saturation", async () => {
+ const { body: payload, contentType } = createMultipartPayload([
+ { name: "file", filename: "image.png", contentType: "image/png", content: PNG_200x150 },
+ { name: "settings", content: JSON.stringify({ saturation: 20 }) },
+ ]);
+
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/tools/saturation",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ "content-type": contentType,
+ },
+ payload,
+ });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ const dlRes = await app.inject({
+ method: "GET",
+ url: body.downloadUrl,
+ headers: { authorization: `Bearer ${adminToken}` },
+ });
+ const sharp = (await import("sharp")).default;
+ const meta = await sharp(dlRes.rawPayload).metadata();
+ expect(meta.format).toBe("png");
+ });
+});
+
// ═══════════════════════════════════════════════════════════════════════════
// EDGE CASES & ADVERSARIAL INPUTS
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/tests/unit/api/output-format.test.ts b/tests/unit/api/output-format.test.ts
new file mode 100644
index 00000000..4c608938
--- /dev/null
+++ b/tests/unit/api/output-format.test.ts
@@ -0,0 +1,54 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { resolveOutputFormat } from "../../../apps/api/src/lib/output-format.js";
+
+const FIXTURES = join(__dirname, "..", "..", "fixtures");
+const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
+const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
+const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
+
+describe("resolveOutputFormat", () => {
+ it("detects JPEG input and returns jpeg config", async () => {
+ const result = await resolveOutputFormat(JPG, "photo.jpg");
+ expect(result.format).toBe("jpeg");
+ expect(result.extension).toBe("jpg");
+ expect(result.contentType).toBe("image/jpeg");
+ expect(result.quality).toBe(95);
+ });
+
+ it("detects PNG input and returns png config", async () => {
+ const result = await resolveOutputFormat(PNG, "image.png");
+ expect(result.format).toBe("png");
+ expect(result.extension).toBe("png");
+ expect(result.contentType).toBe("image/png");
+ expect(result.quality).toBe(95);
+ });
+
+ it("detects WebP input and returns webp config", async () => {
+ const result = await resolveOutputFormat(WEBP, "image.webp");
+ expect(result.format).toBe("webp");
+ expect(result.extension).toBe("webp");
+ expect(result.contentType).toBe("image/webp");
+ expect(result.quality).toBe(95);
+ });
+
+ it("falls back to PNG for unknown format", async () => {
+ const garbage = Buffer.from("not an image at all");
+ const result = await resolveOutputFormat(garbage, "mystery.bin");
+ expect(result.format).toBe("png");
+ expect(result.extension).toBe("png");
+ expect(result.contentType).toBe("image/png");
+ });
+
+ it("respects quality override for lossy formats", async () => {
+ const result = await resolveOutputFormat(JPG, "photo.jpg", 50);
+ expect(result.quality).toBe(50);
+ });
+
+ it("accepts quality override for PNG without error", async () => {
+ const result = await resolveOutputFormat(PNG, "image.png", 50);
+ expect(result.format).toBe("png");
+ expect(result.quality).toBe(50);
+ });
+});