/** * CreatorExportWizard — 4-step modal for exporting a Creator Studio video * with in-browser Piper TTS narration. * * Flow: * 1. Target — MP4 (edit/share) vs MP4 for YouTube. * 2. Voiceover — Piper voice + rate + pitch + subtitle burn-in toggle, * with a Preview button. * 3. Generate — synthesize narration for every scene that has text * (or re-synthesize all if the user checks "regenerate") * and upload each WAV to the backend. * 4. Render — POST /studio/videos/{id}/export/mp4, poll, download. * * Race protection: * Every async op captures a per-mount epoch; closing the modal or * starting a new render bumps the epoch and stale responses are * dropped. Same pattern as App.tsx's account-switch guard. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, CheckCircle2, Download, Film, Loader2, Mic, Play, Square, X, Youtube, } from 'lucide-react' import { listVoices, synthesizeToBlob, speak as piperSpeak, stop as piperStop, isSupported as piperSupported, getSelectedVoiceId, setSelectedVoiceId, } from '../tts/piperTts' import { DEFAULT_PIPER_VOICE_ID } from '../tts/piperVoices' export type ExportKind = 'mp4_plain' | 'mp4_youtube' /** Minimum scene info the wizard needs. Matches the flat shape used * inside CreatorStudioEditor. */ export interface WizardScene { id: string idx: number narration: string audioUrl: string | null } interface Props { open: boolean onClose: () => void backendUrl: string videoId: string /** Ordered scenes the render will concatenate. */ scenes: WizardScene[] /** Display name shown in the modal header. */ videoTitle?: string /** Disable submit when there are no scenes or scenes are still generating. */ disabledReason?: string | null } type JobStatus = 'queued' | 'running' | 'done' | 'error' interface JobView { id: string status: JobStatus progress: number error?: string | null kind?: ExportKind } const POLL_INTERVAL_MS = 1000 const POLL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes const PREVIEW_TEXT = 'Hello, this is a preview of your selected voice.' function authHeaders(): Record { const token = localStorage.getItem('homepilot_auth_token') || '' return token ? { Authorization: `Bearer ${token}` } : {} } function trimBackend(u: string): string { return u.replace(/\/+$/, '') } type Step = 1 | 2 | 3 | 4 export default function CreatorExportWizard({ open, onClose, backendUrl, videoId, scenes, videoTitle, disabledReason, }: Props) { // ── Step ───────────────────────────────────────────────────────────────── const [step, setStep] = useState(1) // ── Step 1 state ───────────────────────────────────────────────────────── const [kind, setKind] = useState('mp4_youtube') // ── Step 2 state ───────────────────────────────────────────────────────── const voices = useMemo(() => listVoices(), []) const [voiceId, setVoiceId] = useState(() => piperSupported() ? getSelectedVoiceId() : DEFAULT_PIPER_VOICE_ID, ) const [rate, setRate] = useState(0.9) const [pitch, setPitch] = useState(1.0) const [subtitlesBurnIn, setSubtitlesBurnIn] = useState(true) // Word-by-word (CapCut/Submagic) captions synced to the narration audio's // real word timestamps. Additive; falls back to sentence cues per-scene // when a scene has no alignment data. const [wordCaptions, setWordCaptions] = useState(false) // Background fill for stills that aren't the target aspect. "blur" gives a // premium full-bleed look (no black bars); "letterbox" is the safe default. const [fillMode, setFillMode] = useState<'letterbox' | 'cover' | 'blur'>('letterbox') // Default: synthesize every scene that has narration text, even if an // audioUrl already exists. The user's invariant is "audio from all // scenes" at export time, so freshness beats speed by default. // "Keep existing audio" below is the opt-in for re-exports of an // unchanged project, where regenerating every take is wasted work. const [keepExisting, setKeepExisting] = useState(false) const [previewing, setPreviewing] = useState(false) const [previewError, setPreviewError] = useState(null) // ── Step 3 state ───────────────────────────────────────────────────────── const [genIdx, setGenIdx] = useState(0) const [genTotal, setGenTotal] = useState(0) const [genError, setGenError] = useState(null) // ── Step 4 state ───────────────────────────────────────────────────────── const [job, setJob] = useState(null) const [submitError, setSubmitError] = useState(null) // Race protection. const epochRef = useRef(0) // Reset when modal opens or closes. useEffect(() => { if (open) { setStep(1) setJob(null) setSubmitError(null) setGenIdx(0) setGenTotal(0) setGenError(null) setPreviewing(false) setPreviewError(null) } else { epochRef.current += 1 try { piperStop() } catch { /* ignore */ } } }, [open]) // Persist voice selection so subsequent renders remember the pick. useEffect(() => { if (voiceId) setSelectedVoiceId(voiceId) }, [voiceId]) const downloadUrl = useMemo(() => { if (!job || job.status !== 'done') return '' const tok = localStorage.getItem('homepilot_auth_token') || '' const qp = tok ? `?token=${encodeURIComponent(tok)}` : '' return `${trimBackend(backendUrl)}/studio/videos/${videoId}/export/jobs/${job.id}/download${qp}` }, [job, backendUrl, videoId]) // ── Step 2 — Preview ───────────────────────────────────────────────────── const handlePreview = useCallback(async () => { setPreviewError(null) if (previewing) { try { piperStop() } catch { /* ignore */ } setPreviewing(false) return } if (!piperSupported()) { setPreviewError('Piper requires a secure context (https) with Web Audio + OPFS support.') return } setPreviewing(true) try { await piperSpeak(PREVIEW_TEXT, { voiceId, rate, onEnd: () => setPreviewing(false), onError: (err) => { setPreviewError(String(err?.message || err)) setPreviewing(false) }, }) } catch (err: any) { setPreviewError(String(err?.message || err)) setPreviewing(false) } }, [previewing, voiceId, rate]) // ── Step 3 — Generate all scene narrations ─────────────────────────────── /** Scenes that need synthesis under the current toggle state. * Default ("keep existing" off): every scene with narration text. * Opt-in ("keep existing" on): only scenes that do NOT already * have an uploaded audioUrl. */ const scenesToSynthesize = useMemo( () => scenes.filter((s) => { const hasText = (s.narration || '').trim().length > 0 if (!hasText) return false if (keepExisting && s.audioUrl) return false return true }), [scenes, keepExisting], ) const handleGenerate = useCallback(async () => { if (!piperSupported()) { setGenError('Piper is not supported in this browser. Use a Chromium-based browser on https.') return } epochRef.current += 1 const myEpoch = epochRef.current setStep(3) setGenError(null) setGenIdx(0) setGenTotal(scenesToSynthesize.length) if (scenesToSynthesize.length === 0) { // Nothing to synthesize — jump straight to render. setStep(4) void startRender(myEpoch) return } for (let i = 0; i < scenesToSynthesize.length; i++) { if (epochRef.current !== myEpoch) return const scene = scenesToSynthesize[i] try { const blob = await synthesizeToBlob(scene.narration, { voiceId }) if (epochRef.current !== myEpoch) return // Upload to the backend so render_mp4 can mix it in. const fd = new FormData() fd.append('file', blob, `${scene.id}.wav`) const url = `${trimBackend(backendUrl)}/studio/videos/${videoId}/scenes/${scene.id}/narration` const r = await fetch(url, { method: 'POST', headers: { ...authHeaders() }, body: fd, }) if (epochRef.current !== myEpoch) return if (!r.ok) { const text = await r.text().catch(() => '') throw new Error(text || `Upload failed (HTTP ${r.status})`) } } catch (err: any) { if (epochRef.current !== myEpoch) return setGenError( `Scene ${scene.idx + 1}: ${String(err?.message || err)}`, ) return } setGenIdx(i + 1) } if (epochRef.current !== myEpoch) return // All done — kick off the render. setStep(4) void startRender(myEpoch) }, [scenesToSynthesize, voiceId, backendUrl, videoId]) // ── Step 4 — Render + poll ─────────────────────────────────────────────── const pollJob = useCallback( async (jobId: string, myEpoch: number) => { const url = `${trimBackend(backendUrl)}/studio/videos/${videoId}/export/jobs/${jobId}` const start = Date.now() while (Date.now() - start < POLL_TIMEOUT_MS) { if (epochRef.current !== myEpoch) return try { const r = await fetch(url, { headers: { ...authHeaders() } }) if (epochRef.current !== myEpoch) return if (!r.ok) { setJob((prev) => prev ? { ...prev, status: 'error', error: `HTTP ${r.status}` } : prev, ) return } const j = await r.json() if (epochRef.current !== myEpoch) return setJob({ id: j.id, status: j.status, progress: typeof j.progress === 'number' ? j.progress : 0, error: j.error || null, kind: j.kind, }) if (j.status === 'done' || j.status === 'error') return } catch (err: any) { if (epochRef.current !== myEpoch) return setJob((prev) => prev ? { ...prev, status: 'error', error: String(err?.message || err) } : prev, ) return } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) } if (epochRef.current === myEpoch) { setJob((prev) => prev ? { ...prev, status: 'error', error: 'Render timed out' } : prev, ) } }, [backendUrl, videoId], ) const startRender = useCallback( async (myEpoch: number) => { setSubmitError(null) setJob({ id: '', status: 'queued', progress: 0, kind }) try { const r = await fetch( `${trimBackend(backendUrl)}/studio/videos/${videoId}/export/mp4`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ kind, audio_rate: rate, audio_pitch: pitch, subtitles: subtitlesBurnIn ? 'burn_in' : 'none', caption_mode: subtitlesBurnIn && wordCaptions ? 'word' : 'sentence', fill_mode: fillMode, }), }, ) if (epochRef.current !== myEpoch) return if (!r.ok) { const text = await r.text().catch(() => '') const detail = text || `HTTP ${r.status}` setSubmitError(detail) setJob({ id: '', status: 'error', progress: 0, error: detail, kind }) return } const j = await r.json() if (epochRef.current !== myEpoch) return setJob({ id: j.id, status: j.status, progress: typeof j.progress === 'number' ? j.progress : 0, kind: j.kind, }) void pollJob(j.id, myEpoch) } catch (err: any) { if (epochRef.current !== myEpoch) return const msg = String(err?.message || err) setSubmitError(msg) setJob({ id: '', status: 'error', progress: 0, error: msg, kind }) } }, [backendUrl, videoId, kind, rate, pitch, subtitlesBurnIn, wordCaptions, fillMode, pollJob], ) if (!open) return null const submitDisabled = Boolean(disabledReason) // ── UI ─────────────────────────────────────────────────────────────────── return (
e.stopPropagation()} > {/* Header */}
Export video
{videoTitle ? (
{videoTitle}
) : null}
= 1 ? 'text-white/80' : ''}>1 · Target = 2 ? 'text-white/80' : ''}>2 · Voiceover = 3 ? 'text-white/80' : ''}>3 · Narration = 4 ? 'text-white/80' : ''}>4 · Render
{/* Body */}
{step === 1 && ( <>
Choose the encode profile. Both produce an .mp4 file you can download.
{disabledReason ? (
{disabledReason}
) : null}
)} {step === 2 && ( <>
Piper runs fully in-browser via WebAssembly. First use downloads a voice model (~20 MB, cached).
Rate {rate.toFixed(2)}
setRate(Number(e.target.value))} className="w-full accent-cyan-400" />
Pitch {pitch.toFixed(2)}
setPitch(Number(e.target.value))} className="w-full accent-cyan-400" />
{PREVIEW_TEXT}
{previewError ? (
{previewError}
) : null} {subtitlesBurnIn ? ( ) : null}
Background fill for stills
{scenesToSynthesize.length === 0 ? 'No scenes have narration text — the export will have silent audio.' : `${scenesToSynthesize.length} of ${scenes.length} scene${scenes.length === 1 ? '' : 's'} will be synthesized with the selected voice.`}
)} {step === 3 && ( <>
Synthesizing narration…
{genIdx} of {genTotal} scenes
{genError ? (
Narration synthesis failed
{genError}
) : null} )} {step === 4 && ( <> {job?.status === 'done' ? (
Render complete
Download .mp4
{job.kind === 'mp4_youtube' ? 'YouTube-optimized' : 'Plain MP4'} {subtitlesBurnIn ? ' · subtitles burned in' : ''}
) : job?.status === 'error' ? (
Render failed
{job.error || submitError || 'Unknown error'}
) : (
{job?.status === 'queued' ? 'Queued…' : 'Rendering…'}
{Math.round(job?.progress || 0)}% — this can take a minute or two
)}
)}
) }