diff --git a/apps/api/src/routes/tools/passport-photo.ts b/apps/api/src/routes/tools/passport-photo.ts index 8cfbf4a0..a7a6e3a0 100644 --- a/apps/api/src/routes/tools/passport-photo.ts +++ b/apps/api/src/routes/tools/passport-photo.ts @@ -34,6 +34,9 @@ const generateSettingsSchema = z.object({ bgColor: z.string().default("#FFFFFF"), printLayout: z.string().default("none"), maxFileSizeKb: z.number().default(0), + dpi: z.number().min(72).max(600).default(300), + customWidthMm: z.number().optional(), + customHeightMm: z.number().optional(), adjustX: z.number().default(0), adjustY: z.number().default(0), landmarks: landmarksSchema, @@ -289,6 +292,9 @@ export function registerPassportPhoto(app: FastifyInstance) { bgColor, printLayout, maxFileSizeKb, + dpi: userDpi, + customWidthMm, + customHeightMm, adjustX, adjustY, landmarks: rawLandmarks, @@ -298,31 +304,51 @@ export function registerPassportPhoto(app: FastifyInstance) { // Look up country spec const countrySpec = PASSPORT_SPECS.find((s) => s.code === countryCode); - if (!countrySpec) { + if (!countrySpec && !customWidthMm) { return reply.status(400).send({ error: `Unknown country code: ${countryCode}` }); } - const docSpec = countrySpec.documents.find((d) => d.type === documentType); - if (!docSpec) { - return reply.status(400).send({ - error: `No ${documentType} spec found for ${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, originalBuffer] = await Promise.all([ - readFile(join(workspacePath, "output", bgRemovedFilename)), - readFile(join(workspacePath, "input", filename)), - ]); + const bgRemovedBuffer = await readFile(join(workspacePath, "output", bgRemovedFilename)); - // Convert normalized landmarks (0-1) to pixel coordinates - const crownYPx = (rawLandmarks.crown.y + adjustY) * imgH; - const chinYPx = (rawLandmarks.chin.y + adjustY) * imgH; - const eyeYPx = (rawLandmarks.eyeCenter.y + adjustY) * imgH; - const faceCenterXPx = (rawLandmarks.faceCenterX + adjustX) * imgW; + // 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; @@ -343,11 +369,8 @@ export function registerPassportPhoto(app: FastifyInstance) { const bgRgb = { r: bgR, g: bgG, b: bgB, alpha: 1 }; // Composite bg-removed subject onto colored background - const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata(); - const srcW = bgRemovedMeta.width ?? imgW; - const srcH = bgRemovedMeta.height ?? imgH; const bgLayer = await sharp({ - create: { width: srcW, height: srcH, channels: 4, background: bgRgb }, + create: { width: actualW, height: actualH, channels: 4, background: bgRgb }, }) .composite([{ input: bgRemovedBuffer, blend: "over" }]) .png() @@ -363,8 +386,8 @@ export function registerPassportPhoto(app: FastifyInstance) { const padLeft = Math.max(0, -rawLeft); const padTop = Math.max(0, -rawTop); - const padRight = Math.max(0, rawLeft + rawW - srcW); - const padBottom = Math.max(0, rawTop + rawH - srcH); + 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) { @@ -425,8 +448,8 @@ export function registerPassportPhoto(app: FastifyInstance) { dpi: docSpec.dpi, }, spec: { - country: countrySpec.name, - countryCode: countrySpec.code, + country: countrySpec?.name ?? "Custom", + countryCode: countrySpec?.code ?? "CUSTOM", documentType: docSpec.type, documentLabel: docSpec.label, }, diff --git a/apps/web/src/components/tools/passport-photo-settings.tsx b/apps/web/src/components/tools/passport-photo-settings.tsx index e7cabb81..31d43000 100644 --- a/apps/web/src/components/tools/passport-photo-settings.tsx +++ b/apps/web/src/components/tools/passport-photo-settings.tsx @@ -70,6 +70,11 @@ interface PassportPhotoStore { setBgColor: (c: string) => void; maxFileSizeKb: number; setMaxFileSizeKb: (s: number) => void; + dpi: number; + setDpi: (d: number) => void; + customWidthMm: number | null; + customHeightMm: number | null; + setCustomDimensions: (w: number | null, h: number | null) => void; adjustX: number; adjustY: number; setAdjustX: (x: number) => void; @@ -88,13 +93,20 @@ const usePassportPhotoStore = create((set) => ({ analyzeResult: null, setAnalyzeResult: (analyzeResult) => set({ analyzeResult, generateResult: null }), countryCode: "US", - setCountryCode: (countryCode) => set({ countryCode, generateResult: null }), + setCountryCode: (countryCode) => + set({ countryCode, generateResult: null, customWidthMm: null, customHeightMm: null }), documentType: "passport", setDocumentType: (documentType) => set({ documentType, generateResult: null }), bgColor: "#FFFFFF", setBgColor: (bgColor) => set({ bgColor, generateResult: null }), maxFileSizeKb: 0, setMaxFileSizeKb: (maxFileSizeKb) => set({ maxFileSizeKb }), + dpi: 300, + setDpi: (dpi) => set({ dpi, generateResult: null }), + customWidthMm: null, + customHeightMm: null, + setCustomDimensions: (customWidthMm, customHeightMm) => + set({ customWidthMm, customHeightMm, countryCode: "CUSTOM", generateResult: null }), adjustX: 0, adjustY: 0, setAdjustX: (adjustX) => set({ adjustX, generateResult: null }), @@ -149,21 +161,51 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } -function getDocSpec(countryCode: string, documentType: string): PassportDocumentSpec { +const CUSTOM_SPEC: PassportSpec = { + code: "CUSTOM", + name: "Custom", + flag: "\u2699\uFE0F", + region: "americas", + documents: [ + { + type: "passport", + label: "Custom", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], +}; + +function getDocSpec( + countryCode: string, + documentType: string, + customW: number | null, + customH: number | null, + dpi: number, +): PassportDocumentSpec { + if (countryCode === "CUSTOM" && customW && customH) { + return { ...CUSTOM_SPEC.documents[0], width: customW, height: customH, dpi }; + } const spec = PASSPORT_SPECS.find((s) => s.code === countryCode) ?? PASSPORT_SPECS[0]; - return spec.documents.find((d) => d.type === documentType) ?? spec.documents[0]; + const doc = spec.documents.find((d) => d.type === documentType) ?? spec.documents[0]; + return { ...doc, dpi }; } function getCountrySpec(countryCode: string): PassportSpec { + if (countryCode === "CUSTOM") return CUSTOM_SPEC; return PASSPORT_SPECS.find((s) => s.code === countryCode) ?? PASSPORT_SPECS[0]; } -/** Format dimensions as "35x45mm" */ function formatDimensions(doc: PassportDocumentSpec): string { return `${doc.width}x${doc.height}mm`; } -/** Get pixel dimensions for a spec */ function getPixelDimensions(doc: PassportDocumentSpec): { w: number; h: number } { const MM_PER_INCH = 25.4; return { @@ -296,6 +338,11 @@ export function PassportPhotoSettings() { setBgColor, maxFileSizeKb, setMaxFileSizeKb, + dpi, + setDpi, + customWidthMm, + customHeightMm, + setCustomDimensions, analyzeResult, setAnalyzeResult, generateResult, @@ -319,8 +366,10 @@ export function PassportPhotoSettings() { width: 0, }); - // Custom file size input + // Custom inputs const [customSizeInput, setCustomSizeInput] = useState(""); + const [customWInput, setCustomWInput] = useState(""); + const [customHInput, setCustomHInput] = useState(""); // Errors const [analyzeError, setAnalyzeError] = useState(null); @@ -328,7 +377,8 @@ export function PassportPhotoSettings() { // Derived state const selectedSpec = getCountrySpec(countryCode); - const docSpec = getDocSpec(countryCode, documentType); + const isCustom = countryCode === "CUSTOM"; + const docSpec = getDocSpec(countryCode, documentType, customWidthMm, customHeightMm, dpi); const hasFile = files.length > 0; const uniqueDocTypes = [...new Set(selectedSpec.documents.map((d) => d.type))]; const pxDims = getPixelDimensions(docSpec); @@ -417,16 +467,19 @@ export function PassportPhotoSettings() { try { const headers = formatHeaders({ "Content-Type": "application/json" }); - const body = { + const body: Record = { jobId: analyzeResult.jobId, filename: analyzeResult.filename, - countryCode, + countryCode: isCustom ? "US" : countryCode, documentType, bgColor, maxFileSizeKb, + dpi, adjustX, adjustY, landmarks: analyzeResult.landmarks, + ...(isCustom && customWidthMm ? { customWidthMm } : {}), + ...(isCustom && customHeightMm ? { customHeightMm } : {}), imageWidth: analyzeResult.imageWidth, imageHeight: analyzeResult.imageHeight, }; @@ -530,6 +583,25 @@ export function PassportPhotoSettings() { {/* Country list */}
+ {/* Custom option */} + {!filteredSpecs && ( + + )} + {filteredSpecs ? ( filteredSpecs.length > 0 ? ( filteredSpecs.map((spec) => ( @@ -600,6 +672,60 @@ export function PassportPhotoSettings() { )} + {/* Custom dimensions input */} + {isCustom && ( + <> + Dimensions (mm) +
+ { + setCustomWInput(e.target.value); + const v = Number.parseInt(e.target.value, 10); + if (v > 0) setCustomDimensions(v, customHeightMm ?? 45); + }} + placeholder="Width" + min="10" + max="200" + className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-xs text-foreground" + /> + {"\u00D7"} + { + setCustomHInput(e.target.value); + const v = Number.parseInt(e.target.value, 10); + if (v > 0) setCustomDimensions(customWidthMm ?? 35, v); + }} + placeholder="Height" + min="10" + max="200" + className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-xs text-foreground" + /> + mm +
+ + )} + + {/* DPI */} + DPI +
+ { + const v = Number.parseInt(e.target.value, 10); + if (v >= 72 && v <= 600) setDpi(v); + }} + min="72" + max="600" + className="w-20 px-2 py-1.5 rounded border border-border bg-background text-xs text-foreground" + /> + pixels per inch +
+ {/* Background color */} Background Color
@@ -743,6 +869,9 @@ export function PassportPhotoPreview() { countryCode, documentType, bgColor, + dpi, + customWidthMm, + customHeightMm, adjustX, adjustY, setAdjustX, @@ -760,7 +889,7 @@ export function PassportPhotoPreview() { const [dragging, setDragging] = useState(false); const dragStartRef = useRef<{ x: number; y: number; ax: number; ay: number } | null>(null); - const docSpec = getDocSpec(countryCode, documentType); + const docSpec = getDocSpec(countryCode, documentType, customWidthMm, customHeightMm, dpi); const pxDims = getPixelDimensions(docSpec); // Compliance checks