- 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>
100 lines
4.2 KiB
JavaScript
100 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const assets = path.join(repoRoot, "web/app/public/tutorial/pbr");
|
|
const stepsDir = path.join(assets, "steps");
|
|
const rendersDir = path.join(assets, "renders");
|
|
const reportDir = path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001");
|
|
const stepIds = [
|
|
"intro", "workspace-shading", "load-studio", "add-suzanne", "add-cube", "add-torus",
|
|
"assign-metal", "assign-glass", "assign-wood", "assign-emission",
|
|
"render-setup", "download-save", "summary",
|
|
];
|
|
const models = ["suzanne", "cube", "torus"];
|
|
const materials = ["metal-brushed", "glass", "wood", "emission-cyan"];
|
|
const errors = [];
|
|
|
|
function requireFile(file, minimum = 1) {
|
|
if (!fs.existsSync(file)) {
|
|
errors.push(`missing: ${path.relative(repoRoot, file)}`);
|
|
return;
|
|
}
|
|
if (fs.statSync(file).size < minimum) errors.push(`empty: ${path.relative(repoRoot, file)}`);
|
|
}
|
|
|
|
function pngSize(file) {
|
|
const bytes = fs.readFileSync(file);
|
|
if (bytes.length < 24 || bytes.toString("ascii", 1, 4) !== "PNG") throw new Error(`not a PNG: ${file}`);
|
|
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
}
|
|
|
|
function sha256(file) {
|
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
}
|
|
|
|
for (const stepId of stepIds) {
|
|
requireFile(path.join(stepsDir, `${stepId}.jpg`), 1024);
|
|
requireFile(path.join(stepsDir, `${stepId}-view.jpg`), 1024);
|
|
}
|
|
requireFile(path.join(assets, "landing.jpg"), 1024);
|
|
requireFile(path.join(stepsDir, "00-assembly.jpg"), 1024);
|
|
|
|
for (const model of models) {
|
|
for (const material of materials) {
|
|
const png = path.join(rendersDir, `${model}-${material}.png`);
|
|
const jpg = path.join(rendersDir, `${model}-${material}.jpg`);
|
|
requireFile(png, 1024);
|
|
requireFile(jpg, 1024);
|
|
if (fs.existsSync(png)) {
|
|
try {
|
|
const { width, height } = pngSize(png);
|
|
if (width !== 1024 || height !== 1024) errors.push(`${path.basename(png)} is ${width}x${height}, expected 1024x1024`);
|
|
}
|
|
catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const studioBlend = path.join(assets, "studio.blend");
|
|
const studioReport = path.join(reportDir, "studio-build.json");
|
|
requireFile(studioBlend, 1024);
|
|
requireFile(studioReport);
|
|
if (fs.existsSync(studioBlend) && fs.existsSync(studioReport)) {
|
|
const report = JSON.parse(fs.readFileSync(studioReport, "utf8"));
|
|
if (report.blend?.sha256 !== sha256(studioBlend)) errors.push("studio-build.json SHA-256 does not match studio.blend");
|
|
}
|
|
|
|
const latestReport = path.join(reportDir, "headed-screenshots-latest.json");
|
|
requireFile(latestReport);
|
|
if (fs.existsSync(latestReport)) {
|
|
const report = JSON.parse(fs.readFileSync(latestReport, "utf8"));
|
|
if (report.status !== "PASS") errors.push(`headed report status is ${report.status}`);
|
|
if (report.course !== "pbr") errors.push("headed report course is not pbr");
|
|
if ((report.steps?.length ?? 0) !== stepIds.length) errors.push(`headed report has ${report.steps?.length ?? 0} steps`);
|
|
if ((report.renders?.length ?? 0) !== 12) errors.push(`headed report has ${report.renders?.length ?? 0} renders`);
|
|
if ((report.errors?.length ?? 0) !== 0) errors.push(`headed report has ${report.errors.length} errors`);
|
|
}
|
|
|
|
const renderManifest = path.join(rendersDir, "manifest.json");
|
|
requireFile(renderManifest);
|
|
if (fs.existsSync(renderManifest)) {
|
|
const manifest = JSON.parse(fs.readFileSync(renderManifest, "utf8"));
|
|
if ((manifest.renders?.length ?? 0) !== 12) errors.push(`render manifest has ${manifest.renders?.length ?? 0} entries`);
|
|
for (const entry of manifest.renders ?? []) {
|
|
if (!fs.existsSync(path.join(repoRoot, entry.png))) errors.push(`render manifest missing ${entry.png}`);
|
|
if (!fs.existsSync(path.join(repoRoot, entry.jpg))) errors.push(`render manifest missing ${entry.jpg}`);
|
|
}
|
|
}
|
|
|
|
if (errors.length) {
|
|
console.error(JSON.stringify({ status: "FAIL", errors }, null, 2));
|
|
process.exit(1);
|
|
}
|
|
console.log(JSON.stringify({ status: "PASS", steps: stepIds.length, renders: 12 }, null, 2));
|