"use client"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { AlertCircle, Check, ChevronDown, Clapperboard, Download, ExternalLink, LoaderCircle, Lock, RotateCcw, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { AnswerTrustFooter, EvidenceBadge } from "@/components/dashboard/EvidenceBadge"; import { useJobPolling } from "@/hooks/useJobPolling"; import { normalizeApiError, resolveMediaUrl } from "@/lib/api/client"; import { createStudyVideoJob, getVideoJob, renderStudyVideoPreview, } from "@/lib/api/video"; import type { StudyVideoJobResponse, StudyVideoMode, StudyVideoPlan, StudyVideoScene, StudyVideoPreviewResponse, VideoRenderJob, } from "@/lib/api/types"; import { VIDEO_DURATION_OPTIONS, type VideoDurationMinutes, videoDurationOption, } from "@/lib/video/durations"; type WizardStage = | "setup" | "planning" | "script_ready" | "preview_rendering" | "preview_ready" | "failed"; type JobViewState = { id: string; status: string; current_step?: string | null; progress?: number | null; output_url?: string | null; evidence_label?: string | null; error_message?: string | null; }; type MaterialType = | "notes" | "chapter" | "textbook" | "question_paper" | "handwritten_notes" | "syllabus" | "resume_or_personal_doc" | "unknown"; export type StudyVideoWizardSource = { id: string; title: string; subject?: string; status?: string; materialType?: MaterialType | null; material_type?: MaterialType | null; evidenceLabel?: string | null; evidence_label?: string | null; }; type StudyVideoWizardProps = { isOpen: boolean; source: StudyVideoWizardSource; onClose: () => void; onUploadClick?: () => void; }; const validVideoMaterialTypes = new Set([ "notes", "chapter", "textbook", "handwritten_notes", "question_paper", "syllabus", ]); const materialLabels: Record = { notes: "Notes", chapter: "Chapter", textbook: "Textbook", question_paper: "Question paper", handwritten_notes: "Handwritten notes", syllabus: "Syllabus", resume_or_personal_doc: "Personal document", unknown: "Unknown source", }; const specialModeOptions: { value: StudyVideoMode; label: string; helper: string; target: number; durationMinutes: VideoDurationMinutes; }[] = [ { value: "last_night_revision", label: "Last-night revision", helper: "Quick recall and scoring points.", target: 600, durationMinutes: 10, }, { value: "pyq_review", label: "PYQ review", helper: "Only for uploaded question paper sources.", target: 600, durationMinutes: 10, }, ]; const languageOptions = ["English", "Malayalam", "Mixed"]; const formatOptions = [ { value: "16:9", label: "Landscape" }, { value: "9:16", label: "Vertical" }, ]; const visualStyleOptions = [ { value: "clean_explainer", label: "Clean explainer" }, { value: "anime_visual_explainer", label: "Anime visual explainer" }, ]; const voiceOptions = [ { value: "", label: "Default" }, { value: "calm_teacher", label: "Calm teacher" }, { value: "malayalam_english_teacher", label: "Malayalam + English teacher" }, ]; const previewPollingTimeoutMs = 10 * 60 * 1000; const previewTimeoutCopy = "Preview is still rendering. You can close this and check Recent videos later."; function getMaterialType(source: StudyVideoWizardSource) { return source.materialType ?? source.material_type ?? null; } function defaultModeForSource(source: StudyVideoWizardSource): StudyVideoMode { if (getMaterialType(source) === "question_paper") return "pyq_review"; return "exam_study_video"; } function targetForMode(mode: StudyVideoMode, durationMinutes: VideoDurationMinutes) { const special = specialModeOptions.find((option) => option.value === mode); if (special) return special.target; return videoDurationOption(durationMinutes).targetSeconds; } function modeForDuration(durationMinutes: VideoDurationMinutes, materialType: MaterialType | null | undefined): StudyVideoMode { if (materialType === "question_paper") return "pyq_review"; return videoDurationOption(durationMinutes).studyMode; } function getSourceEvidenceLabel(source: StudyVideoWizardSource) { return source.evidenceLabel ?? source.evidence_label ?? null; } function getEligibility(source: StudyVideoWizardSource) { const materialType = getMaterialType(source); if (!source.id) { return { canCreate: false, title: "Upload material to create a study video.", body: "Choose a readable PDF or TXT source containing notes, a chapter, syllabus, textbook, or question paper before creating a video.", cta: "Upload material", }; } if (source.status === "processing" || source.status === "uploaded") { return { canCreate: false, title: "This source is still processing.", body: "Create the video after DocDoe finishes reading the source.", cta: null, }; } if (source.status === "failed") { return { canCreate: false, title: "This source is no longer eligible for study videos.", body: "DocDoe could not read this file. Upload another copy or use another source.", cta: "Upload material", }; } if (materialType === "resume_or_personal_doc") { return { canCreate: false, title: "Use study material to create a video.", body: "Personal documents are blocked from exam video generation.", cta: null, }; } if (materialType === "unknown" || !materialType) { return { canCreate: false, title: "Classify this source to create a video.", body: "Use readable notes, a chapter, syllabus, textbook, or question paper to create a study video.", cta: null, }; } if (!validVideoMaterialTypes.has(materialType)) { return { canCreate: false, title: "This source is no longer eligible for study videos.", body: "Use readable notes, a chapter, syllabus, textbook, or question paper to create a study video.", cta: null, }; } return { canCreate: true, title: "", body: "", cta: null }; } function sourceHeader(source: StudyVideoWizardSource) { const materialType = getMaterialType(source); if (materialType === "question_paper") return `Analyzing paper: ${source.title}`; if ( materialType === "notes" || materialType === "chapter" || materialType === "textbook" || materialType === "handwritten_notes" || materialType === "syllabus" ) { return `Working from: ${source.title}`; } return null; } // The "verified" note is only earned by the real evidence marker: the backend // evidence contract emits a "verified" label solely when 8+ distinct years of // reviewed papers are uploaded (backend/app/services/evidence_contract.py). // A marketing span like "10-year" must never mint this state, and a label that // explicitly denies verification ("not verified", "unverified") must not be // read as its opposite. function hasVerifiedEvidence(text: string): boolean { return text.includes("verified") && !text.includes("not verified") && !text.includes("unverified"); } function evidenceContextNote(label: string | null | undefined) { if (!label) return null; const text = label.toLowerCase(); if (text.includes("uploaded material only") || text.includes("material only")) { return "Video will be based on uploaded material only. Upload question papers to unlock PYQ-based video signals."; } if (text.includes("1 uploaded") || text.includes("one uploaded")) { return "Video will analyze this paper only. Upload more papers for trend analysis."; } if (hasVerifiedEvidence(text)) { return "Verified PYQ evidence is available for this context."; } return null; } function isTerminalStatus(status: string | null | undefined) { return status === "complete" || status === "ready" || status === "failed"; } function isCompleteStatus(status: string | null | undefined) { return status === "complete" || status === "ready"; } function fallbackProgress(status: string | null | undefined) { if (status === "audio_generating") return 35; if (status === "rendering" || status === "running" || status === "uploading") return 65; if (isCompleteStatus(status)) return 100; if (status === "script_ready") return 25; return 10; } function progressFor(job: JobViewState | null, stage: WizardStage) { if (!job) return stage === "planning" ? 18 : 0; return Math.max(0, Math.min(job.progress ?? fallbackProgress(job.status), 100)); } function toJobState(job: StudyVideoJobResponse | StudyVideoPreviewResponse | VideoRenderJob): JobViewState { const record = job as Partial; return { id: job.id, status: job.status, current_step: job.current_step, progress: record.progress ?? null, output_url: record.output_url ?? record.public_url ?? record.download_url ?? null, evidence_label: record.evidence_label ?? null, error_message: record.error_message ?? null, }; } function safeVideoError(error: unknown) { const message = normalizeApiError(error); const lower = message.toLowerCase(); if (lower.includes("sign in")) return "Please sign in again."; if (lower.includes("offline") || lower.includes("unreachable") || lower.includes("failed to fetch")) { return "DocDoe is temporarily offline. Check your connection and try again."; } if (lower.includes("pyq") && lower.includes("question")) { return "PYQ review needs a question paper source."; } if (lower.includes("script") && lower.includes("ready")) { return "Preview render can only start after the study video script is ready."; } if (lower.includes("scene plan") || lower.includes("missing")) { return "Scene plan is missing. Create the study video plan again."; } if (lower.includes("source") || lower.includes("eligible") || lower.includes("material")) { return "Use readable notes, a chapter, syllabus, textbook, or question paper to create a study video."; } if (message.includes("Traceback") || message.includes("{") || message.length > 180) { return "Preview render failed."; } return message || "Preview render failed."; } function materialLabel(source: StudyVideoWizardSource) { const materialType = getMaterialType(source); return materialType ? materialLabels[materialType] : "Unknown source"; } function jobTitle(plan: StudyVideoPlan | null, source: StudyVideoWizardSource) { if (plan?.title) return plan.title; const materialType = getMaterialType(source); if (materialType === "question_paper") return "PYQ review video"; if (materialType === "syllabus") return "Syllabus map video"; return "Study video"; } function isStudyVideoPlan(value: unknown): value is StudyVideoPlan { if (!value || typeof value !== "object") return false; const record = value as Partial; return typeof record.title === "string" && Array.isArray(record.scenes); } function normalizeScene(scene: Partial, index: number): StudyVideoScene { const id = typeof scene.id === "string" && scene.id.trim() ? scene.id : `scene_${index + 1}`; const title = typeof scene.title === "string" && scene.title.trim() ? scene.title : `Scene ${index + 1}`; const narration = typeof scene.narration === "string" ? scene.narration : ""; return { id, type: typeof scene.type === "string" && scene.type.trim() ? scene.type : "explanation", title, narration, duration_seconds: typeof scene.duration_seconds === "number" && Number.isFinite(scene.duration_seconds) ? scene.duration_seconds : 30, visual_hint: typeof scene.visual_hint === "string" ? scene.visual_hint : "", on_screen_text: typeof scene.on_screen_text === "string" && scene.on_screen_text.trim() ? scene.on_screen_text : title, keywords: Array.isArray(scene.keywords) ? scene.keywords.filter((keyword): keyword is string => typeof keyword === "string") : [], evidence_note: typeof scene.evidence_note === "string" ? scene.evidence_note : null, animation_style: typeof scene.animation_style === "string" && scene.animation_style.trim() ? scene.animation_style : "clean_explainer", subtitle_segments: Array.isArray(scene.subtitle_segments) ? scene.subtitle_segments : [], }; } function normalizeStudyVideoPlan(value: unknown): StudyVideoPlan | null { if (!isStudyVideoPlan(value)) return null; const rawScenes = value.scenes as unknown[]; const scenes = rawScenes .filter((scene): scene is Partial => Boolean(scene) && typeof scene === "object") .map(normalizeScene); if (scenes.length === 0) return null; return { ...value, scenes }; } export function StudyVideoWizard({ isOpen, source, onClose, onUploadClick }: StudyVideoWizardProps) { const [stage, setStage] = useState("setup"); const [durationMinutes, setDurationMinutes] = useState(10); const [mode, setMode] = useState("exam_study_video"); const [language, setLanguage] = useState("English"); const [format, setFormat] = useState("16:9"); const [visualStyle, setVisualStyle] = useState("clean_explainer"); const [voice, setVoice] = useState(""); const [planResponse, setPlanResponse] = useState(null); const [job, setJob] = useState(null); const [error, setError] = useState(null); const [pollTimedOut, setPollTimedOut] = useState(false); const [expandedScenes, setExpandedScenes] = useState>({}); const eligibility = useMemo(() => getEligibility(source), [source]); const sourceEvidenceLabel = getSourceEvidenceLabel(source); const plan = useMemo(() => normalizeStudyVideoPlan(planResponse?.plan), [planResponse?.plan]); const currentEvidenceLabel = job?.evidence_label ?? plan?.evidence_label ?? planResponse?.evidence_label ?? sourceEvidenceLabel; const sourceContextHeader = sourceHeader(source); const evidenceNote = evidenceContextNote(currentEvidenceLabel); const previewUrl = resolveMediaUrl(job?.output_url); const progress = progressFor(job, stage); const selectedDuration = videoDurationOption(durationMinutes); const selectedSpecialMode = specialModeOptions.find((option) => option.value === mode); const jobId = job?.id ?? null; useEffect(() => { if (!isOpen) return; const resetTimeoutId = window.setTimeout(() => { const nextMode = defaultModeForSource(source); setStage("setup"); setDurationMinutes(nextMode === "pyq_review" ? 10 : 10); setMode(nextMode); setLanguage("English"); setFormat("16:9"); setVisualStyle("clean_explainer"); setVoice(""); setPlanResponse(null); setJob(null); setError(null); setPollTimedOut(false); setExpandedScenes({}); }, 0); return () => window.clearTimeout(resetTimeoutId); }, [isOpen, source]); const pollFetcher = useCallback(async () => { if (!jobId) throw new Error("No preview job is running."); return getVideoJob(jobId); }, [jobId]); const onPollData = useCallback((nextJob: VideoRenderJob) => { const nextState = toJobState(nextJob); setJob(nextState); if (nextState.status === "failed") { setStage("failed"); setError(nextState.error_message ? safeVideoError(new Error(nextState.error_message)) : "Preview render failed."); setPollTimedOut(false); return; } if (isCompleteStatus(nextState.status)) { setStage("preview_ready"); setPollTimedOut(false); return; } setStage("preview_rendering"); setPollTimedOut(false); }, []); const shouldContinuePolling = useCallback((nextJob: VideoRenderJob) => { return !isTerminalStatus(nextJob.status); }, []); const onPollingError = useCallback((caughtError: unknown) => { setStage("failed"); setError(safeVideoError(caughtError)); setPollTimedOut(false); }, []); const onPollingTimeout = useCallback(() => { setPollTimedOut(true); }, []); useJobPolling({ enabled: isOpen && stage === "preview_rendering" && Boolean(jobId) && !pollTimedOut, fetcher: pollFetcher, intervalMs: 2500, timeoutMs: previewPollingTimeoutMs, shouldContinue: shouldContinuePolling, onData: onPollData, onError: onPollingError, onTimeout: onPollingTimeout, }); if (!isOpen) return null; async function handleCreatePlan() { const latestEligibility = getEligibility(source); if (!latestEligibility.canCreate) { setError("This source is no longer eligible for study videos."); return; } if (mode === "pyq_review" && getMaterialType(source) !== "question_paper") { setError("PYQ review needs a question paper source."); return; } setStage("planning"); setError(null); setPollTimedOut(false); setPlanResponse(null); setJob(null); setExpandedScenes({}); try { const response = await createStudyVideoJob({ document_id: source.id, video_mode: mode, target_duration_seconds: targetForMode(mode, durationMinutes), language, voice: voice || null, visual_style: visualStyle, format, preview_quality: true, }); if (!normalizeStudyVideoPlan(response.plan)) { throw new Error("Scene plan is missing. Create the study video plan again."); } setPlanResponse(response); setJob(toJobState(response)); setStage("script_ready"); } catch (caughtError) { setStage("failed"); setError(safeVideoError(caughtError)); } } async function handleRenderPreview() { const latestEligibility = getEligibility(source); if (!latestEligibility.canCreate) { setError("This source is no longer eligible for study videos."); return; } if (!planResponse?.id) { setStage("failed"); setError("Scene plan is missing. Create the study video plan again."); return; } if (job?.status !== "script_ready") { setError("Preview render can only start after the study video script is ready."); return; } setStage("preview_rendering"); setError(null); setPollTimedOut(false); try { const response = await renderStudyVideoPreview(planResponse.id); const nextState = toJobState(response); setJob(nextState); if (nextState.status === "failed") { setStage("failed"); setError("Preview render failed."); setPollTimedOut(false); } else if (isCompleteStatus(nextState.status)) { setStage("preview_ready"); setPollTimedOut(false); } } catch (caughtError) { setStage("failed"); setError(safeVideoError(caughtError)); setPollTimedOut(false); } } function handleRetrySetup() { setStage("setup"); setError(null); setPollTimedOut(false); setJob(null); } function toggleScene(sceneId: string) { setExpandedScenes((current) => ({ ...current, [sceneId]: !current[sceneId] })); } return (
{!eligibility.canCreate ? ( ) : null} {error ? (
{error}
) : null} {stage === "setup" || stage === "planning" ? ( { setDurationMinutes(minutes); setMode(modeForDuration(minutes, getMaterialType(source))); }} onModeChange={setMode} onLanguageChange={setLanguage} onFormatChange={setFormat} onVisualStyleChange={setVisualStyle} onVoiceChange={setVoice} onCreatePlan={() => void handleCreatePlan()} /> ) : null} {stage === "script_ready" && plan ? ( void handleRenderPreview()} onBackToSettings={handleRetrySetup} /> ) : null} {stage === "preview_rendering" ? ( ) : null} {stage === "preview_ready" ? ( ) : null} {stage === "failed" ? ( ) : null}
); } function StagePill({ stage }: { stage: WizardStage }) { const label: Record = { setup: "Setup", planning: "Planning", script_ready: "Scene plan ready", preview_rendering: "Rendering", preview_ready: "Preview ready", failed: "Failed", }; return ( {label[stage]} ); } function SourceSummaryCard({ source, sourceContextHeader, evidenceLabel, }: { source: StudyVideoWizardSource; sourceContextHeader: string | null; evidenceLabel: string | null | undefined; }) { return (
{sourceContextHeader ? (

{sourceContextHeader}

) : null}

{source.title}

{materialLabel(source)} {source.subject ? ( {source.subject} ) : null} {source.status === "ready" || !source.status ? "Ready" : source.status.charAt(0).toUpperCase() + source.status.slice(1)}
); } function BlockedPanel({ title, body, cta, onUploadClick, }: { title: string; body: string; cta: string | null; onUploadClick?: () => void; }) { return (

{title}

{body}

{cta && onUploadClick ? ( ) : null}
); } function SetupStep({ canCreate, durationMinutes, mode, language, format, visualStyle, voice, selectedDuration, selectedSpecialMode, materialType, evidenceNote, isPlanning, onDurationChange, onModeChange, onLanguageChange, onFormatChange, onVisualStyleChange, onVoiceChange, onCreatePlan, }: { canCreate: boolean; durationMinutes: VideoDurationMinutes; mode: StudyVideoMode; language: string; format: string; visualStyle: string; voice: string; selectedDuration: (typeof VIDEO_DURATION_OPTIONS)[number]; selectedSpecialMode?: (typeof specialModeOptions)[number]; materialType?: MaterialType | null; evidenceNote: string | null; isPlanning: boolean; onDurationChange: (minutes: VideoDurationMinutes) => void; onModeChange: (mode: StudyVideoMode) => void; onLanguageChange: (language: string) => void; onFormatChange: (format: string) => void; onVisualStyleChange: (style: string) => void; onVoiceChange: (voice: string) => void; onCreatePlan: () => void; }) { return (

Create study video

DocDoe will turn this source into a short exam-focused video plan, then render a low-quality preview.

{evidenceNote ? (

{evidenceNote}

) : null}

Video length

{VIDEO_DURATION_OPTIONS.map((option) => { const active = durationMinutes === option.minutes && !selectedSpecialMode; return ( ); })}

Special formats

{specialModeOptions.map((option) => { const disabled = option.value === "pyq_review" && materialType !== "question_paper"; const active = mode === option.value; return ( ); })}
option.value)} labels={Object.fromEntries(formatOptions.map((option) => [option.value, option.label]))} onChange={onFormatChange} />

