Replace entire repo content with correct code from /root/shahikitchen-website/
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
Executable
+87
@@ -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 "=========================================="
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 ==="
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user