// Convert a SCERT Chemistry blueprint (math-blueprints/chem-*.json) into an // ordered lesson draft of teaching scenes — the render pipeline's front-end, // mirroring the Physics scene shape so the same rendering/TTS/HLS stages can // consume it. This is content ONLY (narration + on-screen + intent + source // lineage); timing and audio are added by later stages. // // CHAPTER_ID=math-p1-c1 npx tsx scripts/video-engine/build-math-lesson-draft.ts // (no CHAPTER_ID => all seven chapters) import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const ROOT = process.cwd(); const BLUEPRINT_DIR = join(ROOT, "data/curriculum/kerala-sslc/math-blueprints"); const OUT_DIR = join(ROOT, "outputs/video/math-course-2025/lessons"); const CHAPTERS = [ "math-p1-c1", "math-p1-c2", "math-p1-c3", "math-p1-c4", "math-p1-c5", "math-p1-c6", "math-p1-c7", "math-p1-c8", "math-p1-c9", "math-p1-c10", "math-p1-c11", "math-p1-c12", "math-p1-c13", ]; type Block = { heading?: string; explanation?: string; key_terms?: string[]; teacher?: string }; type BoardFrame = { title?: string; teacher_action?: string; board_text?: string[]; diagram_description?: string; student_focus?: string; source_page?: number }; type Exam = { marks?: number; question?: string; answer_points?: string[]; keywords?: string[]; common_mistakes?: string[] }; type Quick = { question?: string; answer?: string; explanation?: string }; type Mission = { mission_id: string; title: string; source_pages?: number[]; hook?: string; teacher_intro?: string; explanation_blocks?: Block[]; analogy_or_observation?: string; board_frames?: BoardFrame[]; exam_practice?: Exam[]; quick_check?: Quick; misconception_check?: string; recap?: string | string[]; // Alternate ("checkpoint") schema used by some pre-authored chapters. checkpoint_question?: string; checkpoint_options?: string[]; checkpoint_answer?: string; }; type Blueprint = { chapter_id: string; subject: string; chapter_title: string; source_book: string; source_pdf_pages?: number[]; learning_objectives?: string[]; missions: Mission[]; chapter_recap?: string[]; render_guidance?: Record; }; type VisualFamily = | "MOLECULAR_STRUCTURE" | "REACTION_EQUATION" | "FUNCTIONAL_GROUP" | "ISOMERISM" | "PERIODIC_TREND" | "ELECTROLYSIS_CELL" | "METALLURGY_PROCESS" | "GAS_LAW" | "MOLE_CALCULATION" | "LAB_EXPERIMENT" | "BOARD_ANSWER" | "RECAP" | "CONCEPT_TEXT"; const clean = (s: string | undefined) => (s ?? "") .replace(/\bnamaste,?\s*/gi, "") // drop the repeated robotic opener anywhere .replace(/\bmalayalam_rescue\b/gi, "") .replace(/\bpause\b\.?/gi, "") .replace(/\s+/g, " ") .replace(/^\s*([a-z])/, (_, c) => c.toUpperCase()) .trim(); // Route a scene to a deterministic math visual family from its topic text. function routeVisual(text: string, kind: string): VisualFamily { const t = text.toLowerCase(); if (kind === "board_answer") return "BOARD_ANSWER"; if (kind === "recap_revision") return "RECAP"; if (/isomer/.test(t)) return "ISOMERISM"; if (/functional group|hydroxyl|carboxyl|aldehyde|keto|amine|ester/.test(t)) return "FUNCTIONAL_GROUP"; if (/electrolysis|electrode|anode|cathode|electroplat|galvanic|electrochem/.test(t)) return "ELECTROLYSIS_CELL"; if (/periodic|group|period|valenc|electron configuration|atomic (size|radius)/.test(t)) return "PERIODIC_TREND"; if (/ore|roast|calcin|smelt|reduction|metallurg|refin|reactivity series|corros|alloy/.test(t)) return "METALLURGY_PROCESS"; if (/gas law|boyle|charles|pressure|volume|temperature of a gas|avogadro/.test(t)) return "GAS_LAW"; if (/mole|molar mass|number of moles|percentage composition|stoichiometr/.test(t)) return "MOLE_CALCULATION"; if (/reaction|equation|→|->|combustion|displacement|precipit|neutralis|oxidation|reduction/.test(t)) return "REACTION_EQUATION"; if (/structure|structural formula|chain|bond|molecule|alkane|alkene|alkyne|hydrocarbon/.test(t)) return "MOLECULAR_STRUCTURE"; if (/experiment|apparatus|test tube|observe|beaker|heat|flame/.test(t)) return "LAB_EXPERIMENT"; return "CONCEPT_TEXT"; } type Scene = { sceneId: string; missionId: string; segmentKind: string; title: string; narration: string; onScreenText: string[]; keyTerms: string[]; visual: { family: VisualFamily; topicKey: string; boardText?: string[]; diagramHint?: string }; examAnswer?: { question: string; marks: number; answerPoints: string[]; keywords: string[]; commonMistakes: string[] }; sourcePage: number; }; function buildChapter(bp: Blueprint) { const scenes: Scene[] = []; const firstPage = bp.source_pdf_pages?.[0] ?? 0; let n = 0; const id = (kind: string) => `${kind}-${String(++n).padStart(3, "0")}`; // Chapter opening / roadmap. scenes.push({ sceneId: id("opening"), missionId: "chapter", segmentKind: "opening", title: clean(bp.chapter_title), narration: `Welcome to ${clean(bp.chapter_title)}. By the end of today you will: ${(bp.learning_objectives ?? []).map((o) => o.toLowerCase()).join("; ")}.`, onScreenText: (bp.missions ?? []).map((m) => clean(m.title)), keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "roadmap" }, sourcePage: firstPage, }); for (const m of bp.missions ?? []) { const page = m.source_pages?.[0] ?? firstPage; // Mission intro. const intro = clean([m.hook, m.teacher_intro].filter(Boolean).join(" ")); if (intro) { scenes.push({ sceneId: id("concept"), missionId: m.mission_id, segmentKind: "concept", title: clean(m.title), narration: intro, onScreenText: [clean(m.title)], keyTerms: [], visual: { family: routeVisual(m.title, "concept"), topicKey: m.title }, sourcePage: page, }); } // Explanation blocks. A block whose explanation spans multiple lines (a // formula/worked-steps block, common in the "checkpoint" schema) becomes a // visual_board scene so those exact lines render as real board_text — never // dropped as plain narration. "teacher" (spoken-style elaboration) is // folded into the narration alongside "explanation". for (const b of m.explanation_blocks ?? []) { if (!clean(b.explanation) && !clean(b.teacher)) continue; const lines = (b.explanation ?? "").split(/\n+/).map((l) => l.trim()).filter(Boolean); const narration = clean([b.explanation && lines.length <= 1 ? b.explanation : "", b.teacher].filter(Boolean).join(" ")) || clean(b.explanation); if (lines.length > 1) { scenes.push({ sceneId: id("visual_board"), missionId: m.mission_id, segmentKind: "visual_board", title: clean(b.heading) || clean(m.title), narration, onScreenText: lines.map(clean), keyTerms: b.key_terms ?? [], visual: { family: routeVisual(`${b.heading} ${lines.join(" ")}`, "visual_board"), topicKey: b.heading || m.title, boardText: lines }, sourcePage: page, }); } else { scenes.push({ sceneId: id("concept"), missionId: m.mission_id, segmentKind: "concept", title: clean(b.heading) || clean(m.title), narration, onScreenText: [clean(b.heading)].filter(Boolean), keyTerms: b.key_terms ?? [], visual: { family: routeVisual(`${b.heading} ${b.explanation}`, "concept"), topicKey: b.heading || m.title }, sourcePage: page, }); } } // Analogy / observation as a short concept beat. if (clean(m.analogy_or_observation)) { scenes.push({ sceneId: id("concept"), missionId: m.mission_id, segmentKind: "concept", title: "Think about it", narration: clean(m.analogy_or_observation), onScreenText: [], keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "analogy" }, sourcePage: page, }); } // Board frames (structures, equations, observations from the textbook). for (const bf of m.board_frames ?? []) { scenes.push({ sceneId: id("visual_board"), missionId: m.mission_id, segmentKind: "visual_board", title: clean(bf.title) || clean(m.title), narration: clean([bf.teacher_action, bf.student_focus].filter(Boolean).join(" ")), onScreenText: (bf.board_text ?? []).map(clean).filter(Boolean), keyTerms: [], visual: { family: routeVisual(`${bf.title} ${bf.diagram_description} ${(bf.board_text ?? []).join(" ")}`, "visual_board"), topicKey: bf.title || m.title, boardText: bf.board_text, diagramHint: bf.diagram_description }, sourcePage: bf.source_page ?? page, }); } // Misconception check. if (clean(m.misconception_check)) { scenes.push({ sceneId: id("common_mistakes"), missionId: m.mission_id, segmentKind: "common_mistakes", title: "Common mistake", narration: clean(m.misconception_check), onScreenText: [clean(m.misconception_check)], keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "misconception" }, sourcePage: page, }); } // Exam practice → board-answer scenes. for (const ex of m.exam_practice ?? []) { if (!clean(ex.question)) continue; scenes.push({ sceneId: id("board_answer"), missionId: m.mission_id, segmentKind: "board_answer", title: clean(ex.question), narration: `Let us write the ${ex.marks ?? 2}-mark answer. ${clean(ex.question)}`, onScreenText: [], keyTerms: ex.keywords ?? [], visual: { family: "BOARD_ANSWER", topicKey: ex.question || m.title }, examAnswer: { question: clean(ex.question), marks: ex.marks ?? 2, answerPoints: (ex.answer_points ?? []).map(clean), keywords: ex.keywords ?? [], commonMistakes: ex.common_mistakes ?? [] }, sourcePage: page, }); } // Quick check (active recall). if (clean(m.quick_check?.question)) { scenes.push({ sceneId: id("recall"), missionId: m.mission_id, segmentKind: "recall", title: "Quick check", narration: `${clean(m.quick_check?.question)} … ${clean(m.quick_check?.answer)}.`, onScreenText: [clean(m.quick_check?.question), clean(m.quick_check?.answer)].filter(Boolean), keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "quick_check" }, sourcePage: page, }); } // Checkpoint question (alternate schema) → a worked board-answer scene, so // it gets the same exam-Q&A treatment as exam_practice, not just a text card. if (clean(m.checkpoint_question)) { const q = clean(m.checkpoint_question); const ans = clean(m.checkpoint_answer); scenes.push({ sceneId: id("board_answer"), missionId: m.mission_id, segmentKind: "board_answer", title: q, narration: `Check yourself. ${q} … The answer is ${ans}.`, onScreenText: [], keyTerms: [], visual: { family: "BOARD_ANSWER", topicKey: q }, examAnswer: { question: q, marks: 2, answerPoints: [ans].filter(Boolean), keywords: [], commonMistakes: [] }, sourcePage: page, }); } } // Chapter recap. Fall back to mission titles when no explicit chapter_recap // is authored (the "checkpoint" schema chapters don't have one). const recapPoints = ((bp.chapter_recap?.length ? bp.chapter_recap : (bp.missions ?? []).map((m) => m.title)) ?? []) .map(clean).filter(Boolean).slice(0, 8); if (recapPoints.length) { scenes.push({ sceneId: id("recap_revision"), missionId: "chapter", segmentKind: "recap_revision", title: `${clean(bp.chapter_title)} — recap`, narration: `Let us revise the key points. ${recapPoints.join(". ")}.`, onScreenText: recapPoints, keyTerms: [], visual: { family: "RECAP", topicKey: "recap" }, sourcePage: firstPage, }); } // Chapter-scoped homework (2-4 tasks from this chapter's own exam questions, // falling back to checkpoint questions when exam_practice is absent). const examQs = (bp.missions ?? []).flatMap((m) => (m.exam_practice ?? []).map((e) => clean(e.question)).filter(Boolean)); const checkpointQs = (bp.missions ?? []).map((m) => clean(m.checkpoint_question)).filter(Boolean); const homework = (examQs.length ? examQs : checkpointQs).slice(0, 3).map((q, i) => `Task ${i + 1}: ${q}`); if (homework.length) { scenes.push({ sceneId: id("homework"), missionId: "chapter", segmentKind: "homework", title: "Homework and next step", narration: `For homework: ${homework.join(" ")}`, onScreenText: homework, keyTerms: [], visual: { family: "CONCEPT_TEXT", topicKey: "homework" }, sourcePage: firstPage, }); } return { chapterId: bp.chapter_id, chapterTitle: bp.chapter_title, subject: "Mathematics", sourceBook: bp.source_book, sourcePages: bp.source_pdf_pages, renderGuidance: bp.render_guidance, sceneCount: scenes.length, scenes, homework, }; } const requested = process.env.CHAPTER_ID ? [process.env.CHAPTER_ID] : CHAPTERS; mkdirSync(OUT_DIR, { recursive: true }); for (const chapterId of requested) { const bpPath = join(BLUEPRINT_DIR, `${chapterId}.json`); if (!existsSync(bpPath)) { console.error(`missing blueprint: ${bpPath}`); continue; } const bp = JSON.parse(readFileSync(bpPath, "utf8")) as Blueprint; const draft = buildChapter(bp); const outPath = join(OUT_DIR, `${chapterId}-lesson-draft.json`); writeFileSync(outPath, `${JSON.stringify(draft, null, 2)}\n`); const kinds = draft.scenes.reduce>((a, s) => { a[s.segmentKind] = (a[s.segmentKind] ?? 0) + 1; return a; }, {}); console.log(`${chapterId}: ${draft.sceneCount} scenes ${JSON.stringify(kinds)}`); }