const fs = require("fs"); const path = require("path"); const sharp = require("sharp"); const { loadMeals, repoRoot } = require("./lib/meals"); const fullsDir = path.join(repoRoot, "images", "fulls"); const thumbsDir = path.join(repoRoot, "images", "thumbs"); const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json"); const THUMB_VERSION = 1; const THUMB_WIDTH = 240; const THUMB_HEIGHT = 320; const JPEG_QUALITY = 82; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function parseArgs(argv) { return { force: argv.includes("--force"), }; } function getManifestKey(mealId) { return `meal:${mealId}`; } function getThumbnailPaths(meal) { return { fullPath: path.join(fullsDir, `${meal.id}.jpg`), thumbPath: path.join(thumbsDir, `${meal.id}.jpg`), }; } function getOrientedDimensions(metadata) { if (!metadata.width || !metadata.height) { throw new Error("Could not determine image dimensions"); } if ([5, 6, 7, 8].includes(metadata.orientation)) { return { width: metadata.height, height: metadata.width, }; } return { width: metadata.width, height: metadata.height, }; } function getCropArea(width, height, focus) { const targetRatio = THUMB_WIDTH / THUMB_HEIGHT; const sourceRatio = width / height; let cropWidth = width; let cropHeight = height; if (sourceRatio > targetRatio) { cropWidth = Math.round(height * targetRatio); } else if (sourceRatio < targetRatio) { cropHeight = Math.round(width / targetRatio); } const centerX = (focus?.x ?? 0.5) * width; const centerY = (focus?.y ?? 0.5) * height; const left = Math.round(centerX - cropWidth / 2); const top = Math.round(centerY - cropHeight / 2); return { left: clamp(left, 0, width - cropWidth), top: clamp(top, 0, height - cropHeight), width: cropWidth, height: cropHeight, }; } function loadManifest() { if (!fs.existsSync(manifestPath)) { return {}; } return JSON.parse(fs.readFileSync(manifestPath, "utf8")); } function getThumbSignature(meal, sourceStats) { return { version: THUMB_VERSION, width: THUMB_WIDTH, height: THUMB_HEIGHT, quality: JPEG_QUALITY, mtimeMs: sourceStats.mtimeMs, size: sourceStats.size, focus: meal.thumbnail?.focus ?? null, }; } function manifestEntryMatches(currentEntry, nextEntry) { return JSON.stringify(currentEntry) === JSON.stringify(nextEntry); } async function removeStaleThumbnails(expectedIds) { if (!fs.existsSync(thumbsDir)) { return 0; } const entries = await fs.promises.readdir(thumbsDir, { withFileTypes: true }); let removed = 0; for (const entry of entries) { if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".jpg") { continue; } const id = path.basename(entry.name, path.extname(entry.name)); if (expectedIds.has(id)) { continue; } await fs.promises.unlink(path.join(thumbsDir, entry.name)); removed += 1; } return removed; } async function generateThumbnail(meal, manifest, options) { const { fullPath, thumbPath } = getThumbnailPaths(meal); if (!fs.existsSync(fullPath)) { throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`); } const sourceStats = await fs.promises.stat(fullPath); const signature = getThumbSignature(meal, sourceStats); const thumbExists = fs.existsSync(thumbPath); if ( !options.force && thumbExists && manifestEntryMatches(manifest[getManifestKey(meal.id)], signature) ) { return { mealId: meal.id, changed: false, manifestEntry: signature, }; } const image = sharp(fullPath); const metadata = await image.metadata(); const { width, height } = getOrientedDimensions(metadata); const cropArea = getCropArea(width, height, meal.thumbnail?.focus); await image .rotate() .extract(cropArea) .resize(THUMB_WIDTH, THUMB_HEIGHT) .jpeg({ quality: JPEG_QUALITY, mozjpeg: true }) .toFile(thumbPath); return { mealId: meal.id, changed: true, manifestEntry: signature, }; } function writeManifest(manifest) { const sortedEntries = Object.entries(manifest).sort(([left], [right]) => left.localeCompare(right, undefined, { numeric: true }) ); const orderedManifest = Object.fromEntries(sortedEntries); fs.writeFileSync(manifestPath, `${JSON.stringify(orderedManifest, null, 2)}\n`); } async function main() { const options = parseArgs(process.argv.slice(2)); const meals = loadMeals(); const manifest = loadManifest(); const nextManifest = {}; const expectedIds = new Set(meals.map((meal) => meal.id)); await fs.promises.mkdir(thumbsDir, { recursive: true }); const removed = await removeStaleThumbnails(expectedIds); let generated = 0; let skipped = 0; for (const meal of meals) { const result = await generateThumbnail(meal, manifest, options); nextManifest[getManifestKey(meal.id)] = result.manifestEntry; if (result.changed) { generated += 1; } else { skipped += 1; } } writeManifest(nextManifest); console.log( `Thumbnail build complete: ${generated} generated, ${skipped} skipped, ${removed} removed` ); } main().catch((error) => { console.error(error.message); process.exitCode = 1; });