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; 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), }; }