DocDoeAI / src /components /dashboard /VideoTutorBrainModal.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
15.9 kB
"use client";
import { useCallback, useState } from "react";
import {
Sparkles,
Settings2,
Clock,
Zap,
Tv2,
LoaderCircle,
CheckCircle2,
Target,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Modal, PrimaryButton, SecondaryButton } from "@/components/dashboard/dashboardPrimitives";
import { useJobPolling } from "@/hooks/useJobPolling";
import { getVideoPlanExtended } from "@/lib/api/intelligence";
import { createVideoRenderJob, createVideoScenePlan, getVideoRenderJob } from "@/lib/api/video";
import { normalizeApiError } from "@/lib/api/client";
import type {
StudentLevel,
VideoFormat,
VideoPlanV2Response,
VideoPlanDurationType,
VideoPlanStyle,
VideoRenderJob,
VoiceMode,
VoiceProfile,
} from "@/lib/api/types";
import { prepareScenePlanForRender } from "@/lib/video/durations";
import { cn } from "@/lib/utils";
const ALLOW_LOCAL_PREVIEW_AUDIO = process.env.NODE_ENV !== "production";
const durationOptions: { value: VideoPlanDurationType; label: string; time: string; icon: LucideIcon }[] = [
{ value: "quick_concept", label: "5-minute video", time: "5 min", icon: Zap },
{ value: "exam_focus", label: "10-minute video", time: "10 min", icon: Target },
{ value: "deep_masterclass", label: "30-minute video", time: "30 min", icon: Tv2 },
];
const styleOptions: { value: VideoPlanStyle; label: string; desc: string }[] = [
{ value: "normal_teacher", label: "Normal Teacher", desc: "Traditional academic style" },
{ value: "visual_tutor", label: "Visual Tutor", desc: "Heavy on diagrams & flowcharts" },
{ value: "anime_tutor", label: "Anime Tutor", desc: "High energy, character-led" },
{ value: "malayalam_english_tutor", label: "Mixed Mode", desc: "Malayalam + English key terms" },
{ value: "exam_war_mode", label: "Exam War Mode", desc: "Strictly mark-focused, fast-paced" },
];
const formatOptions: { value: VideoFormat; label: string }[] = [
{ value: "16:9", label: "Wide Class Video" },
{ value: "9:16", label: "Phone Revision" },
];
const voiceOptions: { value: VoiceProfile; label: string; voiceMode: VoiceMode }[] = [
{ value: "anime_female_tutor", label: "Anime Tutor", voiceMode: "english_soft" },
{ value: "calm_teacher", label: "Calm Teacher", voiceMode: "english_soft" },
{ value: "malayalam_english_teacher", label: "Malayalam + English Tutor", voiceMode: "ml_en_mix" },
{ value: "exam_war_coach", label: "Exam War Coach", voiceMode: "english_soft" },
{ value: "premium_human_teacher", label: "Premium Human Teacher", voiceMode: "english_soft" },
];
export function VideoTutorBrainModal({
isOpen,
onClose,
documentId,
studentLevel,
onPlanGenerated
}: {
isOpen: boolean;
onClose: () => void;
documentId: string;
studentLevel: StudentLevel;
onPlanGenerated: (plan: VideoPlanV2Response) => void;
}) {
const [step, setStep] = useState<"config" | "loading" | "success">("config");
const [duration, setDuration] = useState<VideoPlanDurationType>("exam_focus");
const [style, setStyle] = useState<VideoPlanStyle>("visual_tutor");
const [format, setFormat] = useState<VideoFormat>("16:9");
const [voiceProfile, setVoiceProfile] = useState<VoiceProfile>("calm_teacher");
const [generatedPlan, setGeneratedPlan] = useState<VideoPlanV2Response | null>(null);
const [renderStep, setRenderStep] = useState<"idle" | "planning" | "voice" | "building" | "ready" | "failed">("idle");
const [renderJob, setRenderJob] = useState<VideoRenderJob | null>(null);
const [error, setError] = useState<string | null>(null);
const pollRenderJob = useCallback(async () => {
if (!renderJob) throw new Error("No video render job is running.");
return getVideoRenderJob(renderJob.id);
}, [renderJob]);
useJobPolling({
enabled: Boolean(renderJob && (renderJob.status === "queued" || renderJob.status === "running")),
fetcher: pollRenderJob,
shouldContinue: (job) => job.status === "queued" || job.status === "running",
onData: (job) => {
setRenderJob(job);
if (job.status === "ready") {
setRenderStep("ready");
} else if (job.status === "failed") {
setRenderStep("failed");
setError("DocDoe could not finish this video. Try again from a shorter source.");
} else if (job.current_step?.startsWith("generating_audio")) {
setRenderStep("voice");
} else {
setRenderStep("building");
}
},
onError: () => setError("DocDoe could not refresh the video status. Open Videos to check it."),
});
async function generatePlan() {
setStep("loading");
setError(null);
try {
const data = await getVideoPlanExtended(documentId, duration, style);
setGeneratedPlan(data);
setStep("success");
onPlanGenerated(data);
} catch (caughtError) {
setError(normalizeApiError(caughtError));
setStep("config");
}
}
async function buildVideo() {
setRenderStep("planning");
setError(null);
try {
const selectedVoice = voiceOptions.find((option) => option.value === voiceProfile) ?? voiceOptions[1];
const durationMinutes =
duration === "quick_concept" ? 5 :
duration === "exam_focus" ? 10 :
duration === "deep_masterclass" ? 30 :
10;
const scenePlan = prepareScenePlanForRender(
await createVideoScenePlan({
document_id: documentId,
title: generatedPlan?.title ?? "DocDoe Study Video",
language: selectedVoice.voiceMode === "ml_en_mix" ? "Malayalam + English" : "English",
duration_minutes: durationMinutes,
visual_style: style === "anime_tutor" ? "anime_visual_explainer" : "clean_explainer",
video_format: format,
}),
durationMinutes * 60,
);
setRenderStep("voice");
const job = await createVideoRenderJob({
scene_plan: scenePlan,
title: scenePlan.title,
allow_mock_audio: ALLOW_LOCAL_PREVIEW_AUDIO,
voice_mode: selectedVoice.voiceMode,
voice: selectedVoice.value,
language: scenePlan.language,
});
setRenderJob(job);
setRenderStep(job.status === "ready" ? "ready" : "building");
} catch (caughtError) {
setError(normalizeApiError(caughtError));
setRenderStep("failed");
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Video Tutor Brain V2">
{step === "config" && (
<div className="space-y-6 py-2">
<div className="space-y-3">
<p className="flex items-center gap-2 text-[11px] font-bold text-violet-400 uppercase tracking-widest">
<Clock className="size-3" />
Select Duration
</p>
<div className="grid grid-cols-2 gap-2">
{durationOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setDuration(opt.value)}
className={cn(
"p-3 rounded-2xl border text-left transition-all",
duration === opt.value
? "border-violet-500 bg-violet-500/10 shadow-[0_0_20px_rgba(124,58,237,0.15)]"
: "border-white/5 bg-white/[0.02] hover:border-white/10"
)}
>
<opt.icon className={cn("size-4 mb-2", duration === opt.value ? "text-violet-400" : "text-white/20")} />
<p className="text-[12px] font-bold text-white">{opt.label}</p>
<p className="text-[10px] font-medium text-white/30">{opt.time}</p>
</button>
))}
</div>
</div>
<div className="space-y-3">
<p className="flex items-center gap-2 text-[11px] font-bold text-violet-400 uppercase tracking-widest">
<Settings2 className="size-3" />
Teaching Style
</p>
<div className="grid gap-2">
{styleOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setStyle(opt.value)}
className={cn(
"flex items-center justify-between p-3 rounded-xl border transition-all text-left",
style === opt.value
? "border-violet-500 bg-violet-500/10"
: "border-white/5 bg-white/[0.02] hover:bg-white/[0.04]"
)}
>
<div className="min-w-0">
<p className="text-[12px] font-bold text-white">{opt.label}</p>
<p className="text-[10px] font-medium text-white/30 truncate">{opt.desc}</p>
</div>
{style === opt.value && <CheckCircle2 className="size-4 text-violet-400 shrink-0" />}
</button>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-[11px] font-bold text-violet-400 uppercase tracking-widest">Format</p>
<div className="grid gap-2">
{formatOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setFormat(opt.value)}
className={cn(
"rounded-xl border px-3 py-2 text-left text-[12px] font-bold transition",
format === opt.value ? "border-violet-500 bg-violet-500/10 text-white" : "border-white/5 bg-white/[0.02] text-white/50",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-[11px] font-bold text-violet-400 uppercase tracking-widest">Voice</p>
<select
value={voiceProfile}
onChange={(event) => setVoiceProfile(event.target.value as VoiceProfile)}
className="h-10 w-full rounded-xl border border-white/5 bg-black/40 px-3 text-[12px] font-bold text-white outline-none"
>
{voiceOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="text-[10.5px] leading-4 text-white/32">Current level: {studentLevel}. Long videos can take more time to prepare.</p>
</div>
</div>
{error ? (
<p className="rounded-2xl border border-rose-300/12 bg-rose-500/[0.06] px-3 py-2 text-[12px] leading-5 text-rose-50/70">
{error}
</p>
) : null}
<PrimaryButton onClick={generatePlan} className="w-full h-12 shadow-[0_0_30px_rgba(124,58,237,0.2)]">
<Sparkles className="size-4" />
Generate Extended Video Plan
</PrimaryButton>
</div>
)}
{step === "loading" && (
<div className="py-20 flex flex-col items-center justify-center gap-4 text-center px-6">
<div className="relative">
<div className="absolute inset-0 bg-violet-500/20 blur-3xl rounded-full animate-pulse" />
<LoaderCircle className="relative size-12 animate-spin text-violet-400" strokeWidth={1.5} />
</div>
<div className="space-y-1">
<p className="text-[15px] font-bold text-white tracking-tight">DocDoe is building your study video plan.</p>
<p className="text-[12px] text-white/40">Creating hook, analogy, and source-based teaching flow for your {duration.replace("_", " ")} video.</p>
</div>
</div>
)}
{step === "success" && generatedPlan && (
<div className="space-y-6 py-2">
<div className="flex items-center gap-4 p-4 rounded-[24px] bg-emerald-500/10 border border-emerald-500/20">
<div className="size-10 grid place-items-center rounded-xl bg-emerald-500/20 text-emerald-400">
<CheckCircle2 className="size-6" />
</div>
<div className="min-w-0">
<p className="text-[11px] font-bold text-emerald-400 uppercase tracking-wider">Plan Ready</p>
<h3 className="text-[15px] font-bold text-white truncate">{generatedPlan.title}</h3>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-[10px] font-bold text-white/30 uppercase tracking-widest">Master Script Hook</p>
<div className="p-3 rounded-xl bg-white/5 border border-white/5 text-[13px] text-white/70 italic leading-relaxed">
&quot;{generatedPlan.hook}&quot;
</div>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-bold text-white/30 uppercase tracking-widest">The Analogy</p>
<p className="text-[13px] text-white/80 font-medium px-1">{generatedPlan.analogy}</p>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold text-white/30 uppercase tracking-widest">Scene Breakdown ({generatedPlan.sections.length} scenes)</p>
<div className="grid gap-2">
{generatedPlan.sections.map(s => (
<div key={s.scene_number} className="flex gap-3 p-2.5 rounded-xl bg-black/20 border border-white/5">
<span className="text-[11px] font-black text-violet-400/50 mt-0.5">#{s.scene_number}</span>
<div className="min-w-0">
<p className="text-[12px] font-bold text-white/90">{s.label}</p>
<p className="text-[11px] text-white/40 mt-1">{s.visual_suggestion}</p>
</div>
</div>
))}
</div>
</div>
</div>
{renderStep !== "idle" ? (
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-3">
<p className="text-[11px] font-bold uppercase tracking-wider text-violet-200/70">
{renderStep === "planning" ? "Preparing" :
renderStep === "voice" ? "Adding voice" :
renderStep === "building" ? "Building" :
renderStep === "ready" ? "Your video is ready" :
"Could not build video"}
</p>
{renderJob ? (
<p className="mt-1 text-[12px] text-white/52">Open Videos to track progress and preview the final study video.</p>
) : error ? (
<p className="mt-1 text-[12px] text-rose-50/70">{error}</p>
) : (
<p className="mt-1 text-[12px] text-white/42">Long videos can take more time to prepare.</p>
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-3">
<SecondaryButton onClick={() => setStep("config")} className="h-11">Edit Config</SecondaryButton>
<PrimaryButton onClick={buildVideo} disabled={renderStep === "planning" || renderStep === "voice" || renderStep === "building"} className="h-11">
{renderStep === "idle" || renderStep === "failed" ? "Build Video" : renderStep === "ready" ? "Build Again" : "Preparing"}
</PrimaryButton>
</div>
{renderStep === "ready" ? (
<PrimaryButton onClick={onClose} className="w-full h-11">Close & Save</PrimaryButton>
) : null}
</div>
)}
</Modal>
);
}