#!/usr/bin/env node /** Validate the derived RE 5000PBR asset library and its preview/manifest identity. */ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; const REPO = path.resolve(import.meta.dirname, "../.."); const args = process.argv.slice(2); const flag = (name, fallback) => { const index = args.indexOf(`--${name}`); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; }; const HQ = args.includes("--hq"); const DIR = path.resolve(args.find((value) => !value.startsWith("--") && value !== "hq") ?? flag("dir", path.join(REPO, HQ ? "web/app/public/assets/re-pbr-hq" : "web/app/public/assets/re-pbr"))); const BLENDER = path.join(REPO, "build_blender_native/bin/blender"); const LIBRARY_ID = flag("library-id", HQ ? "library:re-pbr-hq-v1" : "library:re-pbr-v1"); const LIBRARY_NAME = flag("library-name", HQ ? "RE 5000PBR HQ" : "RE 5000PBR"); const LIBRARY_SOURCE = flag("source", HQ ? "libraries/re-pbr-hq-library.blend" : "libraries/re-pbr-library.blend"); const EXPECTED_MATERIALS = 6; const MAX_LIBRARY_BYTES = Number(flag("max-library-bytes", HQ ? 16 * 1024 * 1024 : 5 * 1024 * 1024)); const MAX_PREVIEW_BYTES = 128 * 1024; const MAX_TEXTURE_SIZE = Number(flag("max-texture-size", HQ ? 2048 : 1024)); const SUFFIX = flag("suffix", HQ ? "_HQ" : ""); const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex"); const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8")); const assertAscii = (name, value) => assert.match(value, /^[\x20-\x7e]+$/, `${name} must be ASCII: ${value}`); const manifest = readJson("manifest.json"); const report = readJson("build-report.json"); const previewManifest = readJson("preview-manifest.json"); assert.equal(manifest.schemaVersion, 2); assert.equal(manifest.libraries.length, 1); assert.equal(manifest.libraries[0].libraryId, LIBRARY_ID); assert.equal(manifest.libraries[0].name, LIBRARY_NAME); assert.equal(manifest.libraries[0].sourcePath, LIBRARY_SOURCE); assert.equal(manifest.libraries[0].readOnly, true); assert.equal(manifest.assets.length, EXPECTED_MATERIALS); assert.ok(manifest.assets.every((asset) => asset.idType === "MATERIAL"), "all assets must be MATERIAL"); assert.ok(manifest.assets.every((asset) => asset.assetLibraryIdentifier === LIBRARY_ID)); assert.ok(manifest.assets.every((asset) => asset.preview), "every asset needs a preview"); const libraryPath = path.join(DIR, "re-pbr-library.blend"); const libraryBytes = fs.readFileSync(libraryPath); const librarySha = sha256(libraryBytes); assert.equal(librarySha, manifest.libraries[0].sourceSha256, "manifest library SHA mismatch"); assert.equal(librarySha, report.library.sha256, "build report library SHA mismatch"); assert.ok(libraryBytes.byteLength <= MAX_LIBRARY_BYTES, `library exceeds ${MAX_LIBRARY_BYTES} bytes`); assert.equal(report.materials.length, EXPECTED_MATERIALS); assert.equal(previewManifest.previews.length, EXPECTED_MATERIALS); for (const name of ["manifest.json", "build-report.json", "preview-manifest.json", "blender_assets.cats.txt", "re-pbr-library.blend"]) { assertAscii("file", name); } for (const asset of manifest.assets) { assertAscii("asset name", asset.name); assert.ok(asset.name !== "Dots Stroke", "Dots Stroke must not be exported"); if (SUFFIX) assert.ok(asset.name.endsWith(SUFFIX), `asset name misses suffix ${SUFFIX}: ${asset.name}`); assert.ok(asset.preview.byteLength <= MAX_PREVIEW_BYTES, `preview too large: ${asset.name}`); const previewPath = path.join(DIR, "previews", `${asset.preview.sha256}.png`); const previewBytes = fs.readFileSync(previewPath); assert.equal(sha256(previewBytes), asset.preview.sha256, `preview SHA mismatch: ${asset.name}`); assert.equal(previewBytes.byteLength, asset.preview.byteLength, `preview size mismatch: ${asset.name}`); assert.equal(asset.preview.width, 256); assert.equal(asset.preview.height, 256); } const blenderExpr = ` import bpy, json result = {"materials": {}, "errors": []} expected = {"FRAME", "REROUTE", "DISPLACEMENT", "MAPPING", "VALUE", "BSDF_PRINCIPLED", "NORMAL_MAP"} for material in bpy.data.materials: if material.name == "Dots Stroke" or not material.use_nodes or material.node_tree is None: continue types = {node.type for node in material.node_tree.nodes} images = [] for node in material.node_tree.nodes: if node.type == "TEX_IMAGE" and node.image is not None: image = node.image images.append({ "name": image.name, "size": list(image.size[:]), "colorspace": image.colorspace_settings.name, "packed": bool(image.packed_file), "packedBytes": image.packed_file.size if image.packed_file else 0, "fileFormat": image.file_format, }) result["materials"][material.name] = {"types": sorted(types), "images": images} print("CHECK_JSON:" + json.dumps(result)) `; const output = execFileSync(BLENDER, ["-b", libraryPath, "--python-expr", blenderExpr], { encoding: "utf8", timeout: 120_000, maxBuffer: 8 * 1024 * 1024, }); const line = output.split(/\r?\n/).find((value) => value.startsWith("CHECK_JSON:")); assert.ok(line, "Blender inspection produced no CHECK_JSON line"); const inspection = JSON.parse(line.slice("CHECK_JSON:".length)); assert.equal(Object.keys(inspection.materials).length, EXPECTED_MATERIALS); for (const [name, info] of Object.entries(inspection.materials)) { const types = new Set(info.types); for (const expectedType of ["FRAME", "REROUTE", "DISPLACEMENT", "MAPPING", "VALUE", "BSDF_PRINCIPLED", "NORMAL_MAP"]) { assert.ok(types.has(expectedType), `${name} lost node ${expectedType}`); } assert.equal(info.images.length, 4, `${name} must keep four image textures`); for (const image of info.images) { assert.ok(image.packed, `${image.name} is not packed`); assert.ok(image.size[0] > 0 && image.size[1] > 0, `${image.name} has no pixels`); assert.ok(image.size[0] <= MAX_TEXTURE_SIZE && image.size[1] <= MAX_TEXTURE_SIZE, `${image.name} exceeds ${MAX_TEXTURE_SIZE}px`); assert.equal(image.fileFormat, "JPEG", `${image.name} is not JPEG`); assert.ok(image.colorspace === "sRGB" || image.colorspace === "Non-Color", `${image.name} colorspace ${image.colorspace}`); if (image.name.toLowerCase().includes("base_color")) assert.equal(image.colorspace, "sRGB"); else assert.equal(image.colorspace, "Non-Color"); } } const summary = { status: "PASS", libraryId: LIBRARY_ID, librarySha256: librarySha, libraryBytes: libraryBytes.byteLength, materials: manifest.assets.length, previews: manifest.assets.length, previewBytes: manifest.assets.reduce((sum, asset) => sum + asset.preview.byteLength, 0), textureBytes: report.textures.reduce((sum, item) => sum + item.packedBytes, 0), }; console.log(JSON.stringify(summary, null, 2));