mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(passport-photo): fix preview-download mismatch, dropdown bg, scroll hint
- Backend now pads the image with background color when crop region extends beyond image bounds, instead of clamping (which cut off heads) - Dropdown uses explicit bg-white/dark:bg-zinc-900 instead of CSS variable that was transparent on some themes - Added "Scroll to zoom" hint on the right pane canvas - Fixed file size compression loop to use padded source image
This commit is contained in:
@@ -335,49 +335,62 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
|
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
|
||||||
const leftX = faceCenterXPx - photoWidthPx / 2;
|
const leftX = faceCenterXPx - photoWidthPx / 2;
|
||||||
|
|
||||||
// Clamp to image bounds
|
|
||||||
const cropW = Math.min(Math.round(photoWidthPx), imgW);
|
|
||||||
const cropH = Math.min(Math.round(photoHeightPx), imgH);
|
|
||||||
let cropLeft = Math.max(0, Math.round(leftX));
|
|
||||||
let cropTop = Math.max(0, Math.round(topY));
|
|
||||||
if (cropLeft + cropW > imgW) cropLeft = imgW - cropW;
|
|
||||||
if (cropTop + cropH > imgH) cropTop = imgH - cropH;
|
|
||||||
cropLeft = Math.max(0, cropLeft);
|
|
||||||
cropTop = Math.max(0, cropTop);
|
|
||||||
|
|
||||||
// Parse background color
|
// Parse background color
|
||||||
const hex = bgColor.replace("#", "");
|
const hex = bgColor.replace("#", "");
|
||||||
const bgR = Number.parseInt(hex.slice(0, 2), 16);
|
const bgR = Number.parseInt(hex.slice(0, 2), 16);
|
||||||
const bgG = Number.parseInt(hex.slice(2, 4), 16);
|
const bgG = Number.parseInt(hex.slice(2, 4), 16);
|
||||||
const bgB = Number.parseInt(hex.slice(4, 6), 16);
|
const bgB = Number.parseInt(hex.slice(4, 6), 16);
|
||||||
|
const bgRgb = { r: bgR, g: bgG, b: bgB, alpha: 1 };
|
||||||
|
|
||||||
// Composite bg-removed onto colored background
|
// Composite bg-removed subject onto colored background
|
||||||
const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata();
|
const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata();
|
||||||
|
const srcW = bgRemovedMeta.width ?? imgW;
|
||||||
|
const srcH = bgRemovedMeta.height ?? imgH;
|
||||||
const bgLayer = await sharp({
|
const bgLayer = await sharp({
|
||||||
create: {
|
create: { width: srcW, height: srcH, channels: 4, background: bgRgb },
|
||||||
width: bgRemovedMeta.width ?? imgW,
|
|
||||||
height: bgRemovedMeta.height ?? imgH,
|
|
||||||
channels: 4,
|
|
||||||
background: { r: bgR, g: bgG, b: bgB, alpha: 1 },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.composite([{ input: bgRemovedBuffer, blend: "over" }])
|
.composite([{ input: bgRemovedBuffer, blend: "over" }])
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.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(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 - srcW);
|
||||||
|
const padBottom = Math.max(0, rawTop + rawH - srcH);
|
||||||
|
|
||||||
|
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
|
// Target pixel dimensions at 300 DPI
|
||||||
const MM_PER_INCH = 25.4;
|
const MM_PER_INCH = 25.4;
|
||||||
const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi);
|
const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi);
|
||||||
const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi);
|
const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi);
|
||||||
|
|
||||||
// Extract crop region and resize to target dimensions
|
// Extract crop region and resize to target dimensions
|
||||||
let cropped = await sharp(bgLayer)
|
let cropped = await sharp(sourceForCrop)
|
||||||
.extract({
|
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
|
||||||
left: cropLeft,
|
|
||||||
top: cropTop,
|
|
||||||
width: cropW,
|
|
||||||
height: cropH,
|
|
||||||
})
|
|
||||||
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
||||||
.jpeg({ quality: 95 })
|
.jpeg({ quality: 95 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
@@ -388,13 +401,8 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
let quality = 90;
|
let quality = 90;
|
||||||
while (cropped.length > targetBytes && quality > 10) {
|
while (cropped.length > targetBytes && quality > 10) {
|
||||||
quality -= 5;
|
quality -= 5;
|
||||||
cropped = await sharp(bgLayer)
|
cropped = await sharp(sourceForCrop)
|
||||||
.extract({
|
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
|
||||||
left: cropLeft,
|
|
||||||
top: cropTop,
|
|
||||||
width: cropW,
|
|
||||||
height: cropH,
|
|
||||||
})
|
|
||||||
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
||||||
.jpeg({ quality })
|
.jpeg({ quality })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|||||||
@@ -505,20 +505,16 @@ export function PassportPhotoSettings() {
|
|||||||
|
|
||||||
{dropdownOpen && (
|
{dropdownOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed max-h-64 overflow-auto rounded-lg border border-border shadow-xl"
|
className="fixed max-h-64 overflow-auto rounded-lg border border-border shadow-xl bg-white dark:bg-zinc-900"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
backgroundColor: "var(--popover)",
|
|
||||||
top: dropdownPos.top,
|
top: dropdownPos.top,
|
||||||
left: dropdownPos.left,
|
left: dropdownPos.left,
|
||||||
width: dropdownPos.width,
|
width: dropdownPos.width,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div
|
<div className="sticky top-0 p-2 border-b border-border bg-white dark:bg-zinc-900">
|
||||||
className="sticky top-0 p-2 border-b border-border"
|
|
||||||
style={{ backgroundColor: "var(--popover)" }}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
@@ -1028,9 +1024,14 @@ export function PassportPhotoPreview() {
|
|||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/50 text-white text-[10px] px-2 py-1 rounded-md">
|
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between">
|
||||||
<Move className="h-3 w-3" />
|
<div className="flex items-center gap-1 bg-black/50 text-white text-[10px] px-2 py-1 rounded-md">
|
||||||
Drag to adjust
|
<Move className="h-3 w-3" />
|
||||||
|
Drag to adjust
|
||||||
|
</div>
|
||||||
|
<div className="bg-black/50 text-white text-[10px] px-2 py-1 rounded-md">
|
||||||
|
Scroll to zoom
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user