fix: AVIF sidecar crash, edit-metadata silent no-op, passport batch blank images, color-palette hex overflow, OCR log noise

- Convert all AI bridge inputs to PNG before writing to disk so PIL can
  read AVIF/WebP/TIFF (7 bridge files; face-detection and OCR already
  had this pattern)
- Add title/author aliases to edit-metadata schema so common field names
  actually write EXIF tags instead of being silently stripped by Zod
- Port extend/pad crop logic from passport-photo single endpoint to the
  batch pipeline so crop regions extending beyond the image get filled
  with background color instead of producing all-white output
- Clamp quantized color channels to 255 in color-palette to prevent
  Math.round(255/16)*16=256 from producing invalid hex like #100100100
- Compare OCR fallback warning against expected engine name per tier
  instead of comparing engine name against tier name (always mismatch)
This commit is contained in:
ashim-hq
2026-04-21 23:54:25 +08:00
parent 0b8e0bf774
commit 9a015c8501
12 changed files with 86 additions and 36 deletions
+45 -20
View File
@@ -560,11 +560,18 @@ export function registerPassportPhoto(app: FastifyInstance) {
const docSpec = countrySpec.documents.find((d) => d.type === s.documentType);
if (!docSpec) throw new Error(`No ${s.documentType} spec for ${s.countryCode}`);
// Convert normalized landmarks (0-1) to pixel coordinates
const crownYPx = (landmarks.crown.y + s.adjustY) * imgH;
const chinYPx = (landmarks.chin.y + s.adjustY) * imgH;
const eyeYPx = (landmarks.eyeCenter.y + s.adjustY) * imgH;
const faceCenterXPx = (landmarks.faceCenterX + s.adjustX) * imgW;
// 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;
@@ -575,40 +582,58 @@ export function registerPassportPhoto(app: FastifyInstance) {
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
const leftX = faceCenterXPx - photoWidthPx / 2;
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);
// 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 bgRemovedMeta = await sharp(bgRemovedBuffer).metadata();
const bgLayer = await sharp({
create: {
width: bgRemovedMeta.width ?? imgW,
height: bgRemovedMeta.height ?? imgH,
width: actualW,
height: actualH,
channels: 4,
background: { r: bgR, g: bgG, b: bgB, alpha: 1 },
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(bgLayer)
.extract({ left: cropLeft, top: cropTop, width: cropW, height: cropH })
const result = await sharp(sourceForCrop)
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
.jpeg({ quality: 95 })
.toBuffer();