/** * Rebuild ~/Desktop/ftp-images-new from original ftp-images sources. * Fixes blur + loading issues: single encode, baseline JPEG, no blur masks, higher resolution. */ import { removeBackground } from '@imgly/background-removal-node'; import fs from 'node:fs'; import path from 'node:path'; import sharp from 'sharp'; const OUT_ROOT = path.join(process.env.HOME ?? '', 'Desktop', 'ftp-images-new'); const SRC_ROOT = path.join(__dirname, '..', 'ftp-images'); const SWEETS_MARKER = `${path.sep}sweets${path.sep}`; const OUT_W = 2400; const OUT_H = 1800; const PLATE_CX = OUT_W / 2; const PLATE_CY = OUT_H / 2 + 24; const PLATE_R = 660; const JPEG = { quality: 96, mozjpeg: false, progressive: false, chromaSubsampling: '4:4:4' as const, }; const HARD_CROP_SWEETS = new Set([ 'bilder-bp/sweets/patisa/bild/IMG_1400.jpg', 'bilder-bp/sweets/namak-paray/bild/IMG_0209.jpg', ]); function platePng(seed: number): Buffer { const specks: string[] = []; let s = seed + 17; const rand = () => { s = (s * 16807) % 2147483647; return s / 2147483647; }; const colors = ['#4A7EBB', '#E8843C', '#C4A882', '#6B9E78', '#D4A574']; for (let i = 0; i < 64; i++) { const a = rand() * Math.PI * 2; const d = rand() * (PLATE_R - 80); const x = PLATE_CX + Math.cos(a) * d; const y = PLATE_CY + Math.sin(a) * d; specks.push( `` ); } const svg = ` ${specks.join('')} `; return Buffer.from(svg); } function collectImages(dir: string): string[] { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const full = path.join(dir, e.name); if (e.isDirectory()) return collectImages(full); if (/\.(jpe?g|png|webp)$/i.test(e.name) && !e.name.includes('.polish.')) return [full]; return []; }); } function findSource(rel: string): string | null { const dir = path.dirname(path.join(SRC_ROOT, rel)); const base = path.basename(rel, path.extname(rel)); 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 preparePhoto(src: string): Promise { const rotated = sharp(src, { failOn: 'none', unlimited: true }).rotate(); const { data, info } = await rotated.toColorspace('srgb').removeAlpha().toBuffer({ resolveWithObject: true }); const w = info.width; const h = info.height; const aspect = OUT_W / OUT_H; const sa = w / h; let cropW = w; let cropH = h; let left = 0; let top = 0; if (sa > aspect) { cropW = Math.round(h * aspect); left = Math.round((w - cropW) / 2); } else if (sa < aspect) { cropH = Math.round(w / aspect); top = Math.round((h - cropH) / 2); } cropW = Math.min(cropW, w - left); cropH = Math.min(cropH, h - top); const minDim = Math.min(cropW, cropH); let pipe = sharp(data) .extract({ left, top, width: cropW, height: cropH }) .modulate({ brightness: 1.02, saturation: 1.1 }); if (minDim < 1200) { pipe = pipe.sharpen({ sigma: 1, m1: 0.6, m2: 0.3 }); } else { pipe = pipe.sharpen({ sigma: 0.6, m1: 0.4, m2: 0.2 }); } return pipe .resize(OUT_W, OUT_H, { fit: 'fill', kernel: sharp.kernel.lanczos3 }) .png() .toBuffer(); } async function writeJpeg(buf: Buffer, dest: string): Promise { fs.mkdirSync(path.dirname(dest), { recursive: true }); await sharp(buf).jpeg(JPEG).toFile(dest); } async function rebuildDish(src: string, dest: string): Promise { const photo = await preparePhoto(src); await writeJpeg(photo, dest); } async function cutoutFromPng(pngPath: string): Promise { const blob = await removeBackground(pngPath, { model: 'small' }); return Buffer.from(await blob.arrayBuffer()); } async function cutoutOk(buf: Buffer): Promise { const { data, info } = await sharp(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const w = info.width ?? 1; const h = info.height ?? 1; let o = 0; let t = 0; for (let y = Math.floor(h * 0.15); y < Math.floor(h * 0.85); y++) { for (let x = Math.floor(w * 0.15); x < Math.floor(w * 0.85); x++) { if (data[(y * w + x) * 4 + 3] > 50) o++; t++; } } return o / t > 0.06; } async function placeOnPlate(foodPng: Buffer, index: number): Promise { const meta = await sharp(foodPng).metadata(); const cw = meta.width ?? 1; const ch = meta.height ?? 1; const maxW = PLATE_R * 1.05; const maxH = PLATE_R * 0.88; const scale = Math.min(maxW / cw, maxH / ch, 1); const food = await sharp(foodPng) .resize(Math.round(cw * scale), Math.round(ch * scale), { kernel: sharp.kernel.lanczos3 }) .png() .toBuffer(); const fm = await sharp(food).metadata(); const fw = fm.width ?? 1; const fh = fm.height ?? 1; const left = Math.round(PLATE_CX - fw / 2); const top = Math.round(PLATE_CY - fh / 2 + 4); const shadow = Buffer.from(` `); return sharp(platePng(index)) .composite([ { input: await sharp(shadow).png().toBuffer(), top: 0, left: 0 }, { input: food, top, left }, ]) .png() .toBuffer(); } async function hardCropFood(photo: Buffer): Promise { const meta = await sharp(photo).metadata(); const w = meta.width ?? OUT_W; const h = meta.height ?? OUT_H; const cw = Math.round(w * 0.62); const ch = Math.round(h * 0.62); return sharp(photo) .extract({ left: Math.round((w - cw) / 2), top: Math.round((h - ch) / 2), width: cw, height: ch }) .png() .toBuffer(); } async function rebuildSweet(src: string, dest: string, rel: string, index: number): Promise { const photo = await preparePhoto(src); const tmp = path.join(OUT_ROOT, `.tmp-${index}.png`); fs.writeFileSync(tmp, photo); try { let food: Buffer; if (HARD_CROP_SWEETS.has(rel)) { food = await hardCropFood(photo); } else { const cut = await cutoutFromPng(tmp); food = (await cutoutOk(cut)) ? cut : await hardCropFood(photo); } const composed = await placeOnPlate(food, index); await writeJpeg(composed, dest); } finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } } async function verifyJpeg(file: string): Promise { try { const meta = await sharp(file).metadata(); return (meta.width ?? 0) > 0 && (meta.height ?? 0) > 0; } catch { return false; } } async function main() { const images = collectImages(OUT_ROOT).sort(); let ok = 0; let fail = 0; console.log(`Rebuilding ${images.length} images → ${OUT_W}×${OUT_H} baseline JPEG\n`); for (let i = 0; i < images.length; i++) { const dest = images[i]; const rel = path.relative(OUT_ROOT, dest); const src = findSource(rel); if (!src) { console.error(` ✗ ${rel}: no source`); fail++; continue; } try { if (rel.includes(SWEETS_MARKER)) { await rebuildSweet(src, dest, rel, i); } else { await rebuildDish(src, dest); } if (!(await verifyJpeg(dest))) throw new Error('invalid output JPEG'); const stat = fs.statSync(dest); if (stat.size < 8000) throw new Error('file too small'); ok++; console.log(` ✓ ${rel} (${Math.round(stat.size / 1024)}KB)`); } catch (err) { fail++; console.error(` ✗ ${rel}: ${err instanceof Error ? err.message : err}`); } } console.log(`\nDone: ${ok} rebuilt, ${fail} failed`); if (fail) process.exit(1); } main().catch((err) => { console.error(err); process.exit(1); });