diff --git a/README.md b/README.md index 68e0fdf..89329c2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ If you only need to regenerate thumbnails, run: npm run build:thumbs ``` +To force a full thumbnail rebuild, run: + +```sh +npm run build:thumbs:force +``` + ## Image Conventions - Full-size images and thumbnails share the same numeric ID @@ -45,6 +51,7 @@ npm run build:thumbs ## Thumbnail Focus Thumbnails are generated from `images/fulls` with `sharp` at `240x320`. +The generator auto-rotates images using EXIF orientation, skips unchanged files by default, and removes stale thumbnail `.jpg` files that no longer map to a meal entry. For images that should crop away from the center, add optional thumbnail focus metadata to the meal entry: diff --git a/images/thumbs/.thumbs-manifest.json b/images/thumbs/.thumbs-manifest.json new file mode 100644 index 0000000..e68a568 --- /dev/null +++ b/images/thumbs/.thumbs-manifest.json @@ -0,0 +1,308 @@ +{ + "meal:01": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 1052830, + "focus": { + "x": 0.35, + "y": 0.5 + } + }, + "meal:02": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 835360, + "focus": null + }, + "meal:03": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1034158, + "focus": { + "x": 0.5, + "y": 0.35 + } + }, + "meal:04": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1090215, + "focus": null + }, + "meal:05": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1122236, + "focus": { + "x": 0.5, + "y": 0.35 + } + }, + "meal:06": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770444049000, + "size": 676787, + "focus": null + }, + "meal:07": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 872024, + "focus": null + }, + "meal:08": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 618276, + "focus": null + }, + "meal:09": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500127000, + "size": 1349804, + "focus": null + }, + "meal:10": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1071870, + "focus": null + }, + "meal:11": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 764329, + "focus": null + }, + "meal:12": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1172905, + "focus": null + }, + "meal:13": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 1099540, + "focus": null + }, + "meal:14": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1052362, + "focus": null + }, + "meal:15": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1227608, + "focus": null + }, + "meal:16": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500127000, + "size": 840466, + "focus": null + }, + "meal:17": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1136990, + "focus": null + }, + "meal:18": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500127000, + "size": 1261294, + "focus": null + }, + "meal:19": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1119498, + "focus": null + }, + "meal:20": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 868085, + "focus": null + }, + "meal:21": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1057896, + "focus": null + }, + "meal:22": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1088795, + "focus": null + }, + "meal:23": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 852307, + "focus": null + }, + "meal:24": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 1149955, + "focus": null + }, + "meal:25": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 1242099, + "focus": null + }, + "meal:26": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1414024, + "focus": null + }, + "meal:27": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1022877, + "focus": null + }, + "meal:28": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 1018868, + "focus": null + }, + "meal:29": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500128000, + "size": 1233602, + "focus": null + }, + "meal:30": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1770500129000, + "size": 739786, + "focus": null + }, + "meal:31": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1771126436000, + "size": 1069693, + "focus": null + }, + "meal:32": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1771226059000, + "size": 995282, + "focus": null + }, + "meal:33": { + "version": 1, + "width": 240, + "height": 320, + "quality": 82, + "mtimeMs": 1771226144000, + "size": 729224, + "focus": null + } +} diff --git a/package.json b/package.json index c32cc86..7b9e0ac 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "scripts": { "build": "npm run build:thumbs && npm run build:pages", "build:pages": "node scripts/build.js", - "build:thumbs": "node scripts/generate-thumbnails.js" + "build:thumbs": "node scripts/generate-thumbnails.js", + "build:thumbs:force": "node scripts/generate-thumbnails.js --force" }, "dependencies": { "sharp": "^0.34.5" diff --git a/scripts/generate-thumbnails.js b/scripts/generate-thumbnails.js index d1ceff1..efb539b 100644 --- a/scripts/generate-thumbnails.js +++ b/scripts/generate-thumbnails.js @@ -6,7 +6,9 @@ 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; @@ -15,6 +17,16 @@ 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`), @@ -22,6 +34,24 @@ function getThumbnailPaths(meal) { }; } +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; @@ -48,43 +78,137 @@ function getCropArea(width, height, focus) { }; } -async function generateThumbnail(meal) { +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 image = sharp(fullPath); - const metadata = await image.metadata(); + const sourceStats = await fs.promises.stat(fullPath); + const signature = getThumbSignature(meal, sourceStats); + const thumbExists = fs.existsSync(thumbPath); - if (!metadata.width || !metadata.height) { - throw new Error(`Could not read image dimensions for ${fullPath}`); + if ( + !options.force && + thumbExists && + manifestEntryMatches(manifest[getManifestKey(meal.id)], signature) + ) { + return { + mealId: meal.id, + changed: false, + manifestEntry: signature, + }; } - const cropArea = getCropArea( - metadata.width, - metadata.height, - meal.thumbnail?.focus - ); + 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) { - await generateThumbnail(meal); + const result = await generateThumbnail(meal, manifest, options); + nextManifest[getManifestKey(meal.id)] = result.manifestEntry; + + if (result.changed) { + generated += 1; + } else { + skipped += 1; + } } - console.log(`Generated ${meals.length} thumbnails in images/thumbs`); + writeManifest(nextManifest); + + console.log( + `Thumbnail build complete: ${generated} generated, ${skipped} skipped, ${removed} removed` + ); } main().catch((error) => { diff --git a/scripts/lib/meals.js b/scripts/lib/meals.js index 00151ce..22e7303 100644 --- a/scripts/lib/meals.js +++ b/scripts/lib/meals.js @@ -47,6 +47,8 @@ function validateMeals(meals) { throw new Error("data/meals.json must contain an array"); } + const ids = new Set(); + for (const [index, meal] of meals.entries()) { if (!meal || typeof meal !== "object") { throw new Error(`Meal at index ${index} must be an object`); @@ -62,6 +64,12 @@ function validateMeals(meals) { throw new Error(`Meal ${index} has a non-string "position" value`); } + if (ids.has(meal.id)) { + throw new Error(`Duplicate meal id "${meal.id}" found in data/meals.json`); + } + + ids.add(meal.id); + validateThumbnail(meal, index); } }