#!/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));