added thumbs generation for performation and also added a new deck format for registration

This commit is contained in:
2026-03-08 05:40:53 -07:00
parent 78abb582dd
commit 4713bbd54b
11 changed files with 1255 additions and 44 deletions

View 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;
});

View File

@@ -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 } : {})
});
});