Spaces:
Paused
Paused
| import { useEffect, useRef, useState } from 'react' | |
| import { api, upload, runJob } from '../api' | |
| import { useJob, ProgressBox, ErrorBox } from '../hooks' | |
| import PromptPanel from '../components/PromptPanel' | |
| const PROVIDER_LABEL = { | |
| gemini_tts: 'Gemini (natural)', | |
| elevenlabs: 'ElevenLabs (most human)', | |
| edge_tts: 'edge-tts (free/robotic)', | |
| } | |
| export default function MediaStep({ reelData, refreshReel, reelId, projectId, setStep }) { | |
| const base = `/api/projects/${projectId}/reels/${reelId}` | |
| const [providers, setProviders] = useState([]) | |
| const [provider, setProvider] = useState(reelData?.voice?.provider || '') | |
| const [voices, setVoices] = useState([]) | |
| const [voice, setVoice] = useState(reelData?.voice?.voice || '') | |
| const [elevenUsage, setElevenUsage] = useState(null) // ElevenLabs credit usage | |
| const [busyLine, setBusyLine] = useState(null) | |
| const [measured, setMeasured] = useState({}) // {candId: real seconds, read from the <video>} | |
| const [error, setError] = useState('') | |
| const [previewing, setPreviewing] = useState(false) | |
| const [tracks, setTracks] = useState([]) | |
| const [musicBusy, setMusicBusy] = useState(false) | |
| const [musicQuery, setMusicQuery] = useState('') | |
| const [musicResults, setMusicResults] = useState([]) | |
| const [musicEnabled, setMusicEnabled] = useState(true) | |
| const [searching, setSearching] = useState(false) | |
| const musicInput = useRef(null) | |
| const voiceJob = useJob() | |
| // --- Custom voice (ElevenLabs clone / design) --- | |
| const [voiceMaker, setVoiceMaker] = useState(false) | |
| const [vmMode, setVmMode] = useState('clone') | |
| const [vmName, setVmName] = useState('') | |
| const [vmDesc, setVmDesc] = useState('') | |
| const [vmBusy, setVmBusy] = useState(false) | |
| const [vmPreviews, setVmPreviews] = useState([]) | |
| const vmInput = useRef(null) | |
| const refreshVoices = async (selectName) => { | |
| const v = await api.get('/api/voices?provider=elevenlabs') | |
| setVoices(v.voices || []) | |
| if (selectName) setVoice(selectName) | |
| } | |
| const cloneVoice = async (file) => { | |
| if (!file || !vmName.trim()) { setError('Give the voice a name and pick an audio sample.'); return } | |
| setVmBusy(true); setError('') | |
| try { | |
| const fd = new FormData(); fd.append('name', vmName.trim()); fd.append('description', vmDesc); fd.append('file', file) | |
| const r = await upload('/api/voices/clone', fd) | |
| await refreshVoices(r.name); setVoiceMaker(false) | |
| } catch (e) { setError(e.message) } finally { setVmBusy(false) } | |
| } | |
| const designVoice = async () => { | |
| if (!vmDesc.trim()) { setError('Describe the voice you want.'); return } | |
| setVmBusy(true); setError('') | |
| try { const r = await api.post('/api/voices/design', { description: vmDesc.trim() }); setVmPreviews(r.previews || []) } | |
| catch (e) { setError(e.message) } finally { setVmBusy(false) } | |
| } | |
| const saveDesigned = async (gid) => { | |
| if (!vmName.trim()) { setError('Name the voice before saving.'); return } | |
| setVmBusy(true); setError('') | |
| try { | |
| const r = await api.post('/api/voices/create', { name: vmName.trim(), description: vmDesc.trim(), generated_voice_id: gid }) | |
| await refreshVoices(r.name); setVmPreviews([]); setVoiceMaker(false) | |
| } catch (e) { setError(e.message) } finally { setVmBusy(false) } | |
| } | |
| // Refresh ElevenLabs credit usage whenever it's the selected provider (and after synth). | |
| useEffect(() => { | |
| if (provider === 'elevenlabs') api.get('/api/elevenlabs/usage').then(setElevenUsage).catch(() => {}) | |
| }, [provider, reelData?.voice]) | |
| const mediaJob = useJob() | |
| // Play a short sample of the selected provider+voice (cached server-side). | |
| const previewVoice = async () => { | |
| if (!voice) return | |
| setPreviewing(true) | |
| try { | |
| const { url } = await api.post('/api/voices/sample', { provider, voice }) | |
| const audio = new Audio(url) | |
| audio.onended = () => setPreviewing(false) | |
| audio.onerror = () => { setError('Could not play voice sample'); setPreviewing(false) } | |
| await audio.play() | |
| } catch (e) { | |
| setError(e.message) | |
| setPreviewing(false) | |
| } | |
| } | |
| // Load available providers + default voices once. | |
| useEffect(() => { | |
| api.get('/api/voices') | |
| .then((v) => { | |
| setProviders(v.providers || []) | |
| if (!provider) { setProvider(v.provider); setVoices(v.voices); if (!voice) setVoice(v.voices[0] || '') } | |
| }) | |
| .catch((e) => setError(`Voice list unavailable: ${e.message}`)) | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []) | |
| // Reload voices when the provider changes. | |
| const changeProvider = async (p) => { | |
| setProvider(p) | |
| try { | |
| const v = await api.get(`/api/voices?provider=${encodeURIComponent(p)}`) | |
| setVoices(v.voices) | |
| setVoice(v.voices[0] || '') | |
| } catch (e) { setError(e.message) } | |
| } | |
| const runVoice = async () => { | |
| try { | |
| await voiceJob.start(`${base}/voice`, { voice, provider }) | |
| await refreshReel() | |
| } catch { /* shown */ } | |
| } | |
| const runMedia = async () => { | |
| try { | |
| await mediaJob.start(`${base}/media`) | |
| await refreshReel() | |
| } catch { /* shown */ } | |
| } | |
| // Load music tracks (with preview URLs) once. | |
| const [items, setItems] = useState([]) | |
| useEffect(() => { api.get('/api/music').then((d) => { setTracks(d.tracks || []); setItems(d.items || []) }).catch(() => {}) }, []) | |
| const audioRef = useRef(null) | |
| const [previewUrl, setPreviewUrl] = useState('') | |
| const previewTrack = (url) => { | |
| if (audioRef.current) { audioRef.current.pause(); audioRef.current = null } | |
| if (previewUrl === url) { setPreviewUrl(''); return } // clicking the playing one stops it | |
| const a = new Audio(url); a.volume = vol ?? 0.5; a.onended = () => setPreviewUrl('') | |
| a.play().catch(() => {}); audioRef.current = a; setPreviewUrl(url) | |
| } | |
| const selectClip = async (lineIndex, index) => { | |
| setBusyLine(lineIndex) | |
| try { | |
| await api.post(`${base}/media/select`, { line_index: lineIndex, index }) | |
| await refreshReel() | |
| } catch (e) { setError(e.message) } finally { setBusyLine(null) } | |
| } | |
| const uploadClip = async (lineIndex, file) => { | |
| if (!file) return | |
| setBusyLine(lineIndex) | |
| try { | |
| const fd = new FormData() | |
| fd.append('line_index', lineIndex) | |
| fd.append('file', file) | |
| await upload(`${base}/media/upload-clip`, fd) | |
| await refreshReel() | |
| } catch (e) { setError(e.message) } finally { setBusyLine(null) } | |
| } | |
| const [videogen, setVideogen] = useState(false) | |
| const [genMsg, setGenMsg] = useState('') | |
| useEffect(() => { api.get('/api/health').then((h) => setVideogen(!!h.videogen)).catch(() => {}) }, []) | |
| const [clipPrompts, setClipPrompts] = useState({}) // {lineIndex: AI-video prompt override} | |
| const generateClip = async (lineIndex) => { | |
| setBusyLine(lineIndex); setError(''); setGenMsg('Starting AI generation…') | |
| try { | |
| await runJob(`${base}/media/generate-clip`, { line_index: lineIndex, prompt: clipPrompts[lineIndex] ?? null }, (p) => setGenMsg(p[p.length - 1] || 'Generating…')) | |
| await refreshReel() | |
| } catch (e) { setError(e.message) } finally { setBusyLine(null); setGenMsg('') } | |
| } | |
| // Ask Gemini to write a detailed AI-video prompt for every scene in one call, | |
| // then pre-fill each scene's prompt box so you can generate clips from them. | |
| const [promptsBusy, setPromptsBusy] = useState(false) | |
| const [promptsOpen, setPromptsOpen] = useState({}) // {lineIndex: bool} — reveal the AI prompt panels | |
| const writeAllPrompts = async () => { | |
| setPromptsBusy(true); setError(''); setGenMsg('Writing AI-video prompts for all scenes…') | |
| try { | |
| const res = await runJob(`${base}/media/ai-prompts`, {}, (p) => setGenMsg(p[p.length - 1] || 'Writing…')) | |
| const prompts = res?.prompts || {} | |
| const byLine = Object.fromEntries(Object.entries(prompts).map(([k, v]) => [Number(k), v])) | |
| setClipPrompts((prev) => ({ ...prev, ...byLine })) | |
| // Auto-open each scene's prompt panel so the generated prompts are visible. | |
| setPromptsOpen((prev) => ({ ...prev, ...Object.fromEntries(Object.keys(byLine).map((k) => [k, true])) })) | |
| } catch (e) { setError(e.message) } finally { setPromptsBusy(false); setGenMsg('') } | |
| } | |
| const pickMusic = async (filename) => { | |
| setMusicBusy(true) | |
| try { | |
| await api.put(`${base}/music`, { filename }) | |
| await refreshReel() | |
| } catch (e) { setError(e.message) } finally { setMusicBusy(false) } | |
| } | |
| const [vol, setVol] = useState(null) // null until media loaded | |
| useEffect(() => { | |
| if (reelData?.media && vol === null) setVol(reelData.media.music_volume ?? 0.15) | |
| }, [reelData?.media, vol]) | |
| const saveVolume = async (v) => { | |
| try { await api.put(`${base}/music`, { volume: v }); await refreshReel() } catch (e) { setError(e.message) } | |
| } | |
| const searchMusic = async () => { | |
| setSearching(true) | |
| setError('') | |
| try { | |
| const r = await api.get(`/api/music/search?q=${encodeURIComponent(musicQuery)}`) | |
| setMusicResults(r.results || []) | |
| setMusicEnabled(r.enabled) | |
| if (!r.enabled) setError('Set JAMENDO_CLIENT_ID in .env to search free music.') | |
| } catch (e) { setError(e.message) } finally { setSearching(false) } | |
| } | |
| const useTrack = async (track) => { | |
| setMusicBusy(true) | |
| try { | |
| const res = await api.post('/api/music/import', { track }) | |
| setTracks(res.tracks || []); setItems(res.items || []) | |
| await pickMusic(res.filename) // import + select | |
| setMusicResults([]) | |
| } catch (e) { setError(e.message); setMusicBusy(false) } | |
| } | |
| const uploadMusic = async (file) => { | |
| if (!file) return | |
| setMusicBusy(true) | |
| try { | |
| const fd = new FormData() | |
| fd.append('file', file) | |
| const res = await upload('/api/music/upload', fd) | |
| setTracks(res.tracks || []); setItems(res.items || []) | |
| await pickMusic(res.filename) // auto-select what was just uploaded | |
| } catch (e) { setError(e.message); setMusicBusy(false) } | |
| } | |
| const [musicGenPrompt, setMusicGenPrompt] = useState('') | |
| const [musicGenLen, setMusicGenLen] = useState(20) | |
| const generateMusic = async () => { | |
| if (!musicGenPrompt.trim()) return | |
| setMusicBusy(true); setError('') | |
| try { | |
| const res = await api.post('/api/music/generate', { prompt: musicGenPrompt.trim(), length_sec: musicGenLen }) | |
| setTracks(res.tracks || []); setItems(res.items || []) | |
| await pickMusic(res.filename); setMusicGenPrompt('') | |
| } catch (e) { setError(e.message) } finally { setMusicBusy(false) } | |
| } | |
| const lineLabel = (idx) => { | |
| const line = (reelData?.voice?.lines || []).find((l) => l.index === idx) | |
| return line ? line.text : `Line ${idx + 1}` | |
| } | |
| return ( | |
| <div> | |
| <h2>Voice & media</h2> | |
| <p className="subtitle">Synthesize the narration first — its true timing drives everything — then gather clips per scene.</p> | |
| <ErrorBox error={error || voiceJob.error || mediaJob.error} /> | |
| <div className="card"> | |
| <div className="row"> | |
| <label className="row" style={{ gap: 8 }}> | |
| <span className="muted">Voice provider:</span> | |
| <select value={provider} onChange={(e) => changeProvider(e.target.value)}> | |
| {providers.map((p) => <option key={p} value={p}>{PROVIDER_LABEL[p] || p}</option>)} | |
| </select> | |
| </label> | |
| <label className="row grow" style={{ gap: 8 }}> | |
| <span className="muted">Voice:</span> | |
| <select className="grow" value={voice} onChange={(e) => setVoice(e.target.value)}> | |
| {voices.map((v) => <option key={v} value={v}>{v}</option>)} | |
| </select> | |
| </label> | |
| <button className="btn secondary" onClick={previewVoice} disabled={previewing || !voice} | |
| title="Hear a short sample of this voice"> | |
| {previewing ? '♪ Playing…' : '▶ Preview'} | |
| </button> | |
| <button className="btn" onClick={runVoice} disabled={voiceJob.running}> | |
| {voiceJob.running ? 'Synthesizing…' : reelData?.voice ? '↻ Re-synthesize' : '1. Synthesize voice'} | |
| </button> | |
| </div> | |
| {provider === 'elevenlabs' && (() => { | |
| const scriptChars = (reelData?.script?.scenes || []).reduce((n, s) => n + (s.narration || '').length, 0) | |
| const cost = Math.round(scriptChars * 0.5) // turbo_v2_5 ≈ 0.5 credit/char | |
| const u = elevenUsage | |
| const short = u?.available && u.limit > 0 && cost > u.remaining | |
| return ( | |
| <div className="muted" style={{ marginTop: 4, fontSize: 13 }}> | |
| {scriptChars > 0 && ( | |
| <p style={{ margin: 0 }}> | |
| 📝 <strong>This synthesis:</strong> {scriptChars.toLocaleString()} chars → ≈ <strong>{cost.toLocaleString()} credits</strong> (turbo bills ~½/char) | |
| {short && <span style={{ color: 'var(--bad)' }}> · ⚠ exceeds your remaining credits</span>} | |
| </p> | |
| )} | |
| {u?.available && u.limit > 0 && ( | |
| <p style={{ margin: 0 }}> | |
| ⚡ <strong>Balance:</strong> {u.used.toLocaleString()} / {u.limit.toLocaleString()} used · {u.remaining.toLocaleString()} left | |
| {' '}({Math.max(0, 100 - Math.round((u.used / u.limit) * 100))}%){u.tier ? ` · ${u.tier}` : ''} | |
| </p> | |
| )} | |
| </div> | |
| ) | |
| })()} | |
| {provider === 'elevenlabs' && !providers.includes('elevenlabs') && ( | |
| <p className="muted">Add an ELEVENLABS_API_KEY in .env to enable ElevenLabs.</p> | |
| )} | |
| {provider === 'elevenlabs' && providers.includes('elevenlabs') && ( | |
| <div style={{ marginTop: 6 }}> | |
| <button className="btn small secondary" onClick={() => setVoiceMaker((v) => !v)}> | |
| {voiceMaker ? '× Close' : '+ Create a custom voice (clone / design)'} | |
| </button> | |
| {voiceMaker && ( | |
| <div className="card" style={{ marginTop: 6, background: 'var(--panel-2)' }}> | |
| <div className="row" style={{ gap: 6 }}> | |
| <button className={`btn small ${vmMode === 'clone' ? '' : 'secondary'}`} onClick={() => setVmMode('clone')}>Clone from sample</button> | |
| <button className={`btn small ${vmMode === 'design' ? '' : 'secondary'}`} onClick={() => setVmMode('design')}>Design from description</button> | |
| </div> | |
| <input type="text" className="grow" style={{ marginTop: 8 }} placeholder="Voice name (e.g. My Voice)" | |
| value={vmName} onChange={(e) => setVmName(e.target.value)} /> | |
| {vmMode === 'clone' ? ( | |
| <div style={{ marginTop: 6 }}> | |
| <p className="muted" style={{ margin: '0 0 6px', fontSize: 12 }}>Upload ~30s–1min of clean speech (your own voice, or one you have rights to). It becomes a narration voice.</p> | |
| <input ref={vmInput} type="file" accept="audio/*" style={{ display: 'none' }} onChange={(e) => cloneVoice(e.target.files?.[0])} /> | |
| <button className="btn small" disabled={vmBusy || !vmName.trim()} onClick={() => vmInput.current?.click()}> | |
| {vmBusy ? 'Cloning…' : '⬆ Upload sample & clone'} | |
| </button> | |
| </div> | |
| ) : ( | |
| <div style={{ marginTop: 6 }}> | |
| <input type="text" className="grow" placeholder="Describe the voice — e.g. 'energetic gravelly male sports announcer'" | |
| value={vmDesc} onChange={(e) => setVmDesc(e.target.value)} /> | |
| <button className="btn small" style={{ marginTop: 6 }} disabled={vmBusy || !vmDesc.trim()} onClick={designVoice}> | |
| {vmBusy ? 'Designing…' : '✨ Generate previews'} | |
| </button> | |
| {vmPreviews.map((p, i) => ( | |
| <div key={p.generated_voice_id || i} className="row" style={{ marginTop: 6, gap: 6 }}> | |
| <span className="muted">Preview {i + 1}</span> | |
| {p.audio_base_64 && <audio src={`data:audio/mpeg;base64,${p.audio_base_64}`} controls preload="none" style={{ height: 30 }} />} | |
| <button className="btn small" disabled={vmBusy || !vmName.trim()} onClick={() => saveDesigned(p.generated_voice_id)}>Use this</button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {voiceJob.running && <div style={{ marginTop: 10 }}><ProgressBox progress={voiceJob.progress.length ? voiceJob.progress : ['Starting…']} /></div>} | |
| {reelData?.voice && ( | |
| <p className="muted" style={{ marginBottom: 0 }}> | |
| ✓ {reelData.voice.lines.length} lines · {reelData.voice.total_duration_sec.toFixed(1)}s · {reelData.voice.provider} / {reelData.voice.voice || 'default'} | |
| </p> | |
| )} | |
| </div> | |
| <div className="card"> | |
| <div className="row"> | |
| <span className="grow muted">Stock clips per scene (pick a thumbnail, or upload your own), SFX, and background music.</span> | |
| <button className="btn" onClick={runMedia} disabled={!reelData?.voice || mediaJob.running}> | |
| {mediaJob.running ? 'Gathering…' : reelData?.media ? '↻ Re-gather media' : '2. Gather media'} | |
| </button> | |
| </div> | |
| {!reelData?.voice && <p className="muted">Synthesize the voice first.</p>} | |
| {mediaJob.running && <div style={{ marginTop: 10 }}><ProgressBox progress={mediaJob.progress.length ? mediaJob.progress : ['Starting…']} /></div>} | |
| </div> | |
| {reelData?.media && ( | |
| <> | |
| {videogen && ( | |
| <div className="row" style={{ margin: '4px 0 12px', gap: 8, alignItems: 'center' }}> | |
| <button className="btn small" onClick={writeAllPrompts} disabled={promptsBusy || busyLine !== null}> | |
| {promptsBusy ? <span className="spinner" /> : '✨'} Write AI-video prompts (all scenes) | |
| </button> | |
| <span className="muted" style={{ fontSize: 12 }}> | |
| Then hit “Generate” on any scene. AI clips are free but best-effort (~1–3 min each). | |
| </span> | |
| </div> | |
| )} | |
| {reelData.media.scenes.map((sm) => { | |
| const sel = sm.candidates[sm.selected] | |
| const event = (reelData.script?.scenes || []).find((s) => s.visual_query === sm.query)?.event_info | |
| return ( | |
| <div key={sm.line_index} className="card"> | |
| <p style={{ fontSize: 14, margin: '0 0 2px' }}><strong>Scene {sm.line_index + 1}</strong> — {lineLabel(sm.line_index)}</p> | |
| <p className="muted" style={{ margin: '0 0 6px' }}> | |
| search: “{sm.query}”{sm.sfx_path ? ' · 🔊 SFX' : ''} | |
| {sel?.credit ? ` · using: ${sel.credit}` : ''} | |
| </p> | |
| {event && <p className="muted" style={{ margin: '0 0 10px', color: 'var(--warn)' }}>📌 Real event: {event}</p>} | |
| <div className="clip-picker"> | |
| {sm.candidates.map((c, idx) => ( | |
| <button key={c.id} className={`clip-option ${idx === sm.selected ? 'chosen' : ''}`} | |
| disabled={busyLine === sm.line_index} | |
| onClick={() => selectClip(sm.line_index, idx)} | |
| title={c.credit}> | |
| {c.local_url | |
| ? <video src={c.local_url} muted loop | |
| onMouseEnter={(e) => e.target.play()} onMouseLeave={(e) => { e.target.pause(); e.target.currentTime = 0 }} /> | |
| : c.preview_url | |
| ? <img src={c.preview_url} alt="" /> | |
| : <span className="clip-blank">clip</span>} | |
| {idx === sm.selected && <span className="chosen-badge">✓</span>} | |
| {c.source === 'upload' && <span className="src-badge">yours</span>} | |
| {(measured[c.id] || c.duration_sec) > 0 && ( | |
| <span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.7)', | |
| color: '#fff', fontSize: 10, padding: '1px 5px', borderRadius: 4 }}> | |
| {(measured[c.id] || c.duration_sec).toFixed(1)}s | |
| </span> | |
| )} | |
| </button> | |
| ))} | |
| <label className="clip-option upload-tile"> | |
| <input type="file" accept="video/*" style={{ display: 'none' }} | |
| disabled={busyLine === sm.line_index} | |
| onChange={(e) => uploadClip(sm.line_index, e.target.files?.[0])} /> | |
| {busyLine === sm.line_index ? <span className="spinner" /> : <span>⬆<br />Upload</span>} | |
| </label> | |
| {videogen && ( | |
| <button className="clip-option upload-tile" disabled={busyLine === sm.line_index} | |
| title={`Generate a clip from: ${sm.query}`} onClick={() => generateClip(sm.line_index)}> | |
| {busyLine === sm.line_index ? <span className="spinner" /> : <span>✨<br />Generate</span>} | |
| </button> | |
| )} | |
| </div> | |
| {busyLine === sm.line_index && genMsg && <p className="muted" style={{ margin: '4px 0 0' }}>{genMsg}</p>} | |
| {videogen && ( | |
| <PromptPanel label="AI-video" disabled={busyLine === sm.line_index} | |
| value={clipPrompts[sm.line_index] ?? null} | |
| setValue={(v) => setClipPrompts((p) => ({ ...p, [sm.line_index]: v }))} | |
| open={!!promptsOpen[sm.line_index]} | |
| setOpen={(v) => setPromptsOpen((p) => ({ ...p, [sm.line_index]: v }))} | |
| fetchPrompt={async () => (await runJob(`${base}/media/clip-prompt`, { line_index: sm.line_index })).prompt} /> | |
| )} | |
| {sel?.local_url && ( | |
| <div className="row" style={{ marginTop: 10, gap: 12, alignItems: 'flex-start' }}> | |
| <video key={sel.local_url} src={sel.local_url} controls playsInline preload="metadata" | |
| onLoadedMetadata={(e) => setMeasured((m) => ({ ...m, [sel.id]: e.target.duration }))} | |
| style={{ width: 150, aspectRatio: '9/16', objectFit: 'cover', borderRadius: 8, background: '#000' }} /> | |
| <div className="muted" style={{ fontSize: 13 }}> | |
| <div><strong>Selected clip — full, untrimmed</strong></div> | |
| <div>Length: {(measured[sel.id] || sel.duration_sec || 0).toFixed(1)}s</div> | |
| <div>This scene needs {((reelData?.voice?.lines || []).find((l) => l.index === sm.line_index)?.duration_sec || 0).toFixed(1)}s of it.</div> | |
| <div style={{ marginTop: 4 }}>Play it here to confirm the moment is in the clip. Choose <em>where</em> that slice starts on the Effects/preview step.</div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| {/* Background music */} | |
| <div className="card"> | |
| <div className="row"> | |
| <strong className="grow">🎵 Background music</strong> | |
| <button className={`btn small ${!reelData.media.music_path ? '' : 'secondary'}`} | |
| onClick={() => pickMusic('')}>None</button> | |
| <input ref={musicInput} type="file" accept="audio/*" style={{ display: 'none' }} | |
| onChange={(e) => uploadMusic(e.target.files?.[0])} /> | |
| <button className="btn small secondary" disabled={musicBusy} | |
| onClick={() => musicInput.current?.click()}>⬆ Upload track</button> | |
| {musicBusy && <span className="spinner" />} | |
| </div> | |
| {/* Audition + pick each track */} | |
| {items.map((it) => { | |
| const chosen = (reelData.media.music_path || '').endsWith(it.name) | |
| return ( | |
| <div key={it.name} className={`topic-row ${chosen ? 'selected' : ''}`}> | |
| <button className="btn small secondary" title="Preview / stop" onClick={() => previewTrack(it.url)}> | |
| {previewUrl === it.url ? '⏹' : '▶'}</button> | |
| <span className="grow">{it.name}</span> | |
| <button className={`btn small ${chosen ? '' : 'secondary'}`} | |
| disabled={musicBusy} onClick={() => pickMusic(it.name)}>{chosen ? '✓ Using' : 'Use'}</button> | |
| </div> | |
| ) | |
| })} | |
| {reelData.media.music_credit && ( | |
| <p className="muted" style={{ margin: '6px 0 0' }}>Credit: {reelData.media.music_credit}</p> | |
| )} | |
| {reelData.media.music_path && ( | |
| <div className="row" style={{ marginTop: 8 }}> | |
| <span className="muted">Volume: {Math.round((vol ?? 0.15) * 100)}%</span> | |
| <input type="range" min="0" max="1" step="0.01" className="grow" | |
| value={vol ?? 0.15} | |
| onChange={(e) => setVol(Number(e.target.value))} | |
| onMouseUp={(e) => saveVolume(Number(e.target.value))} | |
| onTouchEnd={(e) => saveVolume(Number(e.target.value))} /> | |
| </div> | |
| )} | |
| {/* Generate music (ElevenLabs) */} | |
| <div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 12 }}> | |
| <div className="row"> | |
| <span className="muted">✨ Generate music:</span> | |
| <input type="text" className="grow" placeholder="e.g. epic orchestral build for a goal montage" | |
| value={musicGenPrompt} onChange={(e) => setMusicGenPrompt(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && generateMusic()} /> | |
| <label className="muted" style={{ whiteSpace: 'nowrap' }}>{musicGenLen}s | |
| <input type="range" min="5" max="60" step="1" value={musicGenLen} onChange={(e) => setMusicGenLen(Number(e.target.value))} /></label> | |
| <button className="btn small" onClick={generateMusic} disabled={musicBusy || !musicGenPrompt.trim()} | |
| title="Generate a track with ElevenLabs (uses credits). Check commercial-use terms before publishing."> | |
| {musicBusy ? '✨ Generating…' : '✨ Generate'} | |
| </button> | |
| </div> | |
| <p className="muted" style={{ margin: '4px 0 0', fontSize: 12 }}>Needs ELEVENLABS_API_KEY · check ElevenLabs’ commercial-use terms before publishing generated music.</p> | |
| </div> | |
| {/* Search royalty-free music (Jamendo) */} | |
| <div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 12 }}> | |
| <div className="row"> | |
| <span className="muted">🔎 Search free music:</span> | |
| <input type="text" className="grow" placeholder="e.g. upbeat sport, epic, lo-fi" | |
| value={musicQuery} onChange={(e) => setMusicQuery(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && searchMusic()} /> | |
| <button className="btn small secondary" onClick={searchMusic} disabled={searching}> | |
| {searching ? 'Searching…' : 'Search'} | |
| </button> | |
| </div> | |
| {!musicEnabled && ( | |
| <p className="muted" style={{ marginBottom: 0 }}> | |
| Add a free JAMENDO_CLIENT_ID in .env (developer.jamendo.com) to search royalty-free music in-app. | |
| </p> | |
| )} | |
| {musicResults.map((t) => ( | |
| <div key={t.id} className="topic-row"> | |
| <div className="grow"> | |
| <strong>{t.title}</strong> <span className="muted">— {t.artist}</span> | |
| <div className="topic-meta">{t.license} · {Math.round(t.duration_sec)}s</div> | |
| </div> | |
| {t.preview_url && <audio src={t.preview_url} controls preload="none" style={{ height: 32 }} />} | |
| <button className="btn small" disabled={musicBusy} onClick={() => useTrack(t)}>Use</button> | |
| </div> | |
| ))} | |
| </div> | |
| {tracks.length === 0 && musicResults.length === 0 && ( | |
| <p className="muted" style={{ marginBottom: 0, marginTop: 8 }}> | |
| No tracks yet — search Jamendo above, upload one, or drop files into assets/music/. | |
| (Trending/licensed audio is added in-app at posting time, never embedded. Credit CC-BY tracks in your post.) | |
| </p> | |
| )} | |
| </div> | |
| <button className="btn" onClick={() => setStep(5)}>Continue to Effects →</button> | |
| </> | |
| )} | |
| </div> | |
| ) | |
| } | |