Files
workinf_Blender_Wasm/tools/web/check-re-pbr-video.mjs
mes123456 d5dd647881
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
feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- 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>
2026-11-12 21:49:24 +08:00

96 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
/** Validate the RE 5000PBR lesson MP4, narration, VTT, poster and chapter screenshots. */
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 OUT = path.join(REPO, "tests/golden/RE-PBR-VIDEO-001");
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
const VIDEO_DIR = path.join(LESSON, "video");
const VIDEO = path.join(VIDEO_DIR, "re-pbr-lesson-720p.mp4");
const VTT = path.join(VIDEO_DIR, "re-pbr-lesson.zh-Hans.vtt");
const POSTER = path.join(VIDEO_DIR, "poster.jpg");
const HTML = path.join(REPO, "web/app/public/tutorial/pbr/render-lesson.html");
const FFPROBE = "/usr/bin/ffprobe";
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const exists = (file, label) => assert.ok(fs.existsSync(file), `missing ${label}: ${path.relative(REPO, file)}`);
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const probe = (file) => JSON.parse(execFileSync(FFPROBE, ["-v", "error", "-show_entries",
"format=duration,size,format_name:stream=codec_type,codec_name,width,height", "-of", "json", file], { encoding: "utf8" }));
const report = readJson(path.join(OUT, "video-report.json"));
const timeline = readJson(path.join(OUT, "timeline.json"));
const narration = readJson(path.join(OUT, "narration.json"));
assert.equal(report.status, "PASS");
assert.equal(timeline.status, "PASS");
assert.equal(report.chapters.length, 13);
assert.equal(timeline.chapters.length, 13);
assert.equal(narration.chapters.length, 13);
for (const file of [VIDEO, VTT, POSTER]) exists(file, path.basename(file));
assert.equal(sha256(VIDEO), report.video.sha256, "video SHA mismatch");
assert.equal(sha256(POSTER), report.poster.sha256, "poster SHA mismatch");
assert.equal(sha256(VTT), report.vtt.sha256, "VTT SHA mismatch");
const media = probe(VIDEO);
const duration = Number(media.format?.duration ?? 0);
assert.ok(duration >= 420 && duration <= 720, `video duration ${duration}s outside 7-12 minutes`);
assert.match(String(media.format?.format_name ?? ""), /mp4/);
const videoStream = media.streams.find((stream) => stream.codec_type === "video");
const audioStream = media.streams.find((stream) => stream.codec_type === "audio");
assert.ok(videoStream && audioStream, "video/audio streams missing");
assert.equal(videoStream.codec_name, "h264");
assert.equal(videoStream.width, 1280);
assert.equal(videoStream.height, 720);
assert.equal(audioStream.codec_name, "aac");
const head = fs.readFileSync(VIDEO).subarray(0, 2 * 1024 * 1024);
const moov = head.indexOf(Buffer.from("moov"));
const mdat = head.indexOf(Buffer.from("mdat"));
assert.ok(moov >= 0 && mdat >= 0 && moov < mdat, "MP4 is not faststart (moov must precede mdat)");
const vttText = fs.readFileSync(VTT, "utf8");
assert.ok(vttText.startsWith("WEBVTT"));
const cues = [...vttText.matchAll(/^(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})$/gm)];
assert.equal(cues.length, 13, `expected 13 VTT cues, got ${cues.length}`);
let previous = -1;
for (const cue of cues) {
const start = cue[1];
const parts = start.split(":").map(Number);
const seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
assert.ok(seconds >= previous, "VTT cue times must be monotonic");
previous = seconds;
}
const posterMedia = probe(POSTER);
const posterStream = posterMedia.streams.find((stream) => stream.codec_type === "video");
assert.equal(posterStream.width, 1280);
assert.equal(posterStream.height, 720);
for (const chapter of timeline.chapters) {
const file = path.join(REPO, chapter.screenshot);
exists(file, `chapter screenshot ${chapter.id}`);
const mediaInfo = probe(file);
const stream = mediaInfo.streams.find((item) => item.codec_type === "video");
assert.equal(stream.width, 1600, `chapter ${chapter.id} width`);
assert.equal(stream.height, 900, `chapter ${chapter.id} height`);
}
assert.equal(new Set(timeline.chapters.map((chapter) => chapter.id)).size, 13);
const html = fs.readFileSync(HTML, "utf8");
assert.ok(html.includes('class="lesson-video"'));
assert.ok(html.includes('type="video/mp4"'));
assert.ok(html.includes('re-pbr-lesson.zh-Hans.vtt'));
assert.ok(html.includes('poster="/tutorial/pbr/lesson/video/poster.jpg"'));
assert.ok(!/https?:\/\//i.test(html), "lesson must not reference remote URLs");
console.log(JSON.stringify({
status: "PASS",
video: { path: path.relative(REPO, VIDEO), bytes: fs.statSync(VIDEO).size, durationSeconds: Number(duration.toFixed(2)), codecs: { video: videoStream.codec_name, audio: audioStream.codec_name }, resolution: `${videoStream.width}x${videoStream.height}`, faststart: true },
narration: { voice: narration.voice, chapters: narration.chapters.length },
vttCues: cues.length,
chapters: timeline.chapters.length,
poster: "1280x720",
}, null, 2));