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>
66 lines
3.9 KiB
JavaScript
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;
|