reel-studio-2 / web /src /steps /ReviewStep.jsx
xvepkj's picture
Add beat sync and power punch captions
4021f36
Raw
History Blame
6.41 kB
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>
)
}