feat(passport-photo): true WYSIWYG - zoom and drag affect the download

- Zoom now controls the actual crop: zoom in = tighter crop, zoom out = more body
- Drag adjusts face position within the frame
- Backend receives zoom value and applies the same zoomed crop
- Download produces exactly what the user sees on the canvas
- Zoom range 0.5x-3x (can zoom out to show more body)
- "What you see is what you download" label
This commit is contained in:
stirling-image
2026-04-14 15:58:31 +08:00
parent 74a6d422c2
commit e7c1efaaf3
2 changed files with 20 additions and 11 deletions
+13 -4
View File
@@ -37,6 +37,7 @@ const generateSettingsSchema = z.object({
dpi: z.number().min(72).max(600).default(300), dpi: z.number().min(72).max(600).default(300),
customWidthMm: z.number().optional(), customWidthMm: z.number().optional(),
customHeightMm: z.number().optional(), customHeightMm: z.number().optional(),
zoom: z.number().min(0.5).max(3).default(1),
adjustX: z.number().default(0), adjustX: z.number().default(0),
adjustY: z.number().default(0), adjustY: z.number().default(0),
landmarks: landmarksSchema, landmarks: landmarksSchema,
@@ -295,6 +296,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
dpi: userDpi, dpi: userDpi,
customWidthMm, customWidthMm,
customHeightMm, customHeightMm,
zoom: userZoom,
adjustX, adjustX,
adjustY, adjustY,
landmarks: rawLandmarks, landmarks: rawLandmarks,
@@ -358,8 +360,15 @@ export function registerPassportPhoto(app: FastifyInstance) {
const photoWidthPx = photoHeightPx * aspectRatio; const photoWidthPx = photoHeightPx * aspectRatio;
// Position: eye line should be at eyeLineFromBottom from photo bottom // Position: eye line should be at eyeLineFromBottom from photo bottom
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom); const baseTopY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
const leftX = faceCenterXPx - photoWidthPx / 2; 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 // Parse background color
const hex = bgColor.replace("#", ""); const hex = bgColor.replace("#", "");
@@ -381,8 +390,8 @@ export function registerPassportPhoto(app: FastifyInstance) {
// image with background color so the full intended region is available. // image with background color so the full intended region is available.
const rawLeft = Math.round(leftX); const rawLeft = Math.round(leftX);
const rawTop = Math.round(topY); const rawTop = Math.round(topY);
const rawW = Math.round(photoWidthPx); const rawW = Math.round(zoomedW);
const rawH = Math.round(photoHeightPx); const rawH = Math.round(zoomedH);
const padLeft = Math.max(0, -rawLeft); const padLeft = Math.max(0, -rawLeft);
const padTop = Math.max(0, -rawTop); const padTop = Math.max(0, -rawTop);
@@ -361,6 +361,7 @@ export function PassportPhotoSettings() {
setAnalyzing, setAnalyzing,
generating, generating,
setGenerating, setGenerating,
zoom,
adjustX, adjustX,
adjustY, adjustY,
} = usePassportPhotoStore(); } = usePassportPhotoStore();
@@ -485,6 +486,7 @@ export function PassportPhotoSettings() {
bgColor, bgColor,
maxFileSizeKb, maxFileSizeKb,
dpi, dpi,
zoom,
adjustX, adjustX,
adjustY, adjustY,
landmarks: analyzeResult.landmarks, landmarks: analyzeResult.landmarks,
@@ -1063,7 +1065,7 @@ export function PassportPhotoPreview() {
const handleWheel = useCallback( const handleWheel = useCallback(
(e: React.WheelEvent<HTMLCanvasElement>) => { (e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault(); e.preventDefault();
setZoom(Math.max(1, Math.min(3, zoom + (e.deltaY > 0 ? -0.1 : 0.1)))); setZoom(Math.max(0.5, Math.min(3, zoom + (e.deltaY > 0 ? -0.1 : 0.1))));
}, },
[zoom, setZoom], [zoom, setZoom],
); );
@@ -1107,7 +1109,7 @@ export function PassportPhotoPreview() {
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button <button
type="button" type="button"
onClick={() => setZoom(Math.max(1, zoom - 0.25))} onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
className="p-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Zoom out" title="Zoom out"
> >
@@ -1139,11 +1141,9 @@ export function PassportPhotoPreview() {
</span> </span>
</div> </div>
{zoom !== 1 && (
<p className="text-[10px] text-muted-foreground text-center -mt-2"> <p className="text-[10px] text-muted-foreground text-center -mt-2">
Zoom is for inspection only. Download is always the full photo. What you see is what you download
</p> </p>
)}
{/* Canvas preview */} {/* Canvas preview */}
<div className="flex-1 flex items-center justify-center overflow-auto min-h-0 w-full"> <div className="flex-1 flex items-center justify-center overflow-auto min-h-0 w-full">