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

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A-Roll Cutter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1726
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "aroll-cutter",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.4.2"
}
}

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

View File

@@ -0,0 +1,94 @@
interface Props {
noiseDb: number
minSilence: number
padding: number
disabled: boolean
onNoiseDb: (v: number) => void
onMinSilence: (v: number) => void
onPadding: (v: number) => void
onReanalyze: () => void
}
export default function Controls({
noiseDb,
minSilence,
padding,
disabled,
onNoiseDb,
onMinSilence,
onPadding,
onReanalyze,
}: Props) {
return (
<div className="controls">
<div className="control-group">
<label>
Noise threshold
<span className="control-value">{noiseDb} dB</span>
</label>
<input
type="range"
min={-60}
max={-10}
step={1}
value={noiseDb}
disabled={disabled}
onChange={(e) => onNoiseDb(Number(e.target.value))}
/>
<div className="control-hint">
<span>Quiet (60)</span>
<span>Loud (10)</span>
</div>
<p className="control-tip">
Lower = only very quiet gaps removed. Raise if pauses are not being caught.
</p>
</div>
<div className="control-group">
<label>
Min silence length
<span className="control-value">{minSilence.toFixed(1)}s</span>
</label>
<input
type="range"
min={0.1}
max={3.0}
step={0.1}
value={minSilence}
disabled={disabled}
onChange={(e) => onMinSilence(Number(e.target.value))}
/>
<div className="control-hint">
<span>0.1s</span>
<span>3.0s</span>
</div>
<p className="control-tip">Gaps shorter than this are left in.</p>
</div>
<div className="control-group">
<label>
Speech padding
<span className="control-value">{padding.toFixed(2)}s</span>
</label>
<input
type="range"
min={0}
max={0.5}
step={0.01}
value={padding}
disabled={disabled}
onChange={(e) => onPadding(Number(e.target.value))}
/>
<div className="control-hint">
<span>None</span>
<span>0.5s</span>
</div>
<p className="control-tip">Buffer kept around each speech burst to avoid clipping words.</p>
</div>
<button className="btn-secondary reanalyze-btn" onClick={onReanalyze} disabled={disabled}>
Re-analyze
</button>
</div>
)
}

View File

@@ -0,0 +1,107 @@
export interface Segment {
start: number
end: number
kept: boolean
}
interface TimelineBlock {
start: number
end: number
type: 'speech' | 'silence'
segmentIndex?: number
kept?: boolean
}
interface Props {
segments: Segment[]
duration: number
onToggle: (index: number) => void
disabled: boolean
}
export default function Timeline({ segments, duration, onToggle, disabled }: Props) {
if (duration === 0 || segments.length === 0) return null
const blocks = buildBlocks(segments, duration)
const keptCount = segments.filter((s) => s.kept).length
return (
<div className="timeline-container">
<div className="timeline-header">
<span className="timeline-title">Timeline</span>
<span className="timeline-subtitle">
{keptCount} of {segments.length} segments kept click a segment to toggle it
</span>
</div>
<div className="timeline-bar">
{blocks.map((block, i) => {
const widthPct = ((block.end - block.start) / duration) * 100
const isSpeech = block.type === 'speech'
const isKept = isSpeech && block.kept
let className = 'timeline-block '
if (!isSpeech) className += 'silence'
else if (isKept) className += 'kept'
else className += 'skipped'
const label = `${block.type}: ${fmt(block.start)}${fmt(block.end)} (${fmt(block.end - block.start)})`
return (
<div
key={i}
className={className}
style={{ width: `${widthPct}%` }}
title={label}
onClick={() => {
if (!disabled && isSpeech && block.segmentIndex !== undefined) {
onToggle(block.segmentIndex)
}
}}
/>
)
})}
</div>
<div className="timeline-labels">
<span>0:00</span>
<span>{fmt(duration / 4)}</span>
<span>{fmt(duration / 2)}</span>
<span>{fmt((duration * 3) / 4)}</span>
<span>{fmt(duration)}</span>
</div>
<div className="timeline-legend">
<span className="legend-kept"> Speech (kept)</span>
<span className="legend-skipped"> Speech (toggled off)</span>
<span className="legend-silence"> Silence (will be cut)</span>
</div>
</div>
)
}
function buildBlocks(segments: Segment[], duration: number): TimelineBlock[] {
const blocks: TimelineBlock[] = []
let cursor = 0
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
if (seg.start > cursor + 0.01) {
blocks.push({ start: cursor, end: seg.start, type: 'silence' })
}
blocks.push({ start: seg.start, end: seg.end, type: 'speech', segmentIndex: i, kept: seg.kept })
cursor = seg.end
}
if (cursor < duration - 0.01) {
blocks.push({ start: cursor, end: duration, type: 'silence' })
}
return blocks
}
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = (s % 60).toFixed(1).padStart(4, '0')
return m > 0 ? `${m}:${sec}` : `${sec}s`
}

