325 lines
11 KiB
TypeScript
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);
|
|
}); |