Download src/lib/video/videoLessonPlanner.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 17.6 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/video/videoLessonPlanner.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/video/videoLessonPlanner.ts
-
curl -L -o videoLessonPlanner.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/video/videoLessonPlanner.ts
17.6 kB
| import type { | |
| LongFormBoardFrame, | |
| LongFormClassRuntime, | |
| LongFormExamPractice, | |
| LongFormMethodCard, | |
| LongFormPracticeItem, | |
| LongFormTeachSegment, | |
| } from "@/lib/tuition/longFormClassRuntime"; | |
| export type VideoLessonPlan = { | |
| missionId: string; | |
| subject: string; | |
| chapterTitle: string; | |
| missionTitle: string; | |
| estimatedDurationMinutes: number; | |
| scenes: VideoLessonScene[]; | |
| audioScript: VideoAudioBlock[]; | |
| subtitleBlocks: VideoSubtitleBlock[]; | |
| boardTimeline: VideoBoardFrame[]; | |
| notebookSections: VideoNotebookSection[]; | |
| practicePauses: VideoPracticePause[]; | |
| renderStatus: "plan_only" | "renderer_ready"; | |
| warnings: string[]; | |
| }; | |
| export type VideoLessonScene = { | |
| id: string; | |
| title: string; | |
| sceneType: | |
| | "intro" | |
| | "session_plan" | |
| | "teacher_explanation" | |
| | "board_drawing" | |
| | "worked_example" | |
| | "method" | |
| | "notes" | |
| | "exam_answer" | |
| | "practice_pause" | |
| | "quiz" | |
| | "revision" | |
| | "outro"; | |
| startMinute: number; | |
| durationMinutes: number; | |
| narration: string[]; | |
| visualDirection: string[]; | |
| boardText: string[]; | |
| studentAction?: string; | |
| sourceStep: | |
| | "sessionPlan" | |
| | "teach" | |
| | "visualBoard" | |
| | "methods" | |
| | "keyNotes" | |
| | "examAnswer" | |
| | "yourTurn" | |
| | "quiz" | |
| | "revision"; | |
| }; | |
| export type VideoBoardFrame = { | |
| id: string; | |
| sceneId: string; | |
| frameTitle: string; | |
| timestampMinute: number; | |
| drawingSteps: string[]; | |
| labels: string[]; | |
| finalBoardText: string[]; | |
| teacherNarration: string[]; | |
| }; | |
| export type VideoAudioBlock = { | |
| sceneId: string; | |
| voiceStyle: "calm_teacher" | "exam_coach" | "quick_revision"; | |
| script: string; | |
| estimatedSeconds: number; | |
| }; | |
| export type VideoSubtitleBlock = { | |
| sceneId: string; | |
| text: string; | |
| startSecond: number; | |
| endSecond: number; | |
| }; | |
| export type VideoNotebookSection = { | |
| sceneId: string; | |
| title: string; | |
| copyToNotebook: string[]; | |
| }; | |
| export type VideoPracticePause = { | |
| sceneId: string; | |
| prompt: string; | |
| pauseSeconds: number; | |
| hint: string; | |
| expectedAnswer: string[]; | |
| }; | |
| type MutableScene = Omit<VideoLessonScene, "startMinute">; | |
| const TARGET_MINUTES_MIN = 30; | |
| const TARGET_MINUTES_MAX = 50; | |
| const DEFAULT_SUBJECT = "Physics"; | |
| function clampDuration(minutes: number): number { | |
| if (!Number.isFinite(minutes)) return TARGET_MINUTES_MIN; | |
| return Math.min(TARGET_MINUTES_MAX, Math.max(TARGET_MINUTES_MIN, Math.round(minutes))); | |
| } | |
| function clean(value: string | null | undefined): string { | |
| return (value ?? "").trim(); | |
| } | |
| function parseMissionTitle(runtime: LongFormClassRuntime): string { | |
| const title = runtime.sessionPlan.title.replace(/^Today's Session Plan\s*(?:-|:|\u2014)\s*/i, "").trim(); | |
| return title || `Mission ${runtime.missionId}`; | |
| } | |
| function parseChapterTitle(runtime: LongFormClassRuntime): string { | |
| const [chapter] = runtime.sessionPlan.chapterPosition.split(/\s[-\u2014]\s/); | |
| return clean(chapter) || "Physics lesson"; | |
| } | |
| function unique(values: string[]): string[] { | |
| return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); | |
| } | |
| function normalizeSceneDurations(scenes: MutableScene[], targetMinutes: number): VideoLessonScene[] { | |
| if (scenes.length === 0) return []; | |
| const baseTotal = scenes.reduce((sum, scene) => sum + scene.durationMinutes, 0); | |
| const scale = baseTotal > 0 ? targetMinutes / baseTotal : 1; | |
| const scaled = scenes.map((scene) => ({ | |
| ...scene, | |
| durationMinutes: Math.max(1, Math.round(scene.durationMinutes * scale)), | |
| })); | |
| let diff = targetMinutes - scaled.reduce((sum, scene) => sum + scene.durationMinutes, 0); | |
| let index = 0; | |
| while (diff !== 0 && scaled.length > 0) { | |
| const scene = scaled[index % scaled.length]; | |
| if (diff > 0) { | |
| scene.durationMinutes += 1; | |
| diff -= 1; | |
| } else if (scene.durationMinutes > 1) { | |
| scene.durationMinutes -= 1; | |
| diff += 1; | |
| } | |
| index += 1; | |
| if (index > 500) break; | |
| } | |
| let startMinute = 0; | |
| return scaled.map((scene) => { | |
| const withStart = { ...scene, startMinute }; | |
| startMinute += scene.durationMinutes; | |
| return withStart; | |
| }); | |
| } | |
| function sceneId(prefix: string, index: number): string { | |
| return `${prefix}-${String(index + 1).padStart(2, "0")}`; | |
| } | |
| function firstNonEmpty(values: string[], fallback: string): string { | |
| return values.find((value) => clean(value).length > 0) ?? fallback; | |
| } | |
| function makeIntroScene(runtime: LongFormClassRuntime): MutableScene { | |
| return { | |
| id: "intro-01", | |
| title: "Class intro", | |
| sceneType: "intro", | |
| durationMinutes: 2, | |
| narration: [ | |
| `Today we are learning ${parseMissionTitle(runtime)}.`, | |
| runtime.sessionPlan.learningGoals[0] ?? "We will learn the concept, board work and exam answer structure.", | |
| ], | |
| visualDirection: ["Show DocDoe blackboard title", "Display subject, mission and duration"], | |
| boardText: [parseMissionTitle(runtime), runtime.sessionPlan.chapterPosition], | |
| studentAction: "Get notebook ready before the class starts.", | |
| sourceStep: "sessionPlan", | |
| }; | |
| } | |
| function makeSessionPlanScene(runtime: LongFormClassRuntime): MutableScene { | |
| return { | |
| id: "session-plan-01", | |
| title: "Today's session plan", | |
| sceneType: "session_plan", | |
| durationMinutes: 3, | |
| narration: [ | |
| "First, see the full route of today's class.", | |
| ...runtime.sessionPlan.classFlow.slice(0, 5), | |
| "By the end, you should be ready to explain the concept and write the exam answer.", | |
| ], | |
| visualDirection: ["Show vertical class timeline", "Highlight Teach, Visual Board, Exam Answer and Practice"], | |
| boardText: runtime.sessionPlan.byTheEnd.slice(0, 4), | |
| studentAction: "Notice what you should be able to do by the end.", | |
| sourceStep: "sessionPlan", | |
| }; | |
| } | |
| function teachScene(segment: LongFormTeachSegment, index: number): MutableScene { | |
| return { | |
| id: sceneId("teach", index), | |
| title: segment.title, | |
| sceneType: segment.examples.length > 0 ? "worked_example" : "teacher_explanation", | |
| durationMinutes: segment.durationMinutes, | |
| narration: segment.teacherScript.length > 0 ? segment.teacherScript : [segment.title], | |
| visualDirection: [ | |
| "Show teacher explanation card", | |
| "Reveal board keywords one by one", | |
| ...segment.examples.map((example) => `Show example: ${example}`), | |
| ], | |
| boardText: unique(segment.boardPoints), | |
| studentAction: segment.checkpointQuestion ? `Pause and answer: ${segment.checkpointQuestion}` : "Listen and copy the board keywords.", | |
| sourceStep: "teach", | |
| }; | |
| } | |
| function boardScene(frame: LongFormBoardFrame, index: number): MutableScene { | |
| return { | |
| id: sceneId("board", index), | |
| title: frame.title, | |
| sceneType: "board_drawing", | |
| durationMinutes: frame.durationMinutes, | |
| narration: frame.teacherNarration.length > 0 ? frame.teacherNarration : [frame.title], | |
| visualDirection: [ | |
| "Draw the board frame step by step", | |
| frame.diagramDescription ? `Diagram: ${frame.diagramDescription}` : "Keep the board clean and monochrome", | |
| ], | |
| boardText: unique([...frame.boardDrawingSteps, ...frame.labels]), | |
| studentAction: firstNonEmpty(frame.studentShouldNotice, "Copy the final board into your notebook."), | |
| sourceStep: "visualBoard", | |
| }; | |
| } | |
| function methodScene(method: LongFormMethodCard, index: number): MutableScene { | |
| return { | |
| id: sceneId("method", index), | |
| title: method.title, | |
| sceneType: "method", | |
| durationMinutes: 2, | |
| narration: [method.method, `Use it when: ${method.whenToUse}`, `It avoids: ${method.mistakeAvoided}`], | |
| visualDirection: ["Show method card", "Highlight the mistake avoided", "Show exam-use line"], | |
| boardText: unique([method.title, method.method, method.example]), | |
| studentAction: "Save the method if this is your weak area.", | |
| sourceStep: "methods", | |
| }; | |
| } | |
| function notesScene(runtime: LongFormClassRuntime): MutableScene { | |
| return { | |
| id: "notes-01", | |
| title: "Notebook notes", | |
| sceneType: "notes", | |
| durationMinutes: Math.max(4, runtime.sessionPlan.timing.find((item) => item.block === "Key Notes")?.minutes ?? 5), | |
| narration: [ | |
| "Now copy only the exam-useful notes.", | |
| ...runtime.detailedNotes.definitions.slice(0, 4), | |
| runtime.detailedNotes.commonMistakes[0] ? `Avoid this mistake: ${runtime.detailedNotes.commonMistakes[0]}` : "", | |
| ].filter(Boolean), | |
| visualDirection: ["Show notebook layout", "Separate definitions, formulas, keywords and mistakes"], | |
| boardText: unique([ | |
| ...runtime.detailedNotes.definitions.slice(0, 5), | |
| ...runtime.detailedNotes.formulas.slice(0, 3), | |
| ...runtime.detailedNotes.examKeywords.slice(0, 6), | |
| ]), | |
| studentAction: "Copy the notebook section.", | |
| sourceStep: "keyNotes", | |
| }; | |
| } | |
| function examScene(practice: LongFormExamPractice, index: number): MutableScene { | |
| return { | |
| id: sceneId("exam", index), | |
| title: `${practice.marks}-mark answer`, | |
| sceneType: "exam_answer", | |
| durationMinutes: practice.marks >= 4 ? 5 : 4, | |
| narration: [ | |
| practice.question, | |
| "Use one answer line per mark.", | |
| ...practice.answerStructure, | |
| `Keywords: ${practice.keywords.join(", ")}`, | |
| practice.improvedAnswer, | |
| ], | |
| visualDirection: ["Show question", "Build answer skeleton", "Tick keywords", "Reveal improved board answer"], | |
| boardText: unique([practice.question, ...practice.answerStructure, ...practice.keywords, practice.improvedAnswer]), | |
| studentAction: "Read the improved answer once, then cover it and recall the structure.", | |
| sourceStep: "examAnswer", | |
| }; | |
| } | |
| function practiceScene(item: LongFormPracticeItem, index: number): MutableScene { | |
| return { | |
| id: sceneId("practice", index), | |
| title: index === 0 ? "Your turn" : `Practice check ${index + 1}`, | |
| sceneType: "practice_pause", | |
| durationMinutes: item.type === "numerical" ? 4 : 3, | |
| narration: [item.question, `Hint: ${item.hint}`, "Pause the lesson and try before seeing the answer."], | |
| visualDirection: ["Show practice prompt", "Show pause timer", "Reveal hint only after pause"], | |
| boardText: [item.question, item.hint], | |
| studentAction: "Pause and write your answer.", | |
| sourceStep: "yourTurn", | |
| }; | |
| } | |
| function quizScene(runtime: LongFormClassRuntime): MutableScene | null { | |
| const quizItems = runtime.practiceSet.slice(1, 5); | |
| if (quizItems.length === 0) return null; | |
| return { | |
| id: "quiz-01", | |
| title: "Quick quiz", | |
| sceneType: "quiz", | |
| durationMinutes: 3, | |
| narration: ["Final understanding check.", ...quizItems.map((item) => item.question)], | |
| visualDirection: ["Show one compact quiz card", "Reveal answers after a pause"], | |
| boardText: quizItems.map((item) => item.question), | |
| studentAction: "Answer before the solution appears.", | |
| sourceStep: "quiz", | |
| }; | |
| } | |
| function revisionScene(runtime: LongFormClassRuntime): MutableScene { | |
| return { | |
| id: "revision-01", | |
| title: "Revision save", | |
| sceneType: "revision", | |
| durationMinutes: 3, | |
| narration: [ | |
| "Save this class into revision.", | |
| ...runtime.revisionPack.onePageRevision.slice(0, 4), | |
| "This is still a video plan only. The renderer is not enabled yet.", | |
| ], | |
| visualDirection: ["Show one-page revision", "Show flashcards", "Show weekend practice reminder"], | |
| boardText: unique(runtime.revisionPack.onePageRevision.slice(0, 6)), | |
| studentAction: "Save the weak area and revise it later.", | |
| sourceStep: "revision", | |
| }; | |
| } | |
| function outroScene(runtime: LongFormClassRuntime): MutableScene { | |
| return { | |
| id: "outro-01", | |
| title: "Class complete", | |
| sceneType: "outro", | |
| durationMinutes: 1, | |
| narration: ["Class complete. Revise the board, then attempt the practice answer once more."], | |
| visualDirection: ["Show class complete card", "Show next revision cue"], | |
| boardText: runtime.revisionPack.lastMinuteMistakes.slice(0, 3), | |
| studentAction: "Close the class after saving revision.", | |
| sourceStep: "revision", | |
| }; | |
| } | |
| function makeScenes(runtime: LongFormClassRuntime, targetMinutes: number): VideoLessonScene[] { | |
| const scenes: MutableScene[] = [ | |
| makeIntroScene(runtime), | |
| makeSessionPlanScene(runtime), | |
| ...runtime.teachSegments.map(teachScene), | |
| ...runtime.visualBoardFrames.map(boardScene), | |
| ...runtime.tricksAndMethods.map(methodScene), | |
| notesScene(runtime), | |
| ...runtime.examPractice.map(examScene), | |
| ...runtime.practiceSet.slice(0, 3).map(practiceScene), | |
| ]; | |
| const quiz = quizScene(runtime); | |
| if (quiz) scenes.push(quiz); | |
| scenes.push(revisionScene(runtime), outroScene(runtime)); | |
| return normalizeSceneDurations(scenes, targetMinutes); | |
| } | |
| function voiceStyleFor(scene: VideoLessonScene): VideoAudioBlock["voiceStyle"] { | |
| if (scene.sceneType === "exam_answer" || scene.sceneType === "practice_pause" || scene.sceneType === "quiz") { | |
| return "exam_coach"; | |
| } | |
| if (scene.sceneType === "revision" || scene.sceneType === "outro") return "quick_revision"; | |
| return "calm_teacher"; | |
| } | |
| function makeAudioScript(scenes: VideoLessonScene[]): VideoAudioBlock[] { | |
| return scenes.map((scene) => ({ | |
| sceneId: scene.id, | |
| voiceStyle: voiceStyleFor(scene), | |
| script: scene.narration.join("\n"), | |
| estimatedSeconds: scene.durationMinutes * 60, | |
| })); | |
| } | |
| function splitSubtitleText(text: string): string[] { | |
| const sentences = text | |
| .split(/(?<=[.!?])\s+/) | |
| .map((item) => item.trim()) | |
| .filter(Boolean); | |
| if (sentences.length > 0) return sentences.slice(0, 3); | |
| return text ? [text.slice(0, 160)] : []; | |
| } | |
| function makeSubtitleBlocks(scenes: VideoLessonScene[]): VideoSubtitleBlock[] { | |
| const blocks: VideoSubtitleBlock[] = []; | |
| for (const scene of scenes) { | |
| const sceneStart = scene.startMinute * 60; | |
| const chunks = splitSubtitleText(scene.narration.join(" ")); | |
| const secondsPerChunk = Math.max(4, Math.floor((scene.durationMinutes * 60) / Math.max(chunks.length, 1))); | |
| chunks.forEach((text, index) => { | |
| blocks.push({ | |
| sceneId: scene.id, | |
| text, | |
| startSecond: sceneStart + index * secondsPerChunk, | |
| endSecond: Math.min(sceneStart + scene.durationMinutes * 60, sceneStart + (index + 1) * secondsPerChunk), | |
| }); | |
| }); | |
| } | |
| return blocks; | |
| } | |
| function makeBoardTimeline(runtime: LongFormClassRuntime, scenes: VideoLessonScene[]): VideoBoardFrame[] { | |
| const boardScenes = scenes.filter((scene) => scene.sceneType === "board_drawing"); | |
| return runtime.visualBoardFrames.map((frame, index) => { | |
| const scene = boardScenes[index]; | |
| return { | |
| id: `frame-${String(index + 1).padStart(2, "0")}`, | |
| sceneId: scene?.id ?? sceneId("board", index), | |
| frameTitle: frame.title, | |
| timestampMinute: scene?.startMinute ?? 0, | |
| drawingSteps: frame.boardDrawingSteps, | |
| labels: frame.labels, | |
| finalBoardText: unique([...frame.boardDrawingSteps, ...frame.studentShouldNotice]), | |
| teacherNarration: frame.teacherNarration, | |
| }; | |
| }); | |
| } | |
| function makeNotebookSections(runtime: LongFormClassRuntime, scenes: VideoLessonScene[]): VideoNotebookSection[] { | |
| const notesSceneId = scenes.find((scene) => scene.sourceStep === "keyNotes")?.id ?? "notes-01"; | |
| const revisionSceneId = scenes.find((scene) => scene.sourceStep === "revision")?.id ?? "revision-01"; | |
| const sections: VideoNotebookSection[] = [ | |
| { | |
| sceneId: notesSceneId, | |
| title: "Definitions and key points", | |
| copyToNotebook: runtime.detailedNotes.copyToNotebook, | |
| }, | |
| ]; | |
| if (runtime.detailedNotes.formulas.length > 0) { | |
| sections.push({ | |
| sceneId: notesSceneId, | |
| title: "Formulas and rules", | |
| copyToNotebook: runtime.detailedNotes.formulas, | |
| }); | |
| } | |
| sections.push({ | |
| sceneId: revisionSceneId, | |
| title: "One-page revision", | |
| copyToNotebook: runtime.revisionPack.onePageRevision, | |
| }); | |
| return sections.filter((section) => section.copyToNotebook.length > 0); | |
| } | |
| function makePracticePauses(runtime: LongFormClassRuntime, scenes: VideoLessonScene[]): VideoPracticePause[] { | |
| const practiceScenes = scenes.filter((scene) => scene.sceneType === "practice_pause"); | |
| return runtime.practiceSet.slice(0, 3).map((item, index) => ({ | |
| sceneId: practiceScenes[index]?.id ?? sceneId("practice", index), | |
| prompt: item.question, | |
| pauseSeconds: item.type === "numerical" ? 90 : 60, | |
| hint: item.hint, | |
| expectedAnswer: item.expectedAnswer, | |
| })); | |
| } | |
| function collectWarnings(runtime: LongFormClassRuntime, targetMinutes: number): string[] { | |
| const warnings = [...runtime.warnings]; | |
| if (targetMinutes < 25) warnings.push("Video lesson plan is under 25 minutes."); | |
| if (runtime.visualBoardFrames.length === 0) warnings.push("Video lesson plan has no board drawing frames."); | |
| if (runtime.estimatedMinutes < TARGET_MINUTES_MIN) { | |
| warnings.push(`Runtime estimate was ${runtime.estimatedMinutes} minutes; planner stretches timing to ${TARGET_MINUTES_MIN} minutes without adding new concepts.`); | |
| } | |
| return unique(warnings); | |
| } | |
| export function createVideoLessonPlan(runtime: LongFormClassRuntime): VideoLessonPlan { | |
| const estimatedDurationMinutes = clampDuration(runtime.estimatedMinutes); | |
| const scenes = makeScenes(runtime, estimatedDurationMinutes); | |
| return { | |
| missionId: runtime.missionId, | |
| subject: DEFAULT_SUBJECT, | |
| chapterTitle: parseChapterTitle(runtime), | |
| missionTitle: parseMissionTitle(runtime), | |
| estimatedDurationMinutes, | |
| scenes, | |
| audioScript: makeAudioScript(scenes), | |
| subtitleBlocks: makeSubtitleBlocks(scenes), | |
| boardTimeline: makeBoardTimeline(runtime, scenes), | |
| notebookSections: makeNotebookSections(runtime, scenes), | |
| practicePauses: makePracticePauses(runtime, scenes), | |
| renderStatus: "plan_only", | |
| warnings: collectWarnings(runtime, estimatedDurationMinutes), | |
| }; | |
| } | |