/** * Sync dish photos from ftp-images/ → public/images/dishes/ * - Matches folders by menu item id (e.g. ftp-images/bilder-bp/chicken-biryani/bild/) * - Center-crops to 4:3, resizes to 1200×900, exports unified JPEG */ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { menuCategories } from '../infrastructure/menu/static-menu-data'; import { videoBaseName } from '../application/media/asset-resolver'; const ROOT = path.resolve(__dirname, '..'); const FTP_ROOT = path.join(ROOT, 'ftp-images/bilder-bp'); const OUT_DIR = path.join(ROOT, 'public/images/dishes'); const TARGET_W = 1200; const TARGET_H = 900; const JPEG_QUALITY = 88; /** Menu ids that share another dish's ftp photo (main bilder-bp folder) */ const FTP_ALIASES: Record = { }; /** Menu id → folder name under ftp-images/bilder-bp/sweets/ */ const SWEETS_FOLDERS: Record = { 'namakpare': 'namak-paray', 'shakar-paray': 'Shakar paray', 'chocolate-barfi': 'chochlate-barfi', 'pistachio-barfi': 'pistacho-barfi', }; function pickLargestImage(dir: string): string | null { if (!fs.existsSync(dir)) return null; const files = fs .readdirSync(dir, { withFileTypes: true }) .flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return []; if (!/\.(jpe?g|png|webp)$/i.test(entry.name)) return []; return [{ full, size: fs.statSync(full).size }]; }) .sort((a, b) => b.size - a.size); return files[0]?.full ?? null; } function findSourceImage(dishId: string): string | null { const mainFolder = FTP_ALIASES[dishId] ?? dishId; const sweetsFolder = SWEETS_FOLDERS[dishId] ?? dishId; const candidates = [ path.join(FTP_ROOT, mainFolder, 'bild'), path.join(FTP_ROOT, 'sweets', sweetsFolder, 'bild'), path.join(FTP_ROOT, 'sweets', sweetsFolder), ]; for (const dir of candidates) { const found = pickLargestImage(dir); if (found) return found; } return null; } function run(cmd: string) { execSync(cmd, { stdio: 'pipe' }); } function processToJpeg(src: string, dest: string) { const tmp = path.join(OUT_DIR, `.tmp-${path.basename(dest)}`); fs.mkdirSync(OUT_DIR, { recursive: true }); // Normalize to JPEG working copy run(`sips -s format jpeg "${src}" --out "${tmp}"`); const width = Number( execSync(`sips -g pixelWidth "${tmp}"`).toString().match(/pixelWidth: (\d+)/)?.[1] ); const height = Number( execSync(`sips -g pixelHeight "${tmp}"`).toString().match(/pixelHeight: (\d+)/)?.[1] ); if (!width || !height) throw new Error(`Could not read dimensions for ${src}`); const targetAspect = TARGET_W / TARGET_H; const sourceAspect = width / height; let cropW = width; let cropH = height; let offsetX = 0; let offsetY = 0; if (sourceAspect > targetAspect) { cropW = Math.round(height * targetAspect); offsetX = Math.round((width - cropW) / 2); } else if (sourceAspect < targetAspect) { cropH = Math.round(width / targetAspect); offsetY = Math.round((height - cropH) / 2); } run( `sips --cropToHeightWidth ${cropH} ${cropW} --cropOffset ${offsetY} ${offsetX} "${tmp}"` ); run( `sips -z ${TARGET_H} ${TARGET_W} -s format jpeg -s formatOptions ${JPEG_QUALITY} "${tmp}" --out "${dest}"` ); if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } function main() { const written = new Set(); let synced = 0; let skipped = 0; for (const category of menuCategories) { for (const item of category.items) { const src = findSourceImage(item.id); if (!src) { skipped++; console.log(` skip (no ftp image): ${item.id}`); continue; } const outputs = new Set(); if (item.image) outputs.add(path.join(OUT_DIR, item.image)); if (item.video) { const base = videoBaseName(item.video); outputs.add(path.join(OUT_DIR, `${base}-poster.jpg`)); outputs.add(path.join(OUT_DIR, `${base}-optimized-poster.jpg`)); } for (const out of outputs) { if (written.has(out)) continue; processToJpeg(src, out); written.add(out); console.log(` ✓ ${path.basename(out)} ← ${item.id}`); } synced++; } } console.log(`\nDone: ${synced} dishes synced, ${skipped} without ftp source, ${written.size} files written.`); } main();