{selectedSpecialMode?.label ?? selectedDuration.label}

Target duration: {Math.round((selectedSpecialMode?.target ?? selectedDuration.targetSeconds) / 60)} minutes

); } function SettingGroup({ label, children }: { label: string; children: ReactNode }) { return (

{label}

{children}
); } function SegmentedControl({ value, options, labels, onChange, }: { value: string; options: string[]; labels?: Record; onChange: (value: string) => void; }) { return (
{options.map((option) => ( ))}
); } function SelectControl({ value, options, onChange, }: { value: string; options: { value: string; label: string }[]; onChange: (value: string) => void; }) { return ( ); } function PlanReadyStep({ plan, warnings, evidenceLabel, evidenceNote, canRender, expandedScenes, onToggleScene, onRenderPreview, onBackToSettings, }: { plan: StudyVideoPlan; warnings: string[]; evidenceLabel: string | null | undefined; evidenceNote: string | null; canRender: boolean; expandedScenes: Record; onToggleScene: (sceneId: string) => void; onRenderPreview: () => void; onBackToSettings: () => void; }) { return (

Scene plan ready

{plan.title}

{plan.caution_note ? (

{plan.caution_note}

) : null} {evidenceNote ? (

{evidenceNote}

) : null}

Next: {plan.next_study_action}

{warnings.length > 0 ? (
{warnings.map((warning) => (

{warning}

))}
) : null}
{plan.scenes.map((scene, index) => (

Scene {index + 1} - {scene.type.replace(/_/g, " ")}

{scene.title}

{scene.duration_seconds}s

{scene.on_screen_text}

{scene.evidence_note ? (

{scene.evidence_note}

) : null} {scene.keywords && scene.keywords.length > 0 ? (
{scene.keywords.slice(0, 8).map((keyword) => ( {keyword} ))}
) : null} {expandedScenes[scene.id] ? (

{scene.narration}

) : null}
))}
); } function Metric({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } // Friendly render pipeline. Each stage maps to a backend status so the student // sees real movement (and which step is live) instead of one vague bar. const RENDER_STAGES: { key: string; label: string; statuses: string[] }[] = [ { key: "script", label: "Writing the script", statuses: ["queued", "script_ready", "planning"] }, { key: "audio", label: "Recording the narration", statuses: ["audio_generating"] }, { key: "render", label: "Drawing the video", statuses: ["rendering", "running", "visuals_generating"] }, { key: "finish", label: "Finishing up", statuses: ["uploading"] }, ]; function renderStageIndex(status: string | null | undefined): number { const s = (status ?? "").toLowerCase(); if (isCompleteStatus(s)) return RENDER_STAGES.length; // all done const found = RENDER_STAGES.findIndex((stage) => stage.statuses.includes(s)); return found === -1 ? 0 : found; } function ElapsedTime() { const [seconds, setSeconds] = useState(0); useEffect(() => { const t = window.setInterval(() => setSeconds((value) => value + 1), 1000); return () => window.clearInterval(t); }, []); const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); const ss = String(seconds % 60).padStart(2, "0"); return {mm}:{ss}; } function PreviewProgressStep({ title, job, progress, evidenceLabel, hasTimedOut, }: { title: string; job: JobViewState | null; progress: number; evidenceLabel: string | null | undefined; hasTimedOut: boolean; }) { const activeIndex = renderStageIndex(job?.status); return (

Building your video

{title}

Working for {progress}%
{/* Live stage checklist */}
    {RENDER_STAGES.map((stage, index) => { const done = index < activeIndex; const active = index === activeIndex; return (
  1. {done ? ( ) : active ? ( ) : ( )} {stage.label}
  2. ); })}

{hasTimedOut ? previewTimeoutCopy : "Keep this open. A short video takes about a minute; longer ones take a few."}

); } function PreviewReadyStep({ title, previewUrl, evidenceLabel, onRetryLater, }: { title: string; previewUrl: string | null; evidenceLabel: string | null | undefined; onRetryLater: () => void; }) { const durationLabel = "Preview"; return (

{durationLabel}

Preview video ready

{title}

{previewUrl ? (
) : (

Preview finished, but no video file was returned.

)}

This is a low-quality preview. Final render comes later.

{previewUrl ? ( <> Open Download ) : null}
); } function FailedStep({ message, onBackToSettings, }: { message: string; onBackToSettings: () => void; }) { return (

Preview render failed

{message}

); }