150 lines
4.3 KiB
TypeScript
150 lines
4.3 KiB
TypeScript
/**
|
||
* 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<string, string> = {
|
||
|
||
};
|
||
|
||
/** Menu id → folder name under ftp-images/bilder-bp/sweets/ */
|
||
const SWEETS_FOLDERS: Record<string, string> = {
|
||
'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<string>();
|
||
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<string>();
|
||
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(); |