Download src/components/study-chat/InlineVideoRender.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/InlineVideoRender.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/study-chat/InlineVideoRender.tsx
-
curl -L -o InlineVideoRender.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/InlineVideoRender.tsx
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> | |
| ); | |
| } | |