DocDoeAI / src /components /dashboard /StudyVideoWizard.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
50.7 kB
"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<MaterialType>([
"notes",
"chapter",
"textbook",
"handwritten_notes",
"question_paper",
"syllabus",
]);
const materialLabels: Record<MaterialType, string> = {
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<VideoRenderJob & StudyVideoJobResponse & StudyVideoPreviewResponse>;
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<StudyVideoPlan>;
return typeof record.title === "string" && Array.isArray(record.scenes);
}
function normalizeScene(scene: Partial<StudyVideoScene>, 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<StudyVideoScene> => 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<WizardStage>("setup");
const [durationMinutes, setDurationMinutes] = useState<VideoDurationMinutes>(10);
const [mode, setMode] = useState<StudyVideoMode>("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<StudyVideoJobResponse | null>(null);
const [job, setJob] = useState<JobViewState | null>(null);
const [error, setError] = useState<string | null>(null);
const [pollTimedOut, setPollTimedOut] = useState(false);
const [expandedScenes, setExpandedScenes] = useState<Record<string, boolean>>({});
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 (
<div className="fixed inset-0 z-[110] flex items-end justify-center p-2 sm:items-center sm:p-4">
<button
type="button"
aria-label="Close study video wizard"
className="absolute inset-0 bg-black/72 backdrop-blur-sm"
onClick={onClose}
/>
<section
aria-modal="true"
role="dialog"
className="relative flex max-h-[94dvh] w-full max-w-4xl flex-col overflow-hidden rounded-[22px] border border-white/[0.1] bg-[#050507] shadow-[0_32px_120px_rgba(0,0,0,0.68)] sm:max-h-[88dvh]"
>
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-white/[0.08] bg-white/[0.025] px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/72">
<Clapperboard className="size-4.5" strokeWidth={1.8} />
</span>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase text-white/36">Study video</p>
<h2 className="truncate text-[15px] font-bold text-white">Create study video</h2>
</div>
</div>
<div className="flex items-center gap-2">
<StagePill stage={stage} />
<button
type="button"
onClick={onClose}
className="grid size-9 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-white/[0.035] text-white/48 transition hover:bg-white/[0.07] hover:text-white"
aria-label="Close wizard"
>
<X className="size-4" strokeWidth={2} />
</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-color:rgba(255,255,255,0.14)_transparent] [scrollbar-width:thin] sm:px-5">
<div className="space-y-4">
<SourceSummaryCard
source={source}
sourceContextHeader={sourceContextHeader}
evidenceLabel={currentEvidenceLabel}
/>
{!eligibility.canCreate ? (
<BlockedPanel
title={eligibility.title}
body={eligibility.body}
cta={eligibility.cta}
onUploadClick={onUploadClick}
/>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-300/16 bg-rose-500/[0.06] px-4 py-3 text-[12px] leading-5 text-rose-50/78">
<div className="flex gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-rose-100/70" strokeWidth={1.8} />
<span>{error}</span>
</div>
</div>
) : null}
{stage === "setup" || stage === "planning" ? (
<SetupStep
canCreate={eligibility.canCreate}
durationMinutes={durationMinutes}
mode={mode}
language={language}
format={format}
visualStyle={visualStyle}
voice={voice}
selectedDuration={selectedDuration}
selectedSpecialMode={selectedSpecialMode}
materialType={getMaterialType(source)}
evidenceNote={evidenceNote}
isPlanning={stage === "planning"}
onDurationChange={(minutes) => {
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 ? (
<PlanReadyStep
plan={plan}
warnings={planResponse?.warnings ?? []}
evidenceLabel={currentEvidenceLabel}
evidenceNote={evidenceNote}
canRender={eligibility.canCreate && job?.status === "script_ready"}
expandedScenes={expandedScenes}
onToggleScene={toggleScene}
onRenderPreview={() => void handleRenderPreview()}
onBackToSettings={handleRetrySetup}
/>
) : null}
{stage === "preview_rendering" ? (
<PreviewProgressStep
title={jobTitle(plan, source)}
job={job}
progress={progress}
evidenceLabel={currentEvidenceLabel}
hasTimedOut={pollTimedOut}
/>
) : null}
{stage === "preview_ready" ? (
<PreviewReadyStep
title={jobTitle(plan, source)}
previewUrl={previewUrl}
evidenceLabel={currentEvidenceLabel}
onRetryLater={handleRetrySetup}
/>
) : null}
{stage === "failed" ? (
<FailedStep
message={error ?? "Preview render failed."}
onBackToSettings={handleRetrySetup}
/>
) : null}
</div>
</div>
</section>
</div>
);
}
function StagePill({ stage }: { stage: WizardStage }) {
const label: Record<WizardStage, string> = {
setup: "Setup",
planning: "Planning",
script_ready: "Scene plan ready",
preview_rendering: "Rendering",
preview_ready: "Preview ready",
failed: "Failed",
};
return (
<span className="hidden rounded-full border border-white/[0.1] bg-white/[0.035] px-2.5 py-1 text-[10px] font-semibold text-white/55 sm:inline-flex">
{label[stage]}
</span>
);
}
function SourceSummaryCard({
source,
sourceContextHeader,
evidenceLabel,
}: {
source: StudyVideoWizardSource;
sourceContextHeader: string | null;
evidenceLabel: string | null | undefined;
}) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3.5">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
{sourceContextHeader ? (
<p className="mb-1 truncate text-[11px] font-semibold text-white/46">{sourceContextHeader}</p>
) : null}
<h3 className="break-words text-[14px] font-bold text-white/88">{source.title}</h3>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/[0.08] bg-black/30 px-2.5 py-1 text-[10px] font-semibold text-white/50">
{materialLabel(source)}
</span>
{source.subject ? (
<span className="rounded-full border border-white/[0.08] bg-black/30 px-2.5 py-1 text-[10px] font-semibold text-white/50">
{source.subject}
</span>
) : null}
<span className="rounded-full border border-white/[0.08] bg-black/30 px-2.5 py-1 text-[10px] font-semibold text-white/50">
{source.status === "ready" || !source.status
? "Ready"
: source.status.charAt(0).toUpperCase() + source.status.slice(1)}
</span>
</div>
</div>
<EvidenceBadge label={evidenceLabel} />
</div>
</div>
);
}
function BlockedPanel({
title,
body,
cta,
onUploadClick,
}: {
title: string;
body: string;
cta: string | null;
onUploadClick?: () => void;
}) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4">
<div className="flex gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-black/30 text-white/46">
<Lock className="size-4" strokeWidth={1.8} />
</span>
<div className="min-w-0">
<h3 className="text-[14px] font-bold text-white/86">{title}</h3>
<p className="mt-1 text-[12px] leading-5 text-white/46">{body}</p>
{cta && onUploadClick ? (
<button
type="button"
onClick={onUploadClick}
className="mt-3 inline-flex h-9 items-center justify-center rounded-2xl border border-white/[0.12] bg-white/[0.06] px-3 text-[12px] font-bold text-white/74 transition hover:bg-white/[0.1] hover:text-white"
>
{cta}
</button>
) : null}
</div>
</div>
</div>
);
}
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 (
<div className="space-y-4">
<div>
<h3 className="text-[18px] font-bold text-white">Create study video</h3>
<p className="mt-1 max-w-2xl text-[12px] leading-5 text-white/48">
DocDoe will turn this source into a short exam-focused video plan, then render a low-quality preview.
</p>
</div>
{evidenceNote ? (
<p className="rounded-2xl border border-white/[0.08] bg-black/28 px-3.5 py-3 text-[12px] leading-5 text-white/50">
{evidenceNote}
</p>
) : null}
<div>
<p className="mb-2 text-[11px] font-semibold uppercase text-white/36">Video length</p>
<div className="grid gap-2 sm:grid-cols-3">
{VIDEO_DURATION_OPTIONS.map((option) => {
const active = durationMinutes === option.minutes && !selectedSpecialMode;
return (
<button
key={option.minutes}
type="button"
onClick={() => onDurationChange(option.minutes)}
className={cn(
"min-h-[88px] rounded-2xl border p-3 text-left transition",
active
? "border-white/[0.22] bg-white/[0.09] text-white"
: "border-white/[0.08] bg-white/[0.025] text-white/66 hover:border-white/[0.14] hover:bg-white/[0.045]",
)}
>
<span className="block text-[1.35rem] font-bold">{option.minutes} min</span>
<span className="mt-1 block text-[11px] leading-4 text-white/42">{option.helper}</span>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-[11px] font-semibold uppercase text-white/36">Special formats</p>
<div className="grid gap-2 sm:grid-cols-2">
{specialModeOptions.map((option) => {
const disabled = option.value === "pyq_review" && materialType !== "question_paper";
const active = mode === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onModeChange(option.value)}
className={cn(
"min-h-[72px] rounded-2xl border p-3 text-left transition disabled:cursor-not-allowed disabled:opacity-45",
active
? "border-white/[0.22] bg-white/[0.09] text-white"
: "border-white/[0.08] bg-white/[0.025] text-white/66 hover:border-white/[0.14] hover:bg-white/[0.045]",
)}
>
<span className="block text-[12.5px] font-bold">{option.label}</span>
<span className="mt-1 block text-[11px] leading-4 text-white/42">
{disabled ? "PYQ review needs a question paper source." : option.helper}
</span>
</button>
);
})}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<SettingGroup label="Language">
<SegmentedControl value={language} options={languageOptions} onChange={onLanguageChange} />
</SettingGroup>
<SettingGroup label="Format">
<SegmentedControl
value={format}
options={formatOptions.map((option) => option.value)}
labels={Object.fromEntries(formatOptions.map((option) => [option.value, option.label]))}
onChange={onFormatChange}
/>
</SettingGroup>
<SettingGroup label="Visual style">
<SelectControl
value={visualStyle}
options={visualStyleOptions}
onChange={onVisualStyleChange}
/>
</SettingGroup>
<SettingGroup label="Voice">
<SelectControl
value={voice}
options={voiceOptions}
onChange={onVoiceChange}
/>
</SettingGroup>
</div>
<div className="flex flex-col gap-3 rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[12px] font-bold text-white/78">
{selectedSpecialMode?.label ?? selectedDuration.label}
</p>
<p className="mt-1 text-[11px] text-white/38">
Target duration: {Math.round((selectedSpecialMode?.target ?? selectedDuration.targetSeconds) / 60)} minutes
</p>
</div>
<button
type="button"
disabled={!canCreate || isPlanning}
onClick={onCreatePlan}
className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-white/[0.14] bg-white/[0.08] px-4 text-[12px] font-bold text-white/84 transition hover:bg-white/[0.13] disabled:cursor-not-allowed disabled:opacity-45"
>
{isPlanning ? (
<>
<LoaderCircle className="size-4 animate-spin" strokeWidth={1.8} />
Building video plan...
</>
) : (
"Create scene plan"
)}
</button>
</div>
</div>
);
}
function SettingGroup({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0 rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3">
<p className="mb-2 text-[10px] font-semibold uppercase text-white/34">{label}</p>
{children}
</div>
);
}
function SegmentedControl({
value,
options,
labels,
onChange,
}: {
value: string;
options: string[];
labels?: Record<string, string>;
onChange: (value: string) => void;
}) {
return (
<div className="flex min-w-0 flex-wrap gap-1.5">
{options.map((option) => (
<button
key={option}
type="button"
onClick={() => onChange(option)}
className={cn(
"min-h-9 rounded-xl border px-2.5 text-[11px] font-semibold transition",
value === option
? "border-white/[0.18] bg-white/[0.1] text-white"
: "border-white/[0.08] bg-black/24 text-white/50 hover:bg-white/[0.05] hover:text-white/72",
)}
>
{labels?.[option] ?? option}
</button>
))}
</div>
);
}
function SelectControl({
value,
options,
onChange,
}: {
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}) {
return (
<label className="relative block">
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-10 w-full appearance-none rounded-xl border border-white/[0.08] bg-black/36 px-3 pr-9 text-[12px] font-semibold text-white/72 outline-none transition focus:border-white/[0.18]"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-white/36" strokeWidth={1.8} />
</label>
);
}
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<string, boolean>;
onToggleScene: (sceneId: string) => void;
onRenderPreview: () => void;
onBackToSettings: () => void;
}) {
return (
<div className="space-y-4">
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase text-white/34">Scene plan ready</p>
<h3 className="mt-1 break-words text-[18px] font-bold text-white">{plan.title}</h3>
</div>
<EvidenceBadge label={evidenceLabel} />
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<Metric label="Target" value={`${Math.round(plan.target_duration_seconds / 60)} min`} />
<Metric label="Scene time" value={`${plan.total_duration_seconds}s`} />
<Metric label="Scenes" value={String(plan.scenes.length)} />
</div>
{plan.caution_note ? (
<p className="mt-3 rounded-xl border border-white/[0.08] bg-black/28 px-3 py-2 text-[12px] leading-5 text-white/50">
{plan.caution_note}
</p>
) : null}
{evidenceNote ? (
<p className="mt-3 text-[12px] leading-5 text-white/46">{evidenceNote}</p>
) : null}
<p className="mt-3 text-[12px] leading-5 text-white/52">
Next: {plan.next_study_action}
</p>
{warnings.length > 0 ? (
<div className="mt-3 space-y-1.5">
{warnings.map((warning) => (
<p key={warning} className="text-[11px] leading-4 text-amber-100/70">
{warning}
</p>
))}
</div>
) : null}
</div>
<div className="space-y-2.5">
{plan.scenes.map((scene, index) => (
<article
key={scene.id}
className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-3.5"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase text-white/34">
Scene {index + 1} - {scene.type.replace(/_/g, " ")}
</p>
<h4 className="mt-1 break-words text-[13px] font-bold text-white/84">{scene.title}</h4>
</div>
<span className="rounded-full border border-white/[0.08] bg-black/28 px-2.5 py-1 text-[10px] font-semibold text-white/50">
{scene.duration_seconds}s
</span>
</div>
<p className="mt-3 break-words rounded-xl border border-white/[0.06] bg-black/26 px-3 py-2 text-[12px] leading-5 text-white/58">
{scene.on_screen_text}
</p>
{scene.evidence_note ? (
<p className="mt-2 text-[11px] leading-4 text-white/40">{scene.evidence_note}</p>
) : null}
{scene.keywords && scene.keywords.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{scene.keywords.slice(0, 8).map((keyword) => (
<span
key={keyword}
className="rounded-full border border-white/[0.08] bg-black/24 px-2 py-0.5 text-[10px] font-semibold text-white/42"
>
{keyword}
</span>
))}
</div>
) : null}
<button
type="button"
onClick={() => onToggleScene(scene.id)}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.025] px-3 text-[11px] font-bold text-white/54 transition hover:bg-white/[0.06] hover:text-white/74"
>
{expandedScenes[scene.id] ? "Hide narration" : "View narration"}
</button>
{expandedScenes[scene.id] ? (
<p className="mt-2 whitespace-pre-wrap break-words rounded-xl border border-white/[0.06] bg-black/30 px-3 py-2 text-[12px] leading-5 text-white/50">
{scene.narration}
</p>
) : null}
</article>
))}
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<button
type="button"
onClick={onBackToSettings}
className="inline-flex h-10 items-center justify-center rounded-2xl border border-white/[0.09] bg-white/[0.025] px-4 text-[12px] font-bold text-white/58 transition hover:bg-white/[0.06] hover:text-white/78"
>
Back to settings
</button>
<button
type="button"
disabled={!canRender}
onClick={onRenderPreview}
className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-white/[0.16] bg-white/[0.09] px-4 text-[12px] font-bold text-white/88 transition hover:bg-white/[0.14] disabled:cursor-not-allowed disabled:opacity-45"
>
Render preview
</button>
</div>
<AnswerTrustFooter />
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-white/[0.07] bg-black/24 px-3 py-2">
<p className="text-[10px] font-semibold uppercase text-white/32">{label}</p>
<p className="mt-1 text-[13px] font-bold text-white/78">{value}</p>
</div>
);
}
// 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 <span className="tabular-nums">{mm}:{ss}</span>;
}
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 (
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase text-white/34">Building your video</p>
<h3 className="mt-1 text-[18px] font-bold text-white">{title}</h3>
</div>
<EvidenceBadge label={evidenceLabel} />
</div>
<div className="mt-3 flex items-center gap-2 text-[11px] font-semibold text-white/42">
<span className="inline-flex size-1.5 rounded-full bg-white/50 animate-pulse" />
<span>Working for <ElapsedTime /></span>
<span className="ml-auto tabular-nums text-white/30">{progress}%</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/[0.06]">
<div
className="h-full rounded-full bg-white/60 transition-[width] duration-700"
style={{ width: `${progress}%` }}
/>
</div>
{/* Live stage checklist */}
<ol className="mt-5 space-y-2.5">
{RENDER_STAGES.map((stage, index) => {
const done = index < activeIndex;
const active = index === activeIndex;
return (
<li key={stage.key} className="flex items-center gap-3">
<span
className={cn(
"grid size-5 shrink-0 place-items-center rounded-full border",
done
? "border-white/40 bg-white/85 text-black"
: active
? "border-white/35 bg-white/[0.06]"
: "border-white/12 bg-transparent",
)}
>
{done ? (
<Check className="size-3" strokeWidth={3} />
) : active ? (
<LoaderCircle className="size-3 animate-spin text-white/80" strokeWidth={2.4} />
) : (
<span className="size-1 rounded-full bg-white/25" />
)}
</span>
<span
className={cn(
"text-[13px]",
done ? "font-medium text-white/55" : active ? "font-bold text-white" : "text-white/35",
)}
>
{stage.label}
</span>
</li>
);
})}
</ol>
<p className="mt-4 text-[11px] leading-4 text-white/38">
{hasTimedOut
? previewTimeoutCopy
: "Keep this open. A short video takes about a minute; longer ones take a few."}
</p>
</div>
);
}
function PreviewReadyStep({
title,
previewUrl,
evidenceLabel,
onRetryLater,
}: {
title: string;
previewUrl: string | null;
evidenceLabel: string | null | undefined;
onRetryLater: () => void;
}) {
const durationLabel = "Preview";
return (
<div className="space-y-4">
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase text-white/34">{durationLabel}</p>
<h3 className="mt-1 text-[18px] font-bold text-white">Preview video ready</h3>
<p className="mt-1 max-w-xl text-[12px] leading-5 text-white/46">{title}</p>
</div>
<EvidenceBadge label={evidenceLabel} />
</div>
{previewUrl ? (
<div className="mt-4 overflow-hidden rounded-2xl border border-white/[0.09] bg-black">
<video
className="aspect-video w-full max-h-[400px] bg-black object-contain sm:max-h-[480px]"
controls
playsInline
preload="metadata"
src={previewUrl}
/>
</div>
) : (
<div className="mt-4 rounded-2xl border border-white/[0.08] bg-black/30 p-4 text-[12px] leading-5 text-white/52">
<p>Preview finished, but no video file was returned.</p>
<button
type="button"
onClick={onRetryLater}
className="mt-3 inline-flex h-9 items-center justify-center rounded-2xl border border-white/[0.1] bg-white/[0.04] px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/[0.08] hover:text-white"
>
Try again later
</button>
</div>
)}
<p className="mt-3 text-[12px] leading-5 text-white/44">
This is a low-quality preview. Final render comes later.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{previewUrl ? (
<>
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.04] px-3 text-[12px] font-bold text-white/64 transition hover:bg-white/[0.08] hover:text-white"
>
<ExternalLink className="size-3.5" strokeWidth={1.8} />
Open
</a>
<a
href={previewUrl}
download
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.04] px-3 text-[12px] font-bold text-white/64 transition hover:bg-white/[0.08] hover:text-white"
>
<Download className="size-3.5" strokeWidth={1.8} />
Download
</a>
</>
) : null}
<button
type="button"
disabled
aria-disabled="true"
className="inline-flex h-9 cursor-not-allowed items-center justify-center rounded-2xl border border-white/[0.07] bg-white/[0.02] px-3 text-[12px] font-bold text-white/32"
>
Final render coming next
</button>
</div>
</div>
<AnswerTrustFooter />
</div>
);
}
function FailedStep({
message,
onBackToSettings,
}: {
message: string;
onBackToSettings: () => void;
}) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4">
<div className="flex gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-2xl border border-white/[0.08] bg-black/30 text-white/50">
<AlertCircle className="size-5" strokeWidth={1.8} />
</span>
<div className="min-w-0">
<h3 className="text-[16px] font-bold text-white">Preview render failed</h3>
<p className="mt-1 text-[12px] leading-5 text-white/48">{message}</p>
<button
type="button"
onClick={onBackToSettings}
className="mt-4 inline-flex h-9 items-center gap-2 rounded-2xl border border-white/[0.1] bg-white/[0.045] px-3 text-[12px] font-bold text-white/68 transition hover:bg-white/[0.08] hover:text-white"
>
<RotateCcw className="size-3.5" strokeWidth={1.8} />
Back to settings
</button>
</div>
</div>
</div>
);
}