Spaces:
Paused
Paused
| import { useState } from 'react' | |
| import { api, runJob } from '../api' | |
| import { useJob, ProgressBox, ErrorBox } from '../hooks' | |
| const PRESETS = ['bold-center-karaoke', 'clean-lower-third', 'minimal', 'typewriter', 'punch'] | |
| const DUB_LANGS = [['es', 'Spanish'], ['hi', 'Hindi'], ['fr', 'French'], ['de', 'German'], | |
| ['pt', 'Portuguese'], ['ja', 'Japanese'], ['ar', 'Arabic'], ['it', 'Italian'], | |
| ['ko', 'Korean'], ['zh', 'Chinese'], ['ru', 'Russian'], ['id', 'Indonesian']] | |
| export default function ReviewStep({ reelData, refreshReel, setStep, projectId, reelId }) { | |
| const base = `/api/projects/${projectId}/reels/${reelId}` | |
| const results = reelData?.renders?.results || [] | |
| const [picked, setPicked] = useState(0) | |
| const [tweakPreset, setTweakPreset] = useState(null) | |
| const [swapping, setSwapping] = useState(false) | |
| const [error, setError] = useState('') | |
| const rerenderJob = useJob() | |
| const [dubLang, setDubLang] = useState('es') | |
| const [dubBusy, setDubBusy] = useState(false) | |
| const [dubMsg, setDubMsg] = useState('') | |
| const [dubbedUrl, setDubbedUrl] = useState('') | |
| if (!results.length) { | |
| return ( | |
| <div> | |
| <h2>Review</h2> | |
| <p className="subtitle">Nothing rendered yet.</p> | |
| <button className="btn secondary" onClick={() => setStep(6)}>← Go to Render</button> | |
| </div> | |
| ) | |
| } | |
| const chosen = results[Math.min(picked, results.length - 1)] | |
| // Tweak: re-render only the picked variant, optionally with a new caption preset. | |
| const rerender = async () => { | |
| const spec = { | |
| name: chosen.variant, | |
| caption_preset: tweakPreset || chosen.caption_preset, | |
| clip_offset: results.indexOf(chosen), | |
| } | |
| try { | |
| await rerenderJob.start(`${base}/render`, { variants: [spec] }) | |
| await refreshReel() | |
| setPicked(0) | |
| setTweakPreset(null) | |
| } catch { /* shown */ } | |
| } | |
| const swapClip = async (lineIndex) => { | |
| setSwapping(true) | |
| try { | |
| await api.post(`${base}/media/swap`, { line_index: lineIndex }) | |
| await refreshReel() | |
| } catch (e) { setError(e.message) } finally { setSwapping(false) } | |
| } | |
| const dubIt = async () => { | |
| setDubBusy(true); setError(''); setDubbedUrl(''); setDubMsg('Starting…') | |
| try { | |
| const r = await runJob(`${base}/dub`, { result_index: results.indexOf(chosen), target_lang: dubLang }, | |
| (p) => setDubMsg(p[p.length - 1] || 'Dubbing…')) | |
| setDubbedUrl(r.url) | |
| } catch (e) { setError(e.message) } finally { setDubBusy(false); setDubMsg('') } | |
| } | |
| return ( | |
| <div> | |
| <h2>Review & export</h2> | |
| <p className="subtitle">Preview the variants side by side, pick one, tweak and re-render, then download.</p> | |
| <ErrorBox error={error || rerenderJob.error} /> | |
| {rerenderJob.running && <ProgressBox progress={rerenderJob.progress.length ? rerenderJob.progress : ['Re-rendering…']} />} | |
| <div className="variant-grid"> | |
| {results.map((r, i) => ( | |
| <div key={r.variant + i} className={`variant-card card ${picked === i ? 'picked' : ''}`}> | |
| <video src={r.url} controls playsInline /> | |
| <p style={{ margin: '8px 0 2px' }}><strong>{r.variant}</strong></p> | |
| <p className="muted" style={{ margin: '0 0 8px' }}>{r.caption_preset} · {r.duration_sec.toFixed(1)}s</p> | |
| <button className={`btn small ${picked === i ? '' : 'secondary'}`} onClick={() => setPicked(i)}> | |
| {picked === i ? '✓ Picked' : 'Pick this one'} | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="card"> | |
| <strong>Tweak “{chosen.variant}”</strong> | |
| <div className="row" style={{ marginTop: 10 }}> | |
| <label className="row" style={{ gap: 8 }}> | |
| <span className="muted">Caption style:</span> | |
| <select value={tweakPreset || chosen.caption_preset} onChange={(e) => setTweakPreset(e.target.value)}> | |
| {PRESETS.map((p) => <option key={p} value={p}>{p}</option>)} | |
| </select> | |
| </label> | |
| <button className="btn secondary" disabled={rerenderJob.running} onClick={rerender}> | |
| ↻ Re-render this variant | |
| </button> | |
| </div> | |
| {reelData?.media && ( | |
| <> | |
| <p className="muted" style={{ marginBottom: 6 }}>Swap a scene’s clip (then re-render):</p> | |
| <div className="row"> | |
| {reelData.media.scenes.map((sm) => ( | |
| <button key={sm.line_index} className="btn small secondary" | |
| disabled={swapping || sm.candidates.length < 2} | |
| onClick={() => swapClip(sm.line_index)}> | |
| Scene {sm.line_index + 1} ({sm.selected + 1}/{sm.candidates.length}) | |
| </button> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| <div className="card"> | |
| <strong>🌍 Dub to another language (ElevenLabs)</strong> | |
| <p className="prompt-hint">Re-voice “{chosen.variant}” in another language, keeping the delivery. Uses credits; takes a few minutes.</p> | |
| <div className="row"> | |
| <select value={dubLang} onChange={(e) => setDubLang(e.target.value)} disabled={dubBusy}> | |
| {DUB_LANGS.map(([c, n]) => <option key={c} value={c}>{n}</option>)} | |
| </select> | |
| <button className="btn" disabled={dubBusy} onClick={dubIt}>{dubBusy ? '🌍 Dubbing…' : '🌍 Dub this'}</button> | |
| {dubMsg && <span className="muted">{dubMsg}</span>} | |
| </div> | |
| {dubbedUrl && ( | |
| <div style={{ marginTop: 10 }}> | |
| <video src={dubbedUrl} controls playsInline style={{ maxWidth: 250, borderRadius: 10, background: '#000' }} /> | |
| <div style={{ marginTop: 6 }}><a className="btn small" style={{ textDecoration: 'none' }} href={dubbedUrl} download>⬇ Download dubbed video</a></div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="row"> | |
| <button className="btn secondary" onClick={() => setStep(6)}>← Render settings</button> | |
| <div className="grow" /> | |
| <a className="btn" style={{ textDecoration: 'none' }} href={chosen.url} download> | |
| ⬇ Download {chosen.variant} | |
| </a> | |
| </div> | |
| <p className="muted" style={{ marginTop: 14 }}> | |
| Tip: trending audio must be added in the YouTube/Instagram app at posting time — platform-licensed sounds are never embedded here. | |
| </p> | |
| </div> | |
| ) | |
| } | |