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(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 (

A-Roll Cutter

Automatically removes silence and warm-up from your recordings

{state.error && (
{state.error}
)} {/* ── Upload ── */} {state.phase === 'idle' && } {/* ── Uploading spinner ── */} {state.phase === 'uploading' && (

Uploading video...

)} {/* ── Video preview + timeline (shown as soon as we have the blob URL) ── */} {showTimeline && ( )} {/* ── Analyzing overlay (shown below the timeline while we wait) ── */} {state.phase === 'analyzing' && (

Detecting silence...

FFmpeg is scanning the audio track

)} {/* ── Controls (only once we have segments) ── */} {(state.phase === 'ready' || state.phase === 'exporting' || state.phase === 'done') && ( <> setState((p) => ({ ...p, noiseDb: v }))} onMinSilence={(v) => setState((p) => ({ ...p, minSilence: v }))} onPadding={(v) => setState((p) => ({ ...p, padding: v }))} onReanalyze={handleReanalyze} />
Keeping {fmtTime(keptDuration)} Removing {fmtTime(removedDuration)} {keptCount} segments · {fmtTime(state.duration)} total
{state.phase === 'ready' && (
)} {state.phase === 'exporting' && (

Exporting... {Math.round(state.progress)}%

)} {state.outputFile && (

Export complete!

Download
)} )}
) } 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` }