first commit
This commit is contained in:
280
frontend/src/App.tsx
Normal file
280
frontend/src/App.tsx
Normal 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`
|
||||
}
|
||||
Reference in New Issue
Block a user