DocDoeAI / src /lib /video /videoLessonPlanner.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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),
};
}