reel-studio-2 / web /src /steps /MediaStep.jsx
xvepkj's picture
Remove music/upscale/talking-head Space features; keep verified translate
b1e2b66
Raw
History Blame
29 kB
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 &amp; 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>
)
}