DocDoeAI / src /components /study-chat /InlineVideoRender.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
10.8 kB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AlertCircle, Cloud, Download, LoaderCircle, Play, Sparkles } from "lucide-react";
import { useJobPolling } from "@/hooks/useJobPolling";
import { generateBrainVideoPlan } from "@/lib/api/brain";
import { resolveMediaUrl, normalizeApiError } from "@/lib/api/client";
import { createVideoRenderJob, createVideoScenePlan, getVideoRenderJob } from "@/lib/api/video";
import type {
BrainVideoPlanResponse,
VideoFormat,
VideoPlanDurationType,
VideoRenderJob,
VideoScenePlanResponse,
} from "@/lib/api/types";
import { prepareScenePlanForRender } from "@/lib/video/durations";
type RenderStage = "planning" | "voice" | "building" | "ready" | "failed";
type Props = {
topic: string;
sourceId: string | null;
minutes: number;
format?: "16:9" | "9:16";
};
function modeForMinutes(minutes: number): VideoPlanDurationType {
if (minutes <= 1) return "quick_concept";
if (minutes <= 5) return "quick_concept";
if (minutes >= 30) return "deep_masterclass";
return "exam_focus";
}
function stageLabel(stage: RenderStage, job: VideoRenderJob | null): string {
if (stage === "ready") return "Ready";
if (stage === "failed") return "Failed";
if (stage === "planning") return "Planning lesson";
if (stage === "voice") return "Generating voice";
if (stage === "building") {
const step = job?.current_step ?? "";
if (step === "rendering_video") return "Rendering video";
if (step === "validating_output") return "Validating output";
if (step === "uploading_or_saving") return "Saving file";
return "Building video";
}
return "Working";
}
function scenePlanFromBrainPlan(
plan: BrainVideoPlanResponse,
language: string,
visualStyle: string,
format: VideoFormat,
): VideoScenePlanResponse {
const scenes = plan.scenes.map((scene, index) => {
const durationSeconds = Number(scene.duration_seconds) || 30;
const subtitleText = Array.isArray(scene.subtitle_segments)
? scene.subtitle_segments.join(" ")
: scene.narration;
return {
scene_id: index + 1,
type: scene.scene_type || "explain",
duration_seconds: durationSeconds,
screen_text: scene.on_screen_text || scene.title,
voice_text: scene.narration,
subtitle_text: subtitleText,
subtitle_segments: [{ start: 0, end: durationSeconds, text: subtitleText }],
keywords: plan.revision_keywords.slice(0, 5),
visual_hint: scene.visual_direction || visualStyle,
purpose: scene.exam_tip || scene.title,
};
});
const totalDuration = scenes.reduce((sum, scene) => sum + scene.duration_seconds, 0);
return {
title: plan.title,
duration_minutes: Math.max(0.1, Math.round((totalDuration / 60) * 10) / 10),
total_duration_seconds: totalDuration,
language,
style: plan.narration_style,
visual_style: visualStyle,
video_format: format,
learning_goal: plan.exam_focus ? "Exam-focused lesson" : "Concept lesson",
voice_mode: "english_soft",
scenes,
recap: plan.revision_keywords,
quick_test: plan.quiz_moments,
evidence_label: plan.evidence_label,
};
}
export function InlineVideoRender({ topic, sourceId, minutes, format = "16:9" }: Props) {
const [stage, setStage] = useState<RenderStage>("planning");
const [job, setJob] = useState<VideoRenderJob | null>(null);
const [scenePlan, setScenePlan] = useState<VideoScenePlanResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const startedRef = useRef(false);
const pollFetcher = useCallback(async () => {
if (!job) throw new Error("No job");
return getVideoRenderJob(job.id);
}, [job]);
useJobPolling({
enabled: Boolean(job && (job.status === "queued" || job.status === "running")),
fetcher: pollFetcher,
shouldContinue: (next) => next.status === "queued" || next.status === "running",
onData: (next) => {
setJob(next);
if (next.status === "ready") setStage("ready");
else if (next.status === "failed") {
setStage("failed");
setError("Render failed. Try a different source or topic.");
} else if (next.current_step?.startsWith("generating_audio")) setStage("voice");
else setStage("building");
},
onError: () => setError("Connection lost. Refresh to check status."),
});
const run = useCallback(async () => {
try {
setStage("planning");
setError(null);
const mode = modeForMinutes(minutes);
const language = "English";
const visualStyle = "clean_explainer";
const brainPlan = await generateBrainVideoPlan({
topic,
video_mode: mode,
teaching_style: "visual_tutor",
language_preference: language,
format,
source_id: sourceId,
source_ids: sourceId ? [sourceId] : [],
level: "intermediate",
goal: "good marks",
});
const targetSeconds = minutes * 60;
let plan: VideoScenePlanResponse;
if (sourceId) {
try {
const generated = await createVideoScenePlan({
document_id: sourceId,
title: brainPlan.title,
language,
duration_minutes: minutes,
visual_style: visualStyle,
video_format: format,
});
plan = prepareScenePlanForRender(generated, targetSeconds);
} catch {
plan = prepareScenePlanForRender(
scenePlanFromBrainPlan(brainPlan, language, visualStyle, format),
targetSeconds,
);
}
} else {
plan = prepareScenePlanForRender(
scenePlanFromBrainPlan(brainPlan, language, visualStyle, format),
targetSeconds,
);
}
setScenePlan(plan);
setStage("voice");
const nextJob = await createVideoRenderJob({
scene_plan: plan,
title: plan.title,
allow_mock_audio: process.env.NODE_ENV !== "production",
voice_mode: "english_soft",
voice: "calm_teacher",
language: plan.language,
});
setJob(nextJob);
setStage(nextJob.status === "ready" ? "ready" : "building");
} catch (caught) {
setError(`Could not build video. ${normalizeApiError(caught)}`);
setStage("failed");
}
}, [format, minutes, sourceId, topic]);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void run();
}, [run]);
const progress = job?.progress ?? (stage === "planning" ? 20 : stage === "voice" ? 45 : stage === "building" ? 70 : 0);
const finalUrl = resolveMediaUrl(job?.public_url || job?.download_url);
const isWorking = stage === "planning" || stage === "voice" || stage === "building";
return (
<article className="animate-msg-assistant rounded-xl border border-white/[0.06] bg-[#080809] p-5">
<div className="mb-3 flex items-center gap-2.5">
<Play className="size-4 shrink-0 text-white/35" />
<div className="min-w-0 flex-1">
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-white/22">Study video</span>
<h2 className="truncate text-[15px] font-semibold tracking-[-0.01em] text-white/95">{scenePlan?.title || topic}</h2>
</div>
<span className="shrink-0 rounded-md border border-white/[0.06] px-2 py-0.5 text-[10px] font-medium text-white/40">
{minutes}min · {format}
</span>
</div>
{finalUrl && stage === "ready" ? (
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border border-white/[0.06] bg-black">
<video
src={finalUrl}
controls
playsInline
className="aspect-video w-full"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href={finalUrl}
download
className="inline-flex items-center gap-1.5 rounded-md border border-white/[0.08] px-3 py-1.5 text-[12px] font-medium text-white/65 transition hover:border-white/[0.14] hover:text-white"
>
<Download className="size-3" />
Download MP4
</a>
{job?.storage_provider === "cloudinary" || /res\.cloudinary\.com/i.test(finalUrl) ? (
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-400/15 bg-emerald-500/[0.04] px-2 py-1 text-[10.5px] font-medium text-emerald-300/80">
<Cloud className="size-2.5" />
Saved to cloud
</span>
) : null}
{job?.duration_seconds ? (
<span className="inline-flex items-center text-[11px] text-white/35">
{Math.round(job.duration_seconds)}s · {job.scene_count} scenes
</span>
) : null}
</div>
</div>
) : null}
{isWorking ? (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<LoaderCircle className="size-3.5 shrink-0 animate-spin text-white/50" />
<p className="text-[13px] text-white/65">{stageLabel(stage, job)}</p>
<span className="ml-auto text-[11px] font-medium text-white/35">{Math.round(progress)}%</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-white/[0.04]">
<div
className="h-full bg-white/60 transition-all duration-500"
style={{ width: `${Math.max(3, Math.min(progress, 100))}%` }}
/>
</div>
{scenePlan ? (
<p className="text-[11px] text-white/30">
{scenePlan.scenes.length} scenes · {Math.round(scenePlan.total_duration_seconds ?? 0)}s lesson
</p>
) : (
<p className="text-[11px] text-white/30">
{sourceId ? "Reading source" : "Working from topic"} · this can take a few minutes
</p>
)}
</div>
) : null}
{stage === "failed" || error ? (
<div className="mt-2 flex items-start gap-2 rounded-md border border-rose-400/15 bg-rose-500/[0.04] p-3">
<AlertCircle className="mt-0.5 size-3.5 shrink-0 text-rose-300/70" />
<p className="text-[12px] leading-5 text-rose-200/80">{error || "Render failed."}</p>
</div>
) : null}
{stage === "ready" && !finalUrl ? (
<div className="flex items-start gap-2 rounded-md border border-white/[0.06] bg-white/[0.02] p-3 text-[12px] text-white/55">
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-white/40" />
Lesson plan built. Video render unavailable — try again later.
</div>
) : null}
</article>
);
}