Download scripts/video-engine/build-math-render-package.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 6.85 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/scripts/video-engine/build-math-render-package.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/scripts/video-engine/build-math-render-package.ts
-
curl -L -o build-math-render-package.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/scripts/video-engine/build-math-render-package.ts
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`); | |
| } | |