218 lines
5.2 KiB
JavaScript
218 lines
5.2 KiB
JavaScript
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;
|
|
});
|