From 449f5dc97ee13b6a8825092660b2570f081da9f6 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 12 May 2026 19:41:23 +0800 Subject: [PATCH] fix: add exotic format decoding to image composition tool The compose route only decoded HEIC/HEIF via ensureSharpCompat, causing EPS, PSD, BMP, RAW, and other exotic formats to fail with "Processing failed". Now uses the same full format pipeline as the tool-factory for both base and overlay buffers. --- apps/api/src/routes/tools/compose.ts | 31 ++++- .../src/components/tools/compose-settings.tsx | 2 +- tests/e2e/gui-tools-overlay.spec.ts | 127 +++++++++++++++++- 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/tools/compose.ts b/apps/api/src/routes/tools/compose.ts index 864928e6..a90e9f1c 100644 --- a/apps/api/src/routes/tools/compose.ts +++ b/apps/api/src/routes/tools/compose.ts @@ -6,10 +6,32 @@ import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { formatZodErrors } from "../../lib/errors.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; -import { ensureSharpCompat } from "../../lib/heic-converter.js"; +import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; +import { decodeHeic } from "../../lib/heic-converter.js"; +import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; import { createWorkspace } from "../../lib/workspace.js"; +async function decodeBuffer(buffer: Buffer, filename: string): Promise { + const validation = await validateImageBuffer(buffer, filename); + if (!validation.valid) { + throw new Error(`Invalid image: ${validation.reason}`); + } + + if (validation.format === "heif") { + buffer = await decodeHeic(buffer); + } else if (needsCliDecode(validation.format)) { + const ext = filename.split(".").pop()?.toLowerCase(); + buffer = await decodeToSharpCompat(buffer, validation.format, ext); + } else if (validation.format === "svg") { + buffer = decompressSvgz(buffer); + buffer = sanitizeSvg(buffer); + } + + return autoOrient(buffer); +} + const settingsSchema = z.object({ x: z.number().min(0).default(0), y: z.number().min(0).default(0), @@ -35,6 +57,7 @@ export function registerCompose(app: FastifyInstance) { let baseBuffer: Buffer | null = null; let overlayBuffer: Buffer | null = null; let filename = "image"; + let overlayFilename = "overlay"; let settingsRaw: string | null = null; try { @@ -48,6 +71,7 @@ export function registerCompose(app: FastifyInstance) { const buf = Buffer.concat(chunks); if (part.fieldname === "overlay") { overlayBuffer = buf; + overlayFilename = sanitizeFilename(part.filename ?? "overlay"); } else { baseBuffer = buf; filename = sanitizeFilename(part.filename ?? "image"); @@ -85,9 +109,8 @@ export function registerCompose(app: FastifyInstance) { } try { - // Decode HEIC/HEIF if needed, then normalize EXIF orientation - baseBuffer = await autoOrient(await ensureSharpCompat(baseBuffer)); - overlayBuffer = await autoOrient(await ensureSharpCompat(overlayBuffer)); + baseBuffer = await decodeBuffer(baseBuffer, filename); + overlayBuffer = await decodeBuffer(overlayBuffer, overlayFilename); // Apply opacity to overlay if needed let processedOverlay = overlayBuffer; diff --git a/apps/web/src/components/tools/compose-settings.tsx b/apps/web/src/components/tools/compose-settings.tsx index 95c62499..e782587d 100644 --- a/apps/web/src/components/tools/compose-settings.tsx +++ b/apps/web/src/components/tools/compose-settings.tsx @@ -65,7 +65,7 @@ export function ComposeSettings() { id="compose-overlay-image" ref={overlayInputRef} type="file" - accept="image/*,.avif,.heic,.heif,.hif" + accept="image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm" onChange={(e) => setOverlayFile(e.target.files?.[0] ?? null)} className="hidden" /> diff --git a/tests/e2e/gui-tools-overlay.spec.ts b/tests/e2e/gui-tools-overlay.spec.ts index f5f5070d..9b3f0b5c 100644 --- a/tests/e2e/gui-tools-overlay.spec.ts +++ b/tests/e2e/gui-tools-overlay.spec.ts @@ -1,4 +1,7 @@ -import { expect, test, uploadTestImage, waitForProcessing } from "./helpers"; +import fs from "node:fs"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { expect, getTestImagePath, test, uploadTestImage, waitForProcessing } from "./helpers"; // --------------------------------------------------------------------------- // GUI E2E: Watermark & Overlay Tools @@ -277,18 +280,36 @@ test.describe("GUI Watermark & Overlay Tools", () => { // ======================================================================== // COMPOSE (Image Composition) + // + // The compose page has two dashed-border elements: the overlay upload + // button in the settings sidebar and the main dropzone. The generic + // uploadTestImage helper picks the first border-dashed element (overlay + // button), so compose tests use the dropzone's aria-label and set + // overlay files directly on the hidden input. // ======================================================================== test.describe("Compose", () => { + async function uploadBaseImage(page: Page) { + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.locator("section[aria-label='File drop zone']").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(getTestImagePath()); + await page.waitForTimeout(500); + } + + async function uploadOverlayImage(page: Page, filePath?: string) { + await page.locator("#compose-overlay-image").setInputFiles(filePath ?? getTestImagePath()); + await page.waitForTimeout(500); + } + test("renders tool page with dropzone", async ({ loggedInPage: page }) => { await page.goto("/compose"); await expect(page.getByText("Image Composition").first()).toBeVisible(); - await expect(page.getByText("Upload from computer")).toBeVisible(); + await expect(page.locator("section[aria-label='File drop zone']")).toBeVisible(); }); test("shows overlay upload and position controls", async ({ loggedInPage: page }) => { await page.goto("/compose"); - // Position and opacity controls visible in settings panel await expect(page.getByText("X Position")).toBeVisible(); await expect(page.getByText("Y Position")).toBeVisible(); await expect(page.getByText("Opacity").first()).toBeVisible(); @@ -322,16 +343,112 @@ test.describe("GUI Watermark & Overlay Tools", () => { const select = page.locator("#compose-blend-mode"); await expect(select).toBeVisible(); const options = select.locator("option"); - await expect(options).toHaveCount(10); // Normal, Multiply, Screen, Overlay, Darken, Lighten, Hard Light, Soft Light, Difference, Exclusion + await expect(options).toHaveCount(10); }); test("submit disabled without overlay file", async ({ loggedInPage: page }) => { await page.goto("/compose"); - await uploadTestImage(page); + await uploadBaseImage(page); const submitBtn = page.getByTestId("compose-submit"); await expect(submitBtn).toBeDisabled(); }); + + test("submit enabled after uploading both base and overlay", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + await uploadOverlayImage(page); + + await expect(page.getByTestId("compose-submit")).toBeEnabled(); + }); + + test("processes composition and shows download", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + await uploadOverlayImage(page); + + await page.getByTestId("compose-submit").click(); + await waitForProcessing(page); + + await expect(page.getByTestId("compose-download")).toBeVisible({ timeout: 15_000 }); + }); + + test("processes with custom position and opacity", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + await uploadOverlayImage(page); + + await page.locator("#compose-x-position").fill("10"); + await page.locator("#compose-y-position").fill("20"); + await page.locator("#compose-opacity").fill("50"); + + await page.getByTestId("compose-submit").click(); + await waitForProcessing(page); + + await expect(page.getByTestId("compose-download")).toBeVisible({ timeout: 15_000 }); + }); + + test("processes with multiply blend mode", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + await uploadOverlayImage(page); + + await page.locator("#compose-blend-mode").selectOption("multiply"); + + await page.getByTestId("compose-submit").click(); + await waitForProcessing(page); + + await expect(page.getByTestId("compose-download")).toBeVisible({ timeout: 15_000 }); + }); + + test("overlay filename shown after selection", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadOverlayImage(page); + + await expect(page.getByText("test-image.png")).toBeVisible(); + }); + + test("shows size info after processing", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + await uploadOverlayImage(page); + + await page.getByTestId("compose-submit").click(); + await waitForProcessing(page); + + await expect(page.getByTestId("compose-download")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(/Original:/).first()).toBeVisible(); + await expect(page.getByText(/Processed:/).first()).toBeVisible(); + }); + + test("processes webp overlay on png base", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + + const webpPath = path.join(process.cwd(), "tests", "fixtures", "test-50x50.webp"); + await uploadOverlayImage(page, webpPath); + + await page.getByTestId("compose-submit").click(); + await waitForProcessing(page); + + await expect(page.getByTestId("compose-download")).toBeVisible({ timeout: 15_000 }); + }); + + test("shows error for corrupt overlay file", async ({ loggedInPage: page }) => { + await page.goto("/compose"); + await uploadBaseImage(page); + + const tmpDir = path.join(process.cwd(), "test-results"); + const corruptPath = path.join(tmpDir, "corrupt-overlay.png"); + fs.writeFileSync(corruptPath, Buffer.from("not-an-image")); + await uploadOverlayImage(page, corruptPath); + + await page.getByTestId("compose-submit").click(); + + await expect(page.getByText(/Processing failed|Invalid image|error/i)).toBeVisible({ + timeout: 15_000, + }); + }); }); // ========================================================================