| import { useState, useRef, useCallback, useEffect } from "react"; |
| import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| import { motion, AnimatePresence } from "framer-motion"; |
| import { |
| Video, Loader2, Download, Trash2, Sparkles, Lock, Globe, Play, |
| Upload, X, ImagePlus, CheckCircle2, AlertCircle, Clock, |
| } from "lucide-react"; |
| import { Button } from "@/components/ui/button"; |
| import { Textarea } from "@/components/ui/textarea"; |
| import { Badge } from "@/components/ui/badge"; |
| import { Progress } from "@/components/ui/progress"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { useLang } from "@/contexts/LanguageContext"; |
| import { useAuth } from "@/contexts/AuthContext"; |
| import { |
| AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, |
| AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, |
| AlertDialogTrigger, |
| } from "@/components/ui/alert-dialog"; |
|
|
| const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); |
|
|
| |
| interface VideoRecord { |
| id: number; |
| videoUrl: string; |
| thumbnailUrl: string | null; |
| prompt: string; |
| negativePrompt: string | null; |
| model: string; |
| aspectRatio: string; |
| resolution: string; |
| duration: number; |
| hasRefImage: boolean; |
| isPrivate: boolean; |
| userId: number | null; |
| createdAt: string; |
| } |
|
|
| interface RefImage { base64: string; mime: string; preview: string } |
|
|
| type VideoModel = "grok-3" | "veo-3-fast"; |
|
|
| interface VideoOptions { |
| model: VideoModel; |
| aspectRatio: string; |
| resolution: string; |
| duration: number; |
| negativePrompt: string; |
| enhancePrompt: boolean; |
| } |
|
|
| type GenPhase = |
| | "idle" |
| | "submitting" |
| | "turnstile" |
| | "connecting" |
| | "generating" |
| | "done" |
| | "error"; |
|
|
| interface GenState { |
| phase: GenPhase; |
| message: string; |
| elapsedMs: number; |
| progress: number | null; |
| result: VideoRecord | null; |
| errorMsg: string | null; |
| } |
|
|
| |
| async function initiateVideoGeneration( |
| prompt: string, |
| isPrivate: boolean, |
| videoOpts: VideoOptions, |
| refImageBase64?: string, |
| refImageMime?: string, |
| ): Promise<{ taskId: string }> { |
| const resp = await fetch(`${BASE}/api/videos/generate`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| credentials: "include", |
| body: JSON.stringify({ |
| prompt, isPrivate, |
| model: videoOpts.model, |
| aspectRatio: videoOpts.aspectRatio, |
| resolution: videoOpts.resolution, |
| duration: videoOpts.duration, |
| negativePrompt: videoOpts.negativePrompt || undefined, |
| enhancePrompt: videoOpts.enhancePrompt, |
| referenceImageBase64: refImageBase64, |
| referenceImageMime: refImageMime, |
| }), |
| }); |
| if (!resp.ok) { |
| const err = await resp.json().catch(() => ({ message: `HTTP ${resp.status}` })); |
| throw new Error(err.message || `HTTP ${resp.status}`); |
| } |
| return resp.json(); |
| } |
|
|
| async function fetchVideoHistory(): Promise<{ videos: VideoRecord[] }> { |
| const resp = await fetch(`${BASE}/api/videos/history?limit=50`, { credentials: "include" }); |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); |
| return resp.json(); |
| } |
|
|
| async function deleteVideo(id: number): Promise<void> { |
| const resp = await fetch(`${BASE}/api/videos/${id}`, { method: "DELETE", credentials: "include" }); |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); |
| } |
|
|
| |
| function useVideoGeneration(onSuccess: (v: VideoRecord) => void) { |
| const { t } = useLang(); |
| const [state, setState] = useState<GenState>({ |
| phase: "idle", message: "", elapsedMs: 0, progress: null, result: null, errorMsg: null, |
| }); |
|
|
| const esRef = useRef<EventSource | null>(null); |
| const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); |
| const startTimeRef = useRef<number>(0); |
| |
| const terminalPhaseRef = useRef<boolean>(false); |
|
|
| const stopTimer = useCallback(() => { |
| if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } |
| }, []); |
|
|
| const stopSSE = useCallback(() => { |
| if (esRef.current) { esRef.current.close(); esRef.current = null; } |
| stopTimer(); |
| }, [stopTimer]); |
|
|
| const startTimer = useCallback(() => { |
| startTimeRef.current = Date.now(); |
| timerRef.current = setInterval(() => { |
| setState((s) => ({ ...s, elapsedMs: Date.now() - startTimeRef.current })); |
| }, 1000); |
| }, []); |
|
|
| const generate = useCallback(async ( |
| prompt: string, |
| isPrivate: boolean, |
| videoOpts: VideoOptions, |
| refImageBase64?: string, |
| refImageMime?: string, |
| ) => { |
| stopSSE(); |
| terminalPhaseRef.current = false; |
| setState({ phase: "submitting", message: t.videoPhaseSubmitting, elapsedMs: 0, progress: null, result: null, errorMsg: null }); |
| startTimer(); |
|
|
| let taskId: string; |
| try { |
| const result = await initiateVideoGeneration(prompt, isPrivate, videoOpts, refImageBase64, refImageMime); |
| taskId = result.taskId; |
| } catch (err) { |
| stopTimer(); |
| setState((s) => ({ |
| ...s, |
| phase: "error", |
| message: "", |
| errorMsg: err instanceof Error ? err.message : String(err), |
| })); |
| return; |
| } |
|
|
| |
| const es = new EventSource(`${BASE}/api/videos/progress/${taskId}`, { withCredentials: true }); |
| esRef.current = es; |
|
|
| es.onmessage = (ev) => { |
| try { |
| const data = JSON.parse(ev.data) as { |
| type: string; |
| message?: string; |
| status?: number; |
| progress?: number; |
| video?: VideoRecord; |
| errorCode?: string; |
| }; |
|
|
| if (data.type === "start") { |
| setState((s) => ({ |
| ...s, |
| phase: data.message?.includes("Turnstile") ? "turnstile" |
| : (data.message?.includes("連接") || data.message?.includes("connect")) ? "connecting" |
| : "submitting", |
| message: data.message || "", |
| })); |
| } else if (data.type === "progress") { |
| setState((s) => ({ |
| ...s, |
| phase: "generating", |
| message: data.message || t.videoPhaseGenerating, |
| progress: typeof data.progress === "number" ? data.progress : s.progress, |
| })); |
| } else if (data.type === "complete" && data.video) { |
| terminalPhaseRef.current = true; |
| stopSSE(); |
| setState((s) => ({ |
| ...s, |
| phase: "done", |
| message: t.videoDone, |
| result: data.video!, |
| })); |
| onSuccess(data.video); |
| } else if (data.type === "error") { |
| terminalPhaseRef.current = true; |
| stopSSE(); |
| setState((s) => ({ |
| ...s, |
| phase: "error", |
| message: "", |
| errorMsg: data.message || t.videoGenFailed, |
| })); |
| } else if (data.type === "done") { |
| |
| es.close(); |
| } |
| } catch { } |
| }; |
|
|
| es.onerror = () => { |
| if (esRef.current === es) { |
| |
| |
| if (terminalPhaseRef.current) { |
| stopSSE(); |
| return; |
| } |
| stopSSE(); |
| setState((s) => { |
| if (s.phase !== "done" && s.phase !== "error") { |
| return { |
| ...s, |
| phase: "error", |
| message: "", |
| errorMsg: t.videoConnectionFailed, |
| }; |
| } |
| return s; |
| }); |
| } |
| }; |
| }, [stopSSE, startTimer, stopTimer, onSuccess, t]); |
|
|
| const reset = useCallback(() => { |
| stopSSE(); |
| setState({ phase: "idle", message: "", elapsedMs: 0, progress: null, result: null, errorMsg: null }); |
| }, [stopSSE]); |
|
|
| |
| useEffect(() => () => { stopSSE(); }, [stopSSE]); |
|
|
| return { state, generate, reset }; |
| } |
|
|
| |
| function formatMs(ms: number): string { |
| const s = Math.floor(ms / 1000); |
| const m = Math.floor(s / 60); |
| const sec = s % 60; |
| return m > 0 ? `${m}m ${sec}s` : `${sec}s`; |
| } |
|
|
| |
| function VideoCard({ vid, onDelete }: { vid: VideoRecord; onDelete: (id: number) => void }) { |
| const { t } = useLang(); |
| return ( |
| <motion.div |
| layout |
| initial={{ opacity: 0, scale: 0.95 }} |
| animate={{ opacity: 1, scale: 1 }} |
| exit={{ opacity: 0, scale: 0.9 }} |
| transition={{ duration: 0.25 }} |
| className="bg-card/60 border border-border/50 rounded-xl overflow-hidden" |
| > |
| <div className="relative aspect-video bg-black/40"> |
| <video |
| src={vid.videoUrl.startsWith("/") ? `${BASE}${vid.videoUrl}` : vid.videoUrl} |
| poster={vid.thumbnailUrl |
| ? (vid.thumbnailUrl.startsWith("/") ? `${BASE}${vid.thumbnailUrl}` : vid.thumbnailUrl) |
| : undefined} |
| className="w-full h-full object-cover" |
| controls |
| preload="metadata" |
| playsInline |
| /> |
| <div className="absolute top-2 left-2 flex gap-1"> |
| {vid.hasRefImage && ( |
| <Badge variant="outline" className="text-[10px] bg-black/60 border-violet-400/40 text-violet-300 backdrop-blur-sm"> |
| <ImagePlus className="w-2.5 h-2.5 mr-1" />{t.videoModeImage} |
| </Badge> |
| )} |
| </div> |
| <div className="absolute top-2 right-2"> |
| <Badge variant="outline" className="text-[10px] bg-black/60 border-white/20 text-white/80 backdrop-blur-sm"> |
| {vid.isPrivate |
| ? <><Lock className="w-2.5 h-2.5 mr-1" />{t.videoPrivateLabel}</> |
| : <><Globe className="w-2.5 h-2.5 mr-1" />{t.videoPublicLabel}</> |
| } |
| </Badge> |
| </div> |
| </div> |
| <div className="p-3"> |
| <p className="text-xs text-muted-foreground line-clamp-2 mb-3">{vid.prompt}</p> |
| <div className="flex items-center gap-1.5"> |
| <Badge variant="secondary" className="text-[10px]">{vid.model}</Badge> |
| <Badge variant="outline" className="text-[10px]">{vid.duration}s</Badge> |
| {vid.aspectRatio && <Badge variant="outline" className="text-[10px]">{vid.aspectRatio}</Badge>} |
| {vid.resolution && <Badge variant="outline" className="text-[10px] text-sky-400 border-sky-400/30">{vid.resolution}</Badge>} |
| <div className="flex-1" /> |
| <Button |
| size="icon" variant="ghost" className="h-7 w-7" |
| onClick={() => { const a = document.createElement("a"); a.href = vid.videoUrl.startsWith("/") ? `${BASE}${vid.videoUrl}` : vid.videoUrl; a.download = `starforge-video-${vid.id}.mp4`; a.click(); }} |
| title={t.videoDownload} |
| > |
| <Download className="w-3.5 h-3.5" /> |
| </Button> |
| <AlertDialog> |
| <AlertDialogTrigger asChild> |
| <Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" title={t.videoDelete}> |
| <Trash2 className="w-3.5 h-3.5" /> |
| </Button> |
| </AlertDialogTrigger> |
| <AlertDialogContent> |
| <AlertDialogHeader> |
| <AlertDialogTitle>{t.videoDelete}</AlertDialogTitle> |
| <AlertDialogDescription>{vid.prompt.slice(0, 100)}</AlertDialogDescription> |
| </AlertDialogHeader> |
| <AlertDialogFooter> |
| <AlertDialogCancel>{t.poolCancel}</AlertDialogCancel> |
| <AlertDialogAction onClick={() => onDelete(vid.id)} className="bg-destructive hover:bg-destructive/90"> |
| {t.videoDelete} |
| </AlertDialogAction> |
| </AlertDialogFooter> |
| </AlertDialogContent> |
| </AlertDialog> |
| </div> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| |
| function GeneratingPanel({ state }: { state: GenState }) { |
| const { t } = useLang(); |
| const phaseProgress: Record<GenPhase, number> = { |
| idle: 0, submitting: 5, turnstile: 12, connecting: 20, generating: 25, done: 100, error: 0, |
| }; |
|
|
| |
| |
| let progress: number; |
| if (state.phase === "generating" && state.progress !== null) { |
| progress = 25 + Math.round(state.progress * 0.70); |
| } else { |
| progress = phaseProgress[state.phase]; |
| } |
|
|
| return ( |
| <motion.div |
| key="generating" |
| initial={{ opacity: 0, y: 10 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0 }} |
| className="bg-card/40 border border-primary/20 rounded-xl p-6 mb-6" |
| > |
| <div className="flex items-center gap-3 mb-4"> |
| <div className="w-10 h-10 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center shrink-0"> |
| <Loader2 className="w-5 h-5 text-primary animate-spin" /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="font-medium text-foreground text-sm"> |
| {state.phase === "submitting" ? t.videoPhaseSubmitting : |
| state.phase === "turnstile" ? t.videoPhaseGettingCaptcha : |
| state.phase === "connecting" ? t.videoPhaseConnecting : |
| state.phase === "generating" ? t.videoPhaseGenerating : |
| t.videoPhaseProcessing} |
| </p> |
| {state.message && ( |
| <p className="text-xs text-muted-foreground mt-0.5 truncate">{state.message}</p> |
| )} |
| </div> |
| {state.elapsedMs > 0 && ( |
| <div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0"> |
| <Clock className="w-3 h-3" /> |
| {formatMs(state.elapsedMs)} |
| </div> |
| )} |
| </div> |
| |
| <Progress value={progress} className="h-1.5 mb-3" /> |
| |
| <div className="flex flex-wrap gap-2"> |
| {(["submitting", "turnstile", "connecting", "generating"] as GenPhase[]).map((ph) => { |
| const labels: Record<string, string> = { |
| submitting: t.videoPhaseSubmitLabel, |
| turnstile: t.videoPhaseTurnstileLabel, |
| connecting: t.videoPhaseConnectLabel, |
| generating: t.videoPhaseGenerateLabel, |
| }; |
| const passed = phaseProgress[state.phase] > phaseProgress[ph]; |
| const active = state.phase === ph; |
| return ( |
| <div |
| key={ph} |
| className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full border transition-colors ${ |
| active ? "bg-primary/20 border-primary/40 text-primary" : |
| passed ? "bg-green-500/10 border-green-500/20 text-green-400" : |
| "bg-muted/30 border-border/30 text-muted-foreground" |
| }`} |
| > |
| {passed ? <CheckCircle2 className="w-2.5 h-2.5" /> : |
| active ? <Loader2 className="w-2.5 h-2.5 animate-spin" /> : |
| <div className="w-2.5 h-2.5 rounded-full border border-current opacity-40" />} |
| {labels[ph]} |
| </div> |
| ); |
| })} |
| </div> |
| |
| {state.phase === "generating" && ( |
| <p className="text-[11px] text-muted-foreground mt-3"> |
| {t.videoGeneratingHint} |
| </p> |
| )} |
| </motion.div> |
| ); |
| } |
| |
| // ── Main VideoPage ──────────────────────────────────────────────────────────── |
| export function VideoPage() { |
| const { t } = useLang(); |
| const { toast } = useToast(); |
| const queryClient = useQueryClient(); |
| const { isSignedIn } = useAuth(); |
| |
| const [prompt, setPrompt] = useState(""); |
| const [isPrivate, setIsPrivate] = useState(false); |
| const [referenceImage, setReferenceImage] = useState<RefImage | null>(null); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
| |
| // ── Model selection ── |
| const [selectedModel, setSelectedModel] = useState<VideoModel>("grok-3"); |
| |
| // ── Video generation options ── |
| const [aspectRatio, setAspectRatio] = useState("16:9"); |
| const [resolution, setResolution] = useState("480p"); |
| const [duration, setDuration] = useState(6); |
| const [negativePrompt, setNegativePrompt] = useState(""); |
| const [enhancePrompt, setEnhancePrompt] = useState(true); |
| const [showAdvanced, setShowAdvanced] = useState(false); |
| |
| // When switching to Veo, enforce its constraints |
| const handleModelChange = (model: VideoModel) => { |
| setSelectedModel(model); |
| if (model === "veo-3-fast") { |
| setDuration(8); |
| if (aspectRatio !== "16:9" && aspectRatio !== "9:16") setAspectRatio("16:9"); |
| } else { |
| setDuration(6); |
| } |
| }; |
| |
| const HISTORY_KEY = ["video-history"]; |
| |
| const { data: historyData, isLoading: historyLoading } = useQuery({ |
| queryKey: HISTORY_KEY, |
| queryFn: fetchVideoHistory, |
| staleTime: 30_000, |
| }); |
| |
| const handleSuccess = useCallback((video: VideoRecord) => { |
| toast({ title: t.videoGenSuccess }); |
| queryClient.invalidateQueries({ queryKey: HISTORY_KEY }); |
| setPrompt(""); |
| setReferenceImage(null); |
| }, [toast, queryClient, t]); |
| |
| const { state: genState, generate, reset } = useVideoGeneration(handleSuccess); |
| |
| const isGenerating = genState.phase !== "idle" && genState.phase !== "done" && genState.phase !== "error"; |
| |
| const handleFileUpload = useCallback((file: File) => { |
| if (!file.type.startsWith("image/")) { |
| toast({ title: t.errorFormatTitle, description: t.errorFormatDesc, variant: "destructive" }); |
| return; |
| } |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| const dataUrl = e.target?.result as string; |
| const base64 = dataUrl.split(",")[1]; |
| setReferenceImage({ base64, mime: file.type, preview: dataUrl }); |
| }; |
| reader.readAsDataURL(file); |
| }, [t, toast]); |
| |
| const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { |
| e.preventDefault(); |
| const file = e.dataTransfer.files[0]; |
| if (file) handleFileUpload(file); |
| }, [handleFileUpload]); |
| |
| const { mutate: removeVideo } = useMutation({ |
| mutationFn: deleteVideo, |
| onSuccess: () => { |
| toast({ title: t.videoDeleteSuccess }); |
| queryClient.invalidateQueries({ queryKey: HISTORY_KEY }); |
| }, |
| onError: () => { toast({ title: t.videoDeleteFailed, variant: "destructive" }); }, |
| }); |
| |
| const videos = historyData?.videos ?? []; |
| |
| return ( |
| <div className="container mx-auto px-4 py-8 max-w-4xl"> |
| {/* Header */} |
| <div className="mb-8"> |
| <div className="flex items-center gap-3 mb-2"> |
| <div className="w-9 h-9 rounded-lg bg-primary/20 border border-primary/30 flex items-center justify-center"> |
| <Video className="w-5 h-5 text-primary" /> |
| </div> |
| <div> |
| <h1 className="text-2xl font-bold tracking-tight">{t.videoTitle}</h1> |
| <p className="text-sm text-muted-foreground">{t.videoSubtitle}</p> |
| </div> |
| </div> |
| </div> |
| |
| {/* Generator Card */} |
| <div className="bg-card/60 border border-border/50 rounded-xl p-5 mb-6 backdrop-blur-sm space-y-4"> |
| |
| {/* ── Model Picker ── */} |
| <div> |
| <p className="text-xs text-muted-foreground mb-2">{t.videoModel}</p> |
| <div className="flex flex-wrap gap-2"> |
| {([ |
| { |
| value: "grok-3" as VideoModel, |
| label: "Grok-3", |
| sub: t.videoModelGrokSub, |
| desc: t.videoModelGrokDesc, |
| color: "text-violet-400", |
| border: "border-violet-500/40", |
| bg: "bg-violet-500/10", |
| }, |
| { |
| value: "veo-3-fast" as VideoModel, |
| label: "Veo 3.1 Fast", |
| sub: t.videoModelVeoSub, |
| desc: t.videoModelVeoDesc, |
| color: "text-sky-400", |
| border: "border-sky-500/40", |
| bg: "bg-sky-500/10", |
| }, |
| ]).map((m) => { |
| const active = selectedModel === m.value; |
| return ( |
| <button |
| key={m.value} |
| onClick={() => !isGenerating && handleModelChange(m.value)} |
| disabled={isGenerating} |
| className={`flex flex-col items-start gap-0.5 px-3 py-2.5 rounded-lg border text-xs font-medium transition-all ${ |
| active |
| ? `${m.border} ${m.bg} ${m.color}` |
| : "border-border/50 hover:border-border text-muted-foreground hover:text-foreground" |
| }`} |
| > |
| <div className="flex items-center gap-1.5"> |
| <Sparkles className={`w-3 h-3 ${active ? m.color : ""}`} /> |
| <span className="font-semibold">{m.label}</span> |
| <span className={`text-[10px] opacity-70 font-normal ${active ? "" : ""}`}>{m.sub}</span> |
| </div> |
| <span className={`text-[10px] opacity-60 ml-5 ${active ? m.color : "text-muted-foreground"}`}>{m.desc}</span> |
| </button> |
| ); |
| })} |
| </div> |
| {selectedModel === "veo-3-fast" && ( |
| <p className="mt-2 text-[11px] text-sky-400/80 flex items-center gap-1"> |
| <span>⏱</span> {t.videoVeoTimingNote} |
| </p> |
| )} |
| </div> |
| |
| {referenceImage && ( |
| <Badge className="text-xs gap-1 bg-violet-500/20 text-violet-300 border-violet-400/30"> |
| <ImagePlus className="w-3 h-3" />{t.videoModeImage} |
| </Badge> |
| )} |
| |
| <Textarea |
| value={prompt} |
| onChange={(e) => setPrompt(e.target.value)} |
| placeholder={t.videoPromptPlaceholder} |
| className="min-h-[90px] resize-none bg-background/50" |
| disabled={isGenerating} |
| /> |
| |
| {/* ── Aspect Ratio ── */} |
| <div> |
| <p className="text-xs text-muted-foreground mb-1.5">{t.ratioLabel}</p> |
| <div className="flex flex-wrap gap-2"> |
| {([ |
| { value: "16:9", label: "16:9", tw: "w-[48px] h-[27px]" }, |
| { value: "9:16", label: "9:16", tw: "w-[19px] h-[34px]" }, |
| { value: "1:1", label: "1:1", tw: "w-[30px] h-[30px]", veoLimited: true }, |
| { value: "4:3", label: "4:3", tw: "w-[38px] h-[28px]", veoLimited: true }, |
| { value: "3:4", label: "3:4", tw: "w-[28px] h-[37px]", veoLimited: true }, |
| ] as const).map(({ value, label, tw, veoLimited }) => { |
| const isLimited = selectedModel === "veo-3-fast" && veoLimited; |
| return ( |
| <div key={value} className="relative group"> |
| <button |
| onClick={() => !isLimited && setAspectRatio(value)} |
| disabled={isGenerating || isLimited} |
| className={`flex flex-col items-center gap-1 px-2.5 py-2 rounded-lg border text-[11px] font-medium transition-all ${ |
| isLimited |
| ? "border-border/20 text-muted-foreground/30 cursor-not-allowed opacity-40 line-through" |
| : aspectRatio === value |
| ? "border-primary/60 bg-primary/10 text-primary" |
| : "border-border/50 hover:border-border text-muted-foreground hover:text-foreground" |
| }`} |
| > |
| <div className={`${tw} rounded-sm border-2 ${ |
| isLimited ? "border-muted-foreground/20" : |
| aspectRatio === value ? "border-primary/70 bg-primary/20" : "border-muted-foreground/40" |
| }`} /> |
| {label} |
| </button> |
| {isLimited && ( |
| <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:block z-10 pointer-events-none"> |
| <div className="bg-popover border border-border text-[10px] text-muted-foreground px-2 py-1 rounded-md whitespace-nowrap shadow-lg"> |
| {t.videoVeoNotSupported} |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| |
| {/* ── Resolution & Duration ── */} |
| <div className="flex flex-wrap gap-5"> |
| {/* Resolution: Veo ignores resolution (server-side), Grok max 720p */} |
| {selectedModel === "grok-3" && ( |
| <div> |
| <p className="text-xs text-muted-foreground mb-1.5"> |
| {t.resolutionLabel} |
| <span className="ml-1.5 text-amber-400/80 text-[10px]">{t.videoGrokMaxRes}</span> |
| </p> |
| <div className="flex gap-1.5"> |
| {([ |
| { value: "480p", label: "480p", sub: "SD", limited: false }, |
| { value: "720p", label: "720p", sub: "HD", limited: false }, |
| { value: "1080p", label: "1080p", sub: "FHD", limited: true }, |
| ] as const).map(({ value, label, sub, limited }) => ( |
| <div key={value} className="relative group"> |
| <button |
| onClick={() => !limited && setResolution(value)} |
| disabled={isGenerating || limited} |
| className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-all ${ |
| limited |
| ? "border-border/20 text-muted-foreground/30 cursor-not-allowed opacity-40 line-through" |
| : resolution === value |
| ? "border-primary/60 bg-primary/10 text-primary" |
| : "border-border/50 hover:border-border text-muted-foreground hover:text-foreground" |
| }`} |
| > |
| {label} <span className="opacity-60 ml-0.5">{sub}</span> |
| </button> |
| {limited && ( |
| <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:block z-10 pointer-events-none"> |
| <div className="bg-popover border border-border text-[10px] text-muted-foreground px-2 py-1 rounded-md whitespace-nowrap shadow-lg"> |
| {t.videoGrokNotSupported} |
| </div> |
| </div> |
| )} |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| {/* Duration */} |
| <div> |
| <p className="text-xs text-muted-foreground mb-1.5"> |
| {t.videoDuration} |
| {selectedModel === "grok-3" && ( |
| <span className="ml-1.5 text-amber-400/80 text-[10px]">{t.videoGrokMaxTime}</span> |
| )} |
| {selectedModel === "veo-3-fast" && ( |
| <span className="ml-1.5 text-sky-400/80 text-[10px]">{t.videoVeoFixedTime}</span> |
| )} |
| </p> |
| <div className="flex gap-1.5"> |
| {([5, 6, 8, 10] as const).map((d) => { |
| const isGrok3Limited = selectedModel === "grok-3" && d > 6; |
| const isVeoLocked = selectedModel === "veo-3-fast" && d !== 8; |
| const isLimited = isGrok3Limited || isVeoLocked; |
| const tooltipMsg = isGrok3Limited ? t.videoGrokMaxTime : isVeoLocked ? t.videoVeoFixedTime : ""; |
| return ( |
| <div key={d} className="relative group"> |
| <button |
| onClick={() => !isLimited && setDuration(d)} |
| disabled={isGenerating || isLimited} |
| className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-all ${ |
| isLimited |
| ? "border-border/20 text-muted-foreground/30 cursor-not-allowed opacity-40 line-through" |
| : duration === d |
| ? "border-primary/60 bg-primary/10 text-primary" |
| : "border-border/50 hover:border-border text-muted-foreground hover:text-foreground" |
| }`} |
| > |
| {d}s |
| </button> |
| {isLimited && tooltipMsg && ( |
| <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:block z-10 pointer-events-none"> |
| <div className="bg-popover border border-border text-[10px] text-muted-foreground px-2 py-1 rounded-md whitespace-nowrap shadow-lg"> |
| {tooltipMsg} |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| </div> |
| |
| {/* ── Advanced ── */} |
| <div> |
| <button |
| onClick={() => setShowAdvanced((v) => !v)} |
| className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" |
| > |
| <span className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}>▶</span> |
| {t.videoAdvanced} |
| </button> |
| {showAdvanced && ( |
| <div className="mt-3 space-y-3 pl-3 border-l border-border/40"> |
| <div> |
| <p className="text-xs text-muted-foreground mb-1">{t.videoNegativePrompt}</p> |
| <Textarea |
| value={negativePrompt} |
| onChange={(e) => setNegativePrompt(e.target.value)} |
| placeholder="blurry, distorted, low quality..." |
| className="min-h-[60px] resize-none bg-background/50 text-xs" |
| disabled={isGenerating} |
| /> |
| </div> |
| <label className="flex items-center gap-2 cursor-pointer"> |
| <input |
| type="checkbox" |
| checked={enhancePrompt} |
| onChange={(e) => setEnhancePrompt(e.target.checked)} |
| disabled={isGenerating} |
| className="w-3.5 h-3.5 accent-primary" |
| /> |
| <span className="text-xs text-muted-foreground">{t.videoEnhancePrompt}</span> |
| </label> |
| </div> |
| )} |
| </div> |
| |
| {/* Reference Image */} |
| <div> |
| <div className="flex items-center gap-2 mb-2"> |
| <span className="text-sm font-medium">{t.videoRefImageLabel}</span> |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-violet-400/40 text-violet-400"> |
| {t.videoRefImageBadge} |
| </Badge> |
| </div> |
| {referenceImage ? ( |
| <div className="relative rounded-lg overflow-hidden border border-border/50 bg-background/30"> |
| <img src={referenceImage.preview} alt="reference" className="w-full max-h-48 object-contain" /> |
| <button |
| onClick={() => setReferenceImage(null)} |
| className="absolute top-2 right-2 p-1 rounded-full bg-black/60 hover:bg-black/80 text-white transition-colors" |
| > |
| <X className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| ) : ( |
| <div |
| onDrop={handleDrop} |
| onDragOver={(e) => e.preventDefault()} |
| onClick={() => !isGenerating && fileInputRef.current?.click()} |
| className="border-2 border-dashed border-border/60 rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors" |
| > |
| <Upload className="w-6 h-6 mx-auto mb-2 text-muted-foreground" /> |
| <p className="text-xs text-muted-foreground">{t.videoRefImageDrop}</p> |
| <p className="text-[11px] text-muted-foreground/60 mt-0.5">{t.videoRefImageFormats}</p> |
| </div> |
| )} |
| <input ref={fileInputRef} type="file" accept="image/*" className="hidden" |
| onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFileUpload(f); }} |
| /> |
| </div> |
| |
| <div className="flex items-center justify-between gap-3 pt-1"> |
| {isSignedIn && ( |
| <button |
| onClick={() => setIsPrivate((v) => !v)} |
| className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border border-border/60 text-muted-foreground hover:text-foreground transition-colors" |
| > |
| {isPrivate |
| ? <><Lock className="w-3.5 h-3.5 text-yellow-400" />{t.videoPrivateLabel}</> |
| : <><Globe className="w-3.5 h-3.5" />{t.videoPublicLabel}</> |
| } |
| </button> |
| )} |
| <div className="flex-1" /> |
| <Button |
| onClick={() => generate(prompt.trim(), isPrivate, { model: selectedModel, aspectRatio, resolution, duration, negativePrompt, enhancePrompt }, referenceImage?.base64, referenceImage?.mime)} |
| disabled={isGenerating || !prompt.trim()} |
| className="gap-2 min-w-[140px]" |
| > |
| {isGenerating |
| ? <><Loader2 className="w-4 h-4 animate-spin" />{t.videoBtnGenerating}</> |
| : <><Play className="w-4 h-4" />{t.videoBtnGenerate}</> |
| } |
| </Button> |
| </div> |
| </div> |
| |
| {/* Progress / Result / Error panels */} |
| <AnimatePresence mode="wait"> |
| {isGenerating && ( |
| <GeneratingPanel key="generating" state={genState} /> |
| )} |
| |
| {genState.phase === "error" && ( |
| <motion.div |
| key="error" |
| initial={{ opacity: 0, y: 10 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0 }} |
| className="bg-destructive/10 border border-destructive/30 rounded-xl p-5 mb-6" |
| > |
| <div className="flex items-start gap-3"> |
| <AlertCircle className="w-5 h-5 text-destructive shrink-0 mt-0.5" /> |
| <div className="flex-1"> |
| <p className="font-medium text-destructive text-sm">{t.videoGenFailed}</p> |
| {genState.errorMsg && ( |
| <p className="text-xs text-muted-foreground mt-1">{genState.errorMsg}</p> |
| )} |
| </div> |
| <Button size="sm" variant="outline" onClick={reset} className="text-xs h-7 shrink-0"> |
| {t.poolCancel} |
| </Button> |
| </div> |
| </motion.div> |
| )} |
| |
| {genState.phase === "done" && genState.result && ( |
| <motion.div |
| key="result" |
| initial={{ opacity: 0, scale: 0.97 }} |
| animate={{ opacity: 1, scale: 1 }} |
| exit={{ opacity: 0 }} |
| className="mb-6" |
| > |
| <div className="flex items-center gap-2 mb-2"> |
| <CheckCircle2 className="w-4 h-4 text-green-400" /> |
| <span className="text-sm font-medium text-green-400">{t.videoComplete}</span> |
| {genState.elapsedMs > 0 && ( |
| <span className="text-xs text-muted-foreground ml-auto">{t.videoElapsed} {formatMs(genState.elapsedMs)}</span> |
| )} |
| </div> |
| <div className="bg-card/60 border border-primary/30 rounded-xl overflow-hidden"> |
| <div className="relative aspect-video bg-black"> |
| <video |
| src={genState.result.videoUrl.startsWith("/") ? `${BASE}${genState.result.videoUrl}` : genState.result.videoUrl} |
| poster={genState.result.thumbnailUrl |
| ? (genState.result.thumbnailUrl.startsWith("/") ? `${BASE}${genState.result.thumbnailUrl}` : genState.result.thumbnailUrl) |
| : undefined} |
| className="w-full h-full object-contain" |
| controls |
| autoPlay |
| playsInline |
| /> |
| {genState.result.hasRefImage && ( |
| <div className="absolute top-2 left-2"> |
| <Badge className="text-[10px] bg-violet-500/80 text-white border-0"> |
| <ImagePlus className="w-2.5 h-2.5 mr-1" />{t.videoModeImage} |
| </Badge> |
| </div> |
| )} |
| </div> |
| <div className="p-3 flex items-center justify-between gap-2"> |
| <p className="text-xs text-muted-foreground line-clamp-1 flex-1">{genState.result.prompt}</p> |
| <Button |
| size="sm" variant="outline" className="gap-1.5 text-xs h-7 shrink-0" |
| onClick={() => { const a = document.createElement("a"); const url = genState.result!.videoUrl; a.href = url.startsWith("/") ? `${BASE}${url}` : url; a.download = `starforge-video-${genState.result!.id}.mp4`; a.click(); }} |
| > |
| <Download className="w-3.5 h-3.5" />{t.videoDownload} |
| </Button> |
| </div> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* History */} |
| <div> |
| <h2 className="text-base font-semibold mb-4 flex items-center gap-2"> |
| <Video className="w-4 h-4 text-muted-foreground" /> |
| {t.videoHistoryTitle} |
| </h2> |
| {historyLoading ? ( |
| <div className="flex items-center justify-center py-12"> |
| <Loader2 className="w-6 h-6 text-muted-foreground animate-spin" /> |
| </div> |
| ) : videos.length === 0 ? ( |
| <div className="text-center py-12 text-muted-foreground"> |
| <Video className="w-10 h-10 mx-auto mb-3 opacity-30" /> |
| <p className="text-sm">{t.videoHistoryEmpty}</p> |
| </div> |
| ) : ( |
| <motion.div layout className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
| <AnimatePresence mode="popLayout"> |
| {videos.map((v) => ( |
| <VideoCard key={v.id} vid={v} onDelete={removeVideo} /> |
| ))} |
| </AnimatePresence> |
| </motion.div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
| |