import { PASSPORT_SPECS, type PassportDocumentSpec, type PassportRegion, type PassportSpec, } from "@snapotter/shared"; import { Check, ChevronDown, Download, Loader2, Move, RotateCcw, Search, UserCheck, X, ZoomIn, ZoomOut, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { create } from "zustand"; import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; // ── Types ────────────────────────────────────────────────────────── interface FaceLandmarks { leftEye: { x: number; y: number }; rightEye: { x: number; y: number }; eyeCenter: { x: number; y: number }; chin: { x: number; y: number }; forehead: { x: number; y: number }; crown: { x: number; y: number }; nose: { x: number; y: number }; faceCenterX: number; } interface AnalyzeResult { preview: string; // base64 PNG landmarks: FaceLandmarks; imageWidth: number; imageHeight: number; jobId: string; filename: string; } interface GenerateResult { downloadUrl: string; dimensions: { width: number; height: number }; spec: { country: string; document: string }; } interface ComplianceCheck { label: string; pass: boolean; detail: string; } // ── Zustand store ───────────────────────────────────────────────── interface PassportPhotoStore { analyzeResult: AnalyzeResult | null; setAnalyzeResult: (r: AnalyzeResult | null) => void; countryCode: string; setCountryCode: (c: string) => void; documentType: string; setDocumentType: (t: string) => void; bgColor: string; 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; setAdjustY: (y: number) => void; zoom: number; setZoom: (z: number) => void; generateResult: GenerateResult | null; setGenerateResult: (r: GenerateResult | null) => void; analyzing: boolean; setAnalyzing: (a: boolean) => void; generating: boolean; setGenerating: (g: boolean) => void; } const usePassportPhotoStore = create((set) => ({ analyzeResult: null, setAnalyzeResult: (analyzeResult) => set({ analyzeResult, generateResult: null }), countryCode: "US", 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 }), setAdjustY: (adjustY) => set({ adjustY, generateResult: null }), zoom: 1, setZoom: (zoom) => set({ zoom }), generateResult: null, setGenerateResult: (generateResult) => set({ generateResult }), analyzing: false, setAnalyzing: (analyzing) => set({ analyzing }), generating: false, setGenerating: (generating) => set({ generating }), })); // ── Region groups ────────────────────────────────────────────────── const REGION_LABELS: Record = { americas: "Americas", europe: "Europe", asia: "Asia", "middle-east": "Middle East", africa: "Africa", oceania: "Oceania", }; const REGION_ORDER: PassportRegion[] = [ "americas", "europe", "asia", "middle-east", "africa", "oceania", ]; function groupByRegion(): Map { const groups = new Map(); for (const r of REGION_ORDER) groups.set(r, []); for (const spec of PASSPORT_SPECS) { const list = groups.get(spec.region); if (list) list.push(spec); } return groups; } // ── Helpers ──────────────────────────────────────────────────────── function SectionLabel({ children }: { children: React.ReactNode }) { return (

{children}

); } 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]; 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]; } function formatDimensions(doc: PassportDocumentSpec): string { return `${doc.width}x${doc.height}mm`; } function getPixelDimensions(doc: PassportDocumentSpec): { w: number; h: number } { const MM_PER_INCH = 25.4; return { w: Math.round((doc.width / MM_PER_INCH) * doc.dpi), h: Math.round((doc.height / MM_PER_INCH) * doc.dpi), }; } // ── File size presets ───────────────────────────────────────────── const FILE_SIZE_PRESETS = [ { label: "No limit", value: 0 }, { label: "50 KB", value: 50 }, { label: "100 KB", value: 100 }, { label: "200 KB", value: 200 }, { label: "500 KB", value: 500 }, ]; // ── Common background colors for passport photos ────────────────── const COMMON_BG_COLORS = [ { color: "#FFFFFF", label: "White" }, { color: "#F0F0F0", label: "Off-white" }, { color: "#D4D4D4", label: "Light gray (UK/DE)" }, { color: "#BFDBFE", label: "Light blue (FR)" }, { color: "#EF4444", label: "Red (ID)" }, ]; // ── Canvas helpers ───────────────────────────────────────────────── function computeCropRegion( doc: PassportDocumentSpec, landmarks: FaceLandmarks, imageWidth: number, imageHeight: number, adjustX: number, adjustY: number, ) { const targetHeadRatio = (doc.headHeightMin + doc.headHeightMax) / 2; const crownYPx = (landmarks.crown.y + adjustY) * imageHeight; const chinYPx = (landmarks.chin.y + adjustY) * imageHeight; const eyeYPx = (landmarks.eyeCenter.y + adjustY) * imageHeight; const faceCenterXPx = (landmarks.faceCenterX + adjustX) * imageWidth; const headHeightPx = chinYPx - crownYPx; const photoHeightPx = headHeightPx / targetHeadRatio; const photoWidthPx = photoHeightPx * (doc.width / doc.height); const topY = eyeYPx - photoHeightPx * (1 - doc.eyeLineFromBottom); const leftX = faceCenterXPx - photoWidthPx / 2; return { leftX, topY, photoWidthPx, photoHeightPx }; } function runComplianceChecks(landmarks: FaceLandmarks): ComplianceCheck[] { // 1. Face centered - is the face horizontally centered in the source photo? const centerOk = Math.abs(landmarks.faceCenterX - 0.5) < 0.08; // 2. Head level - are the eyes at the same height (no tilt)? const eyeTilt = Math.abs(landmarks.leftEye.y - landmarks.rightEye.y); const levelOk = eyeTilt < 0.02; // 3. Looking straight - is the nose centered between the eyes (not turned)? const eyeMidX = (landmarks.leftEye.x + landmarks.rightEye.x) / 2; const noseOffset = Math.abs(landmarks.nose.x - eyeMidX); const straightOk = noseOffset < 0.03; // 4. Face size - is the face large enough for a quality crop? const faceHeight = landmarks.chin.y - landmarks.crown.y; const sizeOk = faceHeight > 0.15; return [ { label: "Face centered", pass: centerOk, detail: "Face is off-center. Crop or reposition your photo.", }, { label: "Head level", pass: levelOk, detail: "Head is tilted. Straighten your head or rotate the photo.", }, { label: "Looking straight", pass: straightOk, detail: "Face is turned sideways. Look directly at the camera.", }, { label: "Face size", pass: sizeOk, detail: "Face is too small. Get closer to the camera or crop tighter.", }, ]; } // ── Country option item ──────────────────────────────────────────── function CountryOption({ spec, selected, onSelect, }: { spec: PassportSpec; selected: boolean; onSelect: () => void; }) { const doc = spec.documents[0]; return ( ); } // ── Settings panel (left side) ───────────────────────────────────── export function PassportPhotoSettings() { const { files } = useFileStore(); const { error } = useToolProcessor("passport-photo"); const { countryCode, setCountryCode, documentType, setDocumentType, bgColor, setBgColor, maxFileSizeKb, setMaxFileSizeKb, dpi, setDpi, customWidthMm, customHeightMm, setCustomDimensions, analyzeResult, setAnalyzeResult, generateResult, setGenerateResult, analyzing, setAnalyzing, generating, setGenerating, zoom, adjustX, adjustY, } = usePassportPhotoStore(); // Country search const [searchQuery, setSearchQuery] = useState(""); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); const buttonRef = useRef(null); const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0, }); // Custom inputs const [customSizeInput, setCustomSizeInput] = useState(""); const [customWInput, setCustomWInput] = useState(""); const [customHInput, setCustomHInput] = useState(""); // Errors const [analyzeError, setAnalyzeError] = useState(null); const [generateError, setGenerateError] = useState(null); // Derived state const selectedSpec = getCountrySpec(countryCode); 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); // Close dropdown on outside click useEffect(() => { function handleClick(e: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setDropdownOpen(false); } } if (dropdownOpen) { document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); } }, [dropdownOpen]); // Auto-select bg color when country changes useEffect(() => { setBgColor(docSpec.bgColor); }, [docSpec.bgColor, setBgColor]); // Ensure valid documentType when country changes useEffect(() => { if (!selectedSpec.documents.some((d) => d.type === documentType)) { setDocumentType(selectedSpec.documents[0].type); } }, [selectedSpec, documentType, setDocumentType]); // Analyze const runAnalyze = useCallback( async (file: File) => { setAnalyzing(true); setAnalyzeError(null); setAnalyzeResult(null); setGenerateResult(null); usePassportPhotoStore.setState({ adjustX: 0, adjustY: 0 }); try { const formData = new FormData(); formData.append("file", file); formData.append("settings", JSON.stringify({})); const headers = formatHeaders(); const response = await fetch("/api/v1/tools/passport-photo/analyze", { method: "POST", headers, body: formData, }); if (!response.ok) { const body = await response.json().catch(() => null); const msg = body ? typeof body.details === "string" ? body.details : typeof body.error === "string" ? body.error : `Analysis failed: ${response.status}` : `Analysis failed: ${response.status}`; throw new Error(msg); } const result = await response.json(); setAnalyzeResult(result); } catch (err) { setAnalyzeError(err instanceof Error ? err.message : "Face analysis failed"); } finally { setAnalyzing(false); } }, [setAnalyzing, setAnalyzeResult, setGenerateResult], ); // Auto-analyze when files change const analyzeRef = useRef(null); useEffect(() => { if (!hasFile || analyzeResult || analyzing) return; const file = files[0]; const fileKey = `${file.name}-${file.size}-${file.lastModified}`; if (analyzeRef.current === fileKey) return; analyzeRef.current = fileKey; runAnalyze(file); }, [hasFile, files, analyzeResult, analyzing, runAnalyze]); // Generate const handleGenerate = useCallback(async () => { if (!analyzeResult) return; setGenerating(true); setGenerateError(null); setGenerateResult(null); try { const headers = formatHeaders({ "Content-Type": "application/json" }); const body: Record = { jobId: analyzeResult.jobId, filename: analyzeResult.filename, countryCode: isCustom ? "US" : countryCode, documentType, bgColor, maxFileSizeKb, dpi, zoom, adjustX, adjustY, landmarks: analyzeResult.landmarks, ...(isCustom && customWidthMm ? { customWidthMm } : {}), ...(isCustom && customHeightMm ? { customHeightMm } : {}), imageWidth: analyzeResult.imageWidth, imageHeight: analyzeResult.imageHeight, }; const response = await fetch("/api/v1/tools/passport-photo/generate", { method: "POST", headers, body: JSON.stringify(body), }); if (!response.ok) { const errBody = await response.json().catch(() => null); const msg = errBody ? typeof errBody.details === "string" ? errBody.details : typeof errBody.error === "string" ? errBody.error : `Generation failed: ${response.status}` : `Generation failed: ${response.status}`; throw new Error(msg); } const result: GenerateResult = await response.json(); setGenerateResult(result); } catch (err) { setGenerateError(err instanceof Error ? err.message : "Photo generation failed"); } finally { setGenerating(false); } }, [ analyzeResult, countryCode, documentType, bgColor, maxFileSizeKb, dpi, zoom, isCustom, customWidthMm, customHeightMm, adjustX, adjustY, setGenerating, setGenerateResult, ]); // Filtered countries const filteredSpecs = searchQuery ? PASSPORT_SPECS.filter( (s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()) || s.code.toLowerCase().includes(searchQuery.toLowerCase()), ) : null; const regionGroups = groupByRegion(); return (
{/* Country selector */} Country
{dropdownOpen && (
{/* Search input */}
el?.focus()} type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search countries..." className="w-full pl-7 pr-2 py-1.5 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" />
{/* Country list */}
{/* Custom option */} {!filteredSpecs && ( )} {filteredSpecs ? ( filteredSpecs.length > 0 ? ( filteredSpecs.map((spec) => ( { setCountryCode(spec.code); setDropdownOpen(false); setSearchQuery(""); }} /> )) ) : (

No countries found

) ) : ( REGION_ORDER.map((region) => { const specs = regionGroups.get(region); if (!specs || specs.length === 0) return null; return (

{REGION_LABELS[region]}

{specs.map((spec) => ( { setCountryCode(spec.code); setDropdownOpen(false); setSearchQuery(""); }} /> ))}
); }) )}
)}
{/* Document type toggle */} {uniqueDocTypes.length > 1 && ( <> Document Type
{uniqueDocTypes.map((type) => ( ))}
)} {/* 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
{COMMON_BG_COLORS.map(({ color, label }) => (
setBgColor(e.target.value)} className="w-7 h-7 rounded border border-border cursor-pointer" /> setBgColor(e.target.value)} placeholder="#FFFFFF" className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground" />
{/* File size limit */} Max File Size
{FILE_SIZE_PRESETS.map((preset) => ( ))}
{ setCustomSizeInput(e.target.value); const val = Number.parseInt(e.target.value, 10); if (val > 0) setMaxFileSizeKb(val); }} placeholder="Custom KB..." min="10" className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground" /> KB
{/* Spec info bar */}

