feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- 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>
This commit is contained in:
mes123456
2026-11-12 21:49:24 +08:00
parent a078648297
commit d5dd647881
276 changed files with 9632 additions and 314 deletions

View File

@@ -0,0 +1,99 @@
#!/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));