206 lines
6.1 KiB
JavaScript
206 lines
6.1 KiB
JavaScript
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
const sharp = require("sharp");
|
||
|
|
|
||
|
|
const projectRoot = path.resolve(__dirname, "..");
|
||
|
|
const decksRoot = path.join(projectRoot, "asset", "tarot deck");
|
||
|
|
const ignoredFolderNames = new Set(["template", "templates", "example", "examples"]);
|
||
|
|
const supportedImageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif"]);
|
||
|
|
const defaultThumbnailConfig = {
|
||
|
|
root: "thumbs",
|
||
|
|
width: 240,
|
||
|
|
height: 360,
|
||
|
|
fit: "inside",
|
||
|
|
quality: 82
|
||
|
|
};
|
||
|
|
|
||
|
|
function isPlainObject(value) {
|
||
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function asNonEmptyString(value) {
|
||
|
|
const normalized = String(value || "").trim();
|
||
|
|
return normalized || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function shouldIgnoreDeckFolder(folderName) {
|
||
|
|
const normalized = String(folderName || "").trim().toLowerCase();
|
||
|
|
if (!normalized) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return normalized.startsWith("_") || normalized.startsWith(".") || ignoredFolderNames.has(normalized);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readManifestJson(manifestPath) {
|
||
|
|
try {
|
||
|
|
const text = fs.readFileSync(manifestPath, "utf8");
|
||
|
|
const parsed = JSON.parse(text);
|
||
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeThumbnailConfig(rawConfig) {
|
||
|
|
if (rawConfig === false) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isPlainObject(rawConfig)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
root: asNonEmptyString(rawConfig.root) || defaultThumbnailConfig.root,
|
||
|
|
width: Number.isInteger(Number(rawConfig.width)) && Number(rawConfig.width) > 0
|
||
|
|
? Number(rawConfig.width)
|
||
|
|
: defaultThumbnailConfig.width,
|
||
|
|
height: Number.isInteger(Number(rawConfig.height)) && Number(rawConfig.height) > 0
|
||
|
|
? Number(rawConfig.height)
|
||
|
|
: defaultThumbnailConfig.height,
|
||
|
|
fit: asNonEmptyString(rawConfig.fit) || defaultThumbnailConfig.fit,
|
||
|
|
quality: Number.isInteger(Number(rawConfig.quality)) && Number(rawConfig.quality) >= 1 && Number(rawConfig.quality) <= 100
|
||
|
|
? Number(rawConfig.quality)
|
||
|
|
: defaultThumbnailConfig.quality
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureDirectory(dirPath) {
|
||
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
function listSourceImages(deckFolderPath, thumbnailRoot) {
|
||
|
|
const results = [];
|
||
|
|
|
||
|
|
function walk(currentPath) {
|
||
|
|
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||
|
|
entries.forEach((entry) => {
|
||
|
|
const entryPath = path.join(currentPath, entry.name);
|
||
|
|
const relativePath = path.relative(deckFolderPath, entryPath).replace(/\\/g, "/");
|
||
|
|
if (!relativePath) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (entry.isDirectory()) {
|
||
|
|
if (relativePath === thumbnailRoot || relativePath.startsWith(`${thumbnailRoot}/`)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
walk(entryPath);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!entry.isFile()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!supportedImageExtensions.has(path.extname(entry.name).toLowerCase())) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
results.push(relativePath);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
walk(deckFolderPath);
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
function shouldRegenerateThumbnail(sourcePath, outputPath) {
|
||
|
|
if (!fs.existsSync(outputPath)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sourceStat = fs.statSync(sourcePath);
|
||
|
|
const outputStat = fs.statSync(outputPath);
|
||
|
|
return sourceStat.mtimeMs > outputStat.mtimeMs;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildSharpPipeline(sourcePath, extension, config) {
|
||
|
|
let pipeline = sharp(sourcePath, { animated: extension === ".gif" }).rotate().resize({
|
||
|
|
width: config.width,
|
||
|
|
height: config.height,
|
||
|
|
fit: config.fit,
|
||
|
|
withoutEnlargement: true
|
||
|
|
});
|
||
|
|
|
||
|
|
if (extension === ".jpg" || extension === ".jpeg") {
|
||
|
|
pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true });
|
||
|
|
} else if (extension === ".png") {
|
||
|
|
pipeline = pipeline.png({ quality: config.quality, compressionLevel: 9 });
|
||
|
|
} else if (extension === ".webp") {
|
||
|
|
pipeline = pipeline.webp({ quality: config.quality });
|
||
|
|
} else if (extension === ".avif") {
|
||
|
|
pipeline = pipeline.avif({ quality: config.quality, effort: 4 });
|
||
|
|
}
|
||
|
|
|
||
|
|
return pipeline;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function generateDeckThumbnails() {
|
||
|
|
const entries = fs.readdirSync(decksRoot, { withFileTypes: true });
|
||
|
|
let generatedCount = 0;
|
||
|
|
const warnings = [];
|
||
|
|
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (!entry.isDirectory() || shouldIgnoreDeckFolder(entry.name)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const deckFolderPath = path.join(decksRoot, entry.name);
|
||
|
|
const manifestPath = path.join(deckFolderPath, "deck.json");
|
||
|
|
if (!fs.existsSync(manifestPath)) {
|
||
|
|
warnings.push(`Skipped '${entry.name}': missing deck.json`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const manifest = readManifestJson(manifestPath);
|
||
|
|
if (!manifest) {
|
||
|
|
warnings.push(`Skipped '${entry.name}': deck.json is invalid JSON`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails);
|
||
|
|
if (!thumbnailConfig) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const thumbnailRoot = thumbnailConfig.root.replace(/^\.\//, "").replace(/\/$/, "");
|
||
|
|
ensureDirectory(path.join(deckFolderPath, thumbnailRoot));
|
||
|
|
const sourceImages = listSourceImages(deckFolderPath, thumbnailRoot);
|
||
|
|
|
||
|
|
for (const relativePath of sourceImages) {
|
||
|
|
const sourcePath = path.join(deckFolderPath, relativePath);
|
||
|
|
const outputPath = path.join(deckFolderPath, thumbnailRoot, relativePath);
|
||
|
|
ensureDirectory(path.dirname(outputPath));
|
||
|
|
|
||
|
|
if (!shouldRegenerateThumbnail(sourcePath, outputPath)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const extension = path.extname(sourcePath).toLowerCase();
|
||
|
|
try {
|
||
|
|
await buildSharpPipeline(sourcePath, extension, thumbnailConfig).toFile(outputPath);
|
||
|
|
generatedCount += 1;
|
||
|
|
} catch (error) {
|
||
|
|
warnings.push(`Failed '${entry.name}/${relativePath}': ${error instanceof Error ? error.message : "unknown error"}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { generatedCount, warnings };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const { generatedCount, warnings } = await generateDeckThumbnails();
|
||
|
|
console.log(`[deck-thumbs] Generated ${generatedCount} thumbnail${generatedCount === 1 ? "" : "s"}`);
|
||
|
|
warnings.forEach((warning) => {
|
||
|
|
console.warn(`[deck-thumbs] ${warning}`);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((error) => {
|
||
|
|
console.error(`[deck-thumbs] ${error instanceof Error ? error.message : "Unknown error"}`);
|
||
|
|
process.exitCode = 1;
|
||
|
|
});
|