add: script to generate thumbnails
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const mealsPath = path.join(repoRoot, "data", "meals.json");
|
||||
const { loadMeals, repoRoot } = require("./lib/meals");
|
||||
|
||||
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
|
||||
const indexOutputPath = path.join(repoRoot, "index.html");
|
||||
|
||||
@@ -17,28 +17,6 @@ function escapeHtml(value) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function validateMeals(meals) {
|
||||
if (!Array.isArray(meals)) {
|
||||
throw new Error("data/meals.json must contain an array");
|
||||
}
|
||||
|
||||
for (const [index, meal] of meals.entries()) {
|
||||
if (!meal || typeof meal !== "object") {
|
||||
throw new Error(`Meal at index ${index} must be an object`);
|
||||
}
|
||||
|
||||
for (const field of ["id", "title", "description"]) {
|
||||
if (typeof meal[field] !== "string" || meal[field].length === 0) {
|
||||
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (meal.position !== undefined && typeof meal.position !== "string") {
|
||||
throw new Error(`Meal ${index} has a non-string "position" value`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderGalleryItem(meal, eol) {
|
||||
const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`];
|
||||
|
||||
@@ -72,9 +50,7 @@ function replaceBlock(template, token, replacement) {
|
||||
function buildIndex() {
|
||||
const template = fs.readFileSync(indexTemplatePath, "utf8");
|
||||
const eol = detectEol(template);
|
||||
const meals = JSON.parse(fs.readFileSync(mealsPath, "utf8"));
|
||||
|
||||
validateMeals(meals);
|
||||
const meals = loadMeals();
|
||||
|
||||
return replaceBlock(template, "gallery_items", renderGallery(meals, eol));
|
||||
}
|
||||
|
||||
93
scripts/generate-thumbnails.js
Normal file
93
scripts/generate-thumbnails.js
Normal file
@@ -0,0 +1,93 @@
|
||||
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 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 getThumbnailPaths(meal) {
|
||||
return {
|
||||
fullPath: path.join(fullsDir, `${meal.id}.jpg`),
|
||||
thumbPath: path.join(thumbsDir, `${meal.id}.jpg`),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateThumbnail(meal) {
|
||||
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();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error(`Could not read image dimensions for ${fullPath}`);
|
||||
}
|
||||
|
||||
const cropArea = getCropArea(
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
meal.thumbnail?.focus
|
||||
);
|
||||
|
||||
await image
|
||||
.extract(cropArea)
|
||||
.resize(THUMB_WIDTH, THUMB_HEIGHT)
|
||||
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
|
||||
.toFile(thumbPath);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const meals = loadMeals();
|
||||
|
||||
await fs.promises.mkdir(thumbsDir, { recursive: true });
|
||||
|
||||
for (const meal of meals) {
|
||||
await generateThumbnail(meal);
|
||||
}
|
||||
|
||||
console.log(`Generated ${meals.length} thumbnails in images/thumbs`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
81
scripts/lib/meals.js
Normal file
81
scripts/lib/meals.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
const mealsPath = path.join(repoRoot, "data", "meals.json");
|
||||
|
||||
function validateThumbnail(meal, index) {
|
||||
if (meal.thumbnail === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!meal.thumbnail ||
|
||||
typeof meal.thumbnail !== "object" ||
|
||||
Array.isArray(meal.thumbnail)
|
||||
) {
|
||||
throw new Error(`Meal ${index} has an invalid "thumbnail" object`);
|
||||
}
|
||||
|
||||
if (meal.thumbnail.focus === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { focus } = meal.thumbnail;
|
||||
|
||||
if (!focus || typeof focus !== "object" || Array.isArray(focus)) {
|
||||
throw new Error(`Meal ${index} has an invalid "thumbnail.focus" object`);
|
||||
}
|
||||
|
||||
for (const axis of ["x", "y"]) {
|
||||
if (typeof focus[axis] !== "number" || !Number.isFinite(focus[axis])) {
|
||||
throw new Error(
|
||||
`Meal ${index} has a non-numeric thumbnail focus value for "${axis}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (focus[axis] < 0 || focus[axis] > 1) {
|
||||
throw new Error(
|
||||
`Meal ${index} thumbnail focus "${axis}" must be between 0 and 1`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMeals(meals) {
|
||||
if (!Array.isArray(meals)) {
|
||||
throw new Error("data/meals.json must contain an array");
|
||||
}
|
||||
|
||||
for (const [index, meal] of meals.entries()) {
|
||||
if (!meal || typeof meal !== "object") {
|
||||
throw new Error(`Meal at index ${index} must be an object`);
|
||||
}
|
||||
|
||||
for (const field of ["id", "title", "description"]) {
|
||||
if (typeof meal[field] !== "string" || meal[field].length === 0) {
|
||||
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (meal.position !== undefined && typeof meal.position !== "string") {
|
||||
throw new Error(`Meal ${index} has a non-string "position" value`);
|
||||
}
|
||||
|
||||
validateThumbnail(meal, index);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMeals() {
|
||||
const meals = JSON.parse(fs.readFileSync(mealsPath, "utf8"));
|
||||
|
||||
validateMeals(meals);
|
||||
|
||||
return meals;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadMeals,
|
||||
mealsPath,
|
||||
repoRoot,
|
||||
};
|
||||
Reference in New Issue
Block a user