/** * Client-side normalisation for face photos headed to a Hikvision face library. * * The terminal is strict in ways a raw camera roll photo never satisfies, so the browser * does the work before upload: * - JPEG only. Phone photos are usually HEIC, which the terminal rejects outright. * - Small. The ~3MB figure in the Hikvision docs is the ISAPI transport ceiling; the * face *library* caps far lower, so an oversized photo uploads happily and then fails * enrolment -- the worst failure mode, because it looks like it worked. * - Portrait. Terminals expect a 3:4 face frame; a square crop tends to cut the chin. * * Canvas does all of this, so there is no image dependency in this project. */ /** Target frame. 3:4 portrait is what the face library expects. */ export const FACE_WIDTH = 480 export const FACE_HEIGHT = 640 /** Face-library budget, well under the ISAPI transport limit. */ export const FACE_TARGET_BYTES = 150 * 1024 export const FACE_MAX_BYTES = 200 * 1024 /** Guard on the *input* file, applied before decoding so a huge file can't exhaust memory. */ export const INPUT_MAX_BYTES = 3 * 1024 * 1024 /** Quality ladder: start high, step down only while over budget. */ const QUALITY_STEPS = [0.85, 0.75, 0.65] export interface NormalizedFacePhoto { /** Bare base64 JPEG, no data: prefix — what the API expects. */ base64: string /** data: URL for previewing in an . */ previewUrl: string bytes: number width: number height: number } export class FacePhotoError extends Error {} /** * Decodes any browser-readable image, cover-crops it to 3:4, resizes to 480x640 and * encodes JPEG under the face-library budget. */ export async function normalizeFacePhoto( source: Blob | HTMLVideoElement ): Promise { if (source instanceof Blob) { if (source.size > INPUT_MAX_BYTES) { throw new FacePhotoError( `That image is ${Math.round(source.size / 1024 / 1024)} MB. Choose one under 3 MB.` ) } if (source.size === 0) { throw new FacePhotoError("That file is empty.") } } const bitmap = await toBitmap(source) try { const canvas = document.createElement("canvas") canvas.width = FACE_WIDTH canvas.height = FACE_HEIGHT const ctx = canvas.getContext("2d") if (!ctx) throw new FacePhotoError("This browser cannot process images.") // Cover-crop: fill the 3:4 frame from the centre rather than squashing the face. const scale = Math.max(FACE_WIDTH / bitmap.width, FACE_HEIGHT / bitmap.height) const drawWidth = bitmap.width * scale const drawHeight = bitmap.height * scale ctx.imageSmoothingQuality = "high" // A face library needs an opaque background; a transparent PNG would otherwise // flatten to black and wreck the exposure. ctx.fillStyle = "#ffffff" ctx.fillRect(0, 0, FACE_WIDTH, FACE_HEIGHT) ctx.drawImage( bitmap, (FACE_WIDTH - drawWidth) / 2, (FACE_HEIGHT - drawHeight) / 2, drawWidth, drawHeight ) let blob: Blob | null = null for (const quality of QUALITY_STEPS) { blob = await canvasToBlob(canvas, quality) if (blob && blob.size <= FACE_TARGET_BYTES) break } if (!blob) throw new FacePhotoError("Could not process that image. Try another photo.") if (blob.size > FACE_MAX_BYTES) { throw new FacePhotoError( "Could not compress that photo enough for the access terminal. Try a simpler " + "background or better lighting." ) } const dataUrl = await blobToDataUrl(blob) return { base64: dataUrl.slice(dataUrl.indexOf(",") + 1), previewUrl: dataUrl, bytes: blob.size, width: FACE_WIDTH, height: FACE_HEIGHT, } } finally { if ("close" in bitmap && typeof bitmap.close === "function") bitmap.close() } } async function toBitmap(source: Blob | HTMLVideoElement): Promise { try { // createImageBitmap decodes HEIC on Safari and every other format the browser knows. return await createImageBitmap(source as ImageBitmapSource) } catch { throw new FacePhotoError( "That file isn't a readable image. Use a JPEG or PNG photo, or take one with the camera." ) } } function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise { return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality)) } function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result)) reader.onerror = () => reject(new FacePhotoError("Could not read the processed image.")) reader.readAsDataURL(blob) }) }