Files
shahikitchen-prod/scripts/stylize-sweets-plates.ts

325 lines
11 KiB
TypeScript

/**
* 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(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" opacity="0.55"/>`);
}
return `<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="plateGrad" cx="50%" cy="42%" r="58%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F3EDE3"/>
</radialGradient>
<filter id="plateShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="22" flood-color="#B8B0A4" flood-opacity="0.35"/>
</filter>
</defs>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 14}" fill="#FFFFFF" filter="url(#plateShadow)"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 10}" fill="none" stroke="#C9A227" stroke-width="8"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="url(#plateGrad)"/>
${dots.join('\n')}
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R - 18}" fill="none" stroke="#E8DCC8" stroke-width="2" opacity="0.9"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="none" stroke="#B8922A" stroke-width="4" opacity="0.95"/>
</svg>`;
}
async function makeBackground(): Promise<Buffer> {
return sharp({
create: { width: CANVAS_W, height: CANVAS_H, channels: 3, background: '#FFFFFF' },
})
.jpeg({ quality: 100 })
.toBuffer();
}
async function cutoutSubject(imagePath: string): Promise<Buffer> {
const blob = await removeBackground(imagePath, { model: 'small' });
return Buffer.from(await blob.arrayBuffer());
}
async function cutoutHasSubject(cutout: Buffer): Promise<boolean> {
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<Buffer> {
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 = `<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="fade" cx="50%" cy="52%" r="48%">
<stop offset="0%" stop-color="white" stop-opacity="1"/>
<stop offset="72%" stop-color="white" stop-opacity="0.85"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#fade)"/>
</svg>`;
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 = `<svg width="${CANVAS_W}" height="${CANVAS_H}">
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + 50}" rx="${PLATE_R * 0.55}" ry="${PLATE_R * 0.12}" fill="#9A9088" opacity="0.22"/>
</svg>`;
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<void> {
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 = `<svg width="${CANVAS_W}" height="${CANVAS_H}">
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + fh * 0.22}" rx="${fw * 0.42}" ry="${fh * 0.1}" fill="#9A9088" opacity="0.2"/>
</svg>`;
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<void> {
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);
});