Files
shahikitchen-prod/scripts/rebuild-ftp-images-new.ts

270 lines
8.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Rebuild ~/Desktop/ftp-images-new from original ftp-images sources.
* Fixes blur + loading issues: single encode, baseline JPEG, no blur masks, higher resolution.
*/
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 OUT_W = 2400;
const OUT_H = 1800;
const PLATE_CX = OUT_W / 2;
const PLATE_CY = OUT_H / 2 + 24;
const PLATE_R = 660;
const JPEG = {
quality: 96,
mozjpeg: false,
progressive: false,
chromaSubsampling: '4:4:4' as const,
};
const HARD_CROP_SWEETS = new Set([
'bilder-bp/sweets/patisa/bild/IMG_1400.jpg',
'bilder-bp/sweets/namak-paray/bild/IMG_0209.jpg',
]);
function platePng(seed: number): Buffer {
const specks: string[] = [];
let s = seed + 17;
const rand = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
const colors = ['#4A7EBB', '#E8843C', '#C4A882', '#6B9E78', '#D4A574'];
for (let i = 0; i < 64; i++) {
const a = rand() * Math.PI * 2;
const d = rand() * (PLATE_R - 80);
const x = PLATE_CX + Math.cos(a) * d;
const y = PLATE_CY + Math.sin(a) * d;
specks.push(
`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(3 + rand() * 8).toFixed(1)}" fill="${colors[i % colors.length]}" opacity="0.5"/>`
);
}
const svg = `<svg width="${OUT_W}" height="${OUT_H}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#FFFFFF"/>
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + 42}" rx="${PLATE_R + 30}" ry="${PLATE_R * 0.14}" fill="#D8D0C4" opacity="0.35"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 14}" fill="#FFFFFF"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 10}" fill="none" stroke="#C9A227" stroke-width="11"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="#FFFDF8"/>
<clipPath id="c"><circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}"/></clipPath>
<g clip-path="url(#c)">${specks.join('')}</g>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R - 28}" fill="none" stroke="#EDE4D4" stroke-width="2"/>
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="none" stroke="#B8922A" stroke-width="5"/>
</svg>`;
return Buffer.from(svg);
}
function collectImages(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const full = path.join(dir, e.name);
if (e.isDirectory()) return collectImages(full);
if (/\.(jpe?g|png|webp)$/i.test(e.name) && !e.name.includes('.polish.')) return [full];
return [];
});
}
function findSource(rel: string): string | null {
const dir = path.dirname(path.join(SRC_ROOT, rel));
const base = path.basename(rel, path.extname(rel));
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 preparePhoto(src: string): Promise<Buffer> {
const rotated = sharp(src, { failOn: 'none', unlimited: true }).rotate();
const { data, info } = await rotated.toColorspace('srgb').removeAlpha().toBuffer({ resolveWithObject: true });
const w = info.width;
const h = info.height;
const aspect = OUT_W / OUT_H;
const sa = w / h;
let cropW = w;
let cropH = h;
let left = 0;
let top = 0;
if (sa > aspect) {
cropW = Math.round(h * aspect);
left = Math.round((w - cropW) / 2);
} else if (sa < 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 })
.modulate({ brightness: 1.02, saturation: 1.1 });
if (minDim < 1200) {
pipe = pipe.sharpen({ sigma: 1, m1: 0.6, m2: 0.3 });
} else {
pipe = pipe.sharpen({ sigma: 0.6, m1: 0.4, m2: 0.2 });
}
return pipe
.resize(OUT_W, OUT_H, { fit: 'fill', kernel: sharp.kernel.lanczos3 })
.png()
.toBuffer();
}
async function writeJpeg(buf: Buffer, dest: string): Promise<void> {
fs.mkdirSync(path.dirname(dest), { recursive: true });
await sharp(buf).jpeg(JPEG).toFile(dest);
}
async function rebuildDish(src: string, dest: string): Promise<void> {
const photo = await preparePhoto(src);
await writeJpeg(photo, dest);
}
async function cutoutFromPng(pngPath: string): Promise<Buffer> {
const blob = await removeBackground(pngPath, { model: 'small' });
return Buffer.from(await blob.arrayBuffer());
}
async function cutoutOk(buf: Buffer): Promise<boolean> {
const { data, info } = await sharp(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const w = info.width ?? 1;
const h = info.height ?? 1;
let o = 0;
let t = 0;
for (let y = Math.floor(h * 0.15); y < Math.floor(h * 0.85); y++) {
for (let x = Math.floor(w * 0.15); x < Math.floor(w * 0.85); x++) {
if (data[(y * w + x) * 4 + 3] > 50) o++;
t++;
}
}
return o / t > 0.06;
}
async function placeOnPlate(foodPng: Buffer, index: number): Promise<Buffer> {
const meta = await sharp(foodPng).metadata();
const cw = meta.width ?? 1;
const ch = meta.height ?? 1;
const maxW = PLATE_R * 1.05;
const maxH = PLATE_R * 0.88;
const scale = Math.min(maxW / cw, maxH / ch, 1);
const food = await sharp(foodPng)
.resize(Math.round(cw * scale), Math.round(ch * scale), { kernel: sharp.kernel.lanczos3 })
.png()
.toBuffer();
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 + 4);
const shadow = Buffer.from(`<svg width="${OUT_W}" height="${OUT_H}">
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + fh * 0.17}" rx="${fw * 0.38}" ry="${fh * 0.085}" fill="#A09890" opacity="0.14"/>
</svg>`);
return sharp(platePng(index))
.composite([
{ input: await sharp(shadow).png().toBuffer(), top: 0, left: 0 },
{ input: food, top, left },
])
.png()
.toBuffer();
}
async function hardCropFood(photo: Buffer): Promise<Buffer> {
const meta = await sharp(photo).metadata();
const w = meta.width ?? OUT_W;
const h = meta.height ?? OUT_H;
const cw = Math.round(w * 0.62);
const ch = Math.round(h * 0.62);
return sharp(photo)
.extract({ left: Math.round((w - cw) / 2), top: Math.round((h - ch) / 2), width: cw, height: ch })
.png()
.toBuffer();
}
async function rebuildSweet(src: string, dest: string, rel: string, index: number): Promise<void> {
const photo = await preparePhoto(src);
const tmp = path.join(OUT_ROOT, `.tmp-${index}.png`);
fs.writeFileSync(tmp, photo);
try {
let food: Buffer;
if (HARD_CROP_SWEETS.has(rel)) {
food = await hardCropFood(photo);
} else {
const cut = await cutoutFromPng(tmp);
food = (await cutoutOk(cut)) ? cut : await hardCropFood(photo);
}
const composed = await placeOnPlate(food, index);
await writeJpeg(composed, dest);
} finally {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
}
}
async function verifyJpeg(file: string): Promise<boolean> {
try {
const meta = await sharp(file).metadata();
return (meta.width ?? 0) > 0 && (meta.height ?? 0) > 0;
} catch {
return false;
}
}
async function main() {
const images = collectImages(OUT_ROOT).sort();
let ok = 0;
let fail = 0;
console.log(`Rebuilding ${images.length} images → ${OUT_W}×${OUT_H} baseline JPEG\n`);
for (let i = 0; i < images.length; i++) {
const dest = images[i];
const rel = path.relative(OUT_ROOT, dest);
const src = findSource(rel);
if (!src) {
console.error(`${rel}: no source`);
fail++;
continue;
}
try {
if (rel.includes(SWEETS_MARKER)) {
await rebuildSweet(src, dest, rel, i);
} else {
await rebuildDish(src, dest);
}
if (!(await verifyJpeg(dest))) throw new Error('invalid output JPEG');
const stat = fs.statSync(dest);
if (stat.size < 8000) throw new Error('file too small');
ok++;
console.log(`${rel} (${Math.round(stat.size / 1024)}KB)`);
} catch (err) {
fail++;
console.error(`${rel}: ${err instanceof Error ? err.message : err}`);
}
}
console.log(`\nDone: ${ok} rebuilt, ${fail} failed`);
if (fail) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});