- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots. - Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060. - Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine. - Harden lesson video recording pipeline; fix stale lesson-link E2E selector. - Add RE PBR derived libraries, lesson page, evidence and status report. Co-Authored-By: Claude Code <noreply@anthropic.com>
131 lines
6.8 KiB
JavaScript
131 lines
6.8 KiB
JavaScript
#!/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));
|