Files
TaroTime/scripts/generate-deck-thumbnails.cjs

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