Spaces:
Running
Running
| 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> | |
| ); | |
| } | |