eldouma-graphics / src /components /TranscriptPanel.tsx
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
10.2 kB
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { Search, Wand2, ClipboardPaste, Loader2, X, Check } from "lucide-react";
import { useStore, selectStats } from "../store";
import { fmtTime } from "../lib/time";
import { timelineToSource, wordTimelineStart, type Word } from "../lib/timeline-model";
import { ModelSwitcher } from "./ModelSwitcher";
import { aiHeaders } from "../lib/aiKeys";
const LENGTHS = ["~30 sec", "~1 minute", "~2 minutes"];
const TONES = ["Friendly", "Authoritative", "Cinematic", "Energetic"];
// Script tools: generate the project's script with AI, or paste your own. Either one
// REPLACES the transcript (estimated timing) and drives captions/timeline/preview.
function ScriptTools() {
const setScript = useStore((s) => s.setScript);
const model = useStore((s) => s.model);
const projectName = useStore((s) => s.projectName);
const hasWords = useStore((s) => s.words.length > 0);
const [mode, setMode] = useState<null | "gen" | "paste">(null);
const [prompt, setPrompt] = useState("");
const [length, setLength] = useState(LENGTHS[1]);
const [tone, setTone] = useState(TONES[0]);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function generate() {
setBusy(true); setErr("");
try {
const d = await fetch("/api/script", { method: "POST", headers: { "Content-Type": "application/json", ...aiHeaders() }, body: JSON.stringify({ prompt: prompt.trim() || projectName, model, length, tone }) }).then((r) => r.json());
if (d.error || !d.script) throw new Error(d.error || "no script returned");
setText(d.script); setMode("paste"); // drop into the editable box to review before applying
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
setBusy(false);
}
function apply() {
if (!text.trim()) return;
const st = useStore.getState();
if ((st.motion.length || st.videos.length) && !window.confirm("Replace the script? Your scenes and clips keep their positions but may no longer line up with the new timing.")) return;
setScript(text.trim()); setMode(null); setText(""); setPrompt("");
}
return (
<div className="border-b border-border">
<div className="flex items-center gap-1.5 px-3 py-2">
<button onClick={() => setMode(mode === "gen" ? null : "gen")} className={"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[12px] font-medium " + (mode === "gen" ? "bg-primary text-white" : "border border-border text-fg-muted hover:border-primary hover:text-fg")}><Wand2 size={13} /> Generate with AI</button>
<button onClick={() => { setMode(mode === "paste" ? null : "paste"); }} className={"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[12px] font-medium " + (mode === "paste" ? "bg-primary text-white" : "border border-border text-fg-muted hover:border-primary hover:text-fg")}><ClipboardPaste size={13} /> Paste script</button>
{mode && <button onClick={() => setMode(null)} className="ml-auto grid h-6 w-6 place-items-center rounded text-fg-subtle hover:bg-white/5"><X size={14} /></button>}
</div>
{mode === "gen" && (
<div className="space-y-2 px-3 pb-3">
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={`What's the video about? (defaults to “${projectName}”)`} className="h-14 w-full resize-none rounded-md border border-border bg-surface px-2.5 py-2 text-[12px] outline-none focus:border-primary" />
<div className="flex flex-wrap gap-3">
<Chips label="Length" value={length} set={setLength} options={LENGTHS} />
<Chips label="Tone" value={tone} set={setTone} options={TONES} />
</div>
<div className="flex items-center gap-2">
<ModelSwitcher />
<div className="flex-1" />
<button onClick={generate} disabled={busy} className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-[12px] font-semibold text-white disabled:opacity-50">{busy ? <Loader2 size={13} className="animate-spin" /> : <Wand2 size={13} />} Generate</button>
</div>
{err && <div className="text-[11px] text-destructive">⚠️ {err}</div>}
</div>
)}
{mode === "paste" && (
<div className="space-y-2 px-3 pb-3">
<textarea value={text} onChange={(e) => setText(e.target.value)} placeholder="Paste or write your script here…" className="h-40 w-full resize-none rounded-md border border-border bg-surface px-2.5 py-2 text-[12px] leading-relaxed outline-none focus:border-primary" />
<button onClick={apply} disabled={!text.trim()} className="flex w-full items-center justify-center gap-1.5 rounded-md bg-primary py-2 text-[13px] font-semibold text-white disabled:opacity-40"><Check size={14} /> {hasWords ? "Replace script" : "Use this script"}</button>
<p className="text-[10px] text-fg-subtle">Becomes the editable transcript with estimated timing — record/import a voiceover later to lock real timing.</p>
</div>
)}
</div>
);
}
function Chips({ label, value, set, options }: { label: string; value: string; set: (v: string) => void; options: string[] }) {
return (
<div>
<div className="eyebrow mb-1 !text-[10px]">{label}</div>
<div className="flex flex-wrap gap-1">{options.map((o) => <button key={o} onClick={() => set(o)} className={"rounded-full border px-2 py-0.5 text-[11px] " + (value === o ? "border-primary bg-primary/15 text-fg" : "border-border text-fg-muted hover:border-primary/50")}>{o}</button>)}</div>
</div>
);
}
// Subscribes to playheadMs ALONE and toggles the highlight class imperatively, so the
// 502-word list doesn't re-render ~30×/sec during playback. Renders nothing.
function ActiveHighlight({ edl }: { edl: ReturnType<typeof useStore.getState>["edl"] }) {
const playheadMs = useStore((s) => s.playheadMs);
const wordsById = useStore((s) => s.wordsById);
const prev = useRef<string | null>(null);
useEffect(() => {
let id: string | undefined;
const ts = timelineToSource(edl, playheadMs);
if (ts) id = ts.clip.wordIds.map((i) => wordsById[i]).find((w) => w && ts.sourceMs >= w.startMs && ts.sourceMs < w.endMs)?.id;
if (prev.current && prev.current !== id) document.getElementById("w-" + prev.current)?.classList.remove("tw-active");
if (id) { document.getElementById("w-" + id)?.classList.add("tw-active"); prev.current = id; } else prev.current = null;
}, [playheadMs, edl, wordsById]);
return null;
}
export function TranscriptPanel() {
const words = useStore((s) => s.words);
const loading = useStore((s) => s.loading);
const error = useStore((s) => s.error);
const toggleWord = useStore((s) => s.toggleWord);
const edl = useStore((s) => s.edl);
const setPlayhead = useStore((s) => s.setPlayhead);
const stats = useStore(useShallow(selectStats));
const [query, setQuery] = useState("");
const matchIdx = useRef(0);
const q = query.trim().toLowerCase();
const matches = useMemo(() => (q ? words.filter((w) => !w.deleted && w.text.toLowerCase().includes(q)) : []), [q, words]);
useEffect(() => { matchIdx.current = 0; }, [matches]); // reset cycle when the match set changes
const seekWord = (w: Word) => {
const ms = wordTimelineStart(edl, w);
if (ms != null) { setPlayhead(ms); useStore.getState().seekFn?.(ms); }
};
const onWordClick = (e: React.MouseEvent, w: Word) => {
if (window.getSelection()?.toString()) return; // ignore text-selection drags
if (w.deleted) return toggleWord(w.id);
if (e.altKey || e.metaKey) return toggleWord(w.id);
seekWord(w);
};
const jumpNext = () => {
if (!matches.length) return;
const m = matches[matchIdx.current % matches.length];
matchIdx.current++;
seekWord(m);
document.getElementById("w-" + m.id)?.scrollIntoView({ block: "center", behavior: "smooth" });
};
return (
<div className="flex h-full flex-col">
<ActiveHighlight edl={edl} />
<ScriptTools />
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<Search size={13} className="text-fg-subtle" />
<input value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") jumpNext(); }}
placeholder="Search transcript…" className="flex-1 bg-transparent text-[12px] outline-none placeholder:text-fg-subtle" />
{q && <span className="num text-[11px] text-fg-subtle">{matches.length} match{matches.length === 1 ? "" : "es"}</span>}
</div>
<div className="flex items-center gap-3 px-3 py-1.5 text-[11px] text-fg-muted">
{loading ? <span>Transcribing episode…</span> : error ? <span className="text-destructive">Error: {error}</span> : (
<>
<span><b className="text-fg">{stats.kept}</b> kept</span>
<span>{stats.deleted} cut</span>
<span className="num">{fmtTime(stats.durationMs)}</span>
</>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4 text-[15px] leading-[1.9]">
{words.length === 0 && !loading && <span className="text-fg-subtle">No transcript.</span>}
{words.map((w) => {
const match = q && !w.deleted && w.text.toLowerCase().includes(q);
return (
<span key={w.id} id={"w-" + w.id} onClick={(e) => onWordClick(e, w)}
title={w.deleted ? "click to restore" : "click to jump · alt-click to cut"}
className={"cursor-pointer rounded px-0.5 " + (w.deleted
? "text-fg-subtle line-through decoration-destructive/60 hover:text-fg-muted"
: match ? "bg-yellow-300/60 text-fg"
: "hover:bg-primary/10")}>
{w.text}{" "}
</span>
);
})}
</div>
<div className="border-t border-border px-3 py-2 text-[11px] text-fg-subtle">
Click a word to jump there · <b>Alt-click</b> to cut · click a cut word to restore
</div>
</div>
);
}