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