/** * Generates optimized, responsive images from assets/source into public/images. * Runs automatically before `next build`. Safe to re-run; skips up-to-date files. * * npm run images */ import sharp from "sharp"; import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SRC = path.join(ROOT, "assets/source"); const OUT = path.join(ROOT, "public/images"); /** name → { file, widths, quality } */ const PHOTOS = { "hero-worship": { file: "photos/hero-north-attleboro-worship.jpg", widths: [640, 1024, 1600, 2000], quality: 72 }, "campus-north-attleboro": { file: "photos/campus-north-attleboro.jpg", widths: [480, 960], quality: 74 }, "campus-norwood": { file: "photos/campus-norwood.jpg", widths: [480, 960], quality: 74 }, "campus-woonsocket": { file: "photos/campus-woonsocket.jpg", widths: [480, 960], quality: 74 }, "campus-southcoast": { file: "photos/campus-southcoast.jpg", widths: [480, 960], quality: 74 }, "campus-apollo-beach": { file: "photos/campus-apollo-beach.jpg", widths: [480, 960], quality: 74 }, "campus-orlando": { file: "photos/campus-orlando.jpg", widths: [480, 960], quality: 74 }, "worship-southcoast": { file: "photos/worship-southcoast.jpg", widths: [640, 1200], quality: 72 }, "community-1": { file: "photos/community-1.jpg", widths: [640, 1200], quality: 74 }, "save-a-seat": { file: "photos/save-a-seat.jpg", widths: [640, 1200], quality: 74 }, }; const LOGOS = { "waters-mark-teal": { file: "logos/waters-mark-teal.png", widths: [96, 192, 512] }, }; const SVGS = ["logos/waters-mark-white.svg", "logos/waters-church-wordmark-white.svg"]; async function newer(src, out) { try { const [a, b] = await Promise.all([fs.stat(src), fs.stat(out)]); return a.mtimeMs > b.mtimeMs; } catch { return true; } } async function main() { await fs.mkdir(OUT, { recursive: true }); const manifest = {}; for (const [name, spec] of Object.entries(PHOTOS)) { const src = path.join(SRC, spec.file); const meta = await sharp(src).metadata(); manifest[name] = { width: meta.width, height: meta.height, widths: [], formats: ["avif", "webp", "jpg"] }; for (const w of spec.widths) { if (w > meta.width) continue; manifest[name].widths.push(w); const base = path.join(OUT, `${name}-${w}`); const resized = () => sharp(src).resize({ width: w, withoutEnlargement: true }); if (await newer(src, `${base}.webp`)) await resized().webp({ quality: spec.quality }).toFile(`${base}.webp`); if (await newer(src, `${base}.avif`)) await resized().avif({ quality: Math.max(40, spec.quality - 20) }).toFile(`${base}.avif`); if (await newer(src, `${base}.jpg`)) await resized().jpeg({ quality: spec.quality + 4, mozjpeg: true }).toFile(`${base}.jpg`); } // tiny blurred placeholder to avoid layout flash const { data } = await sharp(src).resize(24).webp({ quality: 30 }).toBuffer({ resolveWithObject: true }); manifest[name].blur = `data:image/webp;base64,${data.toString("base64")}`; } for (const [name, spec] of Object.entries(LOGOS)) { const src = path.join(SRC, spec.file); for (const w of spec.widths) { const out = path.join(OUT, `${name}-${w}.png`); if (await newer(src, out)) await sharp(src).resize({ width: w }).png({ compressionLevel: 9 }).toFile(out); } } for (const rel of SVGS) { const out = path.join(OUT, path.basename(rel)); if (await newer(path.join(SRC, rel), out)) await fs.copyFile(path.join(SRC, rel), out); } // Favicons / social image const mark = path.join(SRC, LOGOS["waters-mark-teal"].file); await sharp(mark).resize(32).png().toFile(path.join(OUT, "favicon-32.png")); await sharp(mark).resize(180).png().toFile(path.join(OUT, "apple-touch-icon.png")); await sharp(mark).resize(512).png().toFile(path.join(OUT, "icon-512.png")); const og = path.join(OUT, "og-image.jpg"); if (await newer(path.join(SRC, PHOTOS["hero-worship"].file), og)) { await sharp(path.join(SRC, PHOTOS["hero-worship"].file)) .resize(1200, 630, { fit: "cover", position: "centre" }) .jpeg({ quality: 78, mozjpeg: true }) .toFile(og); } await fs.writeFile(path.join(ROOT, "src/config/images.manifest.json"), JSON.stringify(manifest, null, 2)); const files = await fs.readdir(OUT); let total = 0; for (const f of files) total += (await fs.stat(path.join(OUT, f))).size; console.log(`images: ${files.length} files, ${(total / 1024).toFixed(0)} KB total in public/images`); } main().catch((e) => { console.error(e); process.exit(1); });