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