- 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>
96 lines
4.9 KiB
JavaScript
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));
|