File size: 6,853 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// 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<string, KChunk[]>();
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<string, unknown>[] = [];
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`);
}