From 648c8c12f5eda78dcc3cd467937468b4507837b4 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 13 May 2026 14:18:49 +0800 Subject: [PATCH] fix: convert input buffer to PNG before passing to face landmarks Python sidecar AVIF (and other Sharp-native formats) were written as raw bytes to a .png temp file, causing PIL to fail with "cannot identify image file". Every other AI module wrapper already converts via sharp().png().toBuffer() before writing; face-landmarks was the only one that skipped this step. --- packages/ai/src/face-landmarks.ts | 4 +++- tests/unit/ai/face-landmarks.test.ts | 7 ++++--- tests/unit/ai/tools.test.ts | 8 +++++--- tests/unit/api/ai-tools.test.ts | 6 +++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/face-landmarks.ts b/packages/ai/src/face-landmarks.ts index 02913c9c..b42d1411 100644 --- a/packages/ai/src/face-landmarks.ts +++ b/packages/ai/src/face-landmarks.ts @@ -1,6 +1,7 @@ import { 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 FaceLandmarkPoint { @@ -33,7 +34,8 @@ export async function detectFaceLandmarks( const inputPath = join(tmpdir(), `face_landmarks_${Date.now()}.png`); try { - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "face_landmarks.py", [inputPath, "unused", "{}"], diff --git a/tests/unit/ai/face-landmarks.test.ts b/tests/unit/ai/face-landmarks.test.ts index 4f866de4..2b586949 100644 --- a/tests/unit/ai/face-landmarks.test.ts +++ b/tests/unit/ai/face-landmarks.test.ts @@ -68,13 +68,14 @@ describe("detectFaceLandmarks", () => { ); }); - it("writes input buffer directly without sharp conversion", async () => { + it("converts input buffer to PNG before writing", async () => { + const sharp = (await import("sharp")).default; await detectFaceLandmarks(FAKE_INPUT); - // face-landmarks.ts does NOT use sharp -- it writes inputBuffer directly + expect(sharp).toHaveBeenCalledWith(FAKE_INPUT); expect(writeFile).toHaveBeenCalledWith( expect.stringContaining("face_landmarks_"), - FAKE_INPUT, + Buffer.from("mock-png-data"), ); }); diff --git a/tests/unit/ai/tools.test.ts b/tests/unit/ai/tools.test.ts index 8d713c22..6bcf5d41 100644 --- a/tests/unit/ai/tools.test.ts +++ b/tests/unit/ai/tools.test.ts @@ -580,11 +580,13 @@ describe("detectFaceLandmarks", () => { expect(result.landmarks).toBeNull(); }); - it("does not use sharp to convert to PNG (writes buffer directly)", async () => { + it("converts input buffer to PNG before writing", async () => { await detectFaceLandmarks(FAKE_INPUT); - // face-landmarks writes inputBuffer directly, no sharp conversion - expect(writeFile).toHaveBeenCalledWith(expect.stringContaining("face_landmarks_"), FAKE_INPUT); + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining("face_landmarks_"), + Buffer.from("mock-png-data"), + ); }); it("cleans up temp file in finally block", async () => { diff --git a/tests/unit/api/ai-tools.test.ts b/tests/unit/api/ai-tools.test.ts index b427071d..559c7921 100644 --- a/tests/unit/api/ai-tools.test.ts +++ b/tests/unit/api/ai-tools.test.ts @@ -630,13 +630,13 @@ describe("detectFaceLandmarks", () => { await expect(detectFaceLandmarks(INPUT_BUFFER)).rejects.toThrow("MediaPipe not found"); }); - it("writes raw input buffer (no sharp conversion)", async () => { + it("converts input buffer to PNG before writing", async () => { mockParseStdoutJson.mockReturnValue({ success: true, faceDetected: false }); await detectFaceLandmarks(INPUT_BUFFER); - // face-landmarks writes inputBuffer directly, no sharp pipeline - expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), INPUT_BUFFER); + expect(mockSharp).toHaveBeenCalledWith(INPUT_BUFFER); + expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), Buffer.from("mock-png")); }); });