mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Security fixes:
- Add auth + ownership check to thumbnail endpoint (was unauthenticated)
- Validate ExifTool fieldsToRemove against safe tag name pattern
- Add SVG sanitization to pipeline execute and batch endpoints
- Replace basename() with sanitizeFilename() in 16 tool routes
- Escape SQL LIKE wildcards in file search to prevent pattern injection
- Improve settings HTML tag validation pattern
Bug fixes:
- Skip autoOrient for SVG inputs in pipeline (prevents misinterpretation)
- Remove double-encode in compress targetSize (was degrading quality)
- Fix bg-effects alpha value from 255 to 1.0 (Sharp expects float)
- Guard download stream error handler against headers-already-sent race
- Use O_EXCL atomic file creation for install lock (fixes TOCTOU race)
- Truncate collage file array to template image count
UX fixes:
- Accept empty JSON bodies on POST endpoints (install/uninstall)
- Custom JSON content type parser that treats empty body as {}
673 lines
25 KiB
TypeScript
673 lines
25 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { detectFaceLandmarks, removeBackground } from "@snapotter/ai";
|
|
import {
|
|
getBundleForTool,
|
|
PASSPORT_SPECS,
|
|
PRINT_LAYOUTS,
|
|
TOOL_BUNDLE_MAP,
|
|
} from "@snapotter/shared";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import sharp from "sharp";
|
|
import { z } from "zod";
|
|
import { autoOrient } from "../../lib/auto-orient.js";
|
|
import { formatZodErrors } from "../../lib/errors.js";
|
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
|
import { sanitizeFilename } from "../../lib/filename.js";
|
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
|
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
|
import { updateSingleFileProgress } from "../progress.js";
|
|
import { registerToolProcessFn } from "../tool-factory.js";
|
|
|
|
const landmarkPointSchema = z.object({ x: z.number(), y: z.number() });
|
|
|
|
const landmarksSchema = z.object({
|
|
leftEye: landmarkPointSchema,
|
|
rightEye: landmarkPointSchema,
|
|
eyeCenter: landmarkPointSchema,
|
|
chin: landmarkPointSchema,
|
|
forehead: landmarkPointSchema,
|
|
crown: landmarkPointSchema,
|
|
nose: landmarkPointSchema,
|
|
faceCenterX: z.number(),
|
|
});
|
|
|
|
const generateSettingsSchema = z.object({
|
|
jobId: z.string(),
|
|
filename: z.string(),
|
|
countryCode: z.string(),
|
|
documentType: z.string().default("passport"),
|
|
bgColor: z.string().default("#FFFFFF"),
|
|
printLayout: z.string().default("none"),
|
|
maxFileSizeKb: z.number().default(0),
|
|
dpi: z.number().min(72).max(1200).default(300),
|
|
customWidthMm: z.number().optional(),
|
|
customHeightMm: z.number().optional(),
|
|
zoom: z.number().min(0.5).max(3).default(1),
|
|
adjustX: z.number().default(0),
|
|
adjustY: z.number().default(0),
|
|
landmarks: landmarksSchema,
|
|
imageWidth: z.number(),
|
|
imageHeight: z.number(),
|
|
});
|
|
|
|
/**
|
|
* Generate a print sheet that tiles passport photos onto standard paper.
|
|
* Returns JPEG buffer or null if layout is "none".
|
|
*/
|
|
async function generatePrintSheet(
|
|
photoBuffer: Buffer,
|
|
photoWidthMm: number,
|
|
photoHeightMm: number,
|
|
layoutId: string,
|
|
): Promise<Buffer | null> {
|
|
const layout = PRINT_LAYOUTS.find((l) => l.id === layoutId);
|
|
if (!layout || layout.id === "none") return null;
|
|
|
|
const DPI = 300;
|
|
const MM_PER_INCH = 25.4;
|
|
const GUTTER_MM = 2;
|
|
|
|
const paperWidthPx = Math.round((layout.width / MM_PER_INCH) * DPI);
|
|
const paperHeightPx = Math.round((layout.height / MM_PER_INCH) * DPI);
|
|
const photoWidthPx = Math.round((photoWidthMm / MM_PER_INCH) * DPI);
|
|
const photoHeightPx = Math.round((photoHeightMm / MM_PER_INCH) * DPI);
|
|
const gutterPx = Math.round((GUTTER_MM / MM_PER_INCH) * DPI);
|
|
|
|
const cols = Math.floor((paperWidthPx + gutterPx) / (photoWidthPx + gutterPx));
|
|
const rows = Math.floor((paperHeightPx + gutterPx) / (photoHeightPx + gutterPx));
|
|
|
|
if (cols < 1 || rows < 1) return null;
|
|
|
|
// Center the grid on the paper
|
|
const gridWidth = cols * photoWidthPx + (cols - 1) * gutterPx;
|
|
const gridHeight = rows * photoHeightPx + (rows - 1) * gutterPx;
|
|
const offsetX = Math.round((paperWidthPx - gridWidth) / 2);
|
|
const offsetY = Math.round((paperHeightPx - gridHeight) / 2);
|
|
|
|
// Resize photo to exact pixel dimensions
|
|
const resizedPhoto = await sharp(photoBuffer)
|
|
.resize(photoWidthPx, photoHeightPx, { fit: "fill" })
|
|
.toBuffer();
|
|
|
|
// Build composite inputs
|
|
const composites: sharp.OverlayOptions[] = [];
|
|
for (let row = 0; row < rows; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
composites.push({
|
|
input: resizedPhoto,
|
|
left: offsetX + col * (photoWidthPx + gutterPx),
|
|
top: offsetY + row * (photoHeightPx + gutterPx),
|
|
});
|
|
}
|
|
}
|
|
|
|
return sharp({
|
|
create: {
|
|
width: paperWidthPx,
|
|
height: paperHeightPx,
|
|
channels: 3,
|
|
background: { r: 255, g: 255, b: 255 },
|
|
},
|
|
})
|
|
.composite(composites)
|
|
.jpeg({ quality: 95 })
|
|
.toBuffer();
|
|
}
|
|
|
|
/**
|
|
* Passport photo tool with two-phase flow:
|
|
*
|
|
* Phase 1 (POST /passport-photo/analyze): AI face detection + bg removal.
|
|
* Returns landmarks, preview, and caches images for generate phase.
|
|
*
|
|
* Phase 2 (POST /passport-photo/generate): Sharp crop/resize/tile.
|
|
* Uses cached images. No AI re-run. Fast response.
|
|
*/
|
|
export function registerPassportPhoto(app: FastifyInstance) {
|
|
// ── Phase 1: Analyze (face landmarks + bg removal) ────────────────
|
|
app.post(
|
|
"/api/v1/tools/passport-photo/analyze",
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const toolId = "passport-photo";
|
|
if (!isToolInstalled(toolId)) {
|
|
const bundle = getBundleForTool(toolId);
|
|
return reply.status(501).send({
|
|
error: "Feature not installed",
|
|
code: "FEATURE_NOT_INSTALLED",
|
|
feature: TOOL_BUNDLE_MAP[toolId],
|
|
featureName: bundle?.name ?? toolId,
|
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
|
});
|
|
}
|
|
|
|
let fileBuffer: Buffer | null = null;
|
|
let filename = "image";
|
|
let clientJobId: string | null = null;
|
|
|
|
try {
|
|
const parts = request.parts();
|
|
for await (const part of parts) {
|
|
if (part.type === "file") {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of part.file) chunks.push(chunk);
|
|
fileBuffer = Buffer.concat(chunks);
|
|
filename = sanitizeFilename(part.filename ?? "image");
|
|
} else if (part.fieldname === "clientJobId") {
|
|
clientJobId = part.value as string;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return reply.status(400).send({
|
|
error: "Failed to parse multipart request",
|
|
details: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
|
|
if (!fileBuffer || fileBuffer.length === 0) {
|
|
return reply.status(400).send({ error: "No image file provided" });
|
|
}
|
|
|
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
if (!validation.valid) {
|
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
}
|
|
|
|
try {
|
|
// Decode HEIC/HEIF before processing
|
|
if (validation.format === "heif") {
|
|
fileBuffer = await decodeHeic(fileBuffer);
|
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
|
}
|
|
|
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
|
if (needsCliDecode(validation.format)) {
|
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
|
}
|
|
|
|
// Auto-orient to fix EXIF rotation
|
|
fileBuffer = await autoOrient(fileBuffer);
|
|
|
|
request.log.info(
|
|
{ toolId: "passport-photo", imageSize: fileBuffer.length },
|
|
"Starting passport photo analysis",
|
|
);
|
|
|
|
const jobId = randomUUID();
|
|
const workspacePath = await createWorkspace(jobId);
|
|
|
|
// Save original to workspace for generate phase
|
|
const inputPath = join(workspacePath, "input", filename);
|
|
await writeFile(inputPath, fileBuffer);
|
|
|
|
// Progress callback
|
|
const jobIdForProgress = clientJobId;
|
|
const onProgress = jobIdForProgress
|
|
? (percent: number, stage: string) => {
|
|
updateSingleFileProgress({
|
|
jobId: jobIdForProgress,
|
|
phase: "processing",
|
|
stage,
|
|
percent: Math.min(percent, 95),
|
|
});
|
|
}
|
|
: undefined;
|
|
|
|
// Step 1: Detect face landmarks (0-30% of progress)
|
|
const landmarkProgress = onProgress
|
|
? (percent: number, stage: string) => {
|
|
onProgress(Math.round(percent * 0.3), stage);
|
|
}
|
|
: undefined;
|
|
|
|
const landmarksResult = await detectFaceLandmarks(fileBuffer, landmarkProgress);
|
|
|
|
if (!landmarksResult.faceDetected || !landmarksResult.landmarks) {
|
|
if (clientJobId) {
|
|
updateSingleFileProgress({
|
|
jobId: clientJobId,
|
|
phase: "complete",
|
|
percent: 100,
|
|
});
|
|
}
|
|
return reply.status(422).send({
|
|
error: "No face detected",
|
|
details:
|
|
"Could not detect a face in the uploaded image. Please upload a clear, front-facing photo with good lighting.",
|
|
});
|
|
}
|
|
|
|
// Step 2: Remove background with birefnet-portrait (30-95%)
|
|
const bgProgress = onProgress
|
|
? (percent: number, stage: string) => {
|
|
onProgress(30 + Math.round(percent * 0.65), stage);
|
|
}
|
|
: undefined;
|
|
|
|
const bgRemovedBuffer = await removeBackground(
|
|
fileBuffer,
|
|
join(workspacePath, "output"),
|
|
{ model: "birefnet-portrait" },
|
|
bgProgress,
|
|
);
|
|
|
|
// Save bg-removed image to workspace
|
|
const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
|
|
await writeFile(join(workspacePath, "output", bgRemovedFilename), bgRemovedBuffer);
|
|
|
|
// Create a smaller preview for fast transfer (max 800px wide)
|
|
const meta = await sharp(bgRemovedBuffer).metadata();
|
|
const previewWidth = Math.min(meta.width ?? 800, 800);
|
|
const previewBuffer = await sharp(bgRemovedBuffer)
|
|
.resize({ width: previewWidth, withoutEnlargement: true })
|
|
.png()
|
|
.toBuffer({ resolveWithObject: true });
|
|
|
|
const preview = previewBuffer.data.toString("base64");
|
|
|
|
if (clientJobId) {
|
|
updateSingleFileProgress({
|
|
jobId: clientJobId,
|
|
phase: "complete",
|
|
percent: 100,
|
|
});
|
|
}
|
|
|
|
return reply.send({
|
|
jobId,
|
|
filename,
|
|
preview,
|
|
previewWidth: previewBuffer.info.width,
|
|
previewHeight: previewBuffer.info.height,
|
|
landmarks: landmarksResult.landmarks,
|
|
imageWidth: landmarksResult.imageWidth,
|
|
imageHeight: landmarksResult.imageHeight,
|
|
});
|
|
} catch (err) {
|
|
request.log.error({ err, toolId: "passport-photo" }, "Passport photo analysis failed");
|
|
return reply.status(422).send({
|
|
error: "Passport photo analysis failed",
|
|
details: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Base route: return 501 so generic callers don't get 404 ──────
|
|
app.post(
|
|
"/api/v1/tools/passport-photo",
|
|
async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
const toolId = "passport-photo";
|
|
if (!isToolInstalled(toolId)) {
|
|
const bundle = getBundleForTool(toolId);
|
|
return reply.status(501).send({
|
|
error: "Feature not installed",
|
|
code: "FEATURE_NOT_INSTALLED",
|
|
feature: TOOL_BUNDLE_MAP[toolId],
|
|
featureName: bundle?.name ?? toolId,
|
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
|
});
|
|
}
|
|
return reply.status(400).send({
|
|
error: "Use /api/v1/tools/passport-photo/analyze or /generate",
|
|
});
|
|
},
|
|
);
|
|
|
|
// ── Phase 2: Generate (crop + resize + tile) ─────────────────────
|
|
app.post(
|
|
"/api/v1/tools/passport-photo/generate",
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const parseResult = generateSettingsSchema.safeParse(request.body);
|
|
if (!parseResult.success) {
|
|
return reply.status(400).send({
|
|
error: "Invalid settings",
|
|
details: formatZodErrors(parseResult.error.issues),
|
|
});
|
|
}
|
|
const parsed = parseResult.data;
|
|
|
|
const {
|
|
jobId,
|
|
filename,
|
|
countryCode,
|
|
documentType,
|
|
bgColor,
|
|
printLayout,
|
|
maxFileSizeKb,
|
|
dpi: userDpi,
|
|
customWidthMm,
|
|
customHeightMm,
|
|
zoom: userZoom,
|
|
adjustX,
|
|
adjustY,
|
|
landmarks: rawLandmarks,
|
|
imageWidth: imgW,
|
|
imageHeight: imgH,
|
|
} = parsed;
|
|
|
|
// Look up country spec
|
|
const countrySpec = PASSPORT_SPECS.find((s) => s.code === countryCode);
|
|
if (!countrySpec && !customWidthMm) {
|
|
return reply.status(400).send({ error: `Unknown country code: ${countryCode}` });
|
|
}
|
|
|
|
const baseDoc =
|
|
countrySpec?.documents.find((d) => d.type === documentType) ?? countrySpec?.documents[0];
|
|
// Build effective doc spec: custom dimensions/DPI override country defaults
|
|
const docSpec = {
|
|
...(baseDoc ?? {
|
|
headHeightMin: 0.7,
|
|
headHeightMax: 0.8,
|
|
eyeLineFromBottom: 0.63,
|
|
bgColor: "#FFFFFF",
|
|
bgColors: ["#FFFFFF"],
|
|
label: "Custom",
|
|
type: "passport" as const,
|
|
dpi: 300,
|
|
width: 35,
|
|
height: 45,
|
|
}),
|
|
width: customWidthMm ?? baseDoc?.width ?? 35,
|
|
height: customHeightMm ?? baseDoc?.height ?? 45,
|
|
dpi: userDpi,
|
|
};
|
|
|
|
try {
|
|
const workspacePath = getWorkspacePath(jobId);
|
|
const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
|
|
|
|
const bgRemovedBuffer = await readFile(join(workspacePath, "output", bgRemovedFilename));
|
|
|
|
// Use actual bg-removed image dimensions for crop (may differ from
|
|
// the original image dimensions reported by the analyze endpoint).
|
|
const bgMeta = await sharp(bgRemovedBuffer).metadata();
|
|
const actualW = bgMeta.width ?? imgW;
|
|
const actualH = bgMeta.height ?? imgH;
|
|
|
|
// Convert normalized landmarks (0-1) to pixel coordinates in the
|
|
// bg-removed image space (scale if dimensions differ from original).
|
|
const scaleX = actualW / imgW;
|
|
const scaleY = actualH / imgH;
|
|
const crownYPx = (rawLandmarks.crown.y + adjustY) * imgH * scaleY;
|
|
const chinYPx = (rawLandmarks.chin.y + adjustY) * imgH * scaleY;
|
|
const eyeYPx = (rawLandmarks.eyeCenter.y + adjustY) * imgH * scaleY;
|
|
const faceCenterXPx = (rawLandmarks.faceCenterX + adjustX) * imgW * scaleX;
|
|
|
|
// Compute crop region from landmarks
|
|
const targetHeadRatio = (docSpec.headHeightMin + docSpec.headHeightMax) / 2;
|
|
const headHeightPx = chinYPx - crownYPx;
|
|
const photoHeightPx = headHeightPx / targetHeadRatio;
|
|
const aspectRatio = docSpec.width / docSpec.height;
|
|
const photoWidthPx = photoHeightPx * aspectRatio;
|
|
|
|
// Position: eye line should be at eyeLineFromBottom from photo bottom
|
|
const baseTopY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
|
|
const baseLeftX = faceCenterXPx - photoWidthPx / 2;
|
|
|
|
// Apply zoom: zoom > 1 = tighter crop (less body), zoom < 1 = wider (more body)
|
|
// The zoomed region is centered on the base crop
|
|
const zoomedW = photoWidthPx / userZoom;
|
|
const zoomedH = photoHeightPx / userZoom;
|
|
const leftX = baseLeftX + (photoWidthPx - zoomedW) / 2;
|
|
const topY = baseTopY + (photoHeightPx - zoomedH) / 2;
|
|
|
|
// Parse background color
|
|
const hex = bgColor.replace("#", "");
|
|
const bgR = Number.parseInt(hex.slice(0, 2), 16);
|
|
const bgG = Number.parseInt(hex.slice(2, 4), 16);
|
|
const bgB = Number.parseInt(hex.slice(4, 6), 16);
|
|
const bgRgb = { r: bgR, g: bgG, b: bgB, alpha: 1 };
|
|
|
|
// Composite bg-removed subject onto colored background
|
|
const bgLayer = await sharp({
|
|
create: { width: actualW, height: actualH, channels: 4, background: bgRgb },
|
|
})
|
|
.composite([{ input: bgRemovedBuffer, blend: "over" }])
|
|
.png()
|
|
.toBuffer();
|
|
|
|
// The crop region may extend beyond the image (e.g. top of head above
|
|
// the photo). Instead of clamping (which cuts off the head), pad the
|
|
// image with background color so the full intended region is available.
|
|
const rawLeft = Math.round(leftX);
|
|
const rawTop = Math.round(topY);
|
|
const rawW = Math.round(zoomedW);
|
|
const rawH = Math.round(zoomedH);
|
|
|
|
const padLeft = Math.max(0, -rawLeft);
|
|
const padTop = Math.max(0, -rawTop);
|
|
const padRight = Math.max(0, rawLeft + rawW - actualW);
|
|
const padBottom = Math.max(0, rawTop + rawH - actualH);
|
|
|
|
let sourceForCrop = bgLayer;
|
|
if (padLeft > 0 || padTop > 0 || padRight > 0 || padBottom > 0) {
|
|
sourceForCrop = await sharp(bgLayer)
|
|
.extend({
|
|
top: padTop,
|
|
bottom: padBottom,
|
|
left: padLeft,
|
|
right: padRight,
|
|
background: bgRgb,
|
|
})
|
|
.toBuffer();
|
|
}
|
|
|
|
// Crop coordinates adjusted for padding
|
|
const cropLeft = rawLeft + padLeft;
|
|
const cropTop = rawTop + padTop;
|
|
|
|
// Target pixel dimensions at 300 DPI
|
|
const MM_PER_INCH = 25.4;
|
|
const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi);
|
|
const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi);
|
|
|
|
// Extract crop region and resize to target dimensions
|
|
let cropped = await sharp(sourceForCrop)
|
|
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
|
|
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
|
.jpeg({ quality: 95 })
|
|
.toBuffer();
|
|
|
|
// Compress to fit within max file size if specified
|
|
if (maxFileSizeKb > 0) {
|
|
const targetBytes = maxFileSizeKb * 1024;
|
|
let quality = 90;
|
|
while (cropped.length > targetBytes && quality > 10) {
|
|
quality -= 5;
|
|
cropped = await sharp(sourceForCrop)
|
|
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
|
|
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
|
.jpeg({ quality })
|
|
.toBuffer();
|
|
}
|
|
}
|
|
|
|
// Save output
|
|
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_passport.jpg`;
|
|
const outputPath = join(workspacePath, "output", outputFilename);
|
|
await writeFile(outputPath, cropped);
|
|
|
|
const response: Record<string, unknown> = {
|
|
jobId,
|
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
|
dimensions: {
|
|
widthMm: docSpec.width,
|
|
heightMm: docSpec.height,
|
|
widthPx: targetWidthPx,
|
|
heightPx: targetHeightPx,
|
|
dpi: docSpec.dpi,
|
|
},
|
|
spec: {
|
|
country: countrySpec?.name ?? "Custom",
|
|
countryCode: countrySpec?.code ?? "CUSTOM",
|
|
documentType: docSpec.type,
|
|
documentLabel: docSpec.label,
|
|
},
|
|
};
|
|
|
|
// Generate print sheet if requested
|
|
if (printLayout !== "none") {
|
|
const printBuffer = await generatePrintSheet(
|
|
cropped,
|
|
docSpec.width,
|
|
docSpec.height,
|
|
printLayout,
|
|
);
|
|
|
|
if (printBuffer) {
|
|
const printFilename = `${filename.replace(/\.[^.]+$/, "")}_passport_print_${printLayout}.jpg`;
|
|
await writeFile(join(workspacePath, "output", printFilename), printBuffer);
|
|
response.printDownloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(printFilename)}`;
|
|
}
|
|
}
|
|
|
|
return reply.send(response);
|
|
} catch (err) {
|
|
request.log.error({ err, toolId: "passport-photo" }, "Passport photo generation failed");
|
|
return reply.status(422).send({
|
|
error: "Passport photo generation failed",
|
|
details: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Pipeline/batch registry ──────────────────────────────────────
|
|
const pipelineSettingsSchema = z.object({
|
|
countryCode: z.string(),
|
|
documentType: z.string().default("passport"),
|
|
bgColor: z.string().default("#FFFFFF"),
|
|
printLayout: z.string().default("none"),
|
|
adjustX: z.number().default(0),
|
|
adjustY: z.number().default(0),
|
|
});
|
|
|
|
registerToolProcessFn({
|
|
toolId: "passport-photo",
|
|
settingsSchema: pipelineSettingsSchema,
|
|
process: async (inputBuffer, settings, filename) => {
|
|
const s = settings as z.infer<typeof pipelineSettingsSchema>;
|
|
const orientedBuffer = await autoOrient(inputBuffer);
|
|
|
|
// Step 1: Detect face landmarks
|
|
const landmarksResult = await detectFaceLandmarks(orientedBuffer);
|
|
if (!landmarksResult.faceDetected || !landmarksResult.landmarks) {
|
|
throw new Error(
|
|
"No face detected. Please upload a clear, front-facing photo with good lighting.",
|
|
);
|
|
}
|
|
|
|
const landmarks = landmarksResult.landmarks;
|
|
const imgW = landmarksResult.imageWidth;
|
|
const imgH = landmarksResult.imageHeight;
|
|
|
|
// Step 2: Remove background
|
|
const jobId = randomUUID();
|
|
const workspacePath = await createWorkspace(jobId);
|
|
|
|
const bgRemovedBuffer = await removeBackground(
|
|
orientedBuffer,
|
|
join(workspacePath, "output"),
|
|
{
|
|
model: "birefnet-portrait",
|
|
},
|
|
);
|
|
|
|
// Step 3: Look up spec and compute crop
|
|
const countrySpec = PASSPORT_SPECS.find((sp) => sp.code === s.countryCode);
|
|
if (!countrySpec) throw new Error(`Unknown country code: ${s.countryCode}`);
|
|
|
|
const docSpec = countrySpec.documents.find((d) => d.type === s.documentType);
|
|
if (!docSpec) throw new Error(`No ${s.documentType} spec for ${s.countryCode}`);
|
|
|
|
// Use actual bg-removed image dimensions (may differ from original)
|
|
const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata();
|
|
const actualW = bgRemovedMeta.width ?? imgW;
|
|
const actualH = bgRemovedMeta.height ?? imgH;
|
|
const scaleX = actualW / imgW;
|
|
const scaleY = actualH / imgH;
|
|
|
|
// Convert normalized landmarks (0-1) to pixel coordinates in bg-removed space
|
|
const crownYPx = (landmarks.crown.y + s.adjustY) * imgH * scaleY;
|
|
const chinYPx = (landmarks.chin.y + s.adjustY) * imgH * scaleY;
|
|
const eyeYPx = (landmarks.eyeCenter.y + s.adjustY) * imgH * scaleY;
|
|
const faceCenterXPx = (landmarks.faceCenterX + s.adjustX) * imgW * scaleX;
|
|
|
|
const targetHeadRatio = (docSpec.headHeightMin + docSpec.headHeightMax) / 2;
|
|
const headHeightPx = chinYPx - crownYPx;
|
|
const photoHeightPx = headHeightPx / targetHeadRatio;
|
|
const aspectRatio = docSpec.width / docSpec.height;
|
|
const photoWidthPx = photoHeightPx * aspectRatio;
|
|
|
|
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
|
|
const leftX = faceCenterXPx - photoWidthPx / 2;
|
|
|
|
// Composite onto background
|
|
const hex = s.bgColor.replace("#", "");
|
|
const bgR = Number.parseInt(hex.slice(0, 2), 16);
|
|
const bgG = Number.parseInt(hex.slice(2, 4), 16);
|
|
const bgB = Number.parseInt(hex.slice(4, 6), 16);
|
|
const bgRgb = { r: bgR, g: bgG, b: bgB, alpha: 1 };
|
|
|
|
const bgLayer = await sharp({
|
|
create: {
|
|
width: actualW,
|
|
height: actualH,
|
|
channels: 4,
|
|
background: bgRgb,
|
|
},
|
|
})
|
|
.composite([{ input: bgRemovedBuffer, blend: "over" }])
|
|
.png()
|
|
.toBuffer();
|
|
|
|
// Pad instead of clamp so the crop region can extend beyond the image
|
|
const rawLeft = Math.round(leftX);
|
|
const rawTop = Math.round(topY);
|
|
const rawW = Math.round(photoWidthPx);
|
|
const rawH = Math.round(photoHeightPx);
|
|
|
|
const padLeft = Math.max(0, -rawLeft);
|
|
const padTop = Math.max(0, -rawTop);
|
|
const padRight = Math.max(0, rawLeft + rawW - actualW);
|
|
const padBottom = Math.max(0, rawTop + rawH - actualH);
|
|
|
|
let sourceForCrop = bgLayer;
|
|
if (padLeft > 0 || padTop > 0 || padRight > 0 || padBottom > 0) {
|
|
sourceForCrop = await sharp(bgLayer)
|
|
.extend({
|
|
top: padTop,
|
|
bottom: padBottom,
|
|
left: padLeft,
|
|
right: padRight,
|
|
background: bgRgb,
|
|
})
|
|
.toBuffer();
|
|
}
|
|
|
|
const cropLeft = rawLeft + padLeft;
|
|
const cropTop = rawTop + padTop;
|
|
|
|
const MM_PER_INCH = 25.4;
|
|
const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi);
|
|
const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi);
|
|
|
|
const result = await sharp(sourceForCrop)
|
|
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
|
|
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
|
.jpeg({ quality: 95 })
|
|
.toBuffer();
|
|
|
|
const stem = filename.replace(/\.[^.]+$/, "");
|
|
return { buffer: result, filename: `${stem}_passport.jpg`, contentType: "image/jpeg" };
|
|
},
|
|
});
|
|
}
|