import { createHash } from 'node:crypto' import { copyFile, mkdir, readFile, 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)) const harnessRoot = resolve(here, '..') const projectRoot = resolve(harnessRoot, '..') const outputRoot = resolve(harnessRoot, 'public', 'reference') const renderRoot = resolve(projectRoot, 'blender_prototype', 'renders') await mkdir(resolve(outputRoot, 'renders'), { recursive: true }) const sources = { david: resolve(projectRoot, 'legendary.png'), timothy: resolve(projectRoot, 'common.png'), back: resolve(projectRoot, 'card-back.png'), } await Promise.all([ copyFile(sources.david, resolve(outputRoot, 'david-front.png')), copyFile(sources.timothy, resolve(outputRoot, 'timothy-front.png')), copyFile(sources.back, resolve(outputRoot, 'card-back.png')), ]) const referenceRenders = [ 'david_foil_closeup.png', 'david_foil_reflection.png', 'david_holographic_head_on.png', 'david_holographic_spectrum_angle.png', 'david_holographic_opposite_angle.png', 'card_three_quarter_thickness.png', ] await Promise.all(referenceRenders.map((name) => copyFile(resolve(renderRoot, name), resolve(outputRoot, 'renders', name)), )) const smoothstep = (edge0, edge1, value) => { const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))) return t * t * (3 - 2 * t) } const imageHsv = (r, g, b) => { const max = Math.max(r, g, b) const min = Math.min(r, g, b) return { saturation: max === 0 ? 0 : (max - min) / max, value: max, } } async function generateMask(sourcePath, outputPath, kind) { const source = PNG.sync.read(await readFile(sourcePath)) const mask = new PNG({ width: source.width, height: source.height }) for (let y = 0; y < source.height; y += 1) { for (let x = 0; x < source.width; x += 1) { const index = (y * source.width + x) * 4 const { saturation, value } = imageHsv( source.data[index] / 255, source.data[index + 1] / 255, source.data[index + 2] / 255, ) let coverage if (kind === 'david-no-cutouts') { const u = x / (source.width - 1) const v = 1 - y / (source.height - 1) coverage = smoothstep(0.18, 0.62, saturation) * smoothstep(0.015, 0.16, value) const side = smoothstep(0.34, 0.41, Math.abs(u - 0.5)) const above = smoothstep(0.245, 0.29, v) const below = 1 - smoothstep(0.07, 0.105, v) coverage *= Math.max(side, above, below) } else { coverage = smoothstep(0.08, 0.58, saturation) * smoothstep(0.015, 0.16, value) } const byte = Math.round(Math.max(0, Math.min(1, coverage)) * 255) mask.data[index] = byte mask.data[index + 1] = byte mask.data[index + 2] = byte mask.data[index + 3] = 255 } } await writeFile(outputPath, PNG.sync.write(mask)) } async function generateLinenNormal(outputPath) { const size = 512 const png = new PNG({ width: size, height: size }) const heightAt = (x, y) => { const weave = Math.sin(x * 0.27) * 0.55 + Math.sin(y * 0.31) * 0.45 const cross = Math.sin((x + y) * 0.075) * 0.16 return weave + cross } for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { const dx = heightAt(x + 1, y) - heightAt(x - 1, y) const dy = heightAt(x, y + 1) - heightAt(x, y - 1) const nx = -dx * 0.22 const ny = -dy * 0.22 const length = Math.hypot(nx, ny, 1) const index = (y * size + x) * 4 png.data[index] = Math.round((nx / length * 0.5 + 0.5) * 255) png.data[index + 1] = Math.round((ny / length * 0.5 + 0.5) * 255) png.data[index + 2] = Math.round((1 / length * 0.5 + 0.5) * 255) png.data[index + 3] = 255 } } await writeFile(outputPath, PNG.sync.write(png)) } await Promise.all([ generateMask(sources.david, resolve(outputRoot, 'david-finish-mask.png'), 'david-no-cutouts'), generateMask(sources.timothy, resolve(outputRoot, 'timothy-finish-mask.png'), 'automatic'), generateLinenNormal(resolve(outputRoot, 'linen-normal.png')), ]) const generatedFiles = [ 'david-front.png', 'david-finish-mask.png', 'timothy-front.png', 'timothy-finish-mask.png', 'card-back.png', 'linen-normal.png', ...referenceRenders.map((name) => `renders/${name}`), ] const hashes = {} for (const name of generatedFiles) { hashes[name] = createHash('sha256') .update(await readFile(resolve(outputRoot, name))) .digest('hex') } await writeFile(resolve(outputRoot, 'manifest.json'), JSON.stringify({ schemaVersion: 2, referenceRevision: 'runtime-look-v4-2026-09-07', sourceMaterialReference: 'blender_prototype/premium_finishes.py', genericMaterialReference: 'blender_prototype/card_finish_material.py', geometry: { source: 'runtime-procedural', widthMeters: 0.063, heightMeters: 0.0882, thicknessMeters: 0.0004, cornerRadiusMeters: 0.0028, }, fixtures: { david: { artwork: 'david-front.png', finishMask: 'david-finish-mask.png', maskRule: 'Premium v3 saturation/value and title-panel protection; face, hand, and lamb ellipse cutouts removed', }, timothy: { artwork: 'timothy-front.png', finishMask: 'timothy-finish-mask.png', maskRule: 'Artwork-independent saturation/value coverage', }, }, normalMap: 'linen-normal.png', referenceRenders: referenceRenders.map((name) => `renders/${name}`), hashes, }, null, 2)) console.log(`Reference assets ready in ${outputRoot}`)