{docSpec.label}

{docSpec.width}x{docSpec.height}mm ({pxDims.w}x{pxDims.h}px) at {docSpec.dpi} DPI

{maxFileSizeKb > 0 &&

Max file size: {maxFileSizeKb} KB

}
{/* Errors */} {analyzeError &&

{analyzeError}

} {generateError &&

{generateError}

} {error &&

{error}

} {/* Analyze progress */} {analyzing && ( )} {/* Generate button */} {analyzeResult && !generating && !generateResult && ( )} {/* Generating progress */} {generating && (
Generating...
)} {/* Download button */} {generateResult && ( Download Photo )}
); } // ── Preview panel (right side) ──────────────────────────────────── export function PassportPhotoPreview() { const { analyzeResult, countryCode, documentType, bgColor, dpi, customWidthMm, customHeightMm, adjustX, adjustY, setAdjustX, setAdjustY, zoom, setZoom, analyzing, generateResult, } = usePassportPhotoStore(); const canvasRef = useRef(null); const containerRef = useRef(null); const previewImgRef = useRef(null); const [dragging, setDragging] = useState(false); const dragStartRef = useRef<{ x: number; y: number; ax: number; ay: number } | null>(null); const docSpec = getDocSpec(countryCode, documentType, customWidthMm, customHeightMm, dpi); const pxDims = getPixelDimensions(docSpec); // Compliance checks const complianceChecks = analyzeResult ? runComplianceChecks(analyzeResult.landmarks) : []; // Render canvas const renderCanvas = useCallback(() => { const canvas = canvasRef.current; const img = previewImgRef.current; const container = containerRef.current; if (!canvas || !img || !analyzeResult || !container) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const { landmarks, imageWidth, imageHeight } = analyzeResult; // Use container width to size the canvas, respecting passport aspect ratio const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; const aspectRatio = docSpec.width / docSpec.height; // Calculate the max canvas size that fits in the container with correct aspect ratio let canvasDisplayWidth: number; let canvasDisplayHeight: number; if (containerWidth / containerHeight > aspectRatio) { // Container is wider, constrain by height canvasDisplayHeight = Math.min(containerHeight - 40, 700); canvasDisplayWidth = Math.round(canvasDisplayHeight * aspectRatio); } else { // Container is taller, constrain by width canvasDisplayWidth = Math.min(containerWidth - 40, 600); canvasDisplayHeight = Math.round(canvasDisplayWidth / aspectRatio); } // Canvas always shows exactly what will be downloaded - no zoom distortion canvas.width = canvasDisplayWidth; canvas.height = canvasDisplayHeight; // Fill background ctx.fillStyle = bgColor; ctx.fillRect(0, 0, canvasDisplayWidth, canvasDisplayHeight); // Compute crop region in original image coords const crop = computeCropRegion(docSpec, landmarks, imageWidth, imageHeight, adjustX, adjustY); // Map crop region from original image coords to preview image coords const scaleX = img.naturalWidth / imageWidth; const scaleY = img.naturalHeight / imageHeight; // When zoom > 1, show a zoomed-in sub-region for visual inspection. // The download always uses the full crop (zoom=1). const zoomedW = crop.photoWidthPx / zoom; const zoomedH = crop.photoHeightPx / zoom; const zoomedLeft = crop.leftX + (crop.photoWidthPx - zoomedW) / 2; const zoomedTop = crop.topY + (crop.photoHeightPx - zoomedH) / 2; const srcX = zoomedLeft * scaleX; const srcY = zoomedTop * scaleY; const srcW = zoomedW * scaleX; const srcH = zoomedH * scaleY; ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, canvasDisplayWidth, canvasDisplayHeight); // Compliance overlay const checks = runComplianceChecks(landmarks); const centerOk = checks[0].pass; const levelOk = checks[1].pass; ctx.setLineDash([6, 4]); ctx.lineWidth = 1.5; // Helper: convert original-image coords to canvas coords (zoom-aware) const toCanvasY = (origY: number) => ((origY - zoomedTop) / zoomedH) * canvasDisplayHeight; const toCanvasX = (origX: number) => ((origX - zoomedLeft) / zoomedW) * canvasDisplayWidth; // Center line (vertical) - face centered check const centerXCanvas = toCanvasX((landmarks.faceCenterX + adjustX) * imageWidth); ctx.strokeStyle = centerOk ? "#f59e0b" : "#ef4444"; ctx.beginPath(); ctx.moveTo(centerXCanvas, 0); ctx.lineTo(centerXCanvas, canvasDisplayHeight); ctx.stroke(); // Eye-level indicator - line connecting left eye to right eye const leftEyeX = toCanvasX((landmarks.leftEye.x + adjustX) * imageWidth); const leftEyeY = toCanvasY((landmarks.leftEye.y + adjustY) * imageHeight); const rightEyeX = toCanvasX((landmarks.rightEye.x + adjustX) * imageWidth); const rightEyeY = toCanvasY((landmarks.rightEye.y + adjustY) * imageHeight); ctx.strokeStyle = levelOk ? "#22c55e" : "#ef4444"; ctx.beginPath(); ctx.moveTo(leftEyeX, leftEyeY); ctx.lineTo(rightEyeX, rightEyeY); ctx.stroke(); ctx.setLineDash([]); }, [analyzeResult, docSpec, bgColor, adjustX, adjustY, zoom]); // Load preview image when analyzeResult changes useEffect(() => { if (!analyzeResult?.preview) { previewImgRef.current = null; return; } const img = new Image(); img.onload = () => { previewImgRef.current = img; renderCanvas(); }; img.src = `data:image/png;base64,${analyzeResult.preview}`; }, [analyzeResult?.preview, renderCanvas]); // Re-render canvas when settings change useEffect(() => { renderCanvas(); }, [renderCanvas]); // Re-render on container resize useEffect(() => { const observer = new ResizeObserver(() => renderCanvas()); if (containerRef.current) observer.observe(containerRef.current); return () => observer.disconnect(); }, [renderCanvas]); // Drag to adjust const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (!analyzeResult) return; setDragging(true); dragStartRef.current = { x: e.clientX, y: e.clientY, ax: adjustX, ay: adjustY }; }, [analyzeResult, adjustX, adjustY], ); useEffect(() => { if (!dragging) return; function handleMouseMove(e: MouseEvent) { if (!dragStartRef.current) return; const dx = (e.clientX - dragStartRef.current.x) * 0.001; const dy = (e.clientY - dragStartRef.current.y) * 0.001; setAdjustX(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ax - dx))); setAdjustY(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ay - dy))); } function handleMouseUp() { setDragging(false); dragStartRef.current = null; } document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [dragging, setAdjustX, setAdjustY]); // Wheel zoom (visual inspection only, does not affect download) const handleWheel = useCallback( (e: React.WheelEvent) => { e.preventDefault(); setZoom(Math.max(0.5, Math.min(5, zoom + (e.deltaY > 0 ? -0.1 : 0.1)))); }, [zoom, setZoom], ); // No file / no analysis state if (!analyzeResult && !analyzing) { return (

Upload a portrait photo

Drop or upload an image in the left panel to preview your passport photo

); } // Analyzing state if (analyzing) { return (

Analyzing photo

Detecting face landmarks and removing background...

); } return (
{/* Output dimensions */} {/* Zoom controls + dimensions */}
{Math.round(zoom * 100)}% {zoom !== 1 && ( )} {pxDims.w}x{pxDims.h}px

What you see is what you download

{/* Canvas preview */}
Drag to adjust
Scroll to zoom
{/* Compliance checklist */} {complianceChecks.length > 0 && (
{complianceChecks.map((check) => (
{check.pass ? ( ) : ( )} {check.label} {!check.pass && ( - {check.detail} )}
))}
)} {/* Generated result notification */} {generateResult && (
Photo generated. Download from the left panel.
)}
); }