Files

431 lines
14 KiB
TypeScript

/**
* Polish every image in ~/Desktop/ftp-images-new for eye-catching menu quality.
* - Sweets: premium white-bg plate compositing (rebuilt from source)
* - Dishes: vibrance, contrast, and sharpness boost in-place
*/
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 CANVAS_W = 1600;
const CANVAS_H = 1200;
const PLATE_CX = CANVAS_W / 2;
const PLATE_CY = CANVAS_H / 2 + 20;
const PLATE_R = 440;
const VIGNETTE_FALLBACK = new Set([
'bilder-bp/sweets/namak-paray/bild/IMG_0209.jpg',
]);
const CENTER_CROP_FALLBACK = new Set([
'bilder-bp/sweets/patisa/bild/IMG_1400.jpg',
]);
function plateSvg(seed: number): string {
const specks: string[] = [];
let s = seed + 11;
const rand = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
const colors = ['#4A7EBB', '#E8843C', '#C4A882', '#6B9E78', '#D4A574', '#9B7CB8'];
for (let i = 0; i < 88; i++) {
const angle = rand() * Math.PI * 2;
const dist = rand() * (PLATE_R - 55);
const x = PLATE_CX + Math.cos(angle) * dist;
const y = PLATE_CY + Math.sin(angle) * dist;
const r = 2.5 + rand() * 10;
specks.push(
`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" fill="${colors[i % colors.length]}" opacity="0.62"/>`
);
}
return `<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="plateGrad" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#FFFDF8"/>
<stop offset="100%" stop-color="#F0E8DA"/>
</radialGradient>
<clipPath id="plateClip">
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}"/>
</clipPath>
<filter id="plateShadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="14" stdDeviation="28" flood-color="#C8C0B4" flood-opacity="0.28"/>
</filter>
</defs>
<rect width="100%" height="100%" fill="#FFFFFF"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 16}" fill="#FFFFFF" filter="url(#plateShadow)"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 11}" fill="none" stroke="#C9A227" stroke-width="9"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="url(#plateGrad)"/>
<g clip-path="url(#plateClip)">${specks.join('')}</g>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R - 22}" fill="none" stroke="#E8DCC8" stroke-width="1.5" opacity="0.85"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="none" stroke="#B8922A" stroke-width="4.5"/>
</svg>`;
}
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 [];
});
}
function findSource(destPath: string): string | null {
const rel = path.relative(OUT_ROOT, destPath);
const dir = path.dirname(path.join(SRC_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 enhanceFromSource(src: string): Promise<Buffer> {
const rotated = sharp(src, { failOn: 'none' }).rotate();
const { data, info } = await rotated.toColorspace('srgb').removeAlpha().toBuffer({ resolveWithObject: true });
const w = info.width;
const h = info.height;
const aspect = 4 / 3;
const sourceAspect = w / h;
let cropW = w;
let cropH = h;
let left = 0;
let top = 0;
if (sourceAspect > aspect) {
cropW = Math.round(h * aspect);
left = Math.round((w - cropW) / 2);
} else if (sourceAspect < 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 })
.normalize()
.modulate({ brightness: 1.05, saturation: 1.22 })
.gamma(1.04);
pipe =
minDim < 900
? pipe.sharpen({ sigma: 1.1, m1: 0.7, m2: 0.35 })
: pipe.sharpen({ sigma: 0.85, m1: 0.55, m2: 0.28 });
return pipe
.resize(CANVAS_W, CANVAS_H, { fit: 'fill', kernel: sharp.kernel.lanczos3 })
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
}
async function cutout(imagePath: string, model: 'small' | 'medium'): Promise<Buffer> {
const blob = await removeBackground(imagePath, { model });
return Buffer.from(await blob.arrayBuffer());
}
async function cutoutValid(cutout: Buffer): Promise<boolean> {
const { data, info } = await sharp(cutout).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const w = info.width ?? 1;
const h = info.height ?? 1;
const x0 = Math.floor(w * 0.2);
const y0 = Math.floor(h * 0.2);
const x1 = Math.floor(w * 0.8);
const y1 = Math.floor(h * 0.8);
let opaque = 0;
let total = 0;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
if (data[(y * w + x) * 4 + 3] > 45) opaque++;
total++;
}
}
return opaque / total > 0.07;
}
async function beautifyFood(cutout: Buffer, maxW: number, maxH: number): Promise<Buffer> {
const meta = await sharp(cutout).metadata();
const cw = meta.width ?? 1;
const ch = meta.height ?? 1;
const scale = Math.min(maxW / cw, maxH / ch, 1);
return sharp(cutout)
.resize(Math.round(cw * scale), Math.round(ch * scale), { kernel: sharp.kernel.lanczos3 })
.modulate({ brightness: 1.04, saturation: 1.18 })
.sharpen({ sigma: 0.7, m1: 0.5, m2: 0.25 })
.png()
.toBuffer();
}
async function stylizeWithCenterCrop(photo: Buffer, index: number): Promise<Buffer> {
const meta = await sharp(photo).metadata();
const w = meta.width ?? CANVAS_W;
const h = meta.height ?? CANVAS_H;
const cropW = Math.round(w * 0.58);
const cropH = Math.round(h * 0.58);
const left = Math.round((w - cropW) / 2);
const top = Math.round((h - cropH) / 2);
const cropped = await sharp(photo)
.extract({ left, top, width: cropW, height: cropH })
.modulate({ brightness: 1.04, saturation: 1.2 })
.sharpen({ sigma: 0.9 })
.png()
.toBuffer();
const maskSvg = `<svg width="${cropW}" height="${cropH}">
<defs>
<radialGradient id="m" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="white"/>
<stop offset="78%" stop-color="white" stop-opacity="0.92"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#m)"/>
</svg>`;
const masked = await sharp(cropped)
.composite([{ input: await sharp(Buffer.from(maskSvg)).blur(12).png().toBuffer(), blend: 'dest-in' }])
.png()
.toBuffer();
const fm = await sharp(masked).metadata();
const fw = fm.width ?? cropW;
const fh = fm.height ?? cropH;
const scale = Math.min((PLATE_R * 1.25) / fw, (PLATE_R * 1.05) / fh);
const food = await sharp(masked)
.resize(Math.round(fw * scale), Math.round(fh * scale), { kernel: sharp.kernel.lanczos3 })
.png()
.toBuffer();
const f2 = await sharp(food).metadata();
const posL = Math.round(PLATE_CX - (f2.width ?? 0) / 2);
const posT = Math.round(PLATE_CY - (f2.height ?? 0) / 2 + 6);
const shadow = await sharp(
Buffer.from(`<svg width="${CANVAS_W}" height="${CANVAS_H}">
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + (f2.height ?? 0) * 0.16}" rx="${(f2.width ?? 0) * 0.36}" ry="${(f2.height ?? 0) * 0.08}" fill="#8A8278" opacity="0.18"/>
</svg>`)
)
.png()
.toBuffer();
return sharp(Buffer.from(plateSvg(index)))
.composite([
{ input: shadow, top: 0, left: 0 },
{ input: food, top: posT, left: posL },
])
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
}
async function stylizeWithVignette(photo: Buffer, index: number): Promise<Buffer> {
const plateBuf = await sharp(Buffer.from(plateSvg(index))).png().toBuffer();
const maskSvg = `<svg width="${CANVAS_W}" height="${CANVAS_H}">
<defs>
<radialGradient id="f" cx="50%" cy="50%" r="42%">
<stop offset="0%" stop-color="white"/>
<stop offset="68%" stop-color="white" stop-opacity="0.95"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="100%" height="100%" fill="url(#f)"/>
</svg>`;
const masked = await sharp(photo)
.resize(CANVAS_W, CANVAS_H, { fit: 'cover', position: 'centre' })
.composite([{ input: await sharp(Buffer.from(maskSvg)).blur(18).png().toBuffer(), blend: 'dest-in' }])
.modulate({ brightness: 1.03, saturation: 1.15 })
.png()
.toBuffer();
const fm = await sharp(masked).metadata();
const fw = fm.width ?? CANVAS_W;
const fh = fm.height ?? CANVAS_H;
const scale = Math.min((PLATE_R * 1.35) / fw, (PLATE_R * 1.15) / fh, 0.88);
const food = await sharp(masked)
.resize(Math.round(fw * scale), Math.round(fh * scale), { kernel: sharp.kernel.lanczos3 })
.png()
.toBuffer();
const f2 = await sharp(food).metadata();
const left = Math.round(PLATE_CX - (f2.width ?? 0) / 2);
const top = Math.round(PLATE_CY - (f2.height ?? 0) / 2 + 8);
const shadow = await sharp(
Buffer.from(`<svg width="${CANVAS_W}" height="${CANVAS_H}">
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + 58}" rx="${PLATE_R * 0.5}" ry="${PLATE_R * 0.1}" fill="#A09890" opacity="0.18"/>
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + (f2.height ?? 0) * 0.15}" rx="${(f2.width ?? 0) * 0.38}" ry="${(f2.height ?? 0) * 0.08}" fill="#8A8278" opacity="0.16"/>
</svg>`)
)
.png()
.toBuffer();
return sharp(Buffer.from(plateSvg(index)))
.composite([
{ input: shadow, top: 0, left: 0 },
{ input: food, top, left },
])
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
}
async function stylizeSweet(destPath: string, rel: string, index: number): Promise<void> {
const src = findSource(destPath);
if (!src) throw new Error('missing source');
const enhanced = await enhanceFromSource(src);
if (CENTER_CROP_FALLBACK.has(rel)) {
const out = await stylizeWithCenterCrop(enhanced, index);
fs.writeFileSync(destPath, out);
return;
}
const useVignette = VIGNETTE_FALLBACK.has(rel);
if (useVignette) {
const out = await stylizeWithVignette(enhanced, index);
fs.writeFileSync(destPath, out);
return;
}
const tmp = path.join(OUT_ROOT, `.tmp-cut-${index}.jpg`);
fs.writeFileSync(tmp, enhanced);
let cutoutBuf: Buffer | null = null;
try {
for (const model of ['medium', 'small'] as const) {
const attempt = await cutout(tmp, model);
if (await cutoutValid(attempt)) {
cutoutBuf = attempt;
break;
}
}
} finally {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
}
if (!cutoutBuf) {
const out = await stylizeWithVignette(enhanced, index);
fs.writeFileSync(destPath, out);
return;
}
const plateBuf = await sharp(Buffer.from(plateSvg(index))).png().toBuffer();
const food = await beautifyFood(cutoutBuf, PLATE_R * 1.42, PLATE_R * 1.18);
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 + 6);
const shadow = await sharp(
Buffer.from(`<svg width="${CANVAS_W}" height="${CANVAS_H}">
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + fh * 0.18}" rx="${fw * 0.4}" ry="${fh * 0.09}" fill="#8A8278" opacity="0.2"/>
</svg>`)
)
.png()
.toBuffer();
const out = await sharp(plateBuf)
.composite([
{ input: shadow, top: 0, left: 0 },
{ input: food, top, left },
])
.sharpen({ sigma: 0.35, m1: 0.3, m2: 0.15 })
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
fs.writeFileSync(destPath, out);
}
async function polishDish(imagePath: string): Promise<void> {
const tmp = `${imagePath}.polish.jpg`;
await sharp(imagePath, { failOn: 'none' })
.rotate()
.toColorspace('srgb')
.normalize()
.modulate({ brightness: 1.04, saturation: 1.16 })
.gamma(1.03)
.sharpen({ sigma: 0.75, m1: 0.5, m2: 0.25 })
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toFile(tmp);
fs.renameSync(tmp, imagePath);
}
async function polishPoster(imagePath: string): Promise<void> {
const tmp = `${imagePath}.polish.jpg`;
await sharp(imagePath, { failOn: 'none' })
.rotate()
.toColorspace('srgb')
.normalize()
.modulate({ brightness: 1.02, saturation: 1.1 })
.sharpen({ sigma: 0.5 })
.jpeg({ quality: 95, mozjpeg: true })
.toFile(tmp);
fs.renameSync(tmp, imagePath);
}
async function main() {
const onlyArg = process.argv.find((a) => a.startsWith('--only='));
const onlyFilter = onlyArg?.slice('--only='.length);
let images = collectImages(OUT_ROOT).sort();
if (onlyFilter) {
images = images.filter((p) => path.relative(OUT_ROOT, p).includes(onlyFilter));
}
let sweets = 0;
let dishes = 0;
let posters = 0;
console.log(`Polishing ${images.length} images in ${OUT_ROOT}\n`);
for (let i = 0; i < images.length; i++) {
const img = images[i];
const rel = path.relative(OUT_ROOT, img);
try {
if (rel.includes(SWEETS_MARKER)) {
await stylizeSweet(img, rel, i);
sweets++;
console.log(` ✓ sweet ${rel}`);
} else if (rel.includes('/others/')) {
await polishPoster(img);
posters++;
console.log(` ✓ poster ${rel}`);
} else {
await polishDish(img);
dishes++;
console.log(` ✓ dish ${rel}`);
}
} catch (err) {
console.error(`${rel}: ${err instanceof Error ? err.message : err}`);
}
}
console.log(`\nDone: ${sweets} sweets restyled, ${dishes} dishes polished, ${posters} posters`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});