first commit

This commit is contained in:
2026-02-17 17:15:39 -08:00
parent 1bf9c29f09
commit b70ea7e877
25 changed files with 4145 additions and 0 deletions

280
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,280 @@
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: '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 '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: [], 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 }),
})
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.phase === 'done' && 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`
}