DocDoeAI / scripts /video-engine /build-math-render-package.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
6.85 kB
// 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`);
}