added thumbs generation for performation and also added a new deck format for registration
This commit is contained in:
206
scripts/generate-deck-thumbnails.cjs
Normal file
206
scripts/generate-deck-thumbnails.cjs
Normal file
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
});
|
||||
@@ -8,6 +8,15 @@ const ignoredFolderNames = new Set(["template", "templates", "example", "example
|
||||
const tarotSuits = ["wands", "cups", "swords", "disks"];
|
||||
const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index);
|
||||
const expectedMinorCardCount = 56;
|
||||
const defaultPipRankOrder = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
|
||||
const defaultThumbnailConfig = {
|
||||
root: "thumbs",
|
||||
width: 240,
|
||||
height: 360,
|
||||
fit: "inside",
|
||||
quality: 82
|
||||
};
|
||||
const supportedThumbnailFits = new Set(["contain", "cover", "fill", "inside", "outside"]);
|
||||
const cardBackCandidateExtensions = ["webp", "png", "jpg", "jpeg", "avif", "gif"];
|
||||
|
||||
function isPlainObject(value) {
|
||||
@@ -61,6 +70,71 @@ function readManifestJson(manifestPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThumbnailConfig(thumbnails) {
|
||||
if (thumbnails == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (thumbnails === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isPlainObject(thumbnails)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root: asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root,
|
||||
width: Number.isInteger(Number(thumbnails.width)) && Number(thumbnails.width) > 0
|
||||
? Number(thumbnails.width)
|
||||
: defaultThumbnailConfig.width,
|
||||
height: Number.isInteger(Number(thumbnails.height)) && Number(thumbnails.height) > 0
|
||||
? Number(thumbnails.height)
|
||||
: defaultThumbnailConfig.height,
|
||||
fit: asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit,
|
||||
quality: Number.isInteger(Number(thumbnails.quality)) && Number(thumbnails.quality) >= 1 && Number(thumbnails.quality) <= 100
|
||||
? Number(thumbnails.quality)
|
||||
: defaultThumbnailConfig.quality
|
||||
};
|
||||
}
|
||||
|
||||
function validateThumbnailConfig(thumbnails) {
|
||||
if (thumbnails == null || thumbnails === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPlainObject(thumbnails)) {
|
||||
return "thumbnails must be an object or false when provided";
|
||||
}
|
||||
|
||||
const root = asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root;
|
||||
if (!root || root.startsWith("/") || isRemoteAssetPath(root)) {
|
||||
return "thumbnails.root must be a relative folder name";
|
||||
}
|
||||
|
||||
if (thumbnails.width != null && (!Number.isInteger(Number(thumbnails.width)) || Number(thumbnails.width) <= 0)) {
|
||||
return "thumbnails.width must be a positive integer when provided";
|
||||
}
|
||||
|
||||
if (thumbnails.height != null && (!Number.isInteger(Number(thumbnails.height)) || Number(thumbnails.height) <= 0)) {
|
||||
return "thumbnails.height must be a positive integer when provided";
|
||||
}
|
||||
|
||||
if (thumbnails.quality != null) {
|
||||
const quality = Number(thumbnails.quality);
|
||||
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
||||
return "thumbnails.quality must be an integer between 1 and 100 when provided";
|
||||
}
|
||||
}
|
||||
|
||||
const fit = asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit;
|
||||
if (!supportedThumbnailFits.has(fit)) {
|
||||
return `thumbnails.fit must be one of ${Array.from(supportedThumbnailFits).join(", ")}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasNonEmptyStringMapEntries(value) {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
@@ -69,12 +143,20 @@ function hasNonEmptyStringMapEntries(value) {
|
||||
return Object.values(value).some((entryValue) => String(entryValue || "").trim());
|
||||
}
|
||||
|
||||
function hasRankResolver(minorRule) {
|
||||
function getNormalizedRankOrder(rankSource, fallbackRankOrder = []) {
|
||||
const explicitRankOrder = Array.isArray(rankSource?.rankOrder) ? rankSource.rankOrder : [];
|
||||
const rankOrderSource = explicitRankOrder.length ? explicitRankOrder : fallbackRankOrder;
|
||||
return rankOrderSource
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasRankResolver(minorRule, fallbackRankOrder = []) {
|
||||
if (!isPlainObject(minorRule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder.filter((entry) => String(entry || "").trim()) : [];
|
||||
const rankOrder = getNormalizedRankOrder(minorRule, fallbackRankOrder);
|
||||
if (rankOrder.length) {
|
||||
return true;
|
||||
}
|
||||
@@ -166,6 +248,27 @@ function validateMinorsRule(minors) {
|
||||
return "minors.mode is required";
|
||||
}
|
||||
|
||||
if (mode === "split-number-template") {
|
||||
const courtsError = validateMinorNumberTemplateGroup(minors.courts, {
|
||||
label: "minors.courts",
|
||||
expectedRankCount: 4
|
||||
});
|
||||
if (courtsError) {
|
||||
return courtsError;
|
||||
}
|
||||
|
||||
const smallsError = validateMinorNumberTemplateGroup(minors.smalls, {
|
||||
label: "minors.smalls",
|
||||
expectedRankCount: defaultPipRankOrder.length,
|
||||
fallbackRankOrder: defaultPipRankOrder
|
||||
});
|
||||
if (smallsError) {
|
||||
return smallsError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasRankResolver(minors)) {
|
||||
return "minors must define rankOrder or rankIndexByKey";
|
||||
}
|
||||
@@ -205,6 +308,38 @@ function validateMinorsRule(minors) {
|
||||
return `unsupported minors.mode '${mode}'`;
|
||||
}
|
||||
|
||||
function validateMinorNumberTemplateGroup(groupRule, options = {}) {
|
||||
const label = String(options.label || "minor group");
|
||||
const expectedRankCount = Number(options.expectedRankCount);
|
||||
const fallbackRankOrder = Array.isArray(options.fallbackRankOrder) ? options.fallbackRankOrder : [];
|
||||
|
||||
if (!isPlainObject(groupRule)) {
|
||||
return `${label} must be an object`;
|
||||
}
|
||||
|
||||
if (!hasRankResolver(groupRule, fallbackRankOrder)) {
|
||||
return `${label} must define rankOrder or rankIndexByKey`;
|
||||
}
|
||||
|
||||
if (getRankEntries(groupRule, fallbackRankOrder).length !== expectedRankCount) {
|
||||
return `${label} must define exactly ${expectedRankCount} ranks`;
|
||||
}
|
||||
|
||||
if (!hasSuitNumberMap(groupRule.suitBase)) {
|
||||
return `${label}.suitBase must define numeric bases for wands, cups, swords, and disks`;
|
||||
}
|
||||
|
||||
if (!asNonEmptyString(groupRule.template)) {
|
||||
return `${label}.template is required`;
|
||||
}
|
||||
|
||||
if (groupRule.numberPad != null && !Number.isInteger(Number(groupRule.numberPad))) {
|
||||
return `${label}.numberPad must be an integer when provided`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateDeckManifest(manifest) {
|
||||
const errors = [];
|
||||
|
||||
@@ -250,15 +385,16 @@ function validateDeckManifest(manifest) {
|
||||
errors.push("cardBack must be a non-empty string when provided");
|
||||
}
|
||||
|
||||
const thumbnailsError = validateThumbnailConfig(manifest.thumbnails);
|
||||
if (thumbnailsError) {
|
||||
errors.push(thumbnailsError);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function getRankEntries(minors) {
|
||||
const rankOrder = Array.isArray(minors?.rankOrder)
|
||||
? minors.rankOrder
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
function getRankEntries(minors, fallbackRankOrder = []) {
|
||||
const rankOrder = getNormalizedRankOrder(minors, fallbackRankOrder);
|
||||
|
||||
if (rankOrder.length) {
|
||||
return rankOrder.map((rankWord, rankIndex) => ({
|
||||
@@ -311,6 +447,13 @@ function getReferencedMinorFiles(manifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (mode === "split-number-template") {
|
||||
return [
|
||||
...getReferencedSplitMinorGroupFiles(minors.courts),
|
||||
...getReferencedSplitMinorGroupFiles(minors.smalls, defaultPipRankOrder)
|
||||
];
|
||||
}
|
||||
|
||||
const rankEntries = getRankEntries(minors);
|
||||
if (!rankEntries.length) {
|
||||
return [];
|
||||
@@ -367,6 +510,27 @@ function getReferencedMinorFiles(manifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function getReferencedSplitMinorGroupFiles(groupRule, fallbackRankOrder = []) {
|
||||
const rankEntries = getRankEntries(groupRule, fallbackRankOrder);
|
||||
if (!rankEntries.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = String(groupRule?.template || "{number}.png");
|
||||
const numberPad = Number.isInteger(Number(groupRule?.numberPad)) ? Number(groupRule.numberPad) : 2;
|
||||
|
||||
return tarotSuits.flatMap((suitId) => {
|
||||
const suitBase = Number(groupRule?.suitBase?.[suitId]);
|
||||
return rankEntries.map((entry) => applyTemplate(template, {
|
||||
number: String(suitBase + entry.rankIndex).padStart(numberPad, "0"),
|
||||
rank: entry.rankWord,
|
||||
rankKey: entry.rankKey,
|
||||
suitId,
|
||||
index: entry.rankIndex
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function getReferencedCardBackFiles(manifest) {
|
||||
const cardBack = asNonEmptyString(manifest?.cardBack);
|
||||
if (!cardBack || isRemoteAssetPath(cardBack)) {
|
||||
@@ -491,6 +655,7 @@ function compileDeckRegistry() {
|
||||
const label = labelFromManifest || folderName;
|
||||
const basePath = `asset/tarot deck/${folderName}`;
|
||||
const cardBackPath = detectDeckCardBackRelativePath(folderName, manifest);
|
||||
const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails);
|
||||
|
||||
if (seenIds.has(id)) {
|
||||
warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`);
|
||||
@@ -504,6 +669,7 @@ function compileDeckRegistry() {
|
||||
label,
|
||||
basePath,
|
||||
manifestPath: `${basePath}/deck.json`,
|
||||
...(thumbnailConfig && thumbnailConfig !== false ? { thumbnailRoot: thumbnailConfig.root } : {}),
|
||||
...(cardBackPath ? { cardBackPath } : {})
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user