Files
workinf_Blender_Wasm/tools/web/course/check-full-course.mjs
mes123456 693f6b774b docs(course): add the 15-module web course and narrated editor video
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>
2026-11-13 06:02:34 +08:00

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;