Files
workinf_Blender_Wasm/tools/web/course/check-full-course.mjs
mes123456 8a60341d3b feat(course): add per-module narrated video pipeline
Each course module now carries a video record, the generator embeds a
module video block, and the checker requires 15 H.264/AAC 720p videos
between 3 and 8 minutes. The recorder prepares module narration, runs
the module actions with the lesson overlay, records/mixes the video,
and writes per-module reports; it also uses OPFS save/reopen paths.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-13 09:57:07 +08:00

66 lines
3.9 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 moduleIds = [...html.matchAll(/data-module-video="([^"]+)"/g)].map((match) => match[1]);
if (moduleIds.length !== 15) errors.push(`expected 15 module videos, found ${moduleIds.length}`);
if (new Set(moduleIds).size !== moduleIds.length) errors.push("duplicate module video ids");
const moduleMedia = media.filter((item) => /(^|\/)video\/M\d+-lesson-/.test(item.src));
if (moduleMedia.length !== 15) errors.push(`expected 15 module video files, found ${moduleMedia.length}`);
for (const item of moduleMedia) {
if (item.duration < 180 || item.duration > 480) errors.push(`${item.src} duration ${item.duration}s outside 180-480s`);
}
for (const moduleId of moduleIds) {
const slugPrefix = `video/${moduleId}-lesson`;
if (!html.includes(`src="${slugPrefix}-720p.mp4"`)) errors.push(`${moduleId} video src missing`);
if (!html.includes(`${slugPrefix}-poster.jpg`)) errors.push(`${moduleId} poster missing`);
if (!html.includes(`${slugPrefix}.zh-Hans.vtt`)) errors.push(`${moduleId} VTT missing`);
}
const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", modules, moduleVideos: moduleIds.length, videos: media.length, totalMediaBytes: media.reduce((total, item) => total + (item.bytes ?? 0), 0), media, errors };
console.log(JSON.stringify(result, null, 2));
if (errors.length > 0) process.exitCode = 1;