/** * 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'; 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() { const token = localStorage.getItem('homepilot_auth_token') || ''; return token ? { Authorization: `Bearer ${token}` } : {}; } function trimBackend(u) { return u.replace(/\/+$/, ''); } export default function CreatorExportWizard({ open, onClose, backendUrl, videoId, scenes, videoTitle, disabledReason, }) { // ── 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); // 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) { 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) { 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, myEpoch) => { 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) { 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) => { 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', }), }); 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) { 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, pollJob]); if (!open) return null; const submitDisabled = Boolean(disabledReason); // ── UI ─────────────────────────────────────────────────────────────────── return (