Spaces:
Running
Running
| import { useEffect, useRef, useState } from "react"; | |
| import { Download, Film, Loader2, X, Check, Captions, FileCode, AlertTriangle, RotateCcw } from "lucide-react"; | |
| import { useStore, selectStats } from "../store"; | |
| import { useShallow } from "zustand/react/shallow"; | |
| import { msToFrame } from "../lib/time"; | |
| import { captionLinesFor, toSRT, toVTT, downloadFile } from "../lib/export-formats"; | |
| const RANGES = [ | |
| { label: "First 10s", ms: 10_000 }, | |
| { label: "First 30s", ms: 30_000 }, | |
| { label: "Full video", ms: Infinity }, | |
| ]; | |
| // map raw server job states → friendly, non-leaky labels (no "headless Chrome" / internal verbs) | |
| const STATUS_LABEL: Record<string, string> = { | |
| starting: "Preparing…", queued: "Queued…", bundling: "Preparing…", | |
| rendering: "Rendering your video…", stitching: "Finishing up…", encoding: "Finishing up…", | |
| }; | |
| const statusLabel = (s: string, ready: boolean) => (ready ? "Render complete" : STATUS_LABEL[s] || "Rendering your video…"); | |
| export function ExportModal({ open, onClose }: { open: boolean; onClose: () => void }) { | |
| const edl = useStore((s) => s.edl); | |
| const wordsById = useStore((s) => s.wordsById); | |
| const motion = useStore((s) => s.motion); | |
| const videos = useStore((s) => s.videos); | |
| const tracks = useStore((s) => s.tracks); | |
| const audioSrc = useStore((s) => s.audioSrc); | |
| const music = useStore((s) => s.music); | |
| const musicVolume = useStore((s) => s.musicVolume); | |
| const musicFadeInMs = useStore((s) => s.musicFadeInMs); | |
| const musicFadeOutMs = useStore((s) => s.musicFadeOutMs); | |
| const sfxStyle = useStore((s) => s.sfxStyle); | |
| const captionsOn = useStore((s) => s.captionsOn); | |
| const captionStyle = useStore((s) => s.captionStyle); | |
| const captionColors = useStore((s) => s.captionColors); | |
| const captionAnim = useStore((s) => s.captionAnim); | |
| const lang = useStore((s) => s.lang); | |
| const stats = useStore(useShallow(selectStats)); | |
| const [range, setRange] = useState(2); // default Full | |
| const [hi, setHi] = useState(false); // 4K (scale 2) vs 1080p (scale 1) | |
| const [job, setJob] = useState<{ id: string; status: string; progress: number; ready: boolean; error?: string } | null>(null); | |
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| useEffect(() => { | |
| if (!open) return; | |
| // The modal stays mounted (it just renders null when closed), so a finished/failed job from a previous | |
| // export would linger and reopening would show the stale "Download MP4" instead of a fresh Export form. | |
| // Reset a terminal job on every open so the user can always start a new export (no page refresh needed). | |
| setJob((j) => (j && (j.ready || j.status === "error") ? null : j)); | |
| }, [open]); | |
| // stop polling if the modal unmounts mid-render (otherwise the interval leaks) | |
| useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []); | |
| if (!open) return null; | |
| const cap = Math.min(RANGES[range].ms, stats.durationMs); | |
| const seconds = Math.round(cap / 1000); | |
| const empty = stats.durationMs <= 0; // no transcript, motion, or video → nothing to render | |
| async function start() { | |
| const durationInFrames = Math.max(1, msToFrame(cap)); | |
| const res = await fetch("/api/export", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ edl, wordsById, motion, videos, tracks, audioSrc: audioSrc.replace(/^\//, ""), music, musicVolume, musicFadeInMs, musicFadeOutMs, sfxStyle, durationInFrames, captionsOn, captionStyle, captionColors, captionAnim, lang, scale: hi ? 2 : 1 }), | |
| }); | |
| const { jobId, error } = await res.json(); | |
| if (error) { setJob({ id: "", status: "error", progress: 0, ready: false, error }); return; } | |
| setJob({ id: jobId, status: "starting", progress: 0, ready: false }); | |
| if (pollRef.current) clearInterval(pollRef.current); // never run two pollers at once | |
| const poll = setInterval(async () => { | |
| const s = await fetch(`/api/export/status?id=${jobId}`).then((r) => r.json()); | |
| setJob({ id: jobId, status: s.status, progress: s.progress || 0, ready: !!s.ready, error: s.error }); | |
| if (s.ready || s.status === "error") { clearInterval(poll); pollRef.current = null; } | |
| }, 1200); | |
| pollRef.current = poll; | |
| } | |
| async function downloadFcpxml() { | |
| const res = await fetch("/api/export/fcpxml", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ edl, videos, motion, durationInFrames: msToFrame(stats.durationMs), audioSrc: audioSrc.replace(/^\//, "") }), | |
| }); | |
| downloadFile("timeline.fcpxml", await res.text(), "application/xml"); | |
| } | |
| return ( | |
| <div className="fixed inset-0 z-50 grid place-items-center bg-black/40 p-3 sm:p-6" onClick={onClose}> | |
| <div className="max-h-[88vh] w-full min-w-0 max-w-[440px] overflow-y-auto rounded-xl border border-border bg-surface-raised p-4 shadow-2xl sm:p-5" onClick={(e) => e.stopPropagation()}> | |
| <div className="mb-4 flex items-center justify-between"> | |
| <div className="flex items-center gap-2 text-fg"><Film size={16} className="text-primary" /><span className="font-semibold">Export video</span></div> | |
| <button onClick={onClose} className="rounded p-1 text-fg-muted hover:bg-white/5"><X size={16} /></button> | |
| </div> | |
| {!job && ( | |
| <> | |
| <div className="eyebrow mb-2">Range</div> | |
| <div className="mb-4 flex gap-2"> | |
| {RANGES.map((r, i) => ( | |
| <button key={r.label} onClick={() => setRange(i)} | |
| className={"flex-1 rounded-md border px-3 py-2 text-[13px] " + (range === i ? "border-primary bg-primary/10 text-fg" : "border-border text-fg-muted hover:border-primary/40")}> | |
| {r.label} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="eyebrow mb-2">Resolution</div> | |
| <div className="mb-4 flex gap-2"> | |
| {[{ k: false, label: "1080p", sub: "1920×1080" }, { k: true, label: "4K UHD", sub: "3840×2160" }].map((o) => ( | |
| <button key={o.label} onClick={() => setHi(o.k)} | |
| className={"relative flex-1 rounded-md border px-3 py-2 text-left " + (hi === o.k ? "border-primary bg-primary/10 text-fg" : "border-border text-fg-muted hover:border-primary/40")}> | |
| <div className="text-[13px] font-medium">{o.label}</div> | |
| <div className="num text-[10px] text-fg-subtle">{o.sub}</div> | |
| </button> | |
| ))} | |
| </div> | |
| <div className="mb-4 rounded-md bg-surface px-3 py-2 text-[12px] text-fg-muted"> | |
| {hi ? "3840×2160 (4K)" : "1920×1080"} · H.264 + AAC · ~{seconds}s · {stats.kept} words · {stats.motionCount} scene{stats.motionCount === 1 ? "" : "s"} | |
| {hi && <span className="block text-[11px] text-fg-subtle">4K renders ~4× the pixels — slower, larger file.</span>} | |
| </div> | |
| {empty && <div className="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[12px] text-amber-300">Nothing to render yet — add transcript words, a motion-graphics scene, or a video clip first.</div>} | |
| <button onClick={start} disabled={empty} className="w-full rounded-md bg-primary py-2.5 text-[14px] font-semibold text-white hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-40">Render MP4</button> | |
| <div className="mt-4 border-t border-border pt-3"> | |
| <div className="eyebrow mb-2">Captions & interchange</div> | |
| <div className="grid grid-cols-3 gap-2"> | |
| <DlBtn icon={<Captions size={14} />} label="SRT" onClick={() => downloadFile("captions.srt", toSRT(captionLinesFor(edl, wordsById)), "text/plain")} /> | |
| <DlBtn icon={<Captions size={14} />} label="VTT" onClick={() => downloadFile("captions.vtt", toVTT(captionLinesFor(edl, wordsById)), "text/vtt")} /> | |
| <DlBtn icon={<FileCode size={14} />} label="FCPXML" onClick={downloadFcpxml} disabled={empty} /> | |
| </div> | |
| <p className="mt-2 text-[11px] text-fg-subtle">SRT/VTT subtitles from the transcript · FCPXML opens in FCP / DaVinci Resolve / Premiere (relink media if moved).</p> | |
| </div> | |
| </> | |
| )} | |
| {job && job.status !== "error" && ( | |
| <div className="py-2"> | |
| <div className="mb-2 flex items-center gap-2 text-[13px] text-fg"> | |
| {job.ready ? <Check size={16} className="text-success" /> : <Loader2 size={16} className="animate-spin text-primary" />} | |
| {statusLabel(job.status, job.ready)} | |
| </div> | |
| <div className="mb-4 h-2 overflow-hidden rounded-full bg-white/10"> | |
| <div className="h-full bg-primary transition-all" style={{ width: `${Math.round((job.ready ? 1 : job.progress) * 100)}%` }} /> | |
| </div> | |
| {job.ready ? ( | |
| <a href={`/api/export/file?id=${job.id}`} download className="flex w-full items-center justify-center gap-2 rounded-md bg-primary py-2.5 text-[14px] font-semibold text-white hover:brightness-105"> | |
| <Download size={15} /> Download MP4 | |
| </a> | |
| ) : ( | |
| <p className="text-center text-[12px] text-fg-subtle">This can take a minute for longer videos — you can keep editing.</p> | |
| )} | |
| </div> | |
| )} | |
| {job?.status === "error" && ( | |
| <div className="py-2 text-center"> | |
| <div className="mx-auto mb-2 grid h-10 w-10 place-items-center rounded-full bg-destructive/10"><AlertTriangle size={20} className="text-destructive" /></div> | |
| <div className="text-[14px] font-semibold text-fg">Render didn’t finish</div> | |
| <p className="mx-auto mt-1 max-w-[320px] text-[12.5px] text-fg-muted">Something went wrong while rendering. Try again — if it keeps failing, contact support.</p> | |
| {job.error && <p className="mx-auto mt-1.5 max-w-[320px] truncate text-[11px] text-fg-subtle/70" title={job.error}>{job.error}</p>} | |
| <div className="mt-3 flex items-center justify-center gap-2"> | |
| <button onClick={() => setJob(null)} className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-[13px] font-semibold text-white hover:brightness-105"><RotateCcw size={14} /> Try again</button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function DlBtn({ icon, label, onClick, disabled }: { icon: React.ReactNode; label: string; onClick: () => void; disabled?: boolean }) { | |
| return ( | |
| <button onClick={onClick} disabled={disabled} className="flex items-center justify-center gap-1.5 rounded-md border border-border py-2 text-[12px] font-medium text-fg-muted hover:border-primary hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"> | |
| {icon} {label} | |
| </button> | |
| ); | |
| } | |