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);
+ });
+});