| "use client"; |
|
|
| import { Suspense, useCallback, useEffect, useState } from "react"; |
| import Link from "next/link"; |
| import { useRouter, useSearchParams } from "next/navigation"; |
| import { ArrowLeft, Loader2 } from "lucide-react"; |
| import type { Project, RenderProgress } from "@/types"; |
| import { ExportProgress } from "@/components/export-progress"; |
| import { VideoPreview } from "@/components/video-preview"; |
| import { |
| DEFAULT_ANIMATION, |
| DEFAULT_LAYOUT, |
| DEFAULT_STYLE, |
| } from "@/lib/defaults"; |
|
|
| function ExportContent() { |
| const searchParams = useSearchParams(); |
| const router = useRouter(); |
| const id = searchParams.get("id"); |
|
|
| const [project, setProject] = useState<Project | null>(null); |
| const [loading, setLoading] = useState(true); |
| const [rendering, setRendering] = useState(false); |
| const [progress, setProgress] = useState<RenderProgress | null>(null); |
| const [downloadUrl, setDownloadUrl] = useState<string>(); |
| const [filename, setFilename] = useState<string>(); |
|
|
| useEffect(() => { |
| if (!id) { |
| router.push("/"); |
| return; |
| } |
|
|
| void (async () => { |
| const res = await fetch(`/api/project?id=${id}`); |
| const data = await res.json(); |
| if (!res.ok) { |
| router.push("/"); |
| return; |
| } |
| setProject(data); |
| setLoading(false); |
| })(); |
| }, [id, router]); |
|
|
| const pollProgress = useCallback(async () => { |
| if (!id) return; |
| const res = await fetch(`/api/progress?id=${id}`); |
| const data = (await res.json()) as RenderProgress; |
| setProgress(data); |
| if (data.stage === "done" || data.stage === "error") { |
| setRendering(false); |
| } |
| }, [id]); |
|
|
| const startRender = useCallback(async () => { |
| if (!project || !id) return; |
| setRendering(true); |
| setProgress({ percent: 0, stage: "extracting", message: "Starting..." }); |
|
|
| const pollInterval = setInterval(() => void pollProgress(), 1000); |
|
|
| try { |
| const res = await fetch("/api/render", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| id, |
| captions: project.captions, |
| style: project.style, |
| layout: project.layout, |
| animation: project.animation, |
| }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error); |
|
|
| await new Promise<void>((resolve, reject) => { |
| const waitForDone = setInterval(async () => { |
| const progressRes = await fetch(`/api/progress?id=${id}`); |
| const progressData = (await progressRes.json()) as RenderProgress; |
| setProgress(progressData); |
|
|
| if (progressData.stage === "done") { |
| clearInterval(waitForDone); |
| setDownloadUrl(`/api/download/${id}`); |
| setFilename(`captioned_${id}.mp4`); |
| resolve(); |
| } else if (progressData.stage === "error") { |
| clearInterval(waitForDone); |
| reject(new Error(progressData.message)); |
| } |
| }, 1000); |
| }); |
| } catch (error) { |
| setProgress({ |
| percent: 0, |
| stage: "error", |
| message: error instanceof Error ? error.message : "Render failed", |
| }); |
| } finally { |
| clearInterval(pollInterval); |
| setRendering(false); |
| } |
| }, [project, id, pollProgress]); |
|
|
| if (loading || !project || !id) { |
| return ( |
| <div className="flex min-h-screen items-center justify-center"> |
| <Loader2 className="h-8 w-8 animate-spin text-zinc-400" /> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="min-h-screen bg-zinc-50"> |
| <header className="border-b border-zinc-200 bg-white"> |
| <div className="mx-auto flex max-w-3xl items-center gap-4 px-6 py-4"> |
| <Link href={`/editor?id=${id}`} className="flex items-center gap-2 text-sm text-zinc-600 hover:text-zinc-900"> |
| <ArrowLeft className="h-4 w-4" /> Edit captions |
| </Link> |
| <h1 className="font-bold">Export Video</h1> |
| </div> |
| </header> |
| |
| <main className="mx-auto max-w-3xl space-y-8 px-6 py-10"> |
| <VideoPreview |
| videoUrl={`/api/video/${id}`} |
| audioUrl={project.audioPath ? `/api/audio/${id}` : undefined} |
| captions={project.captions} |
| style={project.style ?? DEFAULT_STYLE} |
| layout={project.layout ?? DEFAULT_LAYOUT} |
| animation={project.animation ?? DEFAULT_ANIMATION} |
| videoMeta={project.videoMeta} |
| /> |
| |
| <ExportProgress |
| progress={progress} |
| downloadUrl={downloadUrl} |
| filename={filename} |
| rendering={rendering} |
| onStartRender={() => void startRender()} |
| /> |
| </main> |
| </div> |
| ); |
| } |
|
|
| export default function ExportPage() { |
| return ( |
| <Suspense fallback={<div className="flex min-h-screen items-center justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>}> |
| <ExportContent /> |
| </Suspense> |
| ); |
| } |
|
|