270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
/**
|
||
* 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);
|
||
}); |