Spaces:
Paused
Paused
| import { useEffect, useState } from 'react' | |
| import { api } from '../api' | |
| import { useJob, ProgressBox, ErrorBox } from '../hooks' | |
| import PromptPanel from '../components/PromptPanel' | |
| export default function ScriptStep({ reelData, refreshReel, reelId, projectId, setStep }) { | |
| const reel = reelData?.reel | |
| const script = reelData?.script | |
| const base = `/api/projects/${projectId}/reels/${reelId}` | |
| const [local, setLocal] = useState(script) | |
| const [dirty, setDirty] = useState(false) | |
| const [topic, setTopic] = useState(reel?.topic || '') | |
| const [targetSec, setTargetSec] = useState(0) // 0 = auto (default 30–45s) | |
| const [prompt, setPrompt] = useState(null) | |
| const [scenePrompts, setScenePrompts] = useState({}) // {sceneNo: prompt override} | |
| const [error, setError] = useState('') | |
| const genJob = useJob() | |
| const sceneJob = useJob() | |
| useEffect(() => { setLocal(reelData?.script); setDirty(false) }, [reelData?.script]) | |
| useEffect(() => { setTopic(reel?.topic || '') }, [reel?.topic]) | |
| if (!reelId) { | |
| return ( | |
| <div> | |
| <h2>Script</h2> | |
| <p className="subtitle">Select or create a reel first (left sidebar), or pick a topic on the Topics step.</p> | |
| </div> | |
| ) | |
| } | |
| const mutate = (fn) => { const next = structuredClone(local); fn(next); setLocal(next); setDirty(true) } | |
| const save = async () => { | |
| try { | |
| await api.patch(`${base}/script`, local) | |
| await refreshReel() | |
| setError('') | |
| } catch (e) { setError(e.message) } | |
| } | |
| const generate = async () => { | |
| if (!topic.trim()) { setError('Enter a topic for this reel first.'); return } | |
| try { | |
| await genJob.start(`${base}/script/generate`, { topic: topic.trim(), prompt, target_sec: targetSec || null }) | |
| await refreshReel() | |
| } catch { /* shown */ } | |
| } | |
| const regenerateScene = async (sceneNo) => { | |
| try { | |
| await sceneJob.start(`${base}/script/regenerate-scene`, { scene: sceneNo, prompt: scenePrompts[sceneNo] ?? null }) | |
| await refreshReel() | |
| } catch { /* shown */ } | |
| } | |
| const goMedia = async () => { if (dirty) await save(); setStep(4) } | |
| // --- No script yet: topic + generate (with editable prompt) --- | |
| if (!local) { | |
| return ( | |
| <div> | |
| <h2>Script</h2> | |
| <p className="subtitle">Write the scene-structured script for this reel, in your references' style.</p> | |
| <ErrorBox error={error || genJob.error} onRetry={generate} /> | |
| <div className="card"> | |
| <label className="field"><span className="lbl">Topic for this reel</span> | |
| <input type="text" value={topic} onChange={(e) => setTopic(e.target.value)} | |
| placeholder="e.g. Why the anglerfish glows" /> | |
| </label> | |
| <label className="field" style={{ maxWidth: 260 }}> | |
| <span className="lbl">Target length (the narration drives the reel length)</span> | |
| <select value={targetSec} onChange={(e) => setTargetSec(Number(e.target.value))}> | |
| <option value={0}>Auto (30–45s)</option> | |
| <option value={15}>~15s (quick)</option> | |
| <option value={30}>~30s</option> | |
| <option value={45}>~45s</option> | |
| <option value={60}>~60s</option> | |
| <option value={90}>~90s</option> | |
| <option value={120}>~2 min</option> | |
| </select> | |
| </label> | |
| <PromptPanel | |
| label="scriptwriting" | |
| value={prompt} | |
| setValue={setPrompt} | |
| disabled={genJob.running} | |
| fetchPrompt={async () => (await api.post(`${base}/script/prompt`, { topic: topic.trim() })).prompt} | |
| /> | |
| <button className="btn" onClick={generate} disabled={genJob.running || !topic.trim()}> | |
| {genJob.running ? 'Writing…' : 'Generate script'} | |
| </button> | |
| {genJob.running && <ProgressBox progress={['Writing the script in your references’ style…']} />} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // --- Script exists: editable scenes --- | |
| return ( | |
| <div> | |
| <h2>Script</h2> | |
| <p className="subtitle"> | |
| Topic: <strong>{local.topic || local.title}</strong> | |
| {local.clone_source && <> · cloned structure from <a href={local.clone_source} target="_blank" rel="noreferrer">source</a> (fresh wording)</>} | |
| </p> | |
| <ErrorBox error={error || genJob.error || sceneJob.error} /> | |
| {(genJob.running || sceneJob.running) && <ProgressBox progress={['Rewriting with the style profile…']} />} | |
| <div className="card"> | |
| <label className="field"><span className="lbl">Title</span> | |
| <input type="text" value={local.title} onChange={(e) => mutate((s) => { s.title = e.target.value })} /> | |
| </label> | |
| <span className="lbl" style={{ color: 'var(--muted)', fontSize: 13 }}>Hook — pick one of 3 (spoken in the first 2 seconds)</span> | |
| <div className="spacer" /> | |
| {local.hook_options.map((h, i) => ( | |
| <button key={i} | |
| className={`hook-option ${local.selected_hook === i ? 'selected' : ''}`} | |
| onClick={() => mutate((s) => { s.selected_hook = i })}> | |
| <span className="tag" style={{ marginRight: 8 }}>{i + 1}</span>{h} | |
| </button> | |
| ))} | |
| </div> | |
| {local.scenes.map((scene, i) => ( | |
| <div key={i} className="card scene-card"> | |
| <div className="row" style={{ marginBottom: 8 }}> | |
| <strong>Scene {scene.scene}</strong> | |
| <span className="tag">{scene.duration_sec}s</span> | |
| <div className="grow" /> | |
| <button className="btn small secondary" disabled={sceneJob.running} | |
| onClick={() => regenerateScene(scene.scene)}>↻ Regenerate scene</button> | |
| </div> | |
| <label className="field"><span className="lbl">Narration</span> | |
| <textarea value={scene.narration} | |
| onChange={(e) => mutate((s) => { s.scenes[i].narration = e.target.value })} /> | |
| </label> | |
| <div className="row"> | |
| <label className="field grow"><span className="lbl">Visual query (stock footage search)</span> | |
| <input type="text" value={scene.visual_query} | |
| onChange={(e) => mutate((s) => { s.scenes[i].visual_query = e.target.value })} /> | |
| </label> | |
| <label className="field grow"><span className="lbl">SFX query (optional)</span> | |
| <input type="text" value={scene.sfx_query || ''} | |
| onChange={(e) => mutate((s) => { s.scenes[i].sfx_query = e.target.value || null })} /> | |
| </label> | |
| </div> | |
| <label className="field"><span className="lbl">📌 Real event info — for finding actual footage (optional)</span> | |
| <input type="text" value={scene.event_info || ''} | |
| placeholder="e.g. Ronaldo bicycle kick — Juventus vs Real Madrid, UCL QF, 3 Apr 2018, Allianz Stadium" | |
| onChange={(e) => mutate((s) => { s.scenes[i].event_info = e.target.value })} /> | |
| </label> | |
| <PromptPanel label="scene-rewrite" disabled={sceneJob.running} | |
| value={scenePrompts[scene.scene] ?? null} | |
| setValue={(v) => setScenePrompts((p) => ({ ...p, [scene.scene]: v }))} | |
| fetchPrompt={async () => (await api.post(`${base}/script/scene-prompt`, { scene: scene.scene })).prompt} /> | |
| </div> | |
| ))} | |
| <div className="card"> | |
| <label className="field"><span className="lbl">Call to action</span> | |
| <input type="text" value={local.cta} onChange={(e) => mutate((s) => { s.cta = e.target.value })} /> | |
| </label> | |
| </div> | |
| {/* Regenerate whole script — with editable prompt */} | |
| <div className="card"> | |
| <PromptPanel | |
| label="scriptwriting" | |
| value={prompt} | |
| setValue={setPrompt} | |
| disabled={genJob.running} | |
| fetchPrompt={async () => (await api.post(`${base}/script/prompt`, { topic: local.topic || local.title })).prompt} | |
| /> | |
| <button className="btn secondary" onClick={() => { setTopic(local.topic || local.title); generate() }} disabled={genJob.running}> | |
| ↻ Regenerate whole script | |
| </button> | |
| </div> | |
| <div className="row"> | |
| {dirty && <button className="btn secondary" onClick={save}>Save edits</button>} | |
| <div className="grow" /> | |
| <button className="btn" onClick={goMedia}>Continue to Media →</button> | |
| </div> | |
| </div> | |
| ) | |
| } | |