Spaces:
Running
Running
| import { useState } from "react"; | |
| import { X, Sparkles, Wand2, Loader2, Check, ArrowRight } from "lucide-react"; | |
| import { useStore, type SceneSpec } from "../store"; | |
| import { MG_THEME_LIST, MG_ACCENTS, MG_TEMPLATES, type ThemeKey } from "../remotion/mg/templates"; | |
| import { MG_FONTS } from "../remotion/mg/catalog.mjs"; | |
| import { aiHeaders } from "../lib/aiKeys"; | |
| const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| // the cohesive subset of type systems worth surfacing here (caps-display + Arabic presets are omitted — | |
| // they only read well with very short copy / RTL). "Auto" lets the AI director pick the best-fitting one. | |
| const FONT_PICK_IDS = ["grotesk", "clean", "geometric", "modern", "tech", "editorial", "elegant", "magazine", "mono", "condensed"]; | |
| const FONT_PICKS = FONT_PICK_IDS.map((id) => MG_FONTS.find((f: { id: string }) => f.id === id)).filter(Boolean) as { id: string; label: string; blurb: string; preview: string }[]; | |
| // Guided, step-by-step generator: pick a look → AI writes on-topic scenes → | |
| // they drop onto the timeline one at a time so you can watch the build. | |
| export function GenerateScenesModal({ open, onClose }: { open: boolean; onClose: () => void }) { | |
| const projectName = useStore((s) => s.projectName); | |
| const words = useStore((s) => s.words); | |
| const sections = useStore((s) => s.sections); | |
| const addGeneratedScene = useStore((s) => s.addGeneratedScene); | |
| const selectMotion = useStore((s) => s.selectMotion); | |
| const [theme, setTheme] = useState<ThemeKey>("dark"); | |
| const [accent, setAccent] = useState(MG_ACCENTS[0]); | |
| const [font, setFont] = useState(""); // "" = Auto (the AI picks one cohesive type system for the whole set) | |
| const [count, setCount] = useState(5); | |
| const [topic, setTopic] = useState(""); | |
| const [phase, setPhase] = useState<"setup" | "running" | "done">("setup"); | |
| const [log, setLog] = useState<{ label: string; template: string; state: "pending" | "active" | "done" }[]>([]); | |
| const [err, setErr] = useState(""); | |
| const [skipped, setSkipped] = useState(0); | |
| if (!open) return null; | |
| async function run() { | |
| setErr(""); | |
| setPhase("running"); | |
| setLog([]); | |
| try { | |
| const res = await fetch("/api/generate-scenes", { | |
| method: "POST", headers: { "Content-Type": "application/json", ...aiHeaders() }, | |
| body: JSON.stringify({ | |
| topic: topic.trim() || projectName, | |
| count, font: font || undefined, // omit → AI picks one cohesive typeface; set → locks it | |
| transcript: words.slice(0, 400).map((w) => w.text).join(" "), | |
| sections: sections.map((s) => ({ title: s.title })), | |
| }), | |
| }).catch(() => { throw new Error("couldn't reach the generator — is the dev server running? try again"); }); | |
| const d = await res.json(); | |
| if (!res.ok || !Array.isArray(d.scenes) || !d.scenes.length) throw new Error(d.error || "no scenes returned"); | |
| const specs: SceneSpec[] = d.scenes.filter((s: SceneSpec) => s.template && MG_TEMPLATES[s.template]); | |
| if (!specs.length) throw new Error("the model returned no usable scenes — try again"); | |
| // ONE type system for the whole sequence (user pick wins → else the AI's choice → else a cohesive default), | |
| // applied to every scene alongside the shared theme+accent so colors AND fonts stay consistent. | |
| const seqFont = font || d.font || "clean"; | |
| setSkipped((d.dropped || 0) + (d.scenes.length - specs.length)); | |
| setLog(specs.map((s) => ({ label: s.label || s.template, template: s.template, state: "pending" }))); | |
| // drop them in one at a time → visible step-by-step build | |
| let firstId = ""; | |
| for (let i = 0; i < specs.length; i++) { | |
| setLog((l) => l.map((x, j) => (j === i ? { ...x, state: "active" } : x))); | |
| await sleep(420); | |
| const id = addGeneratedScene({ ...specs[i], theme, accent, font: seqFont }); | |
| if (!firstId) firstId = id; | |
| setLog((l) => l.map((x, j) => (j === i ? { ...x, state: "done" } : x))); | |
| } | |
| if (firstId) selectMotion(firstId); // open the first scene in the inspector for tweaks | |
| setPhase("done"); | |
| } catch (e) { | |
| setErr(e instanceof Error ? e.message : String(e)); | |
| setPhase("setup"); | |
| } | |
| } | |
| return ( | |
| <div className="fixed inset-0 z-50 grid place-items-center bg-black/45 p-3 sm:p-6" onClick={phase === "running" ? undefined : onClose}> | |
| <div className="flex max-h-[88vh] w-full min-w-0 max-w-[560px] flex-col rounded-xl border border-border bg-surface-raised shadow-2xl" onClick={(e) => e.stopPropagation()}> | |
| <div className="flex items-center justify-between border-b border-border px-5 py-3"> | |
| <div className="flex items-center gap-2 font-semibold text-fg"><Sparkles size={16} className="text-primary" /> Generate motion graphics</div> | |
| <button onClick={onClose} className="rounded p-1 text-fg-muted hover:bg-white/5"><X size={16} /></button> | |
| </div> | |
| <div className="min-h-0 flex-1 overflow-y-auto p-5"> | |
| {phase !== "done" ? ( | |
| <div className="space-y-4"> | |
| <p className="text-[13px] leading-relaxed text-fg-muted"> | |
| The AI will design <b className="text-fg">{count}</b> scenes written about <b className="text-fg">{topic.trim() || projectName}</b> — unique to this project, then place them on your timeline. Edit any of them after. | |
| </p> | |
| <div> | |
| <div className="eyebrow mb-1">Topic <span className="normal-case text-fg-subtle">— defaults to the project name</span></div> | |
| <input value={topic} onChange={(e) => setTopic(e.target.value)} placeholder={projectName} className="w-full rounded-md border border-border bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary" /> | |
| </div> | |
| <div> | |
| <div className="eyebrow mb-1.5">Look</div> | |
| <div className="flex flex-wrap gap-2"> | |
| {MG_THEME_LIST.map((t) => ( | |
| <button key={t.id} onClick={() => setTheme(t.id)} title={t.label} | |
| className={"flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-[12px] " + (theme === t.id ? "border-primary text-fg" : "border-border text-fg-muted hover:border-primary/50")}> | |
| <span className="h-4 w-4 rounded" style={{ background: t.swatch }} /> {t.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="eyebrow mb-1.5">Accent</div> | |
| <div className="flex flex-wrap gap-2"> | |
| {MG_ACCENTS.map((c) => ( | |
| <button key={c} onClick={() => setAccent(c)} className={"h-7 w-7 rounded-full border-2 " + (accent.toLowerCase() === c.toLowerCase() ? "border-fg" : "border-transparent")} style={{ background: c }} /> | |
| ))} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="eyebrow mb-1.5">Typeface <span className="normal-case text-fg-subtle">— one type system across every scene</span></div> | |
| <div className="flex flex-wrap gap-2"> | |
| <button onClick={() => setFont("")} title="Let the AI director choose the type system that best fits your topic" | |
| className={"rounded-md border px-2.5 py-1.5 text-[12px] " + (font === "" ? "border-primary text-fg" : "border-border text-fg-muted hover:border-primary/50")}> | |
| ✨ Auto | |
| </button> | |
| {FONT_PICKS.map((f) => ( | |
| <button key={f.id} onClick={() => setFont(f.id)} title={f.blurb} style={{ fontFamily: f.preview }} | |
| className={"rounded-md border px-2.5 py-1.5 text-[12px] " + (font === f.id ? "border-primary text-fg" : "border-border text-fg-muted hover:border-primary/50")}> | |
| {f.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="eyebrow mb-1.5">Scenes · {count}</div> | |
| <input type="range" min={3} max={8} value={count} onChange={(e) => setCount(Number(e.target.value))} className="w-full accent-primary" /> | |
| </div> | |
| <div className="flex items-center gap-1.5 text-[11px] text-fg-subtle"> | |
| <Sparkles size={12} className="text-primary" /> Art-directed by the Eldouma AI director · {Object.keys(MG_TEMPLATES).length} cinematic templates · ~10s | |
| </div> | |
| {err && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">⚠️ {err}</div>} | |
| {phase === "running" ? ( | |
| <div className="space-y-1.5"> | |
| {log.map((s, i) => ( | |
| <div key={i} className="flex items-center gap-2 text-[13px]"> | |
| {s.state === "done" ? <Check size={14} className="text-success" /> : s.state === "active" ? <Loader2 size={14} className="animate-spin text-primary" /> : <span className="h-3.5 w-3.5 rounded-full border border-fg-subtle/40" />} | |
| <span className={s.state === "pending" ? "text-fg-subtle" : "text-fg"}>{s.label}</span> | |
| <span className="rounded bg-white/5 px-1 text-[10px] text-fg-muted">{s.template}</span> | |
| </div> | |
| ))} | |
| {!log.length && <div className="flex items-center gap-2 text-[13px] text-fg-muted"><Loader2 size={14} className="animate-spin text-primary" /> Designing scenes about “{topic.trim() || projectName}”…</div>} | |
| </div> | |
| ) : ( | |
| <button onClick={run} 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"> | |
| <Wand2 size={15} /> Generate {count} scenes | |
| </button> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="space-y-4 text-center"> | |
| <div className="mx-auto grid h-12 w-12 place-items-center rounded-full bg-success/15 text-success"><Check size={26} /></div> | |
| <div className="text-[15px] font-semibold text-fg">Added {log.length} scenes to your timeline</div> | |
| {skipped > 0 && <p className="text-[12px] text-fg-subtle">({skipped} scene{skipped === 1 ? "" : "s"} from the model were unusable and skipped.)</p>} | |
| <p className="text-[13px] text-fg-muted">They're laid out end-to-end. The first one is open in the editor on the left — click any scene (timeline or list) to tweak its text, colors, and timing.</p> | |
| <div className="flex gap-2"> | |
| <button onClick={() => setPhase("setup")} className="flex-1 rounded-md border border-border py-2 text-[13px] font-medium text-fg-muted hover:text-fg">Generate more</button> | |
| <button onClick={onClose} className="flex flex-1 items-center justify-center gap-1 rounded-md bg-primary py-2 text-[13px] font-semibold text-white">Start editing <ArrowRight size={14} /></button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |