130 lines
5.2 KiB
JavaScript
130 lines
5.2 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { PNG } from 'pngjs'
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
export const harnessRoot = resolve(here, '..')
|
|
export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art')
|
|
export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json')
|
|
|
|
function parsePng(name, bytes) {
|
|
try {
|
|
return PNG.sync.read(bytes)
|
|
} catch (error) {
|
|
throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
}
|
|
|
|
function validateDataMask(filename, bytes, artwork) {
|
|
const mask = parsePng(filename, bytes)
|
|
if (mask.width !== artwork.width || mask.height !== artwork.height) {
|
|
throw new Error(`${filename} must match artwork dimensions; found ` +
|
|
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`)
|
|
}
|
|
for (let offset = 0; offset < mask.data.length; offset += 4) {
|
|
if (mask.data[offset] !== mask.data[offset + 1] ||
|
|
mask.data[offset] !== mask.data[offset + 2] || mask.data[offset + 3] !== 255) {
|
|
throw new Error(`${filename} must be an opaque grayscale mask`)
|
|
}
|
|
}
|
|
}
|
|
|
|
function validatePair(name, artworkBytes, maskBytes, textMaskBytes) {
|
|
const artwork = parsePng(`${name}.png`, artworkBytes)
|
|
const mask = parsePng(`${name}-mask.png`, maskBytes)
|
|
if (artwork.width * 7 !== artwork.height * 5) {
|
|
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`)
|
|
}
|
|
if (mask.width !== artwork.width || mask.height !== artwork.height) {
|
|
throw new Error(
|
|
`${name}-mask.png must match ${name}.png dimensions; found ` +
|
|
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`,
|
|
)
|
|
}
|
|
for (let offset = 0; offset < artwork.data.length; offset += 4) {
|
|
if (artwork.data[offset + 3] !== 255) {
|
|
throw new Error(`${name}.png must be fully opaque for the runtime card surface`)
|
|
}
|
|
if (
|
|
mask.data[offset] !== mask.data[offset + 1] ||
|
|
mask.data[offset] !== mask.data[offset + 2] ||
|
|
mask.data[offset + 3] !== 255
|
|
) {
|
|
throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`)
|
|
}
|
|
}
|
|
if (textMaskBytes) validateDataMask(`${name}-text-mask.png`, textMaskBytes, artwork)
|
|
return { width: artwork.width, height: artwork.height }
|
|
}
|
|
|
|
export async function scanCardCatalog(root = cardArtRoot) {
|
|
await mkdir(root, { recursive: true })
|
|
const files = (await readdir(root, { withFileTypes: true }))
|
|
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
|
|
.map(entry => entry.name)
|
|
const fileSet = new Set(files)
|
|
const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png'))
|
|
const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png'))
|
|
const cards = []
|
|
const errors = []
|
|
|
|
for (const artworkFile of artworkFiles) {
|
|
const name = artworkFile.slice(0, -4)
|
|
const maskFile = `${name}-mask.png`
|
|
const textMaskFile = `${name}-text-mask.png`
|
|
const hasTextMask = fileSet.has(textMaskFile)
|
|
if (!fileSet.has(maskFile)) {
|
|
errors.push(`Missing ${maskFile} for ${artworkFile}`)
|
|
continue
|
|
}
|
|
try {
|
|
const [artworkBytes, maskBytes, textMaskBytes] = await Promise.all([
|
|
readFile(resolve(root, artworkFile)),
|
|
readFile(resolve(root, maskFile)),
|
|
hasTextMask ? readFile(resolve(root, textMaskFile)) : undefined,
|
|
])
|
|
const dimensions = validatePair(name, artworkBytes, maskBytes, textMaskBytes)
|
|
const hash = createHash('sha256').update(artworkBytes).update(maskBytes)
|
|
if (textMaskBytes) hash.update(textMaskBytes)
|
|
const revision = hash.digest('hex').slice(0, 16)
|
|
cards.push({
|
|
name,
|
|
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`,
|
|
mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`,
|
|
...(hasTextMask ? { textMask: `/card-art/${encodeURIComponent(textMaskFile)}?v=${revision}` } : {}),
|
|
revision,
|
|
...dimensions,
|
|
})
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error))
|
|
}
|
|
}
|
|
|
|
for (const maskFile of maskFiles) {
|
|
const suffixLength = maskFile.toLowerCase().endsWith('-text-mask.png') ? 14 : 9
|
|
const artworkFile = `${maskFile.slice(0, -suffixLength)}.png`
|
|
if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`)
|
|
}
|
|
|
|
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
|
errors.sort()
|
|
return { schemaVersion: 1, cards, errors }
|
|
}
|
|
|
|
export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) {
|
|
const catalog = await scanCardCatalog(root)
|
|
await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`)
|
|
return catalog
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const catalog = await writeCardCatalog()
|
|
console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
|
|
if (catalog.errors.length) {
|
|
for (const error of catalog.errors) console.error(`- ${error}`)
|
|
process.exitCode = 1
|
|
}
|
|
}
|