/** * Place all sweets from ~/Desktop/ftp-images-new onto decorative plates * on a clean white background. */ import { removeBackground } from '@imgly/background-removal-node'; import fs from 'node:fs'; import path from 'node:path'; import sharp from 'sharp'; const SWEETS_ROOT = path.join(process.env.HOME ?? '', 'Desktop', 'ftp-images-new', 'bilder-bp', 'sweets'); const SRC_SWEETS_ROOT = path.join(__dirname, '..', 'ftp-images', 'bilder-bp', 'sweets'); const ENHANCE_W = 1600; const ENHANCE_H = 1200; const CANVAS_W = 1600; const CANVAS_H = 1200; const PLATE_CX = CANVAS_W / 2; const PLATE_CY = CANVAS_H / 2 + 30; const PLATE_R = 430; function plateSvg(seed: number): string { const dots: string[] = []; let s = seed; const rand = () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; const colors = ['#4A7EBB', '#E8843C', '#C4A882', '#6B9E78', '#D4A574']; for (let i = 0; i < 72; i++) { const angle = rand() * Math.PI * 2; const dist = rand() * (PLATE_R - 70); const x = PLATE_CX + Math.cos(angle) * dist; const y = PLATE_CY + Math.sin(angle) * dist; const r = 3 + rand() * 9; const c = colors[Math.floor(rand() * colors.length)]; dots.push(``); } return ` ${dots.join('\n')} `; } async function makeBackground(): Promise { return sharp({ create: { width: CANVAS_W, height: CANVAS_H, channels: 3, background: '#FFFFFF' }, }) .jpeg({ quality: 100 }) .toBuffer(); } async function cutoutSubject(imagePath: string): Promise { const blob = await removeBackground(imagePath, { model: 'small' }); return Buffer.from(await blob.arrayBuffer()); } async function cutoutHasSubject(cutout: Buffer): Promise { const { data, info } = await sharp(cutout) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); let opaque = 0; for (let i = 3; i < data.length; i += 4) { if (data[i] > 40) opaque++; } const total = (info.width ?? 1) * (info.height ?? 1); return opaque / total > 0.04; } async function stylizeWithVignette(imagePath: string, index: number): Promise { const [bgBuf, plateBuf, photoBuf] = await Promise.all([ makeBackground(), sharp(Buffer.from(plateSvg(index + 7))).png().toBuffer(), sharp(imagePath).rotate().resize(CANVAS_W, CANVAS_H, { fit: 'cover', position: 'centre' }).toBuffer(), ]); const maskSvg = ` `; const mask = await sharp(Buffer.from(maskSvg)).png().toBuffer(); const masked = await sharp(photoBuf).composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer(); const meta = await sharp(masked).metadata(); const fw = meta.width ?? CANVAS_W; const fh = meta.height ?? CANVAS_H; const scale = Math.min((PLATE_R * 1.5) / fw, (PLATE_R * 1.3) / fh, 0.92); const food = await sharp(masked) .resize(Math.round(fw * scale), Math.round(fh * scale), { kernel: sharp.kernel.lanczos3 }) .png() .toBuffer(); const fm = await sharp(food).metadata(); const left = Math.round(PLATE_CX - (fm.width ?? 0) / 2); const top = Math.round(PLATE_CY - (fm.height ?? 0) / 2 + 10); const shadowSvg = ` `; const shadowBuf = await sharp(Buffer.from(shadowSvg)).png().toBuffer(); return sharp(bgBuf) .composite([ { input: plateBuf, top: 0, left: 0 }, { input: shadowBuf, top: 0, left: 0 }, { input: food, top, left }, ]) .jpeg({ quality: 93, mozjpeg: true, chromaSubsampling: '4:4:4' }) .toBuffer(); } async function stylizeSweet(imagePath: string, index: number, forceVignette = false): Promise { if (forceVignette) { const out = await stylizeWithVignette(imagePath, index); fs.writeFileSync(imagePath, out); return; } const [bgBuf, plateBuf, cutoutBuf] = await Promise.all([ makeBackground(), sharp(Buffer.from(plateSvg(index + 7))).png().toBuffer(), cutoutSubject(imagePath), ]); if (!(await cutoutHasSubject(cutoutBuf))) { const fallback = await stylizeWithVignette(imagePath, index); fs.writeFileSync(imagePath, fallback); return; } const cutoutMeta = await sharp(cutoutBuf).metadata(); const cw = cutoutMeta.width ?? 1; const ch = cutoutMeta.height ?? 1; const maxW = PLATE_R * 1.35; const maxH = PLATE_R * 1.1; const scale = Math.min(maxW / cw, maxH / ch, 1); const targetW = Math.round(cw * scale); const targetH = Math.round(ch * scale); const food = await sharp(cutoutBuf) .resize(targetW, targetH, { fit: 'inside', kernel: sharp.kernel.lanczos3 }) .png() .toBuffer(); const foodMeta = await sharp(food).metadata(); const fw = foodMeta.width ?? targetW; const fh = foodMeta.height ?? targetH; const left = Math.round(PLATE_CX - fw / 2); const top = Math.round(PLATE_CY - fh / 2 + 10); const shadowSvg = ` `; const shadowBuf = await sharp(Buffer.from(shadowSvg)).png().toBuffer(); const out = await sharp(bgBuf) .composite([ { input: plateBuf, top: 0, left: 0 }, { input: shadowBuf, top: 0, left: 0 }, { input: food, top, left }, ]) .jpeg({ quality: 93, mozjpeg: true, chromaSubsampling: '4:4:4' }) .toBuffer(); fs.writeFileSync(imagePath, out); } function findSourceImage(destPath: string): string | null { const rel = path.relative(SWEETS_ROOT, destPath); const dir = path.dirname(path.join(SRC_SWEETS_ROOT, rel)); const base = path.basename(destPath, path.extname(destPath)); if (!fs.existsSync(dir)) return null; for (const name of fs.readdirSync(dir)) { if (name.startsWith(base) && /\.(jpe?g|png|webp)$/i.test(name)) { return path.join(dir, name); } } return null; } async function reEnhanceFromSource(destPath: string): Promise { const src = findSourceImage(destPath); if (!src) throw new Error('no source image in ftp-images'); const rotated = sharp(src, { failOn: 'none' }).rotate(); const { data, info } = await rotated.toColorspace('srgb').removeAlpha().toBuffer({ resolveWithObject: true }); const width = info.width; const height = info.height; const targetAspect = ENHANCE_W / ENHANCE_H; const sourceAspect = width / height; let cropW = width; let cropH = height; let left = 0; let top = 0; if (sourceAspect > targetAspect) { cropW = Math.round(height * targetAspect); left = Math.round((width - cropW) / 2); } else if (sourceAspect < targetAspect) { cropH = Math.round(width / targetAspect); top = Math.round((height - cropH) / 2); } cropW = Math.min(cropW, width - left); cropH = Math.min(cropH, height - top); const minDim = Math.min(cropW, cropH); let pipeline = sharp(data) .extract({ left, top, width: cropW, height: cropH }) .normalize() .modulate({ brightness: 1.04, saturation: 1.18 }) .gamma(1.05); pipeline = minDim < 900 ? pipeline.sharpen({ sigma: 1.2, m1: 0.8, m2: 0.4 }) : pipeline.sharpen({ sigma: 0.9, m1: 0.6, m2: 0.3 }); const buf = await pipeline .resize(ENHANCE_W, ENHANCE_H, { fit: 'fill', kernel: sharp.kernel.lanczos3 }) .jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' }) .toBuffer(); fs.writeFileSync(destPath, buf); } function collectImages(dir: string): string[] { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return collectImages(full); if (/\.(jpe?g|png|webp)$/i.test(entry.name)) return [full]; return []; }); } const REPAIR_ONLY = process.argv.includes('--repair'); const FRESH = process.argv.includes('--fresh') || !REPAIR_ONLY; const VIGNETTE_PATHS = new Set([ 'patisa/bild/IMG_1400.jpg', 'shahi-tukra/bild/IMG_1409.jpg', 'habshi-halwa/bild/IMG_1412.jpg', 'milk-cake-plain/bild/IMG_1411.jpg', 'plain-barfi/bild/IMG_1398.jpg', 'coconut-barfi/bild/IMG_1398.jpg', 'namak-paray/bild/IMG_0209.jpg', ]); const REPAIR_PATHS = [ 'patisa/bild/IMG_1400.jpg', 'shahi-tukra/bild/IMG_1409.jpg', 'habshi-halwa/bild/IMG_1412.jpg', 'milk-cake-plain/bild/IMG_1411.jpg', 'plain-barfi/bild/IMG_1398.jpg', 'coconut-barfi/bild/IMG_1398.jpg', 'namak-paray/bild/IMG_0209.jpg', ]; async function main() { const images = (REPAIR_ONLY ? REPAIR_PATHS.map((p) => path.join(SWEETS_ROOT, p)) : collectImages(SWEETS_ROOT) ).sort(); if (!images.length) { console.error(`No images found in ${SWEETS_ROOT}`); process.exit(1); } console.log(`Stylizing ${images.length} sweet images (white background)...\n`); for (let i = 0; i < images.length; i++) { const img = images[i]; const rel = path.relative(SWEETS_ROOT, img); try { if (FRESH || REPAIR_ONLY) { await reEnhanceFromSource(img); console.log(` ↺ re-enhanced ${rel}`); } await stylizeSweet(img, i, VIGNETTE_PATHS.has(rel)); console.log(` ✓ ${rel}`); } catch (err) { console.error(` ✗ ${rel}: ${err instanceof Error ? err.message : err}`); } } console.log(`\nDone — updated images in ${SWEETS_ROOT}`); } main().catch((err) => { console.error(err); process.exit(1); });