289 lines
9.4 KiB
TypeScript
289 lines
9.4 KiB
TypeScript
import { useState, useCallback } from 'react'
|
|
import { useWebSocket } from './hooks/useWebSocket'
|
|
import Upload from './components/Upload'
|
|
import VideoTimeline from './components/VideoTimeline'
|
|
import Controls from './components/Controls'
|
|
|
|
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`
|
|
|
|
type WsMsg =
|
|
| { type: 'segments'; segments: { start: number; end: number }[]; duration: number }
|
|
| { type: 'progress'; percent: number }
|
|
| { type: 'done'; message: string }
|
|
| { type: 'timeline'; segments: { start: number; end: number }[]; duration: number }
|
|
| { type: 'error'; message: string }
|
|
|
|
type Phase = 'idle' | 'uploading' | 'analyzing' | 'ready' | 'exporting' | 'done'
|
|
|
|
interface Segment {
|
|
start: number
|
|
end: number
|
|
kept: boolean
|
|
}
|
|
|
|
interface State {
|
|
phase: Phase
|
|
filename: string | null
|
|
videoUrl: string | null // blob URL for in-browser preview + thumbnail extraction
|
|
duration: number
|
|
segments: Segment[]
|
|
progress: number
|
|
outputFile: string | null
|
|
error: string | null
|
|
noiseDb: number
|
|
minSilence: number
|
|
padding: number
|
|
}
|
|
|
|
const INITIAL: State = {
|
|
phase: 'idle',
|
|
filename: null,
|
|
videoUrl: null,
|
|
duration: 0,
|
|
segments: [],
|
|
progress: 0,
|
|
outputFile: null,
|
|
error: null,
|
|
noiseDb: -30,
|
|
minSilence: 0.5,
|
|
padding: 0.1,
|
|
}
|
|
|
|
export default function App() {
|
|
const [state, setState] = useState<State>(INITIAL)
|
|
|
|
const handleMessage = useCallback((raw: unknown) => {
|
|
const msg = raw as WsMsg
|
|
setState((prev) => {
|
|
switch (msg.type) {
|
|
case 'segments':
|
|
return {
|
|
...prev,
|
|
phase: 'ready',
|
|
duration: msg.duration,
|
|
segments: msg.segments.map((s) => ({ ...s, kept: true })),
|
|
}
|
|
case 'progress':
|
|
return { ...prev, progress: msg.percent }
|
|
case 'done':
|
|
return { ...prev, phase: 'done', outputFile: msg.message, progress: 100 }
|
|
case 'timeline':
|
|
return {
|
|
...prev,
|
|
phase: 'ready',
|
|
duration: msg.duration,
|
|
segments: msg.segments.map((s) => ({ ...s, kept: true })),
|
|
}
|
|
case 'error':
|
|
return { ...prev, phase: 'ready', error: msg.message }
|
|
default:
|
|
return prev
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
useWebSocket(WS_URL, handleMessage)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Actions
|
|
// ------------------------------------------------------------------
|
|
|
|
const runAnalyze = async (
|
|
filename: string,
|
|
noiseDb: number,
|
|
minSilence: number,
|
|
padding: number,
|
|
) => {
|
|
setState((prev) => ({ ...prev, phase: 'analyzing', segments: [], outputFile: null, error: null }))
|
|
try {
|
|
const res = await fetch('/analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename, noiseDb, minSilence, padding }),
|
|
})
|
|
if (!res.ok) throw new Error(await res.text())
|
|
// Segments stream back via WebSocket → handleMessage
|
|
} catch (e) {
|
|
setState((prev) => ({ ...prev, phase: 'ready', error: String(e) }))
|
|
}
|
|
}
|
|
|
|
const handleUpload = async (file: File) => {
|
|
const videoUrl = URL.createObjectURL(file)
|
|
setState((prev) => ({ ...prev, phase: 'uploading', error: null, videoUrl }))
|
|
try {
|
|
const form = new FormData()
|
|
form.append('video', file)
|
|
const res = await fetch('/upload', { method: 'POST', body: form })
|
|
if (!res.ok) throw new Error(await res.text())
|
|
const { filename } = (await res.json()) as { filename: string }
|
|
setState((prev) => ({ ...prev, filename }))
|
|
await runAnalyze(filename, state.noiseDb, state.minSilence, state.padding)
|
|
} catch (e) {
|
|
setState((prev) => ({ ...prev, phase: 'idle', error: String(e) }))
|
|
}
|
|
}
|
|
|
|
const handleReanalyze = () => {
|
|
if (!state.filename) return
|
|
runAnalyze(state.filename, state.noiseDb, state.minSilence, state.padding)
|
|
}
|
|
|
|
const handleToggle = (index: number) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
segments: prev.segments.map((s, i) => (i === index ? { ...s, kept: !s.kept } : s)),
|
|
}))
|
|
}
|
|
|
|
const handleExport = async () => {
|
|
if (!state.filename) return
|
|
const kept = state.segments.filter((s) => s.kept).map(({ start, end }) => ({ start, end }))
|
|
setState((prev) => ({ ...prev, phase: 'exporting', progress: 0, outputFile: null, error: null }))
|
|
try {
|
|
const res = await fetch('/export', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: state.filename, segments: kept, duration: state.duration }),
|
|
})
|
|
if (!res.ok) throw new Error(await res.text())
|
|
} catch (e) {
|
|
setState((prev) => ({ ...prev, phase: 'ready', error: String(e) }))
|
|
}
|
|
}
|
|
|
|
const handleReset = () => {
|
|
if (state.videoUrl) URL.revokeObjectURL(state.videoUrl)
|
|
setState(INITIAL)
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Derived
|
|
// ------------------------------------------------------------------
|
|
|
|
const keptDuration = state.segments
|
|
.filter((s) => s.kept)
|
|
.reduce((acc, s) => acc + (s.end - s.start), 0)
|
|
const removedDuration = state.duration - keptDuration
|
|
const keptCount = state.segments.filter((s) => s.kept).length
|
|
|
|
// Show the video timeline as soon as we have the blob URL
|
|
const showTimeline = state.videoUrl !== null && state.phase !== 'idle'
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="app-header">
|
|
<h1>A-Roll Cutter</h1>
|
|
<p>Automatically removes silence and warm-up from your recordings</p>
|
|
</header>
|
|
|
|
<main className="app-main">
|
|
{state.error && (
|
|
<div className="error-banner">
|
|
<span>{state.error}</span>
|
|
<button onClick={() => setState((p) => ({ ...p, error: null }))}>✕</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Upload ── */}
|
|
{state.phase === 'idle' && <Upload onUpload={handleUpload} />}
|
|
|
|
{/* ── Uploading spinner ── */}
|
|
{state.phase === 'uploading' && (
|
|
<div className="status-card">
|
|
<div className="spinner" />
|
|
<p>Uploading video...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Video preview + timeline (shown as soon as we have the blob URL) ── */}
|
|
{showTimeline && (
|
|
<VideoTimeline
|
|
videoUrl={state.videoUrl!}
|
|
duration={state.duration}
|
|
segments={state.segments}
|
|
onToggle={handleToggle}
|
|
disabled={state.phase !== 'ready'}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Analyzing overlay (shown below the timeline while we wait) ── */}
|
|
{state.phase === 'analyzing' && (
|
|
<div className="status-card status-card--inline">
|
|
<div className="spinner" />
|
|
<p>Detecting silence...</p>
|
|
<p className="status-sub">FFmpeg is scanning the audio track</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Controls (only once we have segments) ── */}
|
|
{(state.phase === 'ready' || state.phase === 'exporting' || state.phase === 'done') && (
|
|
<>
|
|
<Controls
|
|
noiseDb={state.noiseDb}
|
|
minSilence={state.minSilence}
|
|
padding={state.padding}
|
|
disabled={state.phase !== 'ready'}
|
|
onNoiseDb={(v) => setState((p) => ({ ...p, noiseDb: v }))}
|
|
onMinSilence={(v) => setState((p) => ({ ...p, minSilence: v }))}
|
|
onPadding={(v) => setState((p) => ({ ...p, padding: v }))}
|
|
onReanalyze={handleReanalyze}
|
|
/>
|
|
|
|
<div className="stats">
|
|
<span className="stat stat-kept">Keeping {fmtTime(keptDuration)}</span>
|
|
<span className="stat stat-removed">Removing {fmtTime(removedDuration)}</span>
|
|
<span className="stat stat-total">
|
|
{keptCount} segments · {fmtTime(state.duration)} total
|
|
</span>
|
|
</div>
|
|
|
|
{state.phase === 'ready' && (
|
|
<div className="actions">
|
|
<button className="btn-secondary" onClick={handleReset}>
|
|
Start Over
|
|
</button>
|
|
<button
|
|
className="btn-primary"
|
|
onClick={handleExport}
|
|
disabled={keptCount === 0}
|
|
>
|
|
Export
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{state.phase === 'exporting' && (
|
|
<div className="progress-section">
|
|
<p className="progress-label">Exporting... {Math.round(state.progress)}%</p>
|
|
<div className="progress-track">
|
|
<div className="progress-fill" style={{ width: `${state.progress}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{state.outputFile && (
|
|
<div className="done-panel">
|
|
<span className="done-check">✓</span>
|
|
<p>Export complete!</p>
|
|
<a href={`/download/${state.outputFile}`} download className="btn-primary">
|
|
Download
|
|
</a>
|
|
<button className="btn-secondary" onClick={handleReset}>
|
|
Process Another
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function fmtTime(s: number): string {
|
|
const m = Math.floor(s / 60)
|
|
const sec = Math.floor(s % 60)
|
|
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
|
|
}
|