Generate a static beginner course from course data with real Chromium screenshots for every module, and record a narrated walkthrough of UV, Texture Paint, Compositor, Geometry Nodes, Spreadsheet and OPFS reopen. The render lesson video covers M08/M09; the course page is linked from the tutorial landing. Co-Authored-By: Claude Code <noreply@anthropic.com>
52 lines
2.8 KiB
JavaScript
52 lines
2.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Validate the generated 15-module course page and its media.
|
|
*
|
|
* Usage:
|
|
* node tools/web/course/check-full-course.mjs
|
|
*/
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
const DIR = path.join(REPO, "web/app/public/tutorial/full");
|
|
const INDEX = path.join(DIR, "index.html");
|
|
const errors = [];
|
|
|
|
if (!fs.existsSync(INDEX)) errors.push("missing index.html");
|
|
const html = fs.existsSync(INDEX) ? fs.readFileSync(INDEX, "utf8") : "";
|
|
const modules = [...html.matchAll(/class="course-module"/g)].length;
|
|
if (modules !== 15) errors.push(`expected 15 modules, found ${modules}`);
|
|
if (!html.includes("OPFS")) errors.push("course does not mention OPFS");
|
|
if (/<script\b/i.test(html)) errors.push("course page has inline script");
|
|
if (/<style\b/i.test(html)) errors.push("course page has inline style");
|
|
if (/https?:\/\//i.test(html)) errors.push("course page references a remote URL");
|
|
|
|
const checkAsset = (src) => {
|
|
if (!src || /^https?:/i.test(src)) return;
|
|
const file = src.startsWith("/") ? path.join(REPO, "web/app/public", src.slice(1)) : path.join(DIR, src);
|
|
if (!fs.existsSync(file)) errors.push(`missing asset ${src}`);
|
|
};
|
|
for (const match of html.matchAll(/<img[^>]+src="([^"]+)"/g)) checkAsset(match[1]);
|
|
for (const match of html.matchAll(/<video[^>]+src="([^"]+)"/g)) checkAsset(match[1]);
|
|
for (const match of html.matchAll(/<track[^>]+src="([^"]+)"/g)) checkAsset(match[1]);
|
|
|
|
const media = [];
|
|
for (const match of html.matchAll(/<video[^>]+src="([^"]+)"/g)) {
|
|
const file = match[1].startsWith("/") ? path.join(REPO, "web/app/public", match[1].slice(1)) : path.join(DIR, match[1]);
|
|
if (!fs.existsSync(file)) continue;
|
|
const probe = JSON.parse(execFileSync("/usr/bin/ffprobe", ["-v", "error", "-show_entries",
|
|
"format=duration,size:stream=codec_type,codec_name,width,height", "-of", "json", file], { encoding: "utf8" }));
|
|
const video = probe.streams.find((stream) => stream.codec_type === "video") ?? {};
|
|
const audio = probe.streams.find((stream) => stream.codec_type === "audio") ?? {};
|
|
media.push({ src: match[1], duration: Number(probe.format?.duration ?? 0), bytes: Number(probe.format?.size ?? 0), width: video.width, height: video.height, videoCodec: video.codec_name, audioCodec: audio.codec_name });
|
|
if (video.codec_name !== "h264" || audio.codec_name !== "aac") errors.push(`${match[1]} is not H.264/AAC`);
|
|
if (video.width !== 1280 || video.height !== 720) errors.push(`${match[1]} is not 1280x720`);
|
|
}
|
|
|
|
const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", modules, videos: media.length, media, errors };
|
|
console.log(JSON.stringify(result, null, 2));
|
|
if (errors.length > 0) process.exitCode = 1;
|