View File

@@ -0,0 +1,51 @@
import { useState, useRef, DragEvent, ChangeEvent } from 'react'
interface Props {
onUpload: (file: File) => void
}
const ACCEPTED_EXTENSIONS = /\.(mp4|mov|avi|webm|mkv)$/i
export default function Upload({ onUpload }: Props) {
const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const submit = (file: File) => {
if (!ACCEPTED_EXTENSIONS.test(file.name)) return
onUpload(file)
}
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) submit(file)
}
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) submit(file)
}
return (
<div
className={`upload-zone ${dragging ? 'dragging' : ''}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<div className="upload-icon"></div>
<p className="upload-label">Drop your A-roll clip here</p>
<p className="upload-sub">or click to browse</p>
<p className="upload-formats">MP4 · MOV · AVI · WebM · MKV</p>
<input
ref={inputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={onChange}
/>
</div>
)
}

View File

@@ -0,0 +1,302 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import type { Segment } from './Timeline'
const PX_PER_SEC = 80 // timeline scale: 80px = 1 second
const THUMB_H = 60 // rendered thumbnail strip height
const CAP_W = 120 // canvas capture width
const CAP_H = 68 // canvas capture height (16:9 ≈ 120×68)
interface Props {
videoUrl: string
duration: number
segments: Segment[]
onToggle: (index: number) => void
disabled: boolean
}
export default function VideoTimeline({ videoUrl, duration, segments, onToggle, disabled }: Props) {
const videoRef = useRef<HTMLVideoElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const [thumbnails, setThumbnails] = useState<string[]>([])
const [currentTime, setCurrentTime] = useState(0)
const [playing, setPlaying] = useState(false)
const [scrubbing, setScrubbing] = useState(false)
// Get intrinsic duration from the video element itself so we can show thumbnails
// even before the server responds with the segments message.
const [intrinsicDuration, setIntrinsicDuration] = useState(duration)
const effectiveDuration = duration > 0 ? duration : intrinsicDuration
useEffect(() => {
const v = videoRef.current
if (!v) return
const onMeta = () => { if (isFinite(v.duration)) setIntrinsicDuration(v.duration) }
v.addEventListener('loadedmetadata', onMeta)
if (isFinite(v.duration) && v.duration > 0) setIntrinsicDuration(v.duration)
return () => v.removeEventListener('loadedmetadata', onMeta)
}, [])
const trackWidth = Math.max(Math.round(effectiveDuration * PX_PER_SEC), 1)
const thumbCount = Math.max(Math.ceil(trackWidth / 80), 1)
const thumbW = trackWidth / thumbCount
// Extract frames client-side — no server round-trip needed for thumbnails
useEffect(() => {
if (!videoUrl || effectiveDuration === 0) return
setThumbnails([])
const vid = document.createElement('video')
const canvas = document.createElement('canvas')
canvas.width = CAP_W
canvas.height = CAP_H
const ctx = canvas.getContext('2d')!
const result: string[] = []
let idx = 0
let cancelled = false
const captureNext = () => {
if (cancelled || idx >= thumbCount) return
vid.currentTime = ((idx + 0.5) / thumbCount) * effectiveDuration
}
vid.addEventListener('loadedmetadata', () => { if (!cancelled) captureNext() })
vid.addEventListener('seeked', () => {
if (cancelled) return
ctx.drawImage(vid, 0, 0, CAP_W, CAP_H)
result.push(canvas.toDataURL('image/jpeg', 0.55))
idx++
setThumbnails([...result])
captureNext()
})
vid.src = videoUrl
vid.muted = true
vid.preload = 'auto'
return () => {
cancelled = true
vid.src = ''
}
}, [videoUrl, effectiveDuration, thumbCount])
// Keep currentTime in sync with the video element
useEffect(() => {
const v = videoRef.current
if (!v) return
const onTime = () => { if (!scrubbing) setCurrentTime(v.currentTime) }
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onEnded = () => setPlaying(false)
v.addEventListener('timeupdate', onTime)
v.addEventListener('play', onPlay)
v.addEventListener('pause', onPause)
v.addEventListener('ended', onEnded)
return () => {
v.removeEventListener('timeupdate', onTime)
v.removeEventListener('play', onPlay)
v.removeEventListener('pause', onPause)
v.removeEventListener('ended', onEnded)
}
}, [scrubbing])
// Auto-scroll the timeline to follow the playhead while playing
useEffect(() => {
if (!playing || !scrollRef.current || effectiveDuration === 0) return
const el = scrollRef.current
const x = (currentTime / effectiveDuration) * trackWidth
const { scrollLeft, clientWidth } = el
if (x > scrollLeft + clientWidth - 80 || x < scrollLeft + 40) {
el.scrollLeft = x - clientWidth / 2
}
}, [currentTime, playing, effectiveDuration, trackWidth])
// Map a clientX position to a video timestamp
const getTimeAt = useCallback((clientX: number): number => {
if (!trackRef.current || effectiveDuration === 0) return 0
const rect = trackRef.current.getBoundingClientRect()
const x = clientX - rect.left
return Math.max(0, Math.min(effectiveDuration, (x / trackWidth) * effectiveDuration))
}, [effectiveDuration, trackWidth])
const seekTo = useCallback((time: number) => {
setCurrentTime(time)
if (videoRef.current) videoRef.current.currentTime = time
}, [])
// Click / drag on the track to scrub
const onTrackMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
setScrubbing(true)
seekTo(getTimeAt(e.clientX))
videoRef.current?.pause()
}
useEffect(() => {
if (!scrubbing) return
const onMove = (e: MouseEvent) => seekTo(getTimeAt(e.clientX))
const onUp = () => setScrubbing(false)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
return () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
}, [scrubbing, getTimeAt, seekTo])
const togglePlay = () => {
const v = videoRef.current
if (!v) return
playing ? v.pause() : v.play()
}
const silences = buildSilences(segments, effectiveDuration)
const ticks = buildTicks(effectiveDuration, trackWidth)
const playheadX = effectiveDuration > 0 ? (currentTime / effectiveDuration) * trackWidth : 0
const keptCount = segments.filter((s) => s.kept).length
return (
<div className="vt-root">
{/* ── Video preview ── */}
<div className="vt-preview">
<video
ref={videoRef}
src={videoUrl}
className="vt-video"
preload="metadata"
onClick={togglePlay}
/>
<div className="vt-controls-bar">
<button className="vt-play-btn" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
{playing ? '⏸' : '▶'}
</button>
<span className="vt-timecode">
{fmtTime(currentTime)} / {fmtTime(effectiveDuration)}
</span>
</div>
</div>
{/* ── Scrollable timeline ── */}
<div ref={scrollRef} className="vt-scroll">
{/* Ruler sits inside scroll so it tracks with the track */}
<div className="vt-ruler" style={{ minWidth: trackWidth }}>
{ticks.map((t, i) => (
<span key={i} className="vt-tick" style={{ left: t.x }}>
{t.label}
</span>
))}
</div>
<div
ref={trackRef}
className="vt-track"
style={{ width: trackWidth }}
onMouseDown={onTrackMouseDown}
>
{/* ① Thumbnail strip */}
<div className="vt-thumbs" style={{ height: THUMB_H }}>
{Array.from({ length: thumbCount }, (_, i) => (
<div
key={i}
className="vt-thumb"
style={{ width: thumbW, height: THUMB_H }}
>
{thumbnails[i] ? (
<img
src={thumbnails[i]}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
draggable={false}
/>
) : (
<div className="vt-thumb-skeleton" />
)}
</div>
))}
</div>
{/* ② Silence highlights — overlaid on thumbnails */}
{silences.map((r, i) => (
<div
key={i}
className="vt-silence"
style={{
left: (r.start / effectiveDuration) * trackWidth,
width: Math.max(2, ((r.end - r.start) / effectiveDuration) * trackWidth),
height: THUMB_H,
}}
/>
))}
{/* ③ Speech segment handles */}
{segments.map((seg, i) => (
<div
key={i}
className={`vt-seg ${seg.kept ? 'kept' : 'skipped'}`}
style={{
left: (seg.start / effectiveDuration) * trackWidth,
width: Math.max(2, ((seg.end - seg.start) / effectiveDuration) * trackWidth),
height: THUMB_H,
}}
onClick={() => { if (!disabled) onToggle(i) }}
title={
seg.kept
? `Speech ${fmtTime(seg.start)}${fmtTime(seg.end)} · click to exclude`
: `Excluded ${fmtTime(seg.start)}${fmtTime(seg.end)} · click to include`
}
/>
))}
{/* ④ Playhead */}
<div className="vt-playhead" style={{ left: playheadX }}>
<div className="vt-playhead-nub" />
<div className="vt-playhead-line" style={{ height: THUMB_H }} />
</div>
</div>
</div>
{/* ── Legend ── */}
<div className="vt-legend">
<span className="vt-leg vt-leg-kept"> Speech kept ({keptCount})</span>
<span className="vt-leg vt-leg-skip"> Toggled off</span>
<span className="vt-leg vt-leg-sil"> Silence will be removed</span>
{!disabled && (
<span className="vt-leg vt-leg-hint">Click a speech region to include / exclude it</span>
)}
</div>
</div>
)
}
// Invert the speech segments to get silence gaps
function buildSilences(segments: Segment[], duration: number) {
if (duration === 0) return []
const out: { start: number; end: number }[] = []
let cursor = 0
for (const seg of segments) {
if (seg.start > cursor + 0.01) out.push({ start: cursor, end: seg.start })
cursor = seg.end
}
if (cursor < duration - 0.01) out.push({ start: cursor, end: duration })
return out
}
// Build ruler tick marks, aiming for one tick every ~120px
function buildTicks(duration: number, trackWidth: number) {
if (duration === 0 || trackWidth === 0) return []
const ticks: { x: number; label: string }[] = []
const rawStep = (120 / trackWidth) * duration
const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]
const step = steps.find((s) => s >= rawStep) ?? 600
for (let t = 0; t <= duration + 0.001; t += step) {
ticks.push({ x: (t / duration) * trackWidth, label: fmtTime(t) })
}
return ticks
}
function fmtTime(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60).toString().padStart(2, '0')
return `${m}:${sec}`
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef, useCallback } from 'react'
type Handler = (data: unknown) => void
export function useWebSocket(url: string, onMessage: Handler) {
const wsRef = useRef<WebSocket | null>(null)
// Keep a stable ref to the handler so we don't reconnect on every render
const handlerRef = useRef(onMessage)
handlerRef.current = onMessage
useEffect(() => {
const socket = new WebSocket(url)
wsRef.current = socket
socket.onmessage = (e) => {
try {
handlerRef.current(JSON.parse(e.data as string))
} catch {
// ignore malformed frames
}
}
socket.onerror = (e) => console.error('WebSocket error', e)
return () => socket.close()
}, [url])
const send = useCallback((data: unknown) => {
wsRef.current?.send(JSON.stringify(data))
}, [])
return { send }
}

758
frontend/src/index.css Normal file
View File

@@ -0,0 +1,758 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #111111;
--surface: #1c1c1c;
--surface-2: #252525;
--border: #2e2e2e;
--border-2: #3a3a3a;
--text: #e2e2e2;
--text-muted: #777;
--text-dim: #555;
--green: #22c55e;
--green-dim: #14532d;
--red: #ef4444;
--blue: #3b82f6;
--blue-hover: #2563eb;
}
html, body {
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
/* ------------------------------------------------------------------ */
/* App shell */
/* ------------------------------------------------------------------ */
.app {
max-width: 860px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.app-header {
margin-bottom: 36px;
}
.app-header h1 {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.4px;
color: #fff;
}
.app-header p {
margin-top: 4px;
color: var(--text-muted);
font-size: 13px;
}
.app-main {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ------------------------------------------------------------------ */
/* Upload zone */
/* ------------------------------------------------------------------ */
.upload-zone {
border: 2px dashed var(--border-2);
border-radius: 12px;
padding: 72px 40px;
text-align: center;
cursor: pointer;
transition: border-color 0.18s, background 0.18s;
user-select: none;
}
.upload-zone:hover,
.upload-zone.dragging {
border-color: var(--blue);
background: rgba(59, 130, 246, 0.04);
}
.upload-icon {
font-size: 36px;
opacity: 0.35;
margin-bottom: 14px;
line-height: 1;
}
.upload-label {
font-size: 17px;
font-weight: 500;
color: #fff;
margin-bottom: 6px;
}
.upload-sub {
color: var(--text-muted);
margin-bottom: 16px;
}
.upload-formats {
display: inline-block;
padding: 4px 14px;
border-radius: 20px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
letter-spacing: 0.5px;
}
/* ------------------------------------------------------------------ */
/* Status card (uploading / analyzing) */
/* ------------------------------------------------------------------ */
.status-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 64px 40px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-muted);
}
.status-sub {
font-size: 12px;
color: var(--text-dim);
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border-2);
border-top-color: var(--blue);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ------------------------------------------------------------------ */
/* Controls */
/* ------------------------------------------------------------------ */
.controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
gap: 20px;
align-items: end;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-group label {
display: flex;
justify-content: space-between;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-value {
color: var(--text);
font-variant-numeric: tabular-nums;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.control-group input[type='range'] {
width: 100%;
height: 4px;
accent-color: var(--blue);
cursor: pointer;
}
.control-group input[type='range']:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.control-hint {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
}
.control-tip {
font-size: 11px;
color: var(--text-dim);
line-height: 1.4;
}
.reanalyze-btn {
white-space: nowrap;
align-self: end;
}
/* ------------------------------------------------------------------ */
/* Timeline */
/* ------------------------------------------------------------------ */
.timeline-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.timeline-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
}
.timeline-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
}
.timeline-subtitle {
font-size: 12px;
color: var(--text-dim);
}
.timeline-bar {
display: flex;
height: 56px;
border-radius: 6px;
overflow: hidden;
background: var(--border);
gap: 1px;
}
.timeline-block {
height: 100%;
min-width: 1px;
transition: filter 0.12s;
flex-shrink: 0;
}
.timeline-block.kept {
background: var(--green);
cursor: pointer;
}
.timeline-block.kept:hover {
filter: brightness(1.2);
}
.timeline-block.skipped {
background: var(--green-dim);
cursor: pointer;
opacity: 0.6;
}
.timeline-block.skipped:hover {
opacity: 0.8;
}
.timeline-block.silence {
background: rgba(239, 68, 68, 0.75);
cursor: default;
position: relative;
}
/* Diagonal stripe overlay to reinforce "cut" visually */
.timeline-block.silence::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(0, 0, 0, 0.25) 4px,
rgba(0, 0, 0, 0.25) 8px
);
}
.timeline-labels {
display: flex;
justify-content: space-between;
margin-top: 7px;
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.timeline-legend {
display: flex;
gap: 20px;
margin-top: 12px;
font-size: 12px;
}
.legend-kept { color: var(--green); }
.legend-skipped { color: #2d6a42; }
.legend-silence { color: var(--red); }
/* ------------------------------------------------------------------ */
/* Stats row */
/* ------------------------------------------------------------------ */
.stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stat {
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.stat-kept { background: rgba(34, 197, 94, 0.08); color: var(--green); }
.stat-removed { background: rgba(239, 68, 68, 0.08); color: var(--red); }
.stat-total { background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); }
/* ------------------------------------------------------------------ */
/* Action row */
/* ------------------------------------------------------------------ */
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* ------------------------------------------------------------------ */
/* Buttons */
/* ------------------------------------------------------------------ */
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 22px;
border-radius: 8px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
text-decoration: none;
}
.btn-primary {
background: var(--blue);
color: #fff;
}
.btn-primary:hover {
background: var(--blue-hover);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border-2);
}
.btn-secondary:hover:not(:disabled) {
background: var(--border);
}
.btn-secondary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ------------------------------------------------------------------ */
/* Export progress */
/* ------------------------------------------------------------------ */
.progress-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-label {
font-size: 13px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.progress-track {
height: 6px;
background: var(--border-2);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--blue);
border-radius: 3px;
transition: width 0.3s ease;
}
/* ------------------------------------------------------------------ */
/* Done panel */
/* ------------------------------------------------------------------ */
.done-panel {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 12px;
padding: 20px 24px;
}
.done-check {
font-size: 20px;
color: var(--green);
}
.done-panel p {
flex: 1;
font-weight: 500;
color: var(--green);
}
/* ------------------------------------------------------------------ */
/* Error banner */
/* ------------------------------------------------------------------ */
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--red);
font-size: 13px;
}
.error-banner button {
background: none;
border: none;
color: var(--red);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 4px;
opacity: 0.7;
}
.error-banner button:hover {
opacity: 1;
}
/* ------------------------------------------------------------------ */
/* status-card variant — compact, used while analyzing below timeline */
/* ------------------------------------------------------------------ */
.status-card--inline {
flex-direction: row;
padding: 16px 20px;
gap: 12px;
justify-content: flex-start;
}
/* ------------------------------------------------------------------ */
/* VideoTimeline */
/* ------------------------------------------------------------------ */
.vt-root {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Video preview ── */
.vt-preview {
position: relative;
background: #000;
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
max-height: 320px;
overflow: hidden;
border-bottom: 1px solid var(--border);
}
.vt-video {
max-width: 100%;
max-height: 320px;
display: block;
cursor: pointer;
}
.vt-controls-bar {
position: absolute;
bottom: 10px;
left: 10px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
border-radius: 20px;
padding: 5px 14px;
}
.vt-play-btn {
background: none;
border: none;
color: #fff;
font-size: 15px;
cursor: pointer;
line-height: 1;
padding: 0;
opacity: 0.9;
}
.vt-play-btn:hover { opacity: 1; }
.vt-timecode {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
font-variant-numeric: tabular-nums;
font-family: ui-monospace, monospace;
}
/* ── Scrollable area ── */
.vt-scroll {
overflow-x: auto;
overflow-y: hidden;
background: #141414;
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
padding-bottom: 4px;
}
.vt-scroll::-webkit-scrollbar { height: 5px; }
.vt-scroll::-webkit-scrollbar-track { background: transparent; }
.vt-scroll::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; }
/* ── Ruler ── */
.vt-ruler {
position: relative;
height: 22px;
border-bottom: 1px solid var(--border);
}
.vt-tick {
position: absolute;
top: 5px;
font-size: 10px;
color: var(--text-dim);
transform: translateX(-50%);
white-space: nowrap;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.vt-tick::before {
content: '';
position: absolute;
top: -5px;
left: 50%;
width: 1px;
height: 4px;
background: var(--border-2);
}
/* ── Track (the full-width scrollable content) ── */
.vt-track {
position: relative;
cursor: crosshair;
user-select: none;
}
/* ── Thumbnails ── */
.vt-thumbs {
display: flex;
}
.vt-thumb {
flex-shrink: 0;
overflow: hidden;
border-right: 1px solid rgba(0, 0, 0, 0.4);
}
.vt-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.vt-thumb-skeleton {
width: 100%;
height: 100%;
background: var(--surface-2);
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.65; }
}
/* ── Silence highlights ── */
.vt-silence {
position: absolute;
top: 0;
pointer-events: none;
z-index: 2;
background: rgba(239, 68, 68, 0.52);
}
/* Diagonal-stripe overlay for the silence regions */
.vt-silence::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(0, 0, 0, 0.28) 5px,
rgba(0, 0, 0, 0.28) 10px
);
}
/* ── Speech segment handles ── */
.vt-seg {
position: absolute;
top: 0;
z-index: 3;
pointer-events: auto;
cursor: pointer;
box-sizing: border-box;
transition: background 0.12s;
}
.vt-seg.kept {
border-top: 3px solid var(--green);
border-bottom: 3px solid var(--green);
background: rgba(34, 197, 94, 0.07);
}
.vt-seg.kept:hover {
background: rgba(34, 197, 94, 0.22);
}
.vt-seg.skipped {
border-top: 3px solid #444;
border-bottom: 3px solid #444;
background: rgba(0, 0, 0, 0.45);
}
.vt-seg.skipped:hover {
background: rgba(0, 0, 0, 0.25);
}
/* ── Playhead ── */
.vt-playhead {
position: absolute;
top: -8px;
transform: translateX(-50%);
z-index: 10;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
}
/* Small triangle at top */
.vt-playhead-nub {
width: 10px;
height: 7px;
background: #fff;
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
.vt-playhead-line {
width: 1.5px;
background: rgba(255, 255, 255, 0.9);
}
/* ── Legend ── */
.vt-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 10px 16px;
border-top: 1px solid var(--border);
font-size: 12px;
background: var(--surface);
}
.vt-leg { color: var(--text-muted); }
.vt-leg-kept { color: var(--green); }
.vt-leg-skip { color: #2d6a42; }
.vt-leg-sil { color: var(--red); }
.vt-leg-hint { margin-left: auto; color: var(--text-dim); font-style: italic; }

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/upload': 'http://localhost:8080',
'/analyze': 'http://localhost:8080',
'/export': 'http://localhost:8080',
'/download': 'http://localhost:8080',
'/ws': { target: 'ws://localhost:8080', ws: true },
},
},
})