Replace entire repo content with correct code from /root/shahikitchen-website/

This commit is contained in:
root
2026-06-29 16:22:09 +00:00
parent 57cc67d2d3
commit 50e3a34895
723 changed files with 20055 additions and 26101 deletions
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env python3
"""Prepare a sharp, full-frame beef boneless product photo for the shop."""
from __future__ import annotations
import argparse
import urllib.request
from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps, ImageStat
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "public" / "images" / "site"
OUT_PATH = SITE / "beef-boneless.jpg"
SOURCE_CACHE = SITE / "_beef_boneless_src.jpg"
# High-res premium raw boneless beef (Unsplash)
PRIMARY_URL = (
"https://images.unsplash.com/photo-1603048297172-c92544798d5a"
"?auto=format&fit=crop&w=1800&h=1800&crop=center&q=95"
)
# Original product reference (lower res fallback)
FALLBACK_URL = (
"https://www.hotcurrymarket.fi/wp-content/uploads/2025/01/"
"FRESH-BEEF-BONELESS-WITHOUT-FAT-1KG.jpg"
)
OUTPUT_SIZE = (1400, 1400)
def download(url: str, dest: Path) -> None:
req = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; Kottgard-site-builder/1.0)"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
dest.write_bytes(resp.read())
def trim_near_white(img: Image.Image, tolerance: int = 18) -> Image.Image:
"""Remove excess light borders from the source photo."""
rgb = img.convert("RGB")
pixels = rgb.load()
w, h = rgb.size
def row_is_border(y: int) -> bool:
samples = [pixels[x, y] for x in range(0, w, max(1, w // 24))]
return all(min(p) >= 255 - tolerance for p in samples)
def col_is_border(x: int) -> bool:
samples = [pixels[x, y] for y in range(0, h, max(1, h // 24))]
return all(min(p) >= 255 - tolerance for p in samples)
top = 0
while top < h and row_is_border(top):
top += 1
bottom = h - 1
while bottom > top and row_is_border(bottom):
bottom -= 1
left = 0
while left < w and col_is_border(left):
left += 1
right = w - 1
while right > left and col_is_border(right):
right -= 1
if right - left > 80 and bottom - top > 80:
return rgb.crop((left, top, right + 1, bottom + 1))
return rgb
def enhance_photo(img: Image.Image) -> Image.Image:
img = ImageOps.autocontrast(img, cutoff=0.5)
img = ImageEnhance.Brightness(img).enhance(1.03)
img = ImageEnhance.Contrast(img).enhance(1.08)
img = ImageEnhance.Color(img).enhance(1.1)
img = ImageEnhance.Sharpness(img).enhance(1.35)
return img.filter(ImageFilter.UnsharpMask(radius=1.0, percent=110, threshold=2))
def crop_center_cover(img: Image.Image, size: tuple[int, int]) -> Image.Image:
tw, th = size
w, h = img.size
scale = max(tw / w, th / h)
nw, nh = int(w * scale), int(h * scale)
resized = img.resize((nw, nh), Image.Resampling.LANCZOS)
left = (nw - tw) // 2
top = (nh - th) // 2
return resized.crop((left, top, left + tw, top + th))
def prepare_product_image(source: Path, output: Path = OUT_PATH) -> None:
beef = Image.open(source).convert("RGB")
beef = trim_near_white(beef)
beef = enhance_photo(beef)
# Full-frame crop — meat fills the product image (no tiny subject in white void)
beef = crop_center_cover(beef, OUTPUT_SIZE)
beef.save(output, "JPEG", quality=96, optimize=True, subsampling=0)
print(f"Saved product photo {OUTPUT_SIZE[0]}x{OUTPUT_SIZE[1]}{output}")
def main() -> None:
parser = argparse.ArgumentParser(description="Prepare beef boneless product photo")
parser.add_argument("--url", default=PRIMARY_URL)
parser.add_argument("--input", type=Path)
parser.add_argument("--output", type=Path, default=OUT_PATH)
parser.add_argument("--use-cache", action="store_true")
parser.add_argument("--fallback", action="store_true", help="Use hot curry source")
args = parser.parse_args()
if args.input:
src = args.input
elif args.use_cache and SOURCE_CACHE.exists():
src = SOURCE_CACHE
print(f"Using cached source → {src}")
else:
url = FALLBACK_URL if args.fallback else args.url
print(f"Downloading source image…\n {url}")
try:
download(url, SOURCE_CACHE)
except Exception as exc:
print(f"Primary download failed ({exc}), trying fallback…")
download(FALLBACK_URL, SOURCE_CACHE)
src = SOURCE_CACHE
prepare_product_image(src, args.output)
if __name__ == "__main__":
main()
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
# Production build must not reuse a dev .next folder.
set -euo pipefail
cd "$(dirname "$0")/.."
pkill -f "next dev" 2>/dev/null || true
rm -rf .next
echo "Building production bundle (clean cache)…"
exec npx next build
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
#
# Shahi Kitchen Production Deploy Script
# Run as: sudo -u deploy /var/www/shahikitchen.se/scripts/deploy.sh
#
# This script supports two modes:
# 1. Tarball mode (current primary method): Place new shahi.tar.gz in /tmp or /root and run
# 2. Git mode (if you initialize git in the future)
#
set -euo pipefail
APP_DIR="/var/www/shahikitchen.se"
PM2_APP_NAME="shahikitchen"
PORT=3001
echo "=========================================="
echo " Shahi Kitchen - Production Deploy"
echo " $(date)"
echo "=========================================="
cd "$APP_DIR"
# --- Detect mode ---
if [ -f "/tmp/shahi.tar.gz" ] || [ -f "/root/shahi.tar.gz" ]; then
echo "[1/6] Tarball update detected"
TARBALL=""
if [ -f "/tmp/shahi.tar.gz" ]; then TARBALL="/tmp/shahi.tar.gz"; fi
if [ -f "/root/shahi.tar.gz" ]; then TARBALL="/root/shahi.tar.gz"; fi
echo "Using tarball: $TARBALL"
echo "Stopping PM2 app (graceful)..."
pm2 stop "$PM2_APP_NAME" || true
echo "Backing up current .next (quick safety)..."
rm -rf .next.bak 2>/dev/null || true
cp -a .next .next.bak 2>/dev/null || true
echo "Extracting new tarball..."
tar --strip-components=1 -xzf "$TARBALL"
echo "Cleaning shipped node_modules + cache..."
rm -rf node_modules .next/cache 2>/dev/null || true
echo "Running npm ci..."
npm ci
echo "Building..."
npm run build
elif git rev-parse --git-dir > /dev/null 2>&1; then
echo "[1/6] Git update mode"
pm2 stop "$PM2_APP_NAME" || true
git fetch --all
git reset --hard origin/main || git reset --hard origin/master
npm ci
npm run build
else
echo "ERROR: No tarball found in /tmp or /root, and no git repository."
echo "Please either:"
echo " - scp your new shahi.tar.gz to the server, or"
echo " - Run: cp /path/to/shahi.tar.gz /tmp/shahi.tar.gz"
exit 1
fi
echo "[2/6] Dependencies and build complete"
echo "[3/6] Starting / restarting PM2..."
pm2 start ecosystem.config.cjs --only "$PM2_APP_NAME" || pm2 reload "$PM2_APP_NAME" --update-env || true
pm2 save
echo "[4/6] Reloading Nginx..."
sudo nginx -t && sudo systemctl reload nginx
echo "[5/6] Post-deploy health checks..."
echo "PM2 status:"
pm2 list | grep -E "shahikitchen|App name" || true
echo ""
echo "Testing local Next.js process..."
curl -s --max-time 5 "http://127.0.0.1:${PORT}" | head -c 300 || echo "(first request may be slow)"
echo ""
echo "[6/6] Deploy finished successfully at $(date)"
echo "=========================================="
echo "Website should be live at: http://76.13.210.183"
echo "=========================================="
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
# Always start dev with a fresh .next cache — prevents missing vendor-chunks (zustand.js).
set -euo pipefail
cd "$(dirname "$0")/.."
pkill -f "next dev" 2>/dev/null || true
if lsof -ti:3000 >/dev/null 2>&1; then
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
sleep 1
fi
rm -rf .next
echo "Starting Next.js dev server (clean cache)…"
exec npx next dev
+145
View File
@@ -0,0 +1,145 @@
/**
* Enhance all images under ftp-images/ and write to ~/Desktop/ftp-images-new
* preserving directory structure. Non-image files are copied as-is.
*/
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const ROOT = path.resolve(__dirname, '..');
const SRC_ROOT = path.join(ROOT, 'ftp-images');
const OUT_ROOT = path.join(process.env.HOME ?? '', 'Desktop', 'ftp-images-new');
const IMAGE_RE = /\.(jpe?g|png|webp)$/i;
const TARGET_W = 1600;
const TARGET_H = 1200;
const JPEG_QUALITY = 92;
function walk(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return [full];
});
}
function isPosterPath(rel: string): boolean {
return rel.includes('/others/') || /poster/i.test(rel);
}
async function enhanceFoodPhoto(input: string, output: string) {
const rotated = sharp(input, { failOn: 'none' }).rotate();
const { data, info } = await rotated
.toColorspace('srgb')
.removeAlpha()
.toBuffer({ resolveWithObject: true });
const width = info.width;
const height = info.height;
const targetAspect = TARGET_W / TARGET_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);
let pipeline = sharp(data)
.extract({ left, top, width: cropW, height: cropH })
.normalize()
.modulate({ brightness: 1.04, saturation: 1.18 })
.gamma(1.05);
const minDim = Math.min(cropW, cropH);
if (minDim < 900) {
pipeline = pipeline.sharpen({ sigma: 1.2, m1: 0.8, m2: 0.4 });
} else {
pipeline = pipeline.sharpen({ sigma: 0.9, m1: 0.6, m2: 0.3 });
}
await pipeline
.resize(TARGET_W, TARGET_H, { fit: 'fill', kernel: sharp.kernel.lanczos3 })
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toFile(output);
}
async function enhancePoster(input: string, output: string) {
await sharp(input, { failOn: 'none' })
.rotate()
.toColorspace('srgb')
.normalize()
.modulate({ brightness: 1.02, saturation: 1.08 })
.sharpen({ sigma: 0.6, m1: 0.5, m2: 0.25 })
.resize({ width: 2400, withoutEnlargement: false, kernel: sharp.kernel.lanczos3 })
.jpeg({ quality: 94, mozjpeg: true })
.toFile(output);
}
async function processImage(src: string, rel: string) {
const outRel = rel.replace(IMAGE_RE, '.jpg');
const dest = path.join(OUT_ROOT, outRel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (isPosterPath(rel)) {
await enhancePoster(src, dest);
} else {
await enhanceFoodPhoto(src, dest);
}
return dest;
}
async function main() {
if (!fs.existsSync(SRC_ROOT)) {
console.error(`Source not found: ${SRC_ROOT}`);
process.exit(1);
}
fs.mkdirSync(OUT_ROOT, { recursive: true });
const files = walk(SRC_ROOT);
let images = 0;
let copied = 0;
for (const src of files) {
const rel = path.relative(SRC_ROOT, src);
if (IMAGE_RE.test(src)) {
try {
const dest = await processImage(src, rel);
images++;
console.log(`${rel}${path.basename(dest)}`);
} catch (err) {
console.error(`${rel}: ${err instanceof Error ? err.message : err}`);
}
continue;
}
const dest = path.join(OUT_ROOT, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
copied++;
}
console.log(`\nDone: ${images} images enhanced → ${OUT_ROOT}`);
console.log(` ${copied} non-image files copied`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Generate 80 QR code SVGs for table ordering (40 Backaplan + 40 Askim).
*
* Usage:
* node scripts/generate-table-qr-codes.mjs
*
* Output:
* public/images/booking/backaplan/qr-backaplan-001.svg ... qr-backaplan-040.svg
* public/images/booking/askim/qr-askim-001.svg ... qr-askim-040.svg
*
* Each QR points to:
* https://shahikitchen.se/orderfromtable?table=backaplan-001
* https://shahikitchen.se/orderfromtable?table=askim-001
* etc.
*/
import QRCode from 'qrcode';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const BASE_URL = 'https://shahikitchen.se/orderfromtable';
const BRANCHES = [
{ key: 'backaplan', label: 'Backaplan', dir: 'backaplan' },
{ key: 'askim', label: 'Askim', dir: 'askim' },
];
const MIN_TABLE = 1;
const MAX_TABLE = 40;
function pad3(n) {
return String(n).padStart(3, '0');
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function generateOne(branchKey, tableNum, outputPath) {
const tableId = `${branchKey}-${pad3(tableNum)}`;
const url = `${BASE_URL}?table=${tableId}`;
const svg = await QRCode.toString(url, {
type: 'svg',
width: 320,
margin: 2,
errorCorrectionLevel: 'Q', // Good balance for physical use (tables, stands, possibly dirty)
color: {
dark: '#111111', // near-black for high contrast print
light: '#FFFFFF',
},
});
// Add a small metadata comment (harmless in SVG)
const svgWithMeta = svg.replace(
'<svg ',
`<!-- Shahi Kitchen Table QR: ${tableId} -->\n<svg `
);
await fs.writeFile(outputPath, svgWithMeta, 'utf8');
console.log(`${tableId}${path.relative(ROOT, outputPath)}`);
}
async function main() {
console.log('Generating Shahi Kitchen table QR codes (80 total)...\n');
for (const branch of BRANCHES) {
const outDir = path.join(ROOT, 'public', 'images', 'booking', branch.dir);
await ensureDir(outDir);
for (let i = MIN_TABLE; i <= MAX_TABLE; i++) {
const filename = `qr-${branch.key}-${pad3(i)}.svg`;
const fullPath = path.join(outDir, filename);
await generateOne(branch.key, i, fullPath);
}
console.log(''); // blank line between branches
}
console.log('All 80 QR codes generated successfully.');
console.log('They all target the single /orderfromtable page with ?table=... param.');
}
main().catch((err) => {
console.error('QR generation failed:', err);
process.exit(1);
});
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""Compress site images to <= 100 KB while preserving original colors."""
from __future__ import annotations
import argparse
import io
import shutil
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "public" / "images" / "site"
BACKUP = SITE / "_originals"
SKIP = {"logo.jpeg", "README.txt"}
SKIP_PATTERNS = (" copy", "-1.jpg")
def should_skip(path: Path) -> bool:
if path.name in SKIP or path.name.startswith("_"):
return True
return any(p in path.name for p in SKIP_PATTERNS)
def encode_jpeg(img: Image.Image, quality: int) -> bytes:
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
return buf.getvalue()
def compress_to_target(img: Image.Image, max_bytes: int) -> tuple[Image.Image, int]:
working = img.convert("RGB")
scale = 1.0
while scale >= 0.35:
if scale < 1.0:
w, h = working.size
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
candidate = working.resize((nw, nh), Image.Resampling.LANCZOS)
else:
candidate = working
lo, hi = 20, 92
best_q = lo
best_data = encode_jpeg(candidate, lo)
while lo <= hi:
mid = (lo + hi) // 2
data = encode_jpeg(candidate, mid)
if len(data) <= max_bytes:
best_q = mid
best_data = data
lo = mid + 1
else:
hi = mid - 1
if len(best_data) <= max_bytes:
return Image.open(io.BytesIO(best_data)).convert("RGB"), best_q
scale *= 0.88
data = encode_jpeg(candidate, 20)
return Image.open(io.BytesIO(data)).convert("RGB"), 20
def optimize_file(path: Path, max_bytes: int, dry_run: bool) -> tuple[int, int]:
before = path.stat().st_size
if before <= max_bytes:
return before, before
with Image.open(path) as img:
out, quality = compress_to_target(img, max_bytes)
if dry_run:
buf = io.BytesIO()
out.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
return before, len(buf.getvalue())
out.save(path, format="JPEG", quality=quality, optimize=True, subsampling=2)
return before, path.stat().st_size
def main() -> None:
parser = argparse.ArgumentParser(description="Optimize site images to <= 100 KB")
parser.add_argument("--max-kb", type=int, default=100)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
max_bytes = args.max_kb * 1024
BACKUP.mkdir(exist_ok=True)
files = sorted(SITE.glob("*.jpg")) + sorted(SITE.glob("*.jpeg"))
changed = 0
for path in files:
if should_skip(path):
continue
backup = BACKUP / path.name
if not backup.exists() and not args.dry_run:
shutil.copy2(path, backup)
before, after = optimize_file(path, max_bytes, args.dry_run)
if after < before or before > max_bytes:
changed += 1
print(
f"{'~' if args.dry_run else ''} {path.name}: "
f"{before // 1024} KB → {after // 1024} KB"
)
else:
print(f"{path.name}: {before // 1024} KB (ok)")
print(f"\nDone — {changed} optimized, originals in {BACKUP}")
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* Compress sweets modal videos → uniform 640×480 (4:3), ≤ 200 KB, no audio.
* Usage:
* node scripts/optimize-sweets-videos.mjs
* node scripts/optimize-sweets-videos.mjs public/videos/badam-barfi.mp4
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
import ffmpegPath from 'ffmpeg-static';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const VIDEOS_DIR = path.join(ROOT, 'public/videos');
const MENU_DATA = path.join(ROOT, 'infrastructure/menu/static-menu-data.ts');
const REPORT_PATH = path.join(__dirname, 'sweets-videos-optimize-report.json');
const MAX_BYTES = 200 * 1024;
const TARGET_WIDTH = 640;
const TARGET_HEIGHT = 480;
/** Sweets dish ids with video fields in menu data */
function getSweetsVideoFiles() {
const content = fs.readFileSync(MENU_DATA, 'utf8');
const sweetsIdx = content.indexOf('id: "sweets"');
const itemsStart = content.indexOf('items: [', sweetsIdx);
let depth = 0;
let itemsEnd = -1;
for (let i = itemsStart + 7; i < content.length; i++) {
if (content[i] === '[') depth++;
else if (content[i] === ']') {
depth--;
if (depth === 0) {
itemsEnd = i;
break;
}
}
}
const block = content.slice(itemsStart, itemsEnd + 1);
return [...block.matchAll(/video:\s*"([^"]+\.mp4)"/g)].map((m) => m[1]);
}
function compressVideo(inputPath, outputPath, { crf, fps, width, height }) {
const vf = `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},fps=${fps}`;
const result = spawnSync(
ffmpegPath,
[
'-y',
'-i', inputPath,
'-an',
'-vf', vf,
'-c:v', 'libx264',
'-preset', 'medium',
'-profile:v', 'baseline',
'-level', '3.0',
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
'-crf', String(crf),
outputPath,
],
{ stdio: 'pipe' },
);
return result.status === 0 && fs.existsSync(outputPath);
}
function optimizeOne(inputPath) {
if (!fs.existsSync(inputPath)) return { skipped: true, reason: 'missing' };
const temp = `${inputPath}.opt.tmp.mp4`;
const attempts = [
{ crf: 34, fps: 24, width: TARGET_WIDTH, height: TARGET_HEIGHT },
{ crf: 36, fps: 20, width: TARGET_WIDTH, height: TARGET_HEIGHT },
{ crf: 38, fps: 18, width: TARGET_WIDTH, height: TARGET_HEIGHT },
{ crf: 40, fps: 15, width: 560, height: 420 },
{ crf: 42, fps: 12, width: 480, height: 360 },
{ crf: 44, fps: 10, width: 400, height: 300 },
];
let best = null;
for (const opts of attempts) {
if (fs.existsSync(temp)) fs.unlinkSync(temp);
if (!compressVideo(inputPath, temp, opts)) continue;
const bytes = fs.statSync(temp).size;
const entry = { ...opts, bytes, kb: Number((bytes / 1024).toFixed(1)) };
if (bytes <= MAX_BYTES) {
fs.renameSync(temp, inputPath);
return { ok: true, ...entry, warning: null };
}
if (!best || bytes < best.bytes) best = entry;
}
if (best && fs.existsSync(temp)) {
fs.renameSync(temp, inputPath);
return { ok: true, ...best, warning: 'exceeds-200kb' };
}
if (fs.existsSync(temp)) fs.unlinkSync(temp);
return { ok: false, reason: 'encode-failed' };
}
function main() {
if (!ffmpegPath) throw new Error('ffmpeg-static not available');
const arg = process.argv[2];
const files = arg
? [path.basename(arg).endsWith('.mp4') ? path.basename(arg) : `${arg}.mp4`]
: getSweetsVideoFiles();
console.log('Shahi Kitchen — sweets video optimize');
console.log(`Target: ${TARGET_WIDTH}×${TARGET_HEIGHT}, ≤ ${MAX_BYTES / 1024} KB\n`);
const results = [];
for (const file of files) {
const fullPath = path.join(VIDEOS_DIR, file);
const before = fs.existsSync(fullPath) ? fs.statSync(fullPath).size : 0;
const r = optimizeOne(fullPath);
if (r.skipped) {
console.log(`⚠ skip ${file} (${r.reason})`);
results.push({ file, skipped: true, reason: r.reason });
continue;
}
if (!r.ok) {
console.log(`${file} (${r.reason})`);
results.push({ file, ok: false, reason: r.reason });
continue;
}
const after = fs.statSync(fullPath).size;
const warn = r.warning ? ' ⚠' : '';
console.log(
`${file}: ${(before / 1024).toFixed(0)} KB → ${(after / 1024).toFixed(1)} KB ` +
`(${r.width}×${r.height}, crf ${r.crf}, ${r.fps}fps)${warn}`,
);
results.push({
file,
ok: true,
beforeKb: Number((before / 1024).toFixed(1)),
afterKb: Number((after / 1024).toFixed(1)),
width: r.width,
height: r.height,
crf: r.crf,
fps: r.fps,
warning: r.warning,
});
}
fs.writeFileSync(REPORT_PATH, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2));
console.log(`\nReport: ${REPORT_PATH}`);
}
main();
+431
View File
@@ -0,0 +1,431 @@
/**
* 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);
});
-53
View File
@@ -1,53 +0,0 @@
#!/bin/bash
# Re-download default images into public/images/site/ (only image folder used by the site)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SITE="$ROOT/public/images/site"
mkdir -p "$SITE"
dl() {
curl -fsSL "$1" -o "$2"
}
# Keep existing logo if present; otherwise skip (add logo.jpeg manually)
if [[ ! -f "$SITE/logo.jpeg" ]]; then
echo "Note: add logo.jpeg to $SITE manually if missing."
fi
dl "https://images.unsplash.com/photo-1607623814075-e51df1bdc82f?auto=format&fit=crop&w=2560&q=90" "$SITE/hero.jpg"
dl "https://images.unsplash.com/photo-1529692236671-f1f6cf9683ba?auto=format&fit=crop&w=1920&q=90" "$SITE/about.jpg"
cp "$SITE/hero.jpg" "$SITE/weekly-offers.jpg"
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&q=90" "$SITE/category-chicken.jpg"
dl "https://images.unsplash.com/photo-1558030006-450675393462?auto=format&fit=crop&w=1400&q=90" "$SITE/category-beef.jpg"
dl "https://images.unsplash.com/photo-1615937657715-bc7b4b7962c1?auto=format&fit=crop&w=1400&q=90" "$SITE/category-lamb.jpg"
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&q=90" "$SITE/category-fish.jpg"
dl "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?auto=format&fit=crop&w=1400&q=90" "$SITE/chicken-whole.jpg"
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/chicken-whole-2.jpg"
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/chicken-breast.jpg"
cp "$SITE/chicken-whole.jpg" "$SITE/chicken-breast-2.jpg"
dl "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/chicken-thighs.jpg"
dl "https://images.unsplash.com/photo-1587593810167-a84920ea0781?auto=format&fit=crop&w=1400&h=900&crop=top&q=90" "$SITE/chicken-wings.jpg"
dl "https://images.unsplash.com/photo-1546833999-b9f581a1996d?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-nihari.jpg"
dl "https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-nihari-2.jpg"
dl "https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-steak.jpg"
dl "https://images.unsplash.com/photo-1546833999-b9f581a1996d?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-steak-2.jpg"
dl "https://images.unsplash.com/photo-1603048297172-c92544798d5a?auto=format&fit=crop&w=1400&q=90" "$SITE/beef-mince.jpg"
dl "https://images.unsplash.com/photo-1558030006-450675393462?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/beef-boneless.jpg"
dl "https://images.unsplash.com/photo-1615937657715-bc7b4b7962c1?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-shoulder.jpg"
dl "https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-shoulder-2.jpg"
dl "https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-leg.jpg"
dl "https://images.unsplash.com/photo-1574672280600-4accfa5b6f98?auto=format&fit=crop&w=1400&q=90" "$SITE/lamb-chops.jpg"
dl "https://images.unsplash.com/photo-1574672280600-4accfa5b6f98?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/lamb-mince.jpg"
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-salmon.jpg"
dl "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/fish-salmon-2.jpg"
dl "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/fish-rohu.jpg"
dl "https://images.unsplash.com/photo-1565680018434-b513d5e5fd47?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-prawns.jpg"
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&h=900&crop=center&q=90" "$SITE/fish-basa.jpg"
echo "Done. $(ls -1 "$SITE" | wc -l | tr -d ' ') files in $SITE"
-213
View File
@@ -1,213 +0,0 @@
#!/usr/bin/env python3
"""Normalize site images: square canvas, HALAL badge + Kött Gård logo. Colors preserved."""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "public" / "images" / "site"
BACKUP = SITE / "_originals"
LOGO_PATH = SITE / "logo.jpeg"
BRAND_NAME = "Kött Gård"
SIZE = (1400, 1400)
BG = (255, 255, 255)
BURGUNDY = (139, 31, 31)
GOLD = (212, 175, 55)
SKIP = {"logo.jpeg", "README.txt"}
SKIP_PATTERNS = (" copy", "-1.jpg") # duplicate uploads from user
FILL_PRODUCT = 0.9
FILL_PAGE = 0.94
PAGE_IMAGES = {"hero.jpg", "about.jpg", "weekly-offers.jpg"}
CATEGORY_IMAGES = {
"category-chicken.jpg",
"category-beef.jpg",
"category-lamb.jpg",
"category-fish.jpg",
}
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
paths = [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"
if bold
else "/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
]
for p in paths:
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def preserve_colors(img: Image.Image) -> Image.Image:
"""Keep original photo pixels — no contrast, brightness, or color tweaks."""
return img.convert("RGB")
def prep_logo(diameter: int) -> Image.Image:
logo = Image.open(LOGO_PATH).convert("RGBA")
w, h = logo.size
side = min(w, h)
logo = logo.crop(((w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2))
logo = logo.resize((diameter, diameter), Image.Resampling.LANCZOS)
mask = Image.new("L", (diameter, diameter), 0)
ImageDraw.Draw(mask).ellipse((0, 0, diameter - 1, diameter - 1), fill=255)
logo.putalpha(mask)
return logo
def draw_halal_badge(canvas: Image.Image, x: int, y: int, size: int = 130) -> None:
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.ellipse((x + 4, y + 5, x + size + 4, y + size + 5), fill=(0, 0, 0, 100))
draw.ellipse((x - 2, y - 2, x + size + 2, y + size + 2), fill=(*GOLD, 255))
draw.ellipse((x + 6, y + 6, x + size - 6, y + size - 6), fill=(255, 255, 255, 255))
draw.ellipse((x + 10, y + 10, x + size - 10, y + size - 10), outline=(*BURGUNDY, 255), width=4)
f_ar = load_font(24, bold=True)
f_halal = load_font(28, bold=True)
f_sub = load_font(11, bold=True)
ar = draw.textbbox((0, 0), "حلال", font=f_ar)
draw.text((x + (size - ar[2] + ar[0]) // 2, y + 22), "حلال", fill=(*BURGUNDY, 255), font=f_ar)
hl = draw.textbbox((0, 0), "HALAL", font=f_halal)
draw.text((x + (size - hl[2] + hl[0]) // 2, y + 52), "HALAL", fill=(*BURGUNDY, 255), font=f_halal)
ce = draw.textbbox((0, 0), "CERTIFIED", font=f_sub)
draw.text((x + (size - ce[2] + ce[0]) // 2, y + 92), "CERTIFIED", fill=(*GOLD, 255), font=f_sub)
canvas.alpha_composite(layer)
def draw_logo_badge(canvas: Image.Image, logo: Image.Image, x: int, y: int) -> None:
d = logo.size[0]
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.ellipse((x + 3, y + 4, x + d + 3, y + d + 4), fill=(0, 0, 0, 90))
draw.ellipse((x - 4, y - 4, x + d + 4, y + d + 4), fill=(255, 255, 255, 245), outline=(*GOLD, 255), width=3)
canvas.alpha_composite(layer)
canvas.alpha_composite(logo, (x, y))
def draw_brand_name(canvas: Image.Image, x: int, y: int) -> None:
"""Brand name pill beside the logo badge."""
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
font = load_font(22, bold=True)
bbox = draw.textbbox((0, 0), BRAND_NAME, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
pad_x, pad_y = 14, 8
box = (x - tw - pad_x * 2, y, x - 8, y + th + pad_y * 2)
draw.rounded_rectangle(box, radius=10, fill=(255, 255, 255, 235), outline=(*GOLD, 255), width=2)
draw.text((box[0] + pad_x, box[1] + pad_y - 2), BRAND_NAME, fill=(*BURGUNDY, 255), font=font)
canvas.alpha_composite(layer)
def already_branded(path: Path) -> bool:
"""Skip images already at catalog size (user uploads with logos baked in)."""
with Image.open(path) as img:
return img.size == SIZE
def should_skip(path: Path) -> bool:
if path.name in SKIP or path.name.startswith("_"):
return True
return any(p in path.name for p in SKIP_PATTERNS)
def compose(source: Path, dest: Path, logo: Image.Image) -> None:
photo = preserve_colors(Image.open(source))
fill = FILL_PAGE if dest.name in PAGE_IMAGES else FILL_PRODUCT
if dest.name in CATEGORY_IMAGES:
fill = 0.92
canvas = Image.new("RGBA", SIZE, (*BG, 255))
max_side = int(SIZE[0] * fill)
photo.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
shadow = Image.new("RGBA", photo.size, (0, 0, 0, 0))
sh_draw = ImageDraw.Draw(shadow)
sh_draw.rounded_rectangle(
(8, 12, photo.width - 8, photo.height - 4),
radius=18,
fill=(0, 0, 0, 35),
)
px = (SIZE[0] - photo.width) // 2
py = (SIZE[1] - photo.height) // 2 - 16
canvas.alpha_composite(shadow, (px, py + 6))
canvas.alpha_composite(photo.convert("RGBA"), (px, py))
margin = 28
draw_halal_badge(canvas, margin, margin)
logo_size = 108
logo_x = SIZE[0] - logo_size - margin
logo_y = SIZE[1] - logo_size - margin
draw_logo_badge(canvas, logo, logo_x, logo_y)
draw_brand_name(canvas, logo_x, logo_y + logo_size // 2 - 12)
canvas.convert("RGB").save(dest, "JPEG", quality=94, optimize=True, subsampling=0)
def ensure_catalog_files() -> None:
pairs = [
("beef-steak.jpg", "beef-steak-2.jpg"),
("beef-nihari-2.jpg", "beef-nihari.jpg"),
("category-lamb.jpg", "lamb-shoulder.jpg"),
]
for target, source in pairs:
t, s = SITE / target, SITE / source
if not t.exists() and s.exists():
shutil.copy2(s, t)
print(f"Created missing {target} from {source}")
def main() -> None:
parser = argparse.ArgumentParser(description="Brand site images (colors preserved)")
parser.add_argument("--force", action="store_true", help="Re-process even if already 1400×1400")
parser.add_argument("--only", nargs="*", help="Process only these filenames")
args = parser.parse_args()
if not LOGO_PATH.exists():
raise SystemExit(f"Missing logo: {LOGO_PATH}")
BACKUP.mkdir(exist_ok=True)
ensure_catalog_files()
logo = prep_logo(108)
files = sorted(SITE.glob("*.jpg"))
processed = 0
skipped = 0
for path in files:
if should_skip(path):
continue
if args.only and path.name not in args.only:
continue
if not args.force and already_branded(path):
print(f"{path.name} (already branded, skipped)")
skipped += 1
continue
backup = BACKUP / path.name
if not backup.exists():
shutil.copy2(path, backup)
source = backup if backup.exists() else path
compose(source, path, logo)
print(f"{path.name}")
processed += 1
print(f"\nDone — {processed} processed, {skipped} skipped. Originals in {BACKUP}")
if __name__ == "__main__":
main()
+270
View File
@@ -0,0 +1,270 @@
/**
* 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);
});
+325
View File
@@ -0,0 +1,325 @@
/**
* 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);
});
+347
View File
@@ -0,0 +1,347 @@
{
"generatedAt": "2026-06-29T13:19:53.507Z",
"dryRun": false,
"summary": {
"sourceFolders": 26,
"sweetsDishes": 29,
"matched": 26,
"unmatchedDishes": [
{
"id": "gajar-halwa",
"names": [
"Gajar Halwa"
],
"image": "gajar-halwa.jpg"
},
{
"id": "shahi-tukra",
"names": [
"Shahi Tukra"
],
"image": "shahi-tukra.jpg"
},
{
"id": "kulfi",
"names": [
"Kulfi"
],
"image": "kulfi.jpg"
}
],
"unmatchedFolders": []
},
"mappings": [
{
"dish": "Badam Barfi",
"dishId": "badam-barfi",
"sourceFolder": "badam-barfi",
"sourceFile": "badam-barfi.jpg",
"outputFile": "badam-barfi.jpg",
"imagePath": "/images/dishes/badam-barfi.jpg",
"kb": 51,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Besan Barfi",
"dishId": "baisan-barfi",
"sourceFolder": "basen-barfi",
"sourceFile": "basen-barfi.jpg",
"outputFile": "baisan-barfi.jpg",
"imagePath": "/images/dishes/baisan-barfi.jpg",
"kb": 61.54,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Besan Patisa",
"dishId": "baisan-patisa",
"sourceFolder": "besan-patisa",
"sourceFile": "besan-patisa.jpg",
"outputFile": "baisan-patisa.jpg",
"imagePath": "/images/dishes/baisan-patisa.jpg",
"kb": 63.9,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Cham Cham",
"dishId": "cham-cham",
"sourceFolder": "cham-cham",
"sourceFile": "cham-cham.jpg",
"outputFile": "cham-cham.jpg",
"imagePath": "/images/dishes/cham-cham.jpg",
"kb": 59.59,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Chocolate Barfi",
"dishId": "chocolate-barfi",
"sourceFolder": "chochlate-barfi",
"sourceFile": "chochlate-barfi.jpg",
"outputFile": "chocolate-barfi.jpg",
"imagePath": "/images/dishes/chocolate-barfi.jpg",
"kb": 61.1,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Coconut Barfi",
"dishId": "coconut-barfi",
"sourceFolder": "coconut-barfi",
"sourceFile": "coconut-barfi.jpg",
"outputFile": "coconut-barfi.jpg",
"imagePath": "/images/dishes/coconut-barfi.jpg",
"kb": 80.77,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Cream Gulab Jamun",
"dishId": "cream-gulab-jaman",
"sourceFolder": "cream-gulab-jaman",
"sourceFile": "cream-jamun.jpg",
"outputFile": "cream-gulab-jaman.jpg",
"imagePath": "/images/dishes/cream-gulab-jaman.jpg",
"kb": 67.13,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Gajar Barfi",
"dishId": "gajar-barfi",
"sourceFolder": "gajar-barfi",
"sourceFile": "gajar-halwa.jpg",
"outputFile": "gajar-barfi.jpg",
"imagePath": "/images/dishes/gajar-barfi.jpg",
"kb": 88.23,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Gulab Jamun",
"dishId": "gulab-jaman",
"sourceFolder": "gulab-jaman",
"sourceFile": "gol-jamun.jpg",
"outputFile": "gulab-jaman.jpg",
"imagePath": "/images/dishes/gulab-jaman.jpg",
"kb": 73.47,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Habshi Halwa",
"dishId": "habshi-halwa",
"sourceFolder": "habshi-halwa",
"sourceFile": "habshi-halwa.jpg",
"outputFile": "habshi-halwa.jpg",
"imagePath": "/images/dishes/habshi-halwa.jpg",
"kb": 77.89,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Jalebi",
"dishId": "jalebi",
"sourceFolder": "jalebi",
"sourceFile": "Jalebi.jpg",
"outputFile": "jalebi.jpg",
"imagePath": "/images/dishes/jalebi.jpg",
"kb": 77.57,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Laddu",
"dishId": "laddu",
"sourceFolder": "laddu",
"sourceFile": "laddu.jpg",
"outputFile": "laddu.jpg",
"imagePath": "/images/dishes/laddu.jpg",
"kb": 73.62,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Lambay Gulab Jamun",
"dishId": "lambay-gulab-jaman",
"sourceFolder": "lambay-gulab-jaman",
"sourceFile": "lambay-jamun.jpg",
"outputFile": "lambay-gulab-jaman.jpg",
"imagePath": "/images/dishes/lambay-gulab-jaman.jpg",
"kb": 83.35,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Milk Cake Akhrot",
"dishId": "milk-cake-akhrot",
"sourceFolder": "milk-cake-akhrot",
"sourceFile": "milk-cake-akhrot.jpg",
"outputFile": "milk-cake-akhrot.jpg",
"imagePath": "/images/dishes/milk-cake-akhrot.jpg",
"kb": 67.87,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Milk Cake Khajoor",
"dishId": "milk-cake-khajoor",
"sourceFolder": "milk-cake-khajoor",
"sourceFile": "milk-cake-khajoor.jpg",
"outputFile": "milk-cake-khajoor.jpg",
"imagePath": "/images/dishes/milk-cake-khajoor.jpg",
"kb": 85.51,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Milk Cake",
"dishId": "milk-cake-plain",
"sourceFolder": "milk-cake-plain",
"sourceFile": "milk-cake-plain.jpg",
"outputFile": "milk-cake-plain.jpg",
"imagePath": "/images/dishes/milk-cake-plain.jpg",
"kb": 69.26,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Namak Paray",
"dishId": "namakpare",
"sourceFolder": "namak-paray",
"sourceFile": "namak-paray.jpg",
"outputFile": "namakpare.jpg",
"imagePath": "/images/dishes/namakpare.jpg",
"kb": 60.59,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Paira",
"dishId": "paira",
"sourceFolder": "paira",
"sourceFile": "paira.jpg",
"outputFile": "paira.jpg",
"imagePath": "/images/dishes/paira.jpg",
"kb": 66.77,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Patisa",
"dishId": "patisa",
"sourceFolder": "patisa",
"sourceFile": "patisa.jpg",
"outputFile": "patisa.jpg",
"imagePath": "/images/dishes/patisa.jpg",
"kb": 58.62,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Pink Barfi",
"dishId": "pink-barfi",
"sourceFolder": "pink-barfi",
"sourceFile": "pink-barfi.jpg",
"outputFile": "pink-barfi.jpg",
"imagePath": "/images/dishes/pink-barfi.jpg",
"kb": 58.03,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Pistachio Barfi",
"dishId": "pistachio-barfi",
"sourceFolder": "pistacho-barfi",
"sourceFile": "pista-barfi.jpg",
"outputFile": "pistachio-barfi.jpg",
"imagePath": "/images/dishes/pistachio-barfi.jpg",
"kb": 61.41,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Plain Barfi",
"dishId": "plain-barfi",
"sourceFolder": "plain-barfi",
"sourceFile": "plain-barfi.jpg",
"outputFile": "plain-barfi.jpg",
"imagePath": "/images/dishes/plain-barfi.jpg",
"kb": 55.05,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Kalakand",
"dishId": "qalakand",
"sourceFolder": "qalakand",
"sourceFile": "kalakand.jpg",
"outputFile": "qalakand.jpg",
"imagePath": "/images/dishes/qalakand.jpg",
"kb": 82.98,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Ras Gulay",
"dishId": "ras-gulay",
"sourceFolder": "ras-gulay",
"sourceFile": "ras-gulay.jpg",
"outputFile": "ras-gulay.jpg",
"imagePath": "/images/dishes/ras-gulay.jpg",
"kb": 75.29,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Rasmalai",
"dishId": "rasmalai",
"sourceFolder": "ras-malai",
"sourceFile": "ras-malai.jpg",
"outputFile": "rasmalai.jpg",
"imagePath": "/images/dishes/rasmalai.jpg",
"kb": 88.88,
"score": 1,
"matchMethod": "folder-map",
"warning": null
},
{
"dish": "Shakar Paray",
"dishId": "shakar-paray",
"sourceFolder": "shakar-paray",
"sourceFile": "shakar-paray.webp",
"outputFile": "shakar-paray.jpg",
"imagePath": "/images/dishes/shakar-paray.jpg",
"kb": 75.21,
"score": 1,
"matchMethod": "folder-map",
"warning": null
}
]
}
+280
View File
@@ -0,0 +1,280 @@
{
"generatedAt": "2026-06-29T13:22:21.216Z",
"results": [
{
"file": "namakpare.mp4",
"ok": true,
"beforeKb": 190.7,
"afterKb": 187.1,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "shakar-paray.mp4",
"ok": true,
"beforeKb": 191.7,
"afterKb": 196,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "jalebi.mp4",
"ok": true,
"beforeKb": 161.7,
"afterKb": 154.1,
"width": 560,
"height": 420,
"crf": 40,
"fps": 15,
"warning": null
},
{
"file": "gajar-halwa.mp4",
"ok": true,
"beforeKb": 110,
"afterKb": 104,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "gajar-barfi.mp4",
"ok": true,
"beforeKb": 172.7,
"afterKb": 164.1,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "habshi-halwa.mp4",
"ok": true,
"beforeKb": 155.5,
"afterKb": 144.8,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "gulab-jaman.mp4",
"ok": true,
"beforeKb": 122.3,
"afterKb": 117.1,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "ras-gulay.mp4",
"ok": true,
"beforeKb": 128.6,
"afterKb": 123.3,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "rasmalai.mp4",
"ok": true,
"beforeKb": 188.8,
"afterKb": 174.4,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "cham-cham.mp4",
"ok": true,
"beforeKb": 111.8,
"afterKb": 108.8,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "paira.mp4",
"ok": true,
"beforeKb": 123.8,
"afterKb": 121.2,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "laddu.mp4",
"ok": true,
"beforeKb": 157.1,
"afterKb": 147.4,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "qalakand.mp4",
"ok": true,
"beforeKb": 195.3,
"afterKb": 192.4,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "patisa.mp4",
"ok": true,
"beforeKb": 126.3,
"afterKb": 121.5,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "baisan-patisa.mp4",
"ok": true,
"beforeKb": 139.6,
"afterKb": 133.8,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "badam-barfi.mp4",
"ok": true,
"beforeKb": 179.7,
"afterKb": 170.1,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "pistachio-barfi.mp4",
"ok": true,
"beforeKb": 176.4,
"afterKb": 166.9,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "pink-barfi.mp4",
"ok": true,
"beforeKb": 109,
"afterKb": 105,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "coconut-barfi.mp4",
"ok": true,
"beforeKb": 112.1,
"afterKb": 104.5,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "chocolate-barfi.mp4",
"ok": true,
"beforeKb": 123.4,
"afterKb": 118.9,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "baisan-barfi.mp4",
"ok": true,
"beforeKb": 129.3,
"afterKb": 125.1,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "milk-cake-plain.mp4",
"ok": true,
"beforeKb": 136.8,
"afterKb": 129.3,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "milk-cake-khajoor.mp4",
"ok": true,
"beforeKb": 126.7,
"afterKb": 121.4,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "milk-cake-akhrot.mp4",
"ok": true,
"beforeKb": 107.6,
"afterKb": 102.9,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
},
{
"file": "kulfi.mp4",
"ok": true,
"beforeKb": 44.1,
"afterKb": 41.8,
"width": 640,
"height": 480,
"crf": 34,
"fps": 24,
"warning": null
}
]
}
+167
View File
@@ -0,0 +1,167 @@
{
"generatedAt": "2026-06-29T13:21:49.589Z",
"dryRun": false,
"synced": [
{
"dishId": "badam-barfi",
"folder": "badam-barfi",
"sourceFile": "badam-barfi.mp4",
"outputFile": "badam-barfi.mp4",
"kb": 190.7
},
{
"dishId": "baisan-barfi",
"folder": "basen-barfi",
"sourceFile": "basen-barfi.mp4",
"outputFile": "baisan-barfi.mp4",
"kb": 131.6
},
{
"dishId": "baisan-patisa",
"folder": "besan-patisa",
"sourceFile": "besan-patisa.mp4",
"outputFile": "baisan-patisa.mp4",
"kb": 148.5
},
{
"dishId": "cham-cham",
"folder": "cham-cham",
"sourceFile": "cham-cham.mp4",
"outputFile": "cham-cham.mp4",
"kb": 115
},
{
"dishId": "chocolate-barfi",
"folder": "chochlate-barfi",
"sourceFile": "chochlate-barfi.mp4",
"outputFile": "chocolate-barfi.mp4",
"kb": 127.5
},
{
"dishId": "coconut-barfi",
"folder": "coconut-barfi",
"sourceFile": "coconut-barfi.mp4",
"outputFile": "coconut-barfi.mp4",
"kb": 121.1
},
{
"dishId": "gajar-barfi",
"folder": "gajar-barfi",
"sourceFile": "gajar-halwa.mp4",
"outputFile": "gajar-barfi.mp4",
"kb": 166.4
},
{
"dishId": "gulab-jaman",
"folder": "gulab-jaman",
"sourceFile": "gol-jamun.mp4",
"outputFile": "gulab-jaman.mp4",
"kb": 128.5
},
{
"dishId": "habshi-halwa",
"folder": "habshi-halwa",
"sourceFile": "habshi-halwa.mp4",
"outputFile": "habshi-halwa.mp4",
"kb": 171.7
},
{
"dishId": "jalebi",
"folder": "jalebi",
"sourceFile": "jalebi.mp4",
"outputFile": "jalebi.mp4",
"kb": 174.7
},
{
"dishId": "laddu",
"folder": "laddu",
"sourceFile": "laddu.mp4",
"outputFile": "laddu.mp4",
"kb": 170.1
},
{
"dishId": "milk-cake-akhrot",
"folder": "milk-cake-akhrot",
"sourceFile": "milk-cake-akhrot.mp4",
"outputFile": "milk-cake-akhrot.mp4",
"kb": 114.1
},
{
"dishId": "milk-cake-khajoor",
"folder": "milk-cake-khajoor",
"sourceFile": "milk-cake-khajoor.mp4",
"outputFile": "milk-cake-khajoor.mp4",
"kb": 132.9
},
{
"dishId": "milk-cake-plain",
"folder": "milk-cake-plain",
"sourceFile": "milk-cake-plain.mp4",
"outputFile": "milk-cake-plain.mp4",
"kb": 146.8
},
{
"dishId": "namakpare",
"folder": "namak-paray",
"sourceFile": "namak-paray.mp4",
"outputFile": "namakpare.mp4",
"kb": 190.7
},
{
"dishId": "paira",
"folder": "paira",
"sourceFile": "paira.mp4",
"outputFile": "paira.mp4",
"kb": 124.4
},
{
"dishId": "patisa",
"folder": "patisa",
"sourceFile": "patisa.mp4",
"outputFile": "patisa.mp4",
"kb": 133
},
{
"dishId": "pink-barfi",
"folder": "pink-barfi",
"sourceFile": "pink-barfi.mp4",
"outputFile": "pink-barfi.mp4",
"kb": 113
},
{
"dishId": "pistachio-barfi",
"folder": "pistacho-barfi",
"sourceFile": "pista-barfi.mp4",
"outputFile": "pistachio-barfi.mp4",
"kb": 193
},
{
"dishId": "qalakand",
"folder": "qalakand",
"sourceFile": "kalakand.mp4",
"outputFile": "qalakand.mp4",
"kb": 166.4
},
{
"dishId": "ras-gulay",
"folder": "ras-gulay",
"sourceFile": "ras-gulay.mp4",
"outputFile": "ras-gulay.mp4",
"kb": 128.6
},
{
"dishId": "rasmalai",
"folder": "ras-malai",
"sourceFile": "ras-malai.mp4",
"outputFile": "rasmalai.mp4",
"kb": 188.8
},
{
"dishId": "shakar-paray",
"folder": "shakar-paray",
"sourceFile": "shakar-paray.mp4",
"outputFile": "shakar-paray.mp4",
"kb": 191.7
}
]
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Sync ftp-images -> public/images and report changes.
* Usage: node scripts/sync-ftp-images.mjs [--dry-run]
*
* Mapping:
* ftp-images/bilder-bp/ -> public/images/dishes/ (menu uses /images/dishes/...)
* ftp-images/<other>/ -> public/images/<other>/
*/
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const DRY_RUN = process.argv.includes("--dry-run");
const FTP_ROOT = path.join(ROOT, "ftp-images");
const PUBLIC_IMAGES = path.join(ROOT, "public", "images");
const LOG_FILE = path.join(ROOT, "sync-images-log.txt");
const RESULT_FILE = path.join(ROOT, "sync-result.json");
/** FTP subfolder name -> public/images subfolder name */
const DEST_ALIASES = {
"bilder-bp": "dishes",
};
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"]);
const logLines = [];
const log = (...args) => {
const line = args.map(String).join(" ");
logLines.push(line);
console.log(...args);
};
function hashFile(filePath) {
const data = fs.readFileSync(filePath);
return crypto.createHash("md5").update(data).digest("hex");
}
function walk(dir) {
const out = [];
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (entry.isFile()) out.push(full);
}
return out;
}
function rel(from, to) {
return path.relative(from, to).split(path.sep).join("/");
}
function discoverSources() {
if (!fs.existsSync(FTP_ROOT)) return [];
const pairs = [];
for (const entry of fs.readdirSync(FTP_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const destName = DEST_ALIASES[entry.name] ?? entry.name;
pairs.push({
src: path.join(FTP_ROOT, entry.name),
dest: path.join(PUBLIC_IMAGES, destName),
srcLabel: `ftp-images/${entry.name}`,
destLabel: `public/images/${destName}`,
});
}
return pairs;
}
function syncPair({ src, dest, srcLabel, destLabel }) {
fs.mkdirSync(dest, { recursive: true });
const srcFiles = walk(src).filter((f) => IMAGE_EXT.has(path.extname(f).toLowerCase()));
const destFiles = walk(dest).filter((f) => IMAGE_EXT.has(path.extname(f).toLowerCase()));
const srcSet = new Set(srcFiles.map((f) => rel(src, f)));
const copied = [];
const updated = [];
const unchanged = [];
const removed = [];
for (const file of srcFiles) {
const relPath = rel(src, file);
const target = path.join(dest, relPath);
fs.mkdirSync(path.dirname(target), { recursive: true });
if (!fs.existsSync(target)) {
if (!DRY_RUN) fs.copyFileSync(file, target);
copied.push(relPath);
continue;
}
if (hashFile(file) !== hashFile(target)) {
if (!DRY_RUN) fs.copyFileSync(file, target);
updated.push(relPath);
} else {
unchanged.push(relPath);
}
}
for (const file of destFiles) {
const relPath = rel(dest, file);
if (!srcSet.has(relPath)) {
if (!DRY_RUN) fs.unlinkSync(file);
removed.push(relPath);
}
}
log(`\n=== ${srcLabel} -> ${destLabel} ===`);
log(` new: ${copied.length} updated: ${updated.length} unchanged: ${unchanged.length} removed: ${removed.length}`);
for (const f of copied) log(` + ${f}`);
for (const f of updated) log(` ~ ${f}`);
for (const f of removed) log(` - ${f}`);
return { copied, updated, unchanged, removed, src: srcLabel, dest: destLabel };
}
function checkMenuReferences() {
const menuFile = path.join(ROOT, "infrastructure", "menu", "static-menu-data.ts");
if (!fs.existsSync(menuFile)) {
log("\nMenu file not found; skipping reference check.");
return { unique: [], missing: [] };
}
const text = fs.readFileSync(menuFile, "utf8");
const refs = [...text.matchAll(/["'`](\/images\/[^"'`]+)["'`]/g)].map((m) => m[1]);
const unique = [...new Set(refs)];
const missing = unique.filter((ref) => !fs.existsSync(path.join(ROOT, "public", ref.replace(/^\//, ""))));
log(`\n=== Menu references: ${unique.length} unique, ${missing.length} missing ===`);
for (const m of missing) log(` ! ${m}`);
return { unique, missing };
}
function writeOutputs(result) {
if (!DRY_RUN) {
fs.writeFileSync(LOG_FILE, logLines.join("\n") + "\n");
fs.writeFileSync(RESULT_FILE, JSON.stringify(result, null, 2) + "\n");
log(`\nWrote ${path.relative(ROOT, LOG_FILE)}`);
log(`Wrote ${path.relative(ROOT, RESULT_FILE)}`);
}
}
if (!fs.existsSync(FTP_ROOT)) {
console.error(`ftp-images not found at ${FTP_ROOT}`);
process.exit(1);
}
log(DRY_RUN ? "DRY RUN\n" : "Syncing...\n");
const pairs = discoverSources();
if (pairs.length === 0) {
log("No subfolders found in ftp-images/");
writeOutputs({ totals: { copied: 0, updated: 0, unchanged: 0, removed: 0 }, pairs: [], menu: { unique: [], missing: [] } });
process.exit(0);
}
const pairResults = [];
const totals = { copied: 0, updated: 0, unchanged: 0, removed: 0 };
for (const pair of pairs) {
const result = syncPair(pair);
pairResults.push(result);
totals.copied += result.copied.length;
totals.updated += result.updated.length;
totals.unchanged += result.unchanged.length;
totals.removed += result.removed.length;
}
log(`\n=== TOTALS: ${totals.copied} new, ${totals.updated} updated, ${totals.unchanged} unchanged, ${totals.removed} removed ===`);
const menu = checkMenuReferences();
writeOutputs({ dryRun: DRY_RUN, totals, pairs: pairResults, menu });
if (menu.missing.length) process.exitCode = 1;
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Sync images from ftp-images/bilder-bp to public/images/bilder-bp
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FTP="${ROOT}/ftp-images"
DEST_ROOT="${ROOT}/public/images"
if [[ ! -d "$FTP" ]]; then
echo "Source not found: $FTP"
exit 1
fi
mkdir -p "$DEST_ROOT"
dest_name() {
case "$1" in
bilder-bp) echo "dishes" ;;
*) echo "$1" ;;
esac
}
for dir in "$FTP"/*/; do
name="$(basename "$dir")"
dest="${DEST_ROOT}/$(dest_name "$name")"
mkdir -p "$dest"
echo "Syncing $dir -> $dest"
rsync -av --delete --itemize-changes "${dir}/" "${dest}/"
done
echo ""
echo "=== Done. Validate menu refs: node scripts/sync-ftp-images.mjs --dry-run ==="
+150
View File
@@ -0,0 +1,150 @@
/**
* Sync dish photos from ftp-images/ → public/images/dishes/
* - Matches folders by menu item id (e.g. ftp-images/bilder-bp/chicken-biryani/bild/)
* - Center-crops to 4:3, resizes to 1200×900, exports unified JPEG
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { menuCategories } from '../infrastructure/menu/static-menu-data';
import { videoBaseName } from '../application/media/asset-resolver';
const ROOT = path.resolve(__dirname, '..');
const FTP_ROOT = path.join(ROOT, 'ftp-images/bilder-bp');
const OUT_DIR = path.join(ROOT, 'public/images/dishes');
const TARGET_W = 1200;
const TARGET_H = 900;
const JPEG_QUALITY = 88;
/** Menu ids that share another dish's ftp photo (main bilder-bp folder) */
const FTP_ALIASES: Record<string, string> = {
'tikka-boti': 'chicken-tikka',
};
/** Menu id → folder name under ftp-images/bilder-bp/sweets/ */
const SWEETS_FOLDERS: Record<string, string> = {
'namakpare': 'namak-paray',
'shakar-paray': 'Shakar paray',
'chocolate-barfi': 'chochlate-barfi',
'pistachio-barfi': 'pistacho-barfi',
};
function pickLargestImage(dir: string): string | null {
if (!fs.existsSync(dir)) return null;
const files = fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return [];
if (!/\.(jpe?g|png|webp)$/i.test(entry.name)) return [];
return [{ full, size: fs.statSync(full).size }];
})
.sort((a, b) => b.size - a.size);
return files[0]?.full ?? null;
}
function findSourceImage(dishId: string): string | null {
const mainFolder = FTP_ALIASES[dishId] ?? dishId;
const sweetsFolder = SWEETS_FOLDERS[dishId] ?? dishId;
const candidates = [
path.join(FTP_ROOT, mainFolder, 'bild'),
path.join(FTP_ROOT, 'sweets', sweetsFolder, 'bild'),
path.join(FTP_ROOT, 'sweets', sweetsFolder),
];
for (const dir of candidates) {
const found = pickLargestImage(dir);
if (found) return found;
}
return null;
}
function run(cmd: string) {
execSync(cmd, { stdio: 'pipe' });
}
function processToJpeg(src: string, dest: string) {
const tmp = path.join(OUT_DIR, `.tmp-${path.basename(dest)}`);
fs.mkdirSync(OUT_DIR, { recursive: true });
// Normalize to JPEG working copy
run(`sips -s format jpeg "${src}" --out "${tmp}"`);
const width = Number(
execSync(`sips -g pixelWidth "${tmp}"`).toString().match(/pixelWidth: (\d+)/)?.[1]
);
const height = Number(
execSync(`sips -g pixelHeight "${tmp}"`).toString().match(/pixelHeight: (\d+)/)?.[1]
);
if (!width || !height) throw new Error(`Could not read dimensions for ${src}`);
const targetAspect = TARGET_W / TARGET_H;
const sourceAspect = width / height;
let cropW = width;
let cropH = height;
let offsetX = 0;
let offsetY = 0;
if (sourceAspect > targetAspect) {
cropW = Math.round(height * targetAspect);
offsetX = Math.round((width - cropW) / 2);
} else if (sourceAspect < targetAspect) {
cropH = Math.round(width / targetAspect);
offsetY = Math.round((height - cropH) / 2);
}
run(
`sips --cropToHeightWidth ${cropH} ${cropW} --cropOffset ${offsetY} ${offsetX} "${tmp}"`
);
run(
`sips -z ${TARGET_H} ${TARGET_W} -s format jpeg -s formatOptions ${JPEG_QUALITY} "${tmp}" --out "${dest}"`
);
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
}
function main() {
const written = new Set<string>();
let synced = 0;
let skipped = 0;
for (const category of menuCategories) {
for (const item of category.items) {
const src = findSourceImage(item.id);
if (!src) {
skipped++;
console.log(` skip (no ftp image): ${item.id}`);
continue;
}
const outputs = new Set<string>();
if (item.image) outputs.add(path.join(OUT_DIR, item.image));
if (item.video) {
const base = videoBaseName(item.video);
outputs.add(path.join(OUT_DIR, `${base}-poster.jpg`));
outputs.add(path.join(OUT_DIR, `${base}-optimized-poster.jpg`));
}
for (const out of outputs) {
if (written.has(out)) continue;
processToJpeg(src, out);
written.add(out);
console.log(`${path.basename(out)}${item.id}`);
}
synced++;
}
}
console.log(`\nDone: ${synced} dishes synced, ${skipped} without ftp source, ${written.size} files written.`);
}
main();
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env node
/**
* Sync sweets images: ftp-images/bilder-bp/sweets/{dish}/bild/* → public/images/dishes
* - Walk nested FTP folders (one folder per dish)
* - Fuzzy-match to sweets items in static-menu-data.ts
* - Resize/crop uniformly (800×600 cover), JPEG ≤ 100 KB
*
* Usage:
* node scripts/sync-sweets-images.mjs
* node scripts/sync-sweets-images.mjs --dry-run
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync, spawnSync } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SOURCE_DIR = path.join(ROOT, 'ftp-images/bilder-bp/sweets');
const OUTPUT_DIR = path.join(ROOT, 'public/images/dishes');
const MENU_DATA_PATH = path.join(ROOT, 'infrastructure/menu/static-menu-data.ts');
const REPORT_PATH = path.join(__dirname, 'sweets-sync-report.json');
const MAX_BYTES = 100 * 1024;
const TARGET_WIDTH = 800;
const TARGET_HEIGHT = 600;
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.tif', '.tiff', '.heic']);
const DRY_RUN = process.argv.includes('--dry-run');
/** FTP subfolder name (normalized) → menu dish id */
const FOLDER_TO_DISH = {
'badam-barfi': 'badam-barfi',
'besan-patisa': 'baisan-patisa',
'baisan-patisa': 'baisan-patisa',
'cham-cham': 'cham-cham',
'chochlate-barfi': 'chocolate-barfi',
'chocolate-barfi': 'chocolate-barfi',
'coconut-barfi': 'coconut-barfi',
'cream-gulab-jaman': 'cream-gulab-jaman',
'cream-gulab-jamun': 'cream-gulab-jaman',
'gajar-barfi': 'gajar-barfi',
'gajar-halwa': 'gajar-halwa',
'gulab-jaman': 'gulab-jaman',
'gulab-jamun': 'gulab-jaman',
'habshi-halwa': 'habshi-halwa',
'jalebi': 'jalebi',
'laddu': 'laddu',
'ladoo': 'laddu',
'lambay-gulab-jaman': 'lambay-gulab-jaman',
'lambay-gulab-jamun': 'lambay-gulab-jaman',
'milk-cake-akhrot': 'milk-cake-akhrot',
'milk-cake-khajoor': 'milk-cake-khajoor',
'milk-cake-plain': 'milk-cake-plain',
'namak-paray': 'namakpare',
'namakpare': 'namakpare',
'namak-pare': 'namakpare',
'paira': 'paira',
'patisa': 'patisa',
'pink-barfi': 'pink-barfi',
'pistacho-barfi': 'pistachio-barfi',
'pistachio-barfi': 'pistachio-barfi',
'plain-barfi': 'plain-barfi',
'qalakand': 'qalakand',
'kalakand': 'qalakand',
'ras-gulay': 'ras-gulay',
'rasgulla': 'ras-gulay',
'ras-malai': 'rasmalai',
'rasmalai': 'rasmalai',
'shakar-paray': 'shakar-paray',
'shakar-pare': 'shakar-paray',
'baisan-barfi': 'baisan-barfi',
'besan-barfi': 'baisan-barfi',
'basen-barfi': 'baisan-barfi',
'round-gulab-jaman': 'round-gulab-jaman',
'round-gulab-jamun': 'round-gulab-jaman',
'shahi-tukra': 'shahi-tukra',
'kulfi': 'kulfi',
};
/** Folders that are not sweets — skip entirely */
const SKIP_FOLDERS = new Set(['samosa-aloo', 'samosa-keema', 'samosa-chaat']);
let sharp = null;
try {
sharp = (await import('sharp')).default;
} catch {
sharp = null;
}
function normalize(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
function normalizeSourceStem(raw) {
let stem = normalize(raw);
stem = stem
.replace(/-poster$/i, '')
.replace(/-mithai$/i, '')
.replace(/-new$/i, '')
.replace(/-copy\d*$/i, '')
.replace(/-final$/i, '')
.replace(/-img\d*$/i, '')
.replace(/-\d+$/i, '');
return stem;
}
function tokenize(value) {
return normalize(value).split('-').filter((t) => t.length > 1);
}
function levenshtein(a, b) {
const rows = a.length + 1;
const cols = b.length + 1;
const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
for (let i = 0; i < rows; i++) matrix[i][0] = i;
for (let j = 0; j < cols; j++) matrix[0][j] = j;
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
}
}
return matrix[a.length][b.length];
}
function similarity(a, b) {
if (!a || !b) return 0;
if (a === b) return 1;
const maxLen = Math.max(a.length, b.length);
return maxLen ? 1 - levenshtein(a, b) / maxLen : 0;
}
function parseSweetsDishes(menuContent) {
const sweetsIdx = menuContent.indexOf('id: "sweets"');
if (sweetsIdx < 0) throw new Error('Sweets category not found in static-menu-data.ts');
const itemsStart = menuContent.indexOf('items: [', sweetsIdx);
if (itemsStart < 0) throw new Error('Sweets items array not found');
let depth = 0;
let itemsEnd = -1;
for (let i = itemsStart + 'items: '.length; i < menuContent.length; i++) {
const ch = menuContent[i];
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) {
itemsEnd = i;
break;
}
}
}
if (itemsEnd < 0) throw new Error('Could not parse sweets items array');
const itemsBlock = menuContent.slice(itemsStart, itemsEnd + 1);
const dishes = [];
for (const match of itemsBlock.matchAll(/\{\s*id:\s*"([^"]+)"\s*,\s*name:\s*"([^"]+)"/g)) {
const id = match[1];
const name = match[2];
const itemChunk = itemsBlock.slice(match.index, match.index + 800);
const image = itemChunk.match(/\bimage:\s*"([^"]+)"/)?.[1] ?? `${id}.jpg`;
dishes.push({ id, name, image, names: [name] });
}
if (!dishes.length) throw new Error('No sweets dishes found in static-menu-data.ts');
return dishes;
}
function isCameraDump(filename) {
return /^img[_-]/i.test(path.parse(filename).name);
}
function extPriority(ext) {
const e = ext.toLowerCase();
if (e === '.jpg') return 0;
if (e === '.jpeg') return 1;
if (e === '.webp') return 2;
if (e === '.png') return 3;
return 4;
}
/** Pick the best image file inside a dish folder */
function pickBestImage(files, folderStem) {
const candidates = files
.filter((f) => IMAGE_EXTENSIONS.has(path.extname(f).toLowerCase()))
.map((file) => {
const rawStem = path.parse(file).name;
const stem = normalizeSourceStem(rawStem);
const folderNorm = normalizeSourceStem(folderStem);
let score = 0;
if (FOLDER_TO_DISH[folderNorm] && stem === folderNorm) score = 1;
else if (stem === folderNorm) score = 0.95;
else if (stem.includes(folderNorm) || folderNorm.includes(stem)) score = 0.85;
else score = similarity(stem, folderNorm);
if (isCameraDump(file)) score -= 0.35;
score -= extPriority(path.extname(file)) * 0.02;
return { file, stem, score };
})
.sort((a, b) => b.score - a.score);
return candidates[0] ?? null;
}
function listSourceFolders() {
if (!fs.existsSync(SOURCE_DIR)) {
throw new Error(`Source directory not found: ${SOURCE_DIR}`);
}
const sources = [];
for (const entry of fs.readdirSync(SOURCE_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const folder = entry.name;
const folderNorm = normalize(folder);
if (SKIP_FOLDERS.has(folderNorm)) continue;
const searchDirs = ['bild', 'bilder']
.map((sub) => path.join(SOURCE_DIR, folder, sub))
.filter((d) => fs.existsSync(d));
let allFiles = [];
for (const dir of searchDirs) {
allFiles.push(
...fs.readdirSync(dir).map((f) => ({
file: f,
fullPath: path.join(dir, f),
fromDir: dir,
})),
);
}
if (!allFiles.length) continue;
const fileNames = allFiles.map((f) => f.file);
const best = pickBestImage(fileNames, folder);
if (!best) continue;
const chosen = allFiles.find((f) => f.file === best.file);
sources.push({
folder,
folderNorm: normalizeSourceStem(folder),
file: best.file,
fullPath: chosen.fullPath,
stem: best.stem,
pickScore: Number(best.score.toFixed(3)),
});
}
return sources;
}
function outputFileForDish(dish) {
if (dish.image) {
const base = path.basename(dish.image);
if (base) return base.replace(/\.(png|webp|jpeg)$/i, '.jpg');
}
return `${dish.id}.jpg`;
}
function resolveDishForFolder(source) {
const folderNorm = source.folderNorm;
if (FOLDER_TO_DISH[folderNorm]) {
return { dishId: FOLDER_TO_DISH[folderNorm], score: 1, method: 'folder-map' };
}
return { dishId: null, score: 0, method: 'unmapped' };
}
function matchFoldersToDishes(sources, dishes) {
const dishById = new Map(dishes.map((d) => [d.id, d]));
const matches = [];
const unmatchedFolders = [];
const matchedDishIds = new Set();
for (const source of sources) {
const resolved = resolveDishForFolder(source);
let dish = resolved.dishId ? dishById.get(resolved.dishId) : null;
if (!dish) {
let best = null;
for (const d of dishes) {
if (matchedDishIds.has(d.id)) continue;
const score = Math.max(
similarity(source.folderNorm, normalize(d.id)),
similarity(source.folderNorm, normalizeSourceStem(path.parse(d.image).name)),
similarity(source.stem, normalize(d.id)),
);
if (!best || score > best.score) best = { dish: d, score };
}
if (best && best.score >= 0.55) {
dish = best.dish;
resolved.score = best.score;
resolved.method = 'fuzzy';
}
}
if (dish) {
matchedDishIds.add(dish.id);
const outputFile = outputFileForDish(dish);
matches.push({
dishId: dish.id,
dishNames: dish.names,
currentImage: dish.image,
sourceFolder: source.folder,
sourceFile: source.file,
sourcePath: source.fullPath,
score: resolved.score,
matchMethod: resolved.method,
outputFile,
outputPath: `/images/dishes/${outputFile}`,
});
} else {
unmatchedFolders.push(source);
}
}
const unmatchedDishes = dishes.filter((d) => !matchedDishIds.has(d.id));
return { matches, unmatchedDishes, unmatchedFolders };
}
async function optimizeWithSharp(inputPath, outputPath) {
let quality = 84;
let width = TARGET_WIDTH;
let height = TARGET_HEIGHT;
let buffer = null;
while (quality >= 32) {
while (width >= 480) {
buffer = await sharp(inputPath)
.rotate()
.resize(width, height, { fit: 'cover', position: 'centre' })
.modulate({ brightness: 1.02, saturation: 1.05 })
.sharpen({ sigma: 0.6 })
.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:2:0' })
.toBuffer();
if (buffer.length <= MAX_BYTES) {
if (!DRY_RUN) fs.writeFileSync(outputPath, buffer);
return { bytes: buffer.length, width, height, quality, method: 'sharp' };
}
width -= 80;
height -= 60;
}
quality -= 6;
width = TARGET_WIDTH;
height = TARGET_HEIGHT;
}
if (!DRY_RUN) fs.writeFileSync(outputPath, buffer);
return {
bytes: buffer.length,
width,
height,
quality,
method: 'sharp',
warning: buffer.length > MAX_BYTES ? 'exceeds-100kb' : undefined,
};
}
function optimizeWithSips(inputPath, outputPath) {
const temp = `${outputPath}.tmp.jpg`;
fs.copyFileSync(inputPath, temp);
for (const [w, h] of [
[TARGET_WIDTH, TARGET_HEIGHT],
[640, 480],
[520, 390],
]) {
execSync(
`sips -s format jpeg -s formatOptions 65 --resampleHeightWidth ${h} ${w} "${temp}" --out "${outputPath}"`,
{ stdio: 'pipe' },
);
const bytes = fs.statSync(outputPath).size;
if (bytes <= MAX_BYTES) {
fs.unlinkSync(temp);
return { bytes, width: w, height: h, quality: 65, method: 'sips' };
}
}
const bytes = fs.statSync(outputPath).size;
fs.unlinkSync(temp);
return {
bytes,
width: 520,
height: 390,
quality: 65,
method: 'sips',
warning: bytes > MAX_BYTES ? 'exceeds-100kb' : undefined,
};
}
async function optimizeImage(inputPath, outputPath) {
if (DRY_RUN) {
return { bytes: fs.statSync(inputPath).size, width: null, height: null, quality: null, method: 'dry-run' };
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (sharp) return optimizeWithSharp(inputPath, outputPath);
if (process.platform === 'darwin' && spawnSync('which', ['sips']).status === 0) {
return optimizeWithSips(inputPath, outputPath);
}
throw new Error('Install sharp: npm install sharp');
}
async function main() {
console.log('Shahi Kitchen — sweets image sync\n');
console.log(`Source: ${SOURCE_DIR}`);
console.log(`Output: ${OUTPUT_DIR}`);
console.log(`Optimizer: ${sharp ? 'sharp' : 'sips'}`);
if (DRY_RUN) console.log('Mode: DRY RUN\n');
const menuContent = fs.readFileSync(MENU_DATA_PATH, 'utf8');
const dishes = parseSweetsDishes(menuContent);
const sources = listSourceFolders();
const { matches, unmatchedDishes, unmatchedFolders } = matchFoldersToDishes(sources, dishes);
console.log(`Folders: ${sources.length} Sweets dishes: ${dishes.length} Matched: ${matches.length}\n`);
const results = [];
for (const match of matches) {
const dest = path.join(OUTPUT_DIR, match.outputFile);
const opt = await optimizeImage(match.sourcePath, dest);
results.push({ ...match, ...opt });
const kb = (opt.bytes / 1024).toFixed(1);
const warn = opt.warning ? ' ⚠ over 100KB' : '';
console.log(`${match.dishNames[0] || match.dishId}`);
console.log(` ${match.sourceFolder}/bild/${match.sourceFile}${match.outputFile} (${kb} KB)${warn}`);
}
if (unmatchedDishes.length) {
console.log('\n⚠ No FTP image for these sweets (keeping existing):');
for (const d of unmatchedDishes) console.log(` - ${d.id}: ${d.names.join(' / ')}`);
}
if (unmatchedFolders.length) {
console.log('\nUnused FTP folders:');
for (const f of unmatchedFolders) console.log(` - ${f.folder}/ (${f.file})`);
}
const overLimit = results.filter((r) => r.warning);
if (overLimit.length) {
console.log(`\n${overLimit.length} image(s) still exceed 100 KB after optimization`);
}
const report = {
generatedAt: new Date().toISOString(),
dryRun: DRY_RUN,
summary: {
sourceFolders: sources.length,
sweetsDishes: dishes.length,
matched: matches.length,
unmatchedDishes: unmatchedDishes.map((d) => ({ id: d.id, names: d.names, image: d.image })),
unmatchedFolders: unmatchedFolders.map((f) => ({ folder: f.folder, file: f.file })),
},
mappings: results.map((r) => ({
dish: r.dishNames[0] || r.dishId,
dishId: r.dishId,
sourceFolder: r.sourceFolder,
sourceFile: r.sourceFile,
outputFile: r.outputFile,
imagePath: r.outputPath,
kb: Number((r.bytes / 1024).toFixed(2)),
score: r.score,
matchMethod: r.matchMethod,
warning: r.warning ?? null,
})),
};
if (!DRY_RUN) {
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2));
console.log(`\nReport: ${REPORT_PATH}`);
}
if (unmatchedDishes.length) process.exitCode = 0;
}
main().catch((err) => {
console.error('Failed:', err.message);
process.exit(1);
});
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Sync sweets videos: ftp-images/bilder-bp/sweets/{dish}/bild/*.mp4 → public/videos/{dishId}.mp4
* Then compress to uniform 640×480, ≤ 200 KB for modal playback.
*
* Usage:
* node scripts/sync-sweets-videos.mjs
* node scripts/sync-sweets-videos.mjs --dry-run
* node scripts/sync-sweets-videos.mjs --skip-optimize
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SOURCE_DIR = path.join(ROOT, 'ftp-images/bilder-bp/sweets');
const OUTPUT_DIR = path.join(ROOT, 'public/videos');
const REPORT_PATH = path.join(__dirname, 'sweets-videos-sync-report.json');
const DRY_RUN = process.argv.includes('--dry-run');
const SKIP_OPTIMIZE = process.argv.includes('--skip-optimize');
const FOLDER_TO_DISH = {
'badam-barfi': 'badam-barfi',
'besan-patisa': 'baisan-patisa',
'cham-cham': 'cham-cham',
'chochlate-barfi': 'chocolate-barfi',
'coconut-barfi': 'coconut-barfi',
'gajar-barfi': 'gajar-barfi',
'gulab-jaman': 'gulab-jaman',
'habshi-halwa': 'habshi-halwa',
'jalebi': 'jalebi',
'laddu': 'laddu',
'milk-cake-akhrot': 'milk-cake-akhrot',
'milk-cake-plain': 'milk-cake-plain',
'namak-paray': 'namakpare',
'paira': 'paira',
'patisa': 'patisa',
'pink-barfi': 'pink-barfi',
'pistacho-barfi': 'pistachio-barfi',
'qalakand': 'qalakand',
'ras-gulay': 'ras-gulay',
'ras-malai': 'rasmalai',
'shakar-paray': 'shakar-paray',
'cream-gulab-jaman': 'cream-gulab-jaman',
'lambay-gulab-jaman': 'lambay-gulab-jaman',
'milk-cake-khajoor': 'milk-cake-khajoor',
'plain-barfi': 'plain-barfi',
'baisan-barfi': 'baisan-barfi',
'besan-barfi': 'baisan-barfi',
'basen-barfi': 'baisan-barfi',
};
const SKIP_FOLDERS = new Set(['samosa-aloo', 'samosa-keema', 'samosa-chaat']);
function normalize(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function pickVideo(files) {
const mp4s = files.filter((f) => /\.mp4$/i.test(f));
if (!mp4s.length) return null;
return mp4s.sort((a, b) => a.localeCompare(b))[0];
}
function runOptimize(fileName) {
const r = spawnSync(process.execPath, [path.join(__dirname, 'optimize-sweets-videos.mjs'), fileName], {
stdio: 'inherit',
cwd: ROOT,
});
return r.status === 0;
}
function main() {
console.log('Shahi Kitchen — sweets video sync\n');
if (!fs.existsSync(SOURCE_DIR)) throw new Error(`Missing: ${SOURCE_DIR}`);
if (!DRY_RUN) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const synced = [];
for (const folder of fs.readdirSync(SOURCE_DIR, { withFileTypes: true })) {
if (!folder.isDirectory()) continue;
const folderNorm = normalize(folder.name);
if (SKIP_FOLDERS.has(folderNorm)) continue;
const dishId = FOLDER_TO_DISH[folderNorm];
if (!dishId) {
console.log(`⚠ Unmapped folder: ${folder.name}`);
continue;
}
const mediaDir = ['bild', 'bilder']
.map((sub) => path.join(SOURCE_DIR, folder.name, sub))
.find((d) => fs.existsSync(d));
if (!mediaDir) continue;
const videoFile = pickVideo(fs.readdirSync(mediaDir));
if (!videoFile) continue;
const src = path.join(mediaDir, videoFile);
const outputFile = `${dishId}.mp4`;
const dest = path.join(OUTPUT_DIR, outputFile);
if (!DRY_RUN) {
fs.copyFileSync(src, dest);
if (!SKIP_OPTIMIZE) {
console.log(` optimizing ${outputFile}`);
runOptimize(outputFile);
}
}
const kb = (DRY_RUN ? fs.statSync(src).size : fs.statSync(dest).size) / 1024;
synced.push({ dishId, folder: folder.name, sourceFile: videoFile, outputFile, kb: Number(kb.toFixed(1)) });
const sub = path.basename(mediaDir);
console.log(`${dishId}${folder.name}/${sub}/${videoFile} (${kb.toFixed(0)} KB)`);
}
const report = { generatedAt: new Date().toISOString(), dryRun: DRY_RUN, synced };
if (!DRY_RUN) fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2));
console.log(`\nSynced: ${synced.length} videos`);
if (!DRY_RUN) console.log(`Report: ${REPORT_PATH}`);
}
main();
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
# Replace fish images (frozen seafood style)
set -euo pipefail
SITE="$(cd "$(dirname "$0")/.." && pwd)/public/images/site"
dl() { curl -fsSL "$1" -o "$2"; }
dl "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1400&q=90" "$SITE/category-fish.jpg"
dl "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1400&h=1000&crop=center&q=90" "$SITE/fish-salmon.jpg"
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-salmon-2.jpg"
dl "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1400&h=1100&crop=entropy&q=90" "$SITE/fish-rohu.jpg"
dl "https://images.unsplash.com/photo-1565680018434-b513d5e5fd47?auto=format&fit=crop&w=1400&q=90" "$SITE/fish-prawns.jpg"
dl "https://images.unsplash.com/photo-1544551763-46a013bb70d5?auto=format&fit=crop&w=1400&h=900&crop=center&q=90" "$SITE/fish-basa.jpg"
echo "Fish images updated in $SITE"