add: harden thumbnail generation and validate image assets
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-22 20:04:26 -07:00
parent 3439fc834f
commit 21c3a0c4b2
5 changed files with 461 additions and 13 deletions

View File

@@ -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) => {