|
#!/usr/bin/env node |
|
|
|
/** |
|
* Dependency-free SmolForge skin-pack normalizer/scaffolder. |
|
* |
|
* This script does not generate, resize, or repair image assets. It validates |
|
* the supplied manifest and every asset that already exists, then emits a |
|
* deterministic dist plan. In scaffold mode, missing strips remain explicit |
|
* plan entries; in validate mode, any missing asset fails the run. |
|
*/ |
|
|
|
import { createHash } from 'node:crypto'; |
|
import { |
|
constants as fsConstants, |
|
copyFile, |
|
lstat, |
|
mkdir, |
|
readFile, |
|
stat, |
|
writeFile, |
|
} from 'node:fs/promises'; |
|
import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path'; |
|
import { fileURLToPath } from 'node:url'; |
|
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); |
|
const PRODUCT_ACTIONS = [ |
|
'idle', 'walk', 'work', 'review', 'wait', 'fail', 'wave', 'celebrate', |
|
]; |
|
const GAME_ACTIONS = [ |
|
'interact', 'sleep', 'talk', 'dance', 'attack-short', 'attack-long', |
|
'war-cry', 'alert-low', 'alert-high', |
|
]; |
|
const PLAYBACK = new Set(['once', 'loop', 'ping-pong', 'hold']); |
|
const DIRECTIONS = ['left', 'right', 'ne', 'nw', 'se', 'sw']; |
|
const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; |
|
const FRAME = { |
|
width: 192, |
|
height: 208, |
|
anchor: { x: 96, y: 192 }, |
|
baseline: 192, |
|
safeBounds: { left: 18, top: 16, right: 174, bottom: 192 }, |
|
canonicalFacing: 'three-quarter-front-right', |
|
}; |
|
const LIMITS = { |
|
files: 256, |
|
manifestBytes: 128 * 1024, |
|
assetBytes: 8 * 1024 * 1024, |
|
totalBytes: 64 * 1024 * 1024, |
|
frameDurationMs: 60_000, |
|
}; |
|
|
|
const HELP = `Usage: |
|
node regenerate-skin-pack.mjs --dry-run [options] |
|
node regenerate-skin-pack.mjs --out <new-directory> [options] |
|
|
|
Options: |
|
--source <dir> Input root (default: directory containing this script) |
|
--manifest <path> Manifest relative to source (default: skin.product.example.json) |
|
--spec <path> Markdown spec relative to source (default: smol forge checklist.md) |
|
--schema <path> JSON Schema relative to source (default: skin.schema.json) |
|
--scene <path> App-owned scene example relative to source |
|
(default: scene-socket.example.json) |
|
--reference <path> Existing reference/scene image relative to source |
|
(default: thunder-smith-vnext-apron-helmet.png) |
|
--mode <mode> "scaffold" allows missing strips; "validate" requires all |
|
referenced assets (default: scaffold) |
|
--out <dir> New output directory; must not already exist |
|
--dry-run Print the deterministic plan without writing |
|
--help, -h Show this help |
|
|
|
Examples: |
|
node regenerate-skin-pack.mjs --dry-run |
|
node regenerate-skin-pack.mjs --out ./thunder-smith-dist-scaffold |
|
node regenerate-skin-pack.mjs --source ./complete-pack --manifest skin.json \\ |
|
--reference reference.png --mode validate --out ../validated-dist |
|
|
|
The script never synthesizes images, follows remote URLs, installs packages, |
|
or overwrites an existing output directory.`; |
|
|
|
class PackError extends Error {} |
|
|
|
function parseArgs(argv) { |
|
const options = { |
|
source: SCRIPT_DIR, |
|
manifest: 'skin.product.example.json', |
|
spec: 'smol forge checklist.md', |
|
schema: 'skin.schema.json', |
|
scene: 'scene-socket.example.json', |
|
reference: 'thunder-smith-vnext-apron-helmet.png', |
|
mode: 'scaffold', |
|
out: null, |
|
dryRun: false, |
|
help: false, |
|
}; |
|
const valueOptions = new Set([ |
|
'source', 'manifest', 'spec', 'schema', 'scene', 'reference', 'mode', 'out', |
|
]); |
|
|
|
for (let index = 0; index < argv.length; index += 1) { |
|
const token = argv[index]; |
|
if (token === '--dry-run') { |
|
options.dryRun = true; |
|
continue; |
|
} |
|
if (token === '--help' || token === '-h') { |
|
options.help = true; |
|
continue; |
|
} |
|
if (!token.startsWith('--')) throw new PackError(`Unexpected argument: ${token}`); |
|
const [rawName, inlineValue] = token.slice(2).split('=', 2); |
|
if (!valueOptions.has(rawName)) throw new PackError(`Unknown option: --${rawName}`); |
|
const value = inlineValue ?? argv[index + 1]; |
|
if (!value || value.startsWith('--')) throw new PackError(`--${rawName} needs a value`); |
|
if (inlineValue === undefined) index += 1; |
|
options[rawName] = value; |
|
} |
|
|
|
if (!['scaffold', 'validate'].includes(options.mode)) { |
|
throw new PackError('--mode must be "scaffold" or "validate"'); |
|
} |
|
if (!options.help && !options.dryRun && !options.out) { |
|
throw new PackError('--out is required unless --dry-run is used'); |
|
} |
|
return options; |
|
} |
|
|
|
function stableJson(value) { |
|
return `${JSON.stringify(value, null, 2)}\n`; |
|
} |
|
|
|
function sha256(bytes) { |
|
return createHash('sha256').update(bytes).digest('hex'); |
|
} |
|
|
|
function isRecord(value) { |
|
return value !== null && typeof value === 'object' && !Array.isArray(value); |
|
} |
|
|
|
function requireText(record, field, label, maxLength) { |
|
const value = record[field]; |
|
if (typeof value !== 'string' || !value.trim() || value.length > maxLength) { |
|
throw new PackError(`${label} must be between 1 and ${maxLength} characters`); |
|
} |
|
return value.trim(); |
|
} |
|
|
|
function optionalHttps(value, label) { |
|
if (value === undefined) return undefined; |
|
if (typeof value !== 'string' || value.length > 2048 || !value.startsWith('https://')) { |
|
throw new PackError(`${label} must be an HTTPS URL`); |
|
} |
|
return value; |
|
} |
|
|
|
function normalizeCredits(raw) { |
|
if (raw === undefined) return []; |
|
if (!Array.isArray(raw) || raw.length < 1 || raw.length > 32) { |
|
throw new PackError('credits must contain 1 through 32 entries'); |
|
} |
|
return raw.map((credit, index) => { |
|
if (!isRecord(credit)) throw new PackError(`credits[${index}] must be an object`); |
|
return { |
|
name: requireText(credit, 'name', `credits[${index}].name`, 80), |
|
...(credit.role ? { |
|
role: requireText(credit, 'role', `credits[${index}].role`, 80), |
|
} : {}), |
|
...(credit.url ? { url: optionalHttps(credit.url, `credits[${index}].url`) } : {}), |
|
}; |
|
}); |
|
} |
|
|
|
function normalizeLicense(raw) { |
|
if (raw === undefined) return undefined; |
|
if (!isRecord(raw)) throw new PackError('license must be an object'); |
|
return { |
|
spdx: requireText(raw, 'spdx', 'license.spdx', 160), |
|
...(raw.name ? { name: requireText(raw, 'name', 'license.name', 80) } : {}), |
|
...(raw.url ? { url: optionalHttps(raw.url, 'license.url') } : {}), |
|
}; |
|
} |
|
|
|
function normalizeProvenance(raw) { |
|
if (raw === undefined) return undefined; |
|
if (!isRecord(raw)) throw new PackError('provenance must be an object'); |
|
let generators; |
|
if (raw.generators !== undefined) { |
|
if ( |
|
!Array.isArray(raw.generators) |
|
|| raw.generators.length > 32 |
|
|| raw.generators.some((value) => typeof value !== 'string' || !value.trim() || value.length > 160) |
|
) { |
|
throw new PackError('provenance.generators must contain up to 32 short names'); |
|
} |
|
generators = raw.generators.map((value) => value.trim()); |
|
} |
|
return { |
|
...(raw.sourceUrl ? { sourceUrl: optionalHttps(raw.sourceUrl, 'provenance.sourceUrl') } : {}), |
|
...(raw.sourceRevision ? { |
|
sourceRevision: requireText(raw, 'sourceRevision', 'provenance.sourceRevision', 128), |
|
} : {}), |
|
...(generators ? { generators } : {}), |
|
}; |
|
} |
|
|
|
function normalizeExtensions(raw) { |
|
if (raw === undefined) return undefined; |
|
if (!isRecord(raw)) throw new PackError('extensions must be an object'); |
|
const keyPattern = /^[a-z0-9](?:[a-z0-9-]*\.)+[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/; |
|
for (const key of Object.keys(raw)) { |
|
if (!keyPattern.test(key)) throw new PackError(`Invalid extension key: ${key}`); |
|
} |
|
return structuredClone(raw); |
|
} |
|
|
|
function safeRelativePath(value, label) { |
|
if (typeof value !== 'string') throw new PackError(`${label} must be a path`); |
|
const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, ''); |
|
const parts = normalized.split('/'); |
|
if ( |
|
!normalized |
|
|| isAbsolute(normalized) |
|
|| /^[a-z][a-z0-9+.-]*:/i.test(normalized) |
|
|| parts.some((part) => !part || part === '.' || part === '..') |
|
|| /[\0-\x1f\x7f]/.test(normalized) |
|
) { |
|
throw new PackError(`${label} is not a safe package-relative path: ${value}`); |
|
} |
|
return normalized; |
|
} |
|
|
|
function pathInside(root, relativePath, label) { |
|
const target = resolve(root, relativePath); |
|
const fromRoot = relative(root, target); |
|
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { |
|
if (!fromRoot) return target; |
|
throw new PackError(`${label} leaves the source root`); |
|
} |
|
return target; |
|
} |
|
|
|
function normalizeFrame(raw) { |
|
const valid = isRecord(raw) |
|
&& raw.width === FRAME.width |
|
&& raw.height === FRAME.height |
|
&& isRecord(raw.anchor) |
|
&& raw.anchor.x === FRAME.anchor.x |
|
&& raw.anchor.y === FRAME.anchor.y |
|
&& raw.baseline === FRAME.baseline |
|
&& isRecord(raw.safeBounds) |
|
&& raw.safeBounds.left === FRAME.safeBounds.left |
|
&& raw.safeBounds.top === FRAME.safeBounds.top |
|
&& raw.safeBounds.right === FRAME.safeBounds.right |
|
&& raw.safeBounds.bottom === FRAME.safeBounds.bottom |
|
&& raw.canonicalFacing === FRAME.canonicalFacing; |
|
if (!valid) { |
|
throw new PackError('frame must exactly match the SmolForge 192 x 208 placement contract'); |
|
} |
|
return FRAME; |
|
} |
|
|
|
function normalizeClip(raw, family, profile) { |
|
if (!isRecord(raw)) throw new PackError(`${family} contains an invalid clip`); |
|
const frames = raw.frames; |
|
if (!Number.isInteger(frames) || frames < 1 || frames > 8) { |
|
throw new PackError(`${family} frames must be an integer from 1 through 8`); |
|
} |
|
if ( |
|
!Number.isInteger(raw.frameDurationMs) |
|
|| raw.frameDurationMs <= 0 |
|
|| raw.frameDurationMs > LIMITS.frameDurationMs |
|
) { |
|
throw new PackError(`${family} has an invalid frameDurationMs`); |
|
} |
|
const familyPattern = family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
if (typeof raw.id !== 'string' || !new RegExp(`^${familyPattern}\\.v(?:0[1-9]|10)$`).test(raw.id)) { |
|
throw new PackError(`${family} clip IDs must use ${family}.v01 through ${family}.v10`); |
|
} |
|
if (!PLAYBACK.has(raw.playback)) throw new PackError(`${raw.id} has unsupported playback`); |
|
if ( |
|
raw.frameDurationsMs !== undefined |
|
&& ( |
|
!Array.isArray(raw.frameDurationsMs) |
|
|| raw.frameDurationsMs.length !== frames |
|
|| raw.frameDurationsMs.some((duration) => ( |
|
!Number.isInteger(duration) || duration <= 0 || duration > LIMITS.frameDurationMs |
|
)) |
|
) |
|
) { |
|
throw new PackError(`${raw.id} needs one valid duration per frame`); |
|
} |
|
|
|
const clip = { |
|
id: raw.id, |
|
name: requireText(raw, 'name', `${raw.id} name`, 80), |
|
description: requireText(raw, 'description', `${raw.id} description`, 500), |
|
whenToUse: requireText(raw, 'whenToUse', `${raw.id} whenToUse`, 500), |
|
frames, |
|
frameDurationMs: raw.frameDurationMs, |
|
...(raw.frameDurationsMs ? { frameDurationsMs: [...raw.frameDurationsMs] } : {}), |
|
playback: raw.playback, |
|
posterFrame: raw.posterFrame ?? 0, |
|
}; |
|
if (!Number.isInteger(clip.posterFrame) || clip.posterFrame < 0 || clip.posterFrame >= frames) { |
|
throw new PackError(`${raw.id} posterFrame must point to an existing frame`); |
|
} |
|
if (raw.cues !== undefined) { |
|
if (!Array.isArray(raw.cues) || raw.cues.length > 32) { |
|
throw new PackError(`${raw.id} cues must be an array of at most 32 entries`); |
|
} |
|
const cueIds = new Set(); |
|
clip.cues = raw.cues.map((cue, index) => { |
|
if (!isRecord(cue)) throw new PackError(`${raw.id} cue ${index} must be an object`); |
|
const id = requireText(cue, 'id', `${raw.id} cue ${index} id`, 64); |
|
if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new PackError(`${raw.id} has an invalid cue ID: ${id}`); |
|
if (cueIds.has(id)) throw new PackError(`${raw.id} has duplicate cue ID: ${id}`); |
|
cueIds.add(id); |
|
if (!Number.isInteger(cue.frame) || cue.frame < 0 || cue.frame >= frames) { |
|
throw new PackError(`${raw.id} cue ${id} must point to an existing frame`); |
|
} |
|
return { |
|
id, |
|
frame: cue.frame, |
|
...(cue.description ? { |
|
description: requireText(cue, 'description', `${raw.id} cue ${id} description`, 240), |
|
} : {}), |
|
}; |
|
}); |
|
} |
|
if (raw.mirrorSafe !== undefined && typeof raw.mirrorSafe !== 'boolean') { |
|
throw new PackError(`${raw.id} mirrorSafe must be boolean`); |
|
} |
|
clip.mirrorSafe = raw.mirrorSafe ?? false; |
|
|
|
if (raw.strips !== undefined) { |
|
if (!isRecord(raw.strips) || raw.strip !== undefined) { |
|
throw new PackError(`${raw.id} must use exactly one of strip or strips`); |
|
} |
|
const keys = Object.keys(raw.strips); |
|
if (!keys.length || keys.some((direction) => !DIRECTIONS.includes(direction))) { |
|
throw new PackError(`${raw.id} has invalid directional strips`); |
|
} |
|
const requiredDirections = profile === 'game' ? ['ne', 'nw', 'se', 'sw'] : ['left', 'right']; |
|
if (family === 'walk' && requiredDirections.some((direction) => !keys.includes(direction))) { |
|
throw new PackError(`${raw.id} must provide: ${requiredDirections.join(', ')}`); |
|
} |
|
clip.strips = Object.fromEntries( |
|
DIRECTIONS.filter((direction) => keys.includes(direction)).map((direction) => [ |
|
direction, |
|
safeRelativePath(raw.strips[direction], `${raw.id}.${direction}`), |
|
]), |
|
); |
|
} else { |
|
if (family === 'walk') throw new PackError('walk clips must use directional strips'); |
|
clip.strip = safeRelativePath(raw.strip, `${raw.id}.strip`); |
|
} |
|
return clip; |
|
} |
|
|
|
function normalizeManifest(raw, spec) { |
|
if (!isRecord(raw)) throw new PackError('Manifest must contain a JSON object'); |
|
if (raw.schemaVersion !== 1) throw new PackError('Only schemaVersion 1 is supported'); |
|
const profile = raw.profile ?? 'game'; |
|
if (!['product', 'game'].includes(profile)) throw new PackError('profile must be product or game'); |
|
const warnings = []; |
|
const publicationIssues = []; |
|
if (raw.profile === undefined) warnings.push('Legacy schema v1 manifest inferred as the complete game profile.'); |
|
const skinVersion = raw.skinVersion ?? '0.0.0-legacy'; |
|
if (!SEMVER.test(skinVersion)) throw new PackError('skinVersion must be valid semantic versioning'); |
|
if (raw.skinVersion === undefined) { |
|
warnings.push('Missing skinVersion was normalized to 0.0.0-legacy; registry publication is not allowed.'); |
|
publicationIssues.push('skinVersion is required for a publishable package'); |
|
} |
|
if (!Array.isArray(raw.credits) || raw.credits.length === 0) { |
|
publicationIssues.push('at least one credit is required for a publishable package'); |
|
} |
|
if (raw.license === undefined) { |
|
publicationIssues.push('license metadata is required for a publishable package'); |
|
} else if (['NOASSERTION', 'NONE'].includes(raw.license?.spdx)) { |
|
publicationIssues.push('license.spdx must be resolved before registry publication'); |
|
} |
|
if (typeof raw.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(raw.id)) { |
|
throw new PackError('id must use lowercase letters, numbers, and dashes'); |
|
} |
|
if (!isRecord(raw.actions)) throw new PackError('actions must contain an object'); |
|
|
|
const requiredActionOrder = profile === 'game' |
|
? [...PRODUCT_ACTIONS, ...GAME_ACTIONS] |
|
: PRODUCT_ACTIONS; |
|
const actionOrder = profile === 'product' |
|
? [...PRODUCT_ACTIONS, ...GAME_ACTIONS.filter((family) => raw.actions[family] !== undefined)] |
|
: requiredActionOrder; |
|
const actions = {}; |
|
for (const family of actionOrder) { |
|
const variants = raw.actions[family]; |
|
if (!Array.isArray(variants) || variants.length < 1 || variants.length > 10) { |
|
throw new PackError(`${profile} manifests need 1 through 10 ${family} variants`); |
|
} |
|
actions[family] = variants.map((clip) => normalizeClip(clip, family, profile)); |
|
if (!actions[family].some((clip) => clip.id === `${family}.v01`)) { |
|
throw new PackError(`${family} must include ${family}.v01`); |
|
} |
|
if (new Set(actions[family].map((clip) => clip.id)).size !== actions[family].length) { |
|
throw new PackError(`${family} contains duplicate clip IDs`); |
|
} |
|
} |
|
|
|
const ignoredActions = Object.keys(raw.actions) |
|
.filter((name) => ![...PRODUCT_ACTIONS, ...GAME_ACTIONS].includes(name)); |
|
if (ignoredActions.length) warnings.push(`Unrecognized actions were omitted: ${ignoredActions.sort().join(', ')}.`); |
|
const knownTopLevel = new Set([ |
|
'schemaVersion', 'skinVersion', 'profile', 'id', 'name', 'description', |
|
'reference', 'credits', 'license', 'provenance', 'frame', 'actions', |
|
'customActions', 'extensions', |
|
]); |
|
const ignoredFields = Object.keys(raw).filter((field) => !knownTopLevel.has(field)); |
|
if (ignoredFields.length) warnings.push(`Unknown manifest fields were omitted: ${ignoredFields.sort().join(', ')}.`); |
|
if ( |
|
spec.actions[profile].length !== requiredActionOrder.length |
|
|| spec.actions[profile].some((action) => !requiredActionOrder.includes(action)) |
|
) { |
|
throw new PackError(`The parsed ${profile} action table disagrees with this script's contract`); |
|
} |
|
|
|
let customActions = []; |
|
if (raw.customActions !== undefined) { |
|
if (!Array.isArray(raw.customActions) || raw.customActions.length > 4) { |
|
throw new PackError('customActions must contain at most four families'); |
|
} |
|
const ids = new Set(); |
|
customActions = raw.customActions.map((family, index) => { |
|
if (!isRecord(family)) throw new PackError(`customActions[${index}] must be an object`); |
|
const id = requireText(family, 'id', `customActions[${index}].id`, 16); |
|
if (!/^custom-0[1-4]$/.test(id)) throw new PackError(`Invalid custom action ID: ${id}`); |
|
if (ids.has(id)) throw new PackError(`Duplicate custom action ID: ${id}`); |
|
ids.add(id); |
|
if (!Array.isArray(family.variants) || family.variants.length < 1 || family.variants.length > 10) { |
|
throw new PackError(`${id} must contain 1 through 10 variants`); |
|
} |
|
const variants = family.variants.map((variant) => normalizeClip(variant, id, profile)); |
|
if (!variants.some((variant) => variant.id === `${id}.v01`)) { |
|
throw new PackError(`${id} must include ${id}.v01`); |
|
} |
|
if (new Set(variants.map((variant) => variant.id)).size !== variants.length) { |
|
throw new PackError(`${id} contains duplicate clip IDs`); |
|
} |
|
return { |
|
id, |
|
name: requireText(family, 'name', `${id}.name`, 80), |
|
description: requireText(family, 'description', `${id}.description`, 500), |
|
whenToUse: requireText(family, 'whenToUse', `${id}.whenToUse`, 500), |
|
variants, |
|
}; |
|
}); |
|
} |
|
return { |
|
manifest: { |
|
schemaVersion: 1, |
|
skinVersion, |
|
profile, |
|
id: raw.id, |
|
name: requireText(raw, 'name', 'name', 80), |
|
description: requireText(raw, 'description', 'description', 500), |
|
...(raw.reference ? { reference: safeRelativePath(raw.reference, 'reference') } : {}), |
|
credits: normalizeCredits(raw.credits), |
|
...(raw.license ? { license: normalizeLicense(raw.license) } : {}), |
|
...(raw.provenance ? { provenance: normalizeProvenance(raw.provenance) } : {}), |
|
frame: normalizeFrame(raw.frame), |
|
actions, |
|
customActions, |
|
...(raw.extensions ? { extensions: normalizeExtensions(raw.extensions) } : {}), |
|
}, |
|
warnings, |
|
publicationIssues, |
|
}; |
|
} |
|
|
|
function parseSpec(markdown) { |
|
const status = markdown.match(/^Status:\s*(.+)$/m)?.[1]?.trim(); |
|
if (!status) throw new PackError('Spec markdown is missing a Status line'); |
|
const version = status.match(/\bv(\d+\.\d+(?:\.\d+)?)\b/)?.[1]; |
|
if (!version) throw new PackError('Spec status does not contain a version'); |
|
const extractTableActions = (heading, nextHeading) => { |
|
const start = markdown.indexOf(`### ${heading}`); |
|
const end = nextHeading ? markdown.indexOf(`### ${nextHeading}`, start + 1) : markdown.length; |
|
if (start < 0 || end < 0) throw new PackError(`Spec is missing the ${heading} action table`); |
|
return [...markdown.slice(start, end).matchAll(/^\|\s*`([^`]+)`\s*\|/gm)] |
|
.map((match) => match[1]); |
|
}; |
|
const product = extractTableActions('Product', 'Game'); |
|
const gameOnly = extractTableActions('Game', null); |
|
const image = markdown.match(/!\[([^\]]+)\]\((https?:\/\/[^)]+)\)/); |
|
return { |
|
status, |
|
version, |
|
actions: { product, game: [...product, ...gameOnly] }, |
|
documentedScene: image ? { alt: image[1], url: image[2] } : null, |
|
}; |
|
} |
|
|
|
function parseImageMetadata(bytes, filePath) { |
|
const extension = extname(filePath).toLowerCase(); |
|
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); |
|
if (bytes.subarray(0, 8).equals(pngSignature)) { |
|
if (extension !== '.png') throw new PackError(`${filePath} contains PNG bytes but has the wrong extension`); |
|
if (bytes.length < 33 || bytes.toString('ascii', 12, 16) !== 'IHDR') { |
|
throw new PackError(`${filePath} has an invalid PNG header`); |
|
} |
|
const colorType = bytes.readUInt8(25); |
|
return { |
|
format: 'png', |
|
width: bytes.readUInt32BE(16), |
|
height: bytes.readUInt32BE(20), |
|
bitDepth: bytes.readUInt8(24), |
|
colorType, |
|
hasAlphaChannel: colorType === 4 || colorType === 6 || bytes.includes(Buffer.from('tRNS')), |
|
}; |
|
} |
|
|
|
if ( |
|
bytes.length >= 30 |
|
&& bytes.toString('ascii', 0, 4) === 'RIFF' |
|
&& bytes.toString('ascii', 8, 12) === 'WEBP' |
|
) { |
|
if (extension !== '.webp') throw new PackError(`${filePath} contains WebP bytes but has the wrong extension`); |
|
const chunk = bytes.toString('ascii', 12, 16); |
|
let width; |
|
let height; |
|
if (chunk === 'VP8X') { |
|
width = 1 + bytes.readUIntLE(24, 3); |
|
height = 1 + bytes.readUIntLE(27, 3); |
|
} else if (chunk === 'VP8L' && bytes.readUInt8(20) === 0x2f) { |
|
const bits = bytes.readUInt32LE(21); |
|
width = 1 + (bits & 0x3fff); |
|
height = 1 + ((bits >>> 14) & 0x3fff); |
|
} else if (chunk === 'VP8 ' && bytes.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) { |
|
width = bytes.readUInt16LE(26) & 0x3fff; |
|
height = bytes.readUInt16LE(28) & 0x3fff; |
|
} |
|
if (!width || !height) throw new PackError(`${filePath} has an unsupported or invalid WebP header`); |
|
const hasAlphaChannel = chunk === 'VP8X' |
|
? (bytes.readUInt8(20) & 0x10) !== 0 |
|
: chunk === 'VP8L'; |
|
return { format: 'webp', width, height, hasAlphaChannel }; |
|
} |
|
throw new PackError(`${filePath} is not a readable PNG or WebP image`); |
|
} |
|
|
|
async function readRegularFile(path, label) { |
|
const info = await lstat(path).catch((error) => { |
|
if (error.code === 'ENOENT') return null; |
|
throw error; |
|
}); |
|
if (!info) return null; |
|
if (info.isSymbolicLink() || !info.isFile()) throw new PackError(`${label} must be a regular file`); |
|
return { bytes: await readFile(path), size: info.size }; |
|
} |
|
|
|
function assetReferences(manifest) { |
|
const assets = []; |
|
const families = [ |
|
...Object.entries(manifest.actions), |
|
...manifest.customActions.map((family) => [family.id, family.variants]), |
|
]; |
|
for (const [family, variants] of families) { |
|
for (const clip of variants) { |
|
if (clip.strip) { |
|
assets.push({ family, clip: clip.id, distPath: clip.strip, frames: clip.frames }); |
|
} |
|
for (const [direction, path] of Object.entries(clip.strips ?? {})) { |
|
assets.push({ family, clip: clip.id, direction, distPath: path, frames: clip.frames }); |
|
} |
|
} |
|
} |
|
return assets.sort((left, right) => left.distPath.localeCompare(right.distPath)); |
|
} |
|
|
|
async function inspectAsset(sourceRoot, sourceRelative, distPath, details = {}) { |
|
if (extname(sourceRelative).toLowerCase() !== extname(distPath).toLowerCase()) { |
|
throw new PackError(`${sourceRelative} and ${distPath} must use the same image format`); |
|
} |
|
const sourcePath = pathInside(sourceRoot, sourceRelative, sourceRelative); |
|
const file = await readRegularFile(sourcePath, sourceRelative); |
|
if (!file) return { ...details, sourcePath: sourceRelative, distPath, exists: false }; |
|
if (file.size > LIMITS.assetBytes) throw new PackError(`${sourceRelative} is larger than 8 MiB`); |
|
const image = parseImageMetadata(file.bytes, sourceRelative); |
|
if ( |
|
details.frames |
|
&& (image.width !== details.frames * FRAME.width || image.height !== FRAME.height) |
|
) { |
|
throw new PackError( |
|
`${sourceRelative} must be ${details.frames * FRAME.width} x ${FRAME.height}, ` |
|
+ `not ${image.width} x ${image.height}`, |
|
); |
|
} |
|
if (details.kind === 'strip' && !image.hasAlphaChannel) { |
|
throw new PackError(`${sourceRelative} must contain an alpha channel`); |
|
} |
|
return { |
|
...details, |
|
sourcePath: sourceRelative, |
|
distPath, |
|
exists: true, |
|
bytes: file.size, |
|
sha256: sha256(file.bytes), |
|
image, |
|
}; |
|
} |
|
|
|
async function assertFreshOutput(outputPath) { |
|
const absolute = resolve(outputPath); |
|
if (absolute === resolve('/') || basename(absolute) === '') throw new PackError('Refusing an unsafe output path'); |
|
const existing = await lstat(absolute).catch((error) => { |
|
if (error.code === 'ENOENT') return null; |
|
throw error; |
|
}); |
|
if (existing) throw new PackError(`Output already exists; refusing to overwrite: ${absolute}`); |
|
const parent = await stat(dirname(absolute)).catch(() => null); |
|
if (!parent?.isDirectory()) throw new PackError('The output parent directory must already exist'); |
|
return absolute; |
|
} |
|
|
|
async function buildPlan(options) { |
|
const sourceRoot = resolve(options.source); |
|
const sourceInfo = await stat(sourceRoot).catch(() => null); |
|
if (!sourceInfo?.isDirectory()) throw new PackError(`Source is not a directory: ${sourceRoot}`); |
|
const manifestRelative = safeRelativePath(options.manifest, 'manifest'); |
|
const specRelative = safeRelativePath(options.spec, 'spec'); |
|
const schemaRelative = safeRelativePath(options.schema, 'schema'); |
|
const sceneRelative = safeRelativePath(options.scene, 'scene'); |
|
const referenceRelative = safeRelativePath(options.reference, 'reference source'); |
|
const manifestInput = await readRegularFile(pathInside(sourceRoot, manifestRelative, 'manifest'), 'manifest'); |
|
const specInput = await readRegularFile(pathInside(sourceRoot, specRelative, 'spec'), 'spec'); |
|
const schemaInput = await readRegularFile(pathInside(sourceRoot, schemaRelative, 'schema'), 'schema'); |
|
const sceneInput = await readRegularFile(pathInside(sourceRoot, sceneRelative, 'scene'), 'scene'); |
|
if (!manifestInput) throw new PackError(`Missing manifest: ${manifestRelative}`); |
|
if (!specInput) throw new PackError(`Missing spec: ${specRelative}`); |
|
if (!schemaInput) throw new PackError(`Missing schema: ${schemaRelative}`); |
|
if (!sceneInput) throw new PackError(`Missing scene example: ${sceneRelative}`); |
|
if (manifestInput.size > LIMITS.manifestBytes) throw new PackError('Manifest is larger than 128 KiB'); |
|
|
|
let rawManifest; |
|
let rawSchema; |
|
let rawScene; |
|
try { |
|
rawManifest = JSON.parse(manifestInput.bytes.toString('utf8')); |
|
rawSchema = JSON.parse(schemaInput.bytes.toString('utf8')); |
|
rawScene = JSON.parse(sceneInput.bytes.toString('utf8')); |
|
} catch { |
|
throw new PackError('Manifest, schema, and scene example must be valid JSON'); |
|
} |
|
if (rawSchema.$id !== 'urn:smolforge:schema:character-skin:1') { |
|
throw new PackError('Schema uses an unexpected canonical $id'); |
|
} |
|
if (rawScene.schemaVersion !== 1 || typeof rawScene.id !== 'string') { |
|
throw new PackError('Scene example uses an unexpected contract'); |
|
} |
|
const spec = parseSpec(specInput.bytes.toString('utf8')); |
|
const normalized = normalizeManifest(rawManifest, spec); |
|
const normalizedBytes = Buffer.from(stableJson(normalized.manifest)); |
|
const files = []; |
|
|
|
if (normalized.manifest.reference) { |
|
files.push(await inspectAsset( |
|
sourceRoot, |
|
referenceRelative, |
|
normalized.manifest.reference, |
|
{ kind: 'reference' }, |
|
)); |
|
} |
|
for (const asset of assetReferences(normalized.manifest)) { |
|
const assetSourceRelative = relative( |
|
sourceRoot, |
|
resolve(sourceRoot, dirname(manifestRelative), asset.distPath), |
|
); |
|
files.push(await inspectAsset( |
|
sourceRoot, |
|
safeRelativePath(assetSourceRelative, `${asset.distPath} source`), |
|
asset.distPath, |
|
{ kind: 'strip', family: asset.family, clip: asset.clip, direction: asset.direction, frames: asset.frames }, |
|
)); |
|
} |
|
files.sort((left, right) => left.distPath.localeCompare(right.distPath)); |
|
if (files.length + 1 > LIMITS.files) throw new PackError(`Package exceeds ${LIMITS.files} files`); |
|
const totalExistingBytes = files.reduce((sum, file) => sum + (file.bytes ?? 0), normalizedBytes.length); |
|
if (totalExistingBytes > LIMITS.totalBytes) throw new PackError('Package exceeds 64 MiB'); |
|
const missing = files.filter((file) => !file.exists).map((file) => file.distPath); |
|
const publicationIssues = normalized.publicationIssues; |
|
const reference = files.find((file) => file.kind === 'reference'); |
|
|
|
return { |
|
planVersion: 1, |
|
generator: 'regenerate-skin-pack.mjs', |
|
generatorVersion: 1, |
|
truthfulBoundary: 'Validates and copies existing files only; does not generate or transform images.', |
|
mode: options.mode, |
|
complete: missing.length === 0 && publicationIssues.length === 0, |
|
contract: { |
|
schemaVersion: normalized.manifest.schemaVersion, |
|
specStatus: spec.status, |
|
specVersion: spec.version, |
|
profile: normalized.manifest.profile, |
|
requiredActions: spec.actions[normalized.manifest.profile], |
|
}, |
|
package: { |
|
id: normalized.manifest.id, |
|
name: normalized.manifest.name, |
|
normalizedManifestSha256: sha256(normalizedBytes), |
|
}, |
|
inputs: [ |
|
{ kind: 'manifest-sample', path: manifestRelative, bytes: manifestInput.size, sha256: sha256(manifestInput.bytes) }, |
|
{ kind: 'spec', path: specRelative, bytes: specInput.size, sha256: sha256(specInput.bytes) }, |
|
{ kind: 'schema', path: schemaRelative, bytes: schemaInput.size, sha256: sha256(schemaInput.bytes) }, |
|
{ kind: 'scene-example', path: sceneRelative, bytes: sceneInput.size, sha256: sha256(sceneInput.bytes) }, |
|
], |
|
sceneMetadata: { |
|
documentedScene: spec.documentedScene, |
|
localReference: reference |
|
? { |
|
sourcePath: reference.sourcePath, |
|
distPath: reference.distPath, |
|
exists: reference.exists, |
|
bytes: reference.bytes, |
|
sha256: reference.sha256, |
|
image: reference.image, |
|
} |
|
: null, |
|
}, |
|
files: [ |
|
{ |
|
kind: 'manifest', |
|
sourcePath: manifestRelative, |
|
distPath: 'skin.json', |
|
exists: true, |
|
bytes: normalizedBytes.length, |
|
sha256: sha256(normalizedBytes), |
|
normalized: true, |
|
}, |
|
...files, |
|
], |
|
missing, |
|
publicationIssues, |
|
warnings: [ |
|
...normalized.warnings, |
|
...(missing.length |
|
? [`${missing.length} referenced asset${missing.length === 1 ? ' is' : 's are'} missing; scaffold remains incomplete.`] |
|
: []), |
|
...publicationIssues, |
|
], |
|
normalizedManifest: normalized.manifest, |
|
}; |
|
} |
|
|
|
async function writeScaffold(outputPath, sourceRoot, plan) { |
|
await mkdir(outputPath); |
|
await writeFile(resolve(outputPath, 'skin.json'), stableJson(plan.normalizedManifest), { flag: 'wx' }); |
|
for (const file of plan.files) { |
|
if (file.kind === 'manifest' || !file.exists) continue; |
|
const destination = resolve(outputPath, file.distPath); |
|
await mkdir(dirname(destination), { recursive: true }); |
|
await copyFile(resolve(sourceRoot, file.sourcePath), destination, fsConstants.COPYFILE_EXCL); |
|
} |
|
const planForDisk = { ...plan }; |
|
delete planForDisk.normalizedManifest; |
|
await writeFile(resolve(outputPath, 'integrity.json'), stableJson(planForDisk), { flag: 'wx' }); |
|
} |
|
|
|
async function main() { |
|
const options = parseArgs(process.argv.slice(2)); |
|
if (options.help) { |
|
console.log(HELP); |
|
return; |
|
} |
|
options.source = resolve(options.source); |
|
const plan = await buildPlan(options); |
|
|
|
if (options.dryRun) { |
|
console.log(stableJson(plan).trimEnd()); |
|
if (options.mode === 'validate' && !plan.complete) process.exitCode = 2; |
|
return; |
|
} |
|
if (options.mode === 'validate' && !plan.complete) { |
|
const reasons = [ |
|
...(plan.missing.length ? [`missing: ${plan.missing.join(', ')}`] : []), |
|
...plan.publicationIssues, |
|
]; |
|
throw new PackError(`Validation failed; ${reasons.join('; ')}`); |
|
} |
|
const outputPath = await assertFreshOutput(options.out); |
|
await writeScaffold(outputPath, options.source, plan); |
|
console.log( |
|
`${plan.complete ? 'Validated dist' : 'Incomplete scaffold'} written to ${outputPath}\n` |
|
+ `Manifest: ${plan.package.normalizedManifestSha256}\n` |
|
+ `Missing assets: ${plan.missing.length}`, |
|
); |
|
} |
|
|
|
main().catch((error) => { |
|
console.error(`regenerate-skin-pack: ${error instanceof Error ? error.message : String(error)}`); |
|
process.exitCode = 1; |
|
}); |