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 = { 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 | 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 (
e.stopPropagation()}>
Export video
{!job && ( <>
Range
{RANGES.map((r, i) => ( ))}
Resolution
{[{ k: false, label: "1080p", sub: "1920×1080" }, { k: true, label: "4K UHD", sub: "3840×2160" }].map((o) => ( ))}
{hi ? "3840×2160 (4K)" : "1920×1080"} · H.264 + AAC · ~{seconds}s · {stats.kept} words · {stats.motionCount} scene{stats.motionCount === 1 ? "" : "s"} {hi && 4K renders ~4× the pixels — slower, larger file.}
{empty &&
Nothing to render yet — add transcript words, a motion-graphics scene, or a video clip first.
}
Captions & interchange
} label="SRT" onClick={() => downloadFile("captions.srt", toSRT(captionLinesFor(edl, wordsById)), "text/plain")} /> } label="VTT" onClick={() => downloadFile("captions.vtt", toVTT(captionLinesFor(edl, wordsById)), "text/vtt")} /> } label="FCPXML" onClick={downloadFcpxml} disabled={empty} />

SRT/VTT subtitles from the transcript · FCPXML opens in FCP / DaVinci Resolve / Premiere (relink media if moved).

)} {job && job.status !== "error" && (
{job.ready ? : } {statusLabel(job.status, job.ready)}
{job.ready ? ( Download MP4 ) : (

This can take a minute for longer videos — you can keep editing.

)}
)} {job?.status === "error" && (
Render didn’t finish

Something went wrong while rendering. Try again — if it keeps failing, contact support.

{job.error &&

{job.error}

}
)}
); } function DlBtn({ icon, label, onClick, disabled }: { icon: React.ReactNode; label: string; onClick: () => void; disabled?: boolean }) { return ( ); }