diff --git a/apps/api/src/lib/exiftool.ts b/apps/api/src/lib/exiftool.ts index 737a1b2e..981b43eb 100644 --- a/apps/api/src/lib/exiftool.ts +++ b/apps/api/src/lib/exiftool.ts @@ -137,6 +137,8 @@ export async function writeMetadata( /** Settings shape that buildTagArgs accepts */ export interface EditMetadataSettings { + title?: string; + author?: string; artist?: string; copyright?: string; imageDescription?: string; @@ -165,11 +167,16 @@ export interface EditMetadataSettings { export function buildTagArgs(settings: EditMetadataSettings): string[] { const args: string[] = []; + // Common aliases + const artist = settings.artist || settings.author; + const description = settings.imageDescription || settings.title; + // Basic EXIF fields - if (settings.artist) args.push(`-Artist=${settings.artist}`); + if (artist) args.push(`-Artist=${artist}`); if (settings.copyright) args.push(`-Copyright=${settings.copyright}`); - if (settings.imageDescription) args.push(`-ImageDescription=${settings.imageDescription}`); + if (description) args.push(`-ImageDescription=${description}`); if (settings.software) args.push(`-Software=${settings.software}`); + if (settings.title) args.push(`-XMP:Title=${settings.title}`); // Date fields if (settings.dateTime) args.push(`-ModifyDate=${settings.dateTime}`); diff --git a/apps/api/src/routes/tools/color-palette.ts b/apps/api/src/routes/tools/color-palette.ts index 816a7c7d..bbabd374 100644 --- a/apps/api/src/routes/tools/color-palette.ts +++ b/apps/api/src/routes/tools/color-palette.ts @@ -12,9 +12,9 @@ function extractColors(pixels: Buffer, channelCount: number, maxColors: number): for (let i = 0; i < pixels.length; i += channelCount) { // Quantize to reduce noise (round to nearest 16) - const r = Math.round(pixels[i] / 16) * 16; - const g = Math.round(pixels[i + 1] / 16) * 16; - const b = Math.round(pixels[i + 2] / 16) * 16; + const r = Math.min(Math.round(pixels[i] / 16) * 16, 255); + const g = Math.min(Math.round(pixels[i + 1] / 16) * 16, 255); + const b = Math.min(Math.round(pixels[i + 2] / 16) * 16, 255); const key = `${r},${g},${b}`; colorMap.set(key, (colorMap.get(key) ?? 0) + 1); } diff --git a/apps/api/src/routes/tools/edit-metadata.ts b/apps/api/src/routes/tools/edit-metadata.ts index b79f2851..2528b73e 100644 --- a/apps/api/src/routes/tools/edit-metadata.ts +++ b/apps/api/src/routes/tools/edit-metadata.ts @@ -17,6 +17,8 @@ import { createWorkspace } from "../../lib/workspace.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ + title: z.string().optional(), + author: z.string().optional(), artist: z.string().optional(), copyright: z.string().optional(), imageDescription: z.string().optional(), diff --git a/apps/api/src/routes/tools/ocr.ts b/apps/api/src/routes/tools/ocr.ts index 298aa99e..a2304b38 100644 --- a/apps/api/src/routes/tools/ocr.ts +++ b/apps/api/src/routes/tools/ocr.ts @@ -150,10 +150,12 @@ export function registerOcr(app: FastifyInstance) { }); } - if (result.engine && result.engine !== tier) { + const expectedEngine = + tier === "fast" ? "tesseract" : tier === "balanced" ? "paddleocr" : "paddleocr-vl"; + if (result.engine && result.engine !== expectedEngine) { request.log.warn( - { toolId: "ocr", requested: tier, actual: result.engine }, - `OCR engine fallback: requested ${tier} but used ${result.engine}`, + { toolId: "ocr", requested: tier, expected: expectedEngine, actual: result.engine }, + `OCR engine fallback: requested ${tier} (${expectedEngine}) but used ${result.engine}`, ); } diff --git a/apps/api/src/routes/tools/passport-photo.ts b/apps/api/src/routes/tools/passport-photo.ts index ff7f03ec..4681af84 100644 --- a/apps/api/src/routes/tools/passport-photo.ts +++ b/apps/api/src/routes/tools/passport-photo.ts @@ -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(); diff --git a/packages/ai/src/background-removal.ts b/packages/ai/src/background-removal.ts index bf7a24a5..b3b35c87 100644 --- a/packages/ai/src/background-removal.ts +++ b/packages/ai/src/background-removal.ts @@ -20,7 +20,8 @@ export async function removeBackground( const inputPath = join(tmpdir(), `rembg_in_${id}.png`); const outputPath = join(outputDir, `rembg_out_${id}.png`); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); try { const meta = await sharp(inputBuffer).metadata(); const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000; diff --git a/packages/ai/src/colorization.ts b/packages/ai/src/colorization.ts index 25805d71..4ee380f5 100644 --- a/packages/ai/src/colorization.ts +++ b/packages/ai/src/colorization.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface ColorizeOptions { @@ -23,7 +24,8 @@ export async function colorize( const inputPath = join(outputDir, "input_colorize.png"); const outputPath = join(outputDir, "output_colorize.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "colorize.py", [inputPath, outputPath, JSON.stringify(options)], diff --git a/packages/ai/src/face-enhancement.ts b/packages/ai/src/face-enhancement.ts index e9f5ddc2..47d8b8b8 100644 --- a/packages/ai/src/face-enhancement.ts +++ b/packages/ai/src/face-enhancement.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface EnhanceFacesOptions { @@ -25,7 +26,8 @@ export async function enhanceFaces( const inputPath = join(outputDir, "input_enhance_faces.png"); const outputPath = join(outputDir, "output_enhance_faces.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "enhance_faces.py", [inputPath, outputPath, JSON.stringify(options)], diff --git a/packages/ai/src/inpainting.ts b/packages/ai/src/inpainting.ts index fc4ba17c..6b72924b 100644 --- a/packages/ai/src/inpainting.ts +++ b/packages/ai/src/inpainting.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export async function inpaint( @@ -12,8 +13,10 @@ export async function inpaint( const maskPath = join(outputDir, "mask_inpaint.png"); const outputPath = join(outputDir, "output_inpaint.png"); - await writeFile(inputPath, inputBuffer); - await writeFile(maskPath, maskBuffer); + const pngInput = await sharp(inputBuffer).png().toBuffer(); + const pngMask = await sharp(maskBuffer).png().toBuffer(); + await writeFile(inputPath, pngInput); + await writeFile(maskPath, pngMask); const { stdout } = await runPythonWithProgress("inpaint.py", [inputPath, maskPath, outputPath], { onProgress, diff --git a/packages/ai/src/noise-removal.ts b/packages/ai/src/noise-removal.ts index d28d69ab..268fb270 100644 --- a/packages/ai/src/noise-removal.ts +++ b/packages/ai/src/noise-removal.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface NoiseRemovalOptions { @@ -28,7 +29,8 @@ export async function noiseRemoval( const inputPath = join(outputDir, "input_denoise.png"); const outputPath = join(outputDir, "output_denoise.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "noise_removal.py", [inputPath, outputPath, JSON.stringify(options)], diff --git a/packages/ai/src/restoration.ts b/packages/ai/src/restoration.ts index 9cad74eb..fe7d08f9 100644 --- a/packages/ai/src/restoration.ts +++ b/packages/ai/src/restoration.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface RestorePhotoOptions { @@ -32,7 +33,8 @@ export async function restorePhoto( const inputPath = join(outputDir, "input_restore.png"); const outputPath = join(outputDir, "output_restore.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "restore.py", [inputPath, outputPath, JSON.stringify(options)], diff --git a/packages/ai/src/upscaling.ts b/packages/ai/src/upscaling.ts index bde8766f..d7ffeac9 100644 --- a/packages/ai/src/upscaling.ts +++ b/packages/ai/src/upscaling.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import sharp from "sharp"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; export interface UpscaleOptions { @@ -28,7 +29,8 @@ export async function upscale( const inputPath = join(outputDir, "input_upscale.png"); const outputPath = join(outputDir, "output_upscale.png"); - await writeFile(inputPath, inputBuffer); + const pngBuffer = await sharp(inputBuffer).png().toBuffer(); + await writeFile(inputPath, pngBuffer); const { stdout } = await runPythonWithProgress( "upscale.py", [inputPath, outputPath, JSON.stringify(options)],