From 8c83d7efcd4a06e407010fd91fa51871ba72bbe5 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sun, 19 Apr 2026 19:52:23 +0800 Subject: [PATCH] fix: handle HEIC images in blur-faces and red-eye-removal, show warning when no faces detected --- apps/api/src/routes/tools/blur-faces.ts | 12 +++- apps/api/src/routes/tools/red-eye-removal.ts | 9 ++- .../components/tools/blur-faces-settings.tsx | 6 +- apps/web/src/hooks/use-tool-processor.ts | 5 ++ packages/ai/src/face-detection.ts | 7 +- packages/ai/src/red-eye-removal.ts | 4 +- tests/e2e-docker/blur-faces.spec.ts | 65 +++++++++++++++++++ tests/e2e/blur-faces.spec.ts | 48 ++++++++++++++ 8 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 tests/e2e-docker/blur-faces.spec.ts create mode 100644 tests/e2e/blur-faces.spec.ts diff --git a/apps/api/src/routes/tools/blur-faces.ts b/apps/api/src/routes/tools/blur-faces.ts index 839d815f..370ed718 100644 --- a/apps/api/src/routes/tools/blur-faces.ts +++ b/apps/api/src/routes/tools/blur-faces.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; +import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; @@ -66,6 +67,11 @@ export function registerBlurFaces(app: FastifyInstance) { try { const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + + if (validation.format === "heif") { + fileBuffer = await decodeHeic(fileBuffer); + } + request.log.info( { toolId: "blur-faces", @@ -76,7 +82,6 @@ export function registerBlurFaces(app: FastifyInstance) { "Starting face blur", ); - // Auto-orient to fix EXIF rotation before face detection fileBuffer = await autoOrient(fileBuffer); const jobId = randomUUID(); @@ -129,6 +134,9 @@ export function registerBlurFaces(app: FastifyInstance) { processedSize: result.buffer.length, facesDetected: result.facesDetected, faces: result.faces, + ...(result.facesDetected === 0 && { + warning: "No faces detected in this image. Try increasing detection sensitivity.", + }), }); } catch (err) { request.log.error({ err, toolId: "blur-faces" }, "Face blur failed"); @@ -149,7 +157,7 @@ export function registerBlurFaces(app: FastifyInstance) { }), process: async (inputBuffer, settings, filename) => { const s = settings as { blurRadius?: number; sensitivity?: number }; - const orientedBuffer = await autoOrient(inputBuffer); + const orientedBuffer = await autoOrient(await ensureSharpCompat(inputBuffer)); const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); const result = await blurFaces(orientedBuffer, join(workspacePath, "output"), { diff --git a/apps/api/src/routes/tools/red-eye-removal.ts b/apps/api/src/routes/tools/red-eye-removal.ts index c4d1744f..45489417 100644 --- a/apps/api/src/routes/tools/red-eye-removal.ts +++ b/apps/api/src/routes/tools/red-eye-removal.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; +import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; @@ -68,6 +69,11 @@ export function registerRedEyeRemoval(app: FastifyInstance) { try { const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + + if (validation.format === "heif") { + fileBuffer = await decodeHeic(fileBuffer); + } + request.log.info( { toolId: "red-eye-removal", @@ -78,7 +84,6 @@ export function registerRedEyeRemoval(app: FastifyInstance) { "Starting red eye removal", ); - // Auto-orient to fix EXIF rotation before face detection fileBuffer = await autoOrient(fileBuffer); const jobId = randomUUID(); @@ -162,7 +167,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) { format?: string; quality?: number; }; - const orientedBuffer = await autoOrient(inputBuffer); + const orientedBuffer = await autoOrient(await ensureSharpCompat(inputBuffer)); const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); const result = await removeRedEye(orientedBuffer, join(workspacePath, "output"), { diff --git a/apps/web/src/components/tools/blur-faces-settings.tsx b/apps/web/src/components/tools/blur-faces-settings.tsx index aeb534f6..a8459d6c 100644 --- a/apps/web/src/components/tools/blur-faces-settings.tsx +++ b/apps/web/src/components/tools/blur-faces-settings.tsx @@ -75,7 +75,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF />
More faces - Fewer false positives + Fewer faces
@@ -89,6 +89,7 @@ export function BlurFacesSettings() { processAllFiles, processing, error, + warning, downloadUrl, originalSize, processedSize, @@ -110,9 +111,10 @@ export function BlurFacesSettings() {
- {/* Error */} {error &&

{error}

} + {warning &&

{warning}

} + {/* Size info */} {originalSize != null && processedSize != null && (
diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 65dd7684..95e47d41 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -11,6 +11,7 @@ interface ProcessResult { originalSize: number; processedSize: number; savedFileId?: string; + warning?: string; } export interface ToolProgress { @@ -39,6 +40,7 @@ export function useToolProcessor(toolId: string) { useFileStore(); const [progress, setProgress] = useState(IDLE_PROGRESS); + const [warning, setWarning] = useState(null); const elapsedRef = useRef | null>(null); const xhrRef = useRef(null); const eventSourceRef = useRef(null); @@ -69,6 +71,7 @@ export function useToolProcessor(toolId: string) { const capturedIndex = useFileStore.getState().selectedIndex; setError(null); + setWarning(null); // Mark the target entry as processing and clear any old result useFileStore.getState().updateEntry(capturedIndex, { processedUrl: null, @@ -216,6 +219,7 @@ export function useToolProcessor(toolId: string) { if (xhr.status >= 200 && xhr.status < 300) { try { const result: ProcessResult = JSON.parse(xhr.responseText); + setWarning(result.warning ?? null); // Write result to the entry that was being processed (captured at // request time), not whatever entry happens to be selected now. useFileStore.getState().updateEntry(capturedIndex, { @@ -429,6 +433,7 @@ export function useToolProcessor(toolId: string) { processAllFiles, processing, error, + warning, downloadUrl: processedUrl, originalSize, processedSize, diff --git a/packages/ai/src/face-detection.ts b/packages/ai/src/face-detection.ts index c8641d8d..2e737422 100644 --- a/packages/ai/src/face-detection.ts +++ b/packages/ai/src/face-detection.ts @@ -1,6 +1,7 @@ import { readFile, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface BlurFacesOptions { @@ -39,7 +40,8 @@ export async function blurFaces( const inputPath = join(outputDir, "input_faces.png"); const outputPath = join(outputDir, "output_faces.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "detect_faces.py", [inputPath, outputPath, JSON.stringify(options)], @@ -67,7 +69,8 @@ export async function detectFaces( const inputPath = join(tmpdir(), `detect_faces_${Date.now()}.png`); try { - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "detect_faces.py", [inputPath, "unused", JSON.stringify({ ...options, detectOnly: true })], diff --git a/packages/ai/src/red-eye-removal.ts b/packages/ai/src/red-eye-removal.ts index f1773248..4698a2c2 100644 --- a/packages/ai/src/red-eye-removal.ts +++ b/packages/ai/src/red-eye-removal.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface RedEyeRemovalOptions { @@ -27,7 +28,8 @@ export async function removeRedEye( const inputPath = join(outputDir, "input_redeye.png"); const outputPath = join(outputDir, "output_redeye.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "red_eye_removal.py", [inputPath, outputPath, JSON.stringify(options)], diff --git a/tests/e2e-docker/blur-faces.spec.ts b/tests/e2e-docker/blur-faces.spec.ts new file mode 100644 index 00000000..79949efa --- /dev/null +++ b/tests/e2e-docker/blur-faces.spec.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +function fixturePath(name: string): string { + return path.join(process.cwd(), "tests", "fixtures", name); +} + +async function uploadFile(page: import("@playwright/test").Page, filePath: string) { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePath); + await page.waitForTimeout(1000); +} + +test.describe("Blur Faces - HEIC fix and no-face warning", () => { + test("HEIC image processes without error", async ({ page }) => { + await page.goto("/blur-faces"); + await uploadFile(page, fixturePath("test-portrait.heic")); + + await page.getByTestId("blur-faces-submit").click(); + + // Wait for processing to complete — download button proves it worked + await expect(page.getByTestId("blur-faces-download")).toBeVisible({ timeout: 120_000 }); + + // The old bug showed "cannot identify image" or "Face blur failed" + await expect(page.locator("text=cannot identify image")).not.toBeVisible(); + await expect(page.locator("text=Face blur failed")).not.toBeVisible(); + }); + + test("no-face image shows warning message", async ({ page }) => { + await page.goto("/blur-faces"); + await uploadFile(page, fixturePath("test-blank.png")); + + await page.getByTestId("blur-faces-submit").click(); + + await expect(page.getByText("No faces detected")).toBeVisible({ timeout: 120_000 }); + }); + + test("HEIC image via API returns 200", async ({ request }) => { + // Login to get auth token + const loginRes = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const { token } = await loginRes.json(); + + const response = await request.post("/api/v1/tools/blur-faces", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { + name: "test.heic", + mimeType: "image/heic", + buffer: fs.readFileSync(fixturePath("test-portrait.heic")), + }, + settings: JSON.stringify({ blurRadius: 30, sensitivity: 0.5 }), + }, + }); + + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); diff --git a/tests/e2e/blur-faces.spec.ts b/tests/e2e/blur-faces.spec.ts new file mode 100644 index 00000000..3714f7b4 --- /dev/null +++ b/tests/e2e/blur-faces.spec.ts @@ -0,0 +1,48 @@ +import path from "node:path"; +import { expect, test } from "./helpers"; + +function fixturePath(name: string): string { + return path.join(process.cwd(), "tests", "fixtures", name); +} + +async function uploadFile(page: import("@playwright/test").Page, filePath: string) { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePath); + await page.waitForTimeout(1000); +} + +test.describe("Blur Faces tool", () => { + test("page loads with correct UI controls", async ({ loggedInPage: page }) => { + await page.goto("/blur-faces"); + + await expect(page.getByText("Blur Radius")).toBeVisible(); + await expect(page.getByText("Detection Sensitivity")).toBeVisible(); + await expect(page.getByTestId("blur-faces-submit")).toBeVisible(); + }); + + test("HEIC image processes without error", async ({ loggedInPage: page }) => { + await page.goto("/blur-faces"); + await uploadFile(page, fixturePath("test-portrait.heic")); + + await page.getByTestId("blur-faces-submit").click(); + + // Should complete without the old "cannot identify image file" error + await expect( + page.getByTestId("blur-faces-download").or(page.getByText("No faces detected")), + ).toBeVisible({ timeout: 120_000 }); + + await expect(page.locator("text=cannot identify image")).not.toBeVisible(); + }); + + test("no-face image shows warning message", async ({ loggedInPage: page }) => { + await page.goto("/blur-faces"); + await uploadFile(page, fixturePath("test-blank.png")); + + await page.getByTestId("blur-faces-submit").click(); + + await expect(page.getByText("No faces detected")).toBeVisible({ timeout: 120_000 }); + }); +});