Download frontend/src/ui/components/CreatorExportWizard.jsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 26.7 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/components/CreatorExportWizard.jsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/components/CreatorExportWizard.jsx
-
curl -L -o CreatorExportWizard.jsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/components/CreatorExportWizard.jsx
26.7 kB
| /** | |
| * 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 (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4" onClick={onClose} role="dialog" aria-modal="true" aria-label="Export video"> | |
| <div className="w-full max-w-xl rounded-2xl border border-white/10 bg-[#0b0b0c] text-white shadow-2xl max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}> | |
| {/* Header */} | |
| <div className="flex items-center justify-between border-b border-white/10 px-5 py-3 flex-shrink-0"> | |
| <div> | |
| <div className="text-sm font-semibold">Export video</div> | |
| {videoTitle ? (<div className="text-xs text-white/50 truncate max-w-[28rem]">{videoTitle}</div>) : null} | |
| </div> | |
| <div className="flex items-center gap-3 text-[11px] text-white/40"> | |
| <span className={step >= 1 ? 'text-white/80' : ''}>1 Β· Target</span> | |
| <span>βΊ</span> | |
| <span className={step >= 2 ? 'text-white/80' : ''}>2 Β· Voiceover</span> | |
| <span>βΊ</span> | |
| <span className={step >= 3 ? 'text-white/80' : ''}>3 Β· Narration</span> | |
| <span>βΊ</span> | |
| <span className={step >= 4 ? 'text-white/80' : ''}>4 Β· Render</span> | |
| </div> | |
| <button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10 text-white/60 hover:text-white" aria-label="Close"> | |
| <X size={16}/> | |
| </button> | |
| </div> | |
| {/* Body */} | |
| <div className="px-5 py-5 space-y-4 overflow-y-auto"> | |
| {step === 1 && (<> | |
| <div className="text-xs text-white/60"> | |
| Choose the encode profile. Both produce an .mp4 file you can | |
| download. | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> | |
| <button type="button" onClick={() => setKind('mp4_plain')} className={[ | |
| 'text-left rounded-xl border p-4 transition-all', | |
| kind === 'mp4_plain' | |
| ? 'border-cyan-400/60 bg-cyan-400/5' | |
| : 'border-white/10 hover:border-white/30 bg-white/5', | |
| ].join(' ')} aria-pressed={kind === 'mp4_plain'}> | |
| <div className="flex items-center gap-2 font-medium"> | |
| <Film size={16} className="text-cyan-300"/> | |
| MP4 (edit / share) | |
| </div> | |
| <div className="mt-2 text-[11px] leading-relaxed text-white/60"> | |
| Balanced size and quality. Plays on any device. Good for | |
| re-editing later in another tool. | |
| </div> | |
| <div className="mt-2 text-[10px] text-white/40"> | |
| H.264 Β· CRF 20 Β· AAC 128 kbps | |
| </div> | |
| </button> | |
| <button type="button" onClick={() => setKind('mp4_youtube')} className={[ | |
| 'text-left rounded-xl border p-4 transition-all', | |
| kind === 'mp4_youtube' | |
| ? 'border-red-400/60 bg-red-400/5' | |
| : 'border-white/10 hover:border-white/30 bg-white/5', | |
| ].join(' ')} aria-pressed={kind === 'mp4_youtube'}> | |
| <div className="flex items-center gap-2 font-medium"> | |
| <Youtube size={16} className="text-red-400"/> | |
| MP4 for YouTube | |
| </div> | |
| <div className="mt-2 text-[11px] leading-relaxed text-white/60"> | |
| Matches YouTube's recommended upload spec. Faststart layout | |
| means YouTube ingests without re-mux. | |
| </div> | |
| <div className="mt-2 text-[10px] text-white/40"> | |
| H.264 high Β· CRF 18 Β· AAC 192 kbps Β· +faststart | |
| </div> | |
| </button> | |
| </div> | |
| {disabledReason ? (<div className="flex items-start gap-2 rounded-lg border border-amber-400/30 bg-amber-400/10 p-3 text-[12px] text-amber-200"> | |
| <AlertTriangle size={14} className="mt-0.5 flex-shrink-0"/> | |
| <span>{disabledReason}</span> | |
| </div>) : null} | |
| <div className="flex justify-end gap-2 pt-2"> | |
| <button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg text-sm text-white/70 hover:bg-white/10"> | |
| Cancel | |
| </button> | |
| <button type="button" onClick={() => setStep(2)} disabled={submitDisabled} className={[ | |
| 'px-3 py-1.5 rounded-lg text-sm font-medium', | |
| submitDisabled | |
| ? 'bg-white/10 text-white/40 cursor-not-allowed' | |
| : 'bg-white text-black hover:bg-white/90', | |
| ].join(' ')}> | |
| Next: Voiceover | |
| </button> | |
| </div> | |
| </>)} | |
| {step === 2 && (<> | |
| <div className="flex items-center gap-2 text-xs text-white/60"> | |
| <Mic size={14} className="text-cyan-300"/> | |
| Piper runs fully in-browser via WebAssembly. First use | |
| downloads a voice model (~20 MB, cached). | |
| </div> | |
| <div className="space-y-3"> | |
| <label className="block text-[11px] uppercase tracking-wide text-white/50"> | |
| Piper voice | |
| </label> | |
| <select value={voiceId} onChange={(e) => setVoiceId(e.target.value)} className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-sm text-white/90 focus:outline-none focus:border-white/30"> | |
| {voices.map((v) => (<option key={v.id} value={v.id}> | |
| {v.name} β {v.lang} Β· {v.gender} Β· {v.quality} | |
| </option>))} | |
| </select> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <div className="flex items-center justify-between text-[11px] uppercase tracking-wide text-white/50"> | |
| <span>Rate</span> | |
| <span className="font-mono text-white/70">{rate.toFixed(2)}</span> | |
| </div> | |
| <input type="range" min={0.5} max={2.0} step={0.05} value={rate} onChange={(e) => setRate(Number(e.target.value))} className="w-full accent-cyan-400"/> | |
| </div> | |
| <div> | |
| <div className="flex items-center justify-between text-[11px] uppercase tracking-wide text-white/50"> | |
| <span>Pitch</span> | |
| <span className="font-mono text-white/70">{pitch.toFixed(2)}</span> | |
| </div> | |
| <input type="range" min={0.5} max={2.0} step={0.05} value={pitch} onChange={(e) => setPitch(Number(e.target.value))} className="w-full accent-cyan-400"/> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button type="button" onClick={handlePreview} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-white/10 hover:bg-white/15 border border-white/10"> | |
| {previewing ? <Square size={14}/> : <Play size={14}/>} | |
| {previewing ? 'Stop preview' : 'Preview voice'} | |
| </button> | |
| <span className="text-[11px] text-white/40 truncate">{PREVIEW_TEXT}</span> | |
| </div> | |
| {previewError ? (<div className="text-[11px] text-red-300/80">{previewError}</div>) : null} | |
| <label className="flex items-center gap-2 text-sm text-white/80 cursor-pointer"> | |
| <input type="checkbox" checked={subtitlesBurnIn} onChange={(e) => setSubtitlesBurnIn(e.target.checked)} className="accent-cyan-400"/> | |
| Burn subtitles into the video (from scene narration text) | |
| </label> | |
| <label className="flex items-center gap-2 text-sm text-white/80 cursor-pointer"> | |
| <input type="checkbox" checked={keepExisting} onChange={(e) => setKeepExisting(e.target.checked)} className="accent-cyan-400"/> | |
| Keep existing narration audio where present | |
| <span className="text-[11px] text-white/40">(skip scenes that already have a take)</span> | |
| </label> | |
| <div className="text-[11px] text-white/40"> | |
| {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.`} | |
| </div> | |
| <div className="flex justify-between gap-2 pt-2"> | |
| <button type="button" onClick={() => setStep(1)} className="px-3 py-1.5 rounded-lg text-sm text-white/70 hover:bg-white/10"> | |
| Back | |
| </button> | |
| <button type="button" onClick={handleGenerate} className="px-3 py-1.5 rounded-lg text-sm font-medium bg-white text-black hover:bg-white/90"> | |
| {scenesToSynthesize.length === 0 ? 'Skip & render' : 'Generate narration'} | |
| </button> | |
| </div> | |
| </>)} | |
| {step === 3 && (<> | |
| <div className="flex items-center gap-2 text-white/80"> | |
| <Loader2 size={16} className="animate-spin"/> | |
| <span className="text-sm">Synthesizing narrationβ¦</span> | |
| </div> | |
| <div className="h-2 rounded-full bg-white/10 overflow-hidden"> | |
| <div className="h-full bg-cyan-400 transition-all" style={{ | |
| width: `${genTotal === 0 ? 0 : Math.round((genIdx / genTotal) * 100)}%`, | |
| }}/> | |
| </div> | |
| <div className="text-[11px] text-white/40 text-center"> | |
| {genIdx} of {genTotal} scenes | |
| </div> | |
| {genError ? (<div className="space-y-3"> | |
| <div className="flex items-start gap-2 rounded-lg border border-red-400/30 bg-red-400/10 p-3 text-[12px] text-red-200"> | |
| <AlertTriangle size={14} className="mt-0.5 flex-shrink-0"/> | |
| <div> | |
| <div className="font-medium mb-0.5">Narration synthesis failed</div> | |
| <div className="text-red-300/80 break-words">{genError}</div> | |
| </div> | |
| </div> | |
| <button type="button" onClick={() => setStep(2)} className="w-full px-3 py-1.5 rounded-lg text-sm bg-white/10 hover:bg-white/15"> | |
| Back to voiceover | |
| </button> | |
| </div>) : null} | |
| </>)} | |
| {step === 4 && (<> | |
| {job?.status === 'done' ? (<div className="space-y-4"> | |
| <div className="flex items-center gap-2 text-emerald-300"> | |
| <CheckCircle2 size={18}/> | |
| <span className="font-medium">Render complete</span> | |
| </div> | |
| <a href={downloadUrl} download className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-xl bg-white text-black font-medium hover:bg-white/90"> | |
| <Download size={16}/> | |
| Download .mp4 | |
| </a> | |
| <div className="text-[11px] text-white/40 text-center"> | |
| {job.kind === 'mp4_youtube' ? 'YouTube-optimized' : 'Plain MP4'} | |
| {subtitlesBurnIn ? ' Β· subtitles burned in' : ''} | |
| </div> | |
| </div>) : job?.status === 'error' ? (<div className="space-y-3"> | |
| <div className="flex items-start gap-2 rounded-lg border border-red-400/30 bg-red-400/10 p-3 text-[12px] text-red-200"> | |
| <AlertTriangle size={14} className="mt-0.5 flex-shrink-0"/> | |
| <div> | |
| <div className="font-medium mb-0.5">Render failed</div> | |
| <div className="text-red-300/80 break-words"> | |
| {job.error || submitError || 'Unknown error'} | |
| </div> | |
| </div> | |
| </div> | |
| <button type="button" onClick={() => setStep(2)} className="w-full px-3 py-1.5 rounded-lg text-sm bg-white/10 hover:bg-white/15"> | |
| Back to voiceover | |
| </button> | |
| </div>) : (<div className="space-y-3"> | |
| <div className="flex items-center gap-2 text-white/80"> | |
| <Loader2 size={16} className="animate-spin"/> | |
| <span className="text-sm"> | |
| {job?.status === 'queued' ? 'Queuedβ¦' : 'Renderingβ¦'} | |
| </span> | |
| </div> | |
| <div className="h-2 rounded-full bg-white/10 overflow-hidden"> | |
| <div className="h-full bg-cyan-400 transition-all" style={{ | |
| width: `${Math.max(2, Math.min(100, job?.progress || 0))}%`, | |
| }}/> | |
| </div> | |
| <div className="text-[11px] text-white/40 text-center"> | |
| {Math.round(job?.progress || 0)}% β this can take a minute or two | |
| </div> | |
| </div>)} | |
| <div className="flex justify-end pt-2"> | |
| <button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg text-sm text-white/70 hover:bg-white/10"> | |
| Close | |
| </button> | |
| </div> | |
| </>)} | |
| </div> | |
| </div> | |
| </div>); | |
| } | |