// Assemble a timed Chemistry render package from a lesson draft + measured // Kokoro narration. Produces the same render-props schema the Physics V2 engine // consumes, so the existing renderer/HLS pipeline renders Chemistry unchanged: // - concatenates per-scene narration wavs into one chapter track // - builds a frame-accurate explicitTimeline from measured durations // - segments captions with the shared phrase-aware segmenter // // CHAPTER_ID=math-p1-c1 npx tsx scripts/video-engine/build-math-render-package.ts import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { resegmentCaptions, type CaptionCue } from "../../src/video-engine/physics/captionSegmentation"; const ROOT = process.cwd(); const FPS = 30; const GAP = 0.28; // seconds of silence between scenes const ALL_CHAPTERS = ["math-p1-c1", "math-p1-c2", "math-p1-c3", "math-p1-c4", "math-p1-c5"]; const targetChapters = process.env.CHAPTER_ID ? [process.env.CHAPTER_ID] : ALL_CHAPTERS; type DraftScene = { sceneId: string; segmentKind: string; title: string; narration: string; onScreenText: string[]; keyTerms: string[]; visual: { family: string; topicKey: string; boardText?: string[]; diagramHint?: string }; examAnswer?: { question: string; marks: number; answerPoints: string[]; keywords: string[]; commonMistakes: string[] }; sourcePage: number; }; type Draft = { chapterId: string; chapterTitle: string; sourceBook: string; scenes: DraftScene[] }; type KChunk = { id: string; sceneId: string; segmentKind: string; text: string; durationSeconds: number; outputPath: string }; for (const chapterId of targetChapters) { const draftPath = join(ROOT, `outputs/video/math-course-2025/lessons/${chapterId}-lesson-draft.json`); const manifestPath = join(ROOT, `outputs/video/tuition/${chapterId}/audio/kokoro-full-chapter/manifest.json`); if (!existsSync(draftPath) || !existsSync(manifestPath)) { console.error(`Skipping ${chapterId}: draft or kokoro manifest missing`); continue; } const draft = JSON.parse(readFileSync(draftPath, "utf8")) as Draft; const kmanifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { chunks: KChunk[] }; // Group audio chunks by scene, preserving order. const bySceneChunks = new Map(); for (const c of kmanifest.chunks) { const list = bySceneChunks.get(c.sceneId) ?? []; list.push(c); bySceneChunks.set(c.sceneId, list); } const publicAudioDir = join(ROOT, "public/generated/math-production"); mkdirSync(publicAudioDir, { recursive: true }); const narrationRel = `generated/math-production/${chapterId}-full-chapter-narration.wav`; const narrationAbs = join(ROOT, "public", narrationRel); // Silence pad between scenes. const silencePath = join(publicAudioDir, `_gap.wav`); execFileSync("ffmpeg", ["-y", "-f", "lavfi", "-i", `anullsrc=r=24000:cl=mono`, "-t", String(GAP), "-sample_fmt", "s16", silencePath], { stdio: "ignore" }); const orderedWavs: string[] = []; const scenes: Record[] = []; const captions: CaptionCue[] = []; let cursorSec = 0; let cursorFrame = 0; for (const scene of draft.scenes) { const chunks = (bySceneChunks.get(scene.sceneId) ?? []).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })); if (!chunks.length) continue; const sceneAudio = chunks.reduce((s, c) => s + (c.durationSeconds || 0), 0); const sceneDur = sceneAudio + GAP; const fromSeconds = cursorSec; const fromFrame = cursorFrame; const durationInFrames = Math.max(1, Math.round(sceneDur * FPS)); for (const c of chunks) { orderedWavs.push(join(ROOT, c.outputPath)); } orderedWavs.push(silencePath); captions.push({ id: `${scene.sceneId}-voice-1-1`, sceneId: scene.sceneId, startSecond: fromSeconds, endSecond: fromSeconds + sceneAudio, text: scene.narration, }); scenes.push({ scene: { id: scene.sceneId, title: scene.title, segmentKind: scene.segmentKind, subject: "Mathematics", chapter: draft.chapterTitle, narration: scene.narration, onScreenText: scene.onScreenText, teachingBeats: [], recapNote: "", keyTerms: scene.keyTerms, visual: scene.visual, sourcePage: scene.sourcePage, ...(scene.examAnswer ? { examAnswer: { question: scene.examAnswer.question, marks: scene.examAnswer.marks, boardStyleRewrite: scene.examAnswer.answerPoints.join(". "), answerPoints: scene.examAnswer.answerPoints } } : {}), }, segmentId: scene.sceneId, segmentKind: scene.segmentKind, fromFrame, durationInFrames, fromSeconds: Number(fromSeconds.toFixed(3)), durationSeconds: Number(sceneDur.toFixed(3)), }); cursorSec += sceneDur; cursorFrame += durationInFrames; } const listPath = join(publicAudioDir, `_concat-${chapterId}.txt`); writeFileSync(listPath, orderedWavs.map((w) => `file '${w.replaceAll("\\", "/")}'`).join("\n"), "utf8"); execFileSync("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-ar", "24000", "-ac", "1", "-sample_fmt", "s16", narrationAbs], { stdio: "ignore" }); const segmented = resegmentCaptions(captions); const totalFrames = cursorFrame; const props = { chapterId, canonicalChapterId: chapterId, plan: { schemaVersion: 2, generatedAt: new Date().toISOString(), fps: FPS, classLevel: "Class 10", board: "Kerala SSLC", subject: "Mathematics", chapter: draft.chapterTitle, language: "English", examGoal: "Board-exam readiness", honestyNote: "Source-backed SCERT math lesson.", targetDurationMinutes: Math.round(cursorSec / 60), totalDurationSeconds: Number(cursorSec.toFixed(2)), segments: [{ scenes: scenes.map((s) => (s as { scene: unknown }).scene) }], needsContentReview: false, strategyReady: true }, explicitTimeline: { fps: FPS, totalFrames, totalSeconds: Number(cursorSec.toFixed(3)), scenes }, audioTimeline: { sampleRate: 24000, silenceSeconds: GAP, totalSeconds: Number(cursorSec.toFixed(3)), chunks: [] }, captions: segmented, narrationAudioSrc: narrationRel, sourceLabel: "Kerala SCERT · Standard X · Mathematics First Edition 2025", segmentLabel: "complete chapter", contextAssets: {}, }; const outDir = join(ROOT, `outputs/video/math-course-2025/packages/${chapterId}`); mkdirSync(outDir, { recursive: true }); writeFileSync(join(outDir, "render-props.json"), `${JSON.stringify(props, null, 2)}\n`); const probe = existsSync(narrationAbs) ? execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", narrationAbs], { encoding: "utf8" }).trim() : "?"; console.log(`${chapterId}: ${scenes.length} scenes, ${(totalFrames / FPS / 60).toFixed(2)} min, ${segmented.length} caption cues`); console.log(` narration wav: ${narrationRel} (${probe}s)`); console.log(` package: outputs/video/math-course-2025/packages/${chapterId}/render-props.json`); }