eldouma-graphics / src /components /ExportModal.tsx
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
10.9 kB
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>
);
}