first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/frontend/node_modules/
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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
1726
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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
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`
|
||||||
|
}
|
||||||
94
frontend/src/components/Controls.tsx
Normal file
94
frontend/src/components/Controls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/components/Timeline.tsx
Normal file
107
frontend/src/components/Timeline.tsx
Normal 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`
|
||||||
|
}
|
||||||
51
frontend/src/components/Upload.tsx
Normal file
51
frontend/src/components/Upload.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
frontend/src/components/VideoTimeline.tsx
Normal file
302
frontend/src/components/VideoTimeline.tsx
Normal 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}`
|
||||||
|
}
|
||||||
33
frontend/src/hooks/useWebSocket.ts
Normal file
33
frontend/src/hooks/useWebSocket.ts
Normal 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
758
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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
20
frontend/tsconfig.json
Normal 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
15
frontend/vite.config.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module aroll
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
)
|
||||||
10
go.sum
Normal file
10
go.sum
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
81
handlers/analyze.go
Normal file
81
handlers/analyze.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"aroll/store"
|
||||||
|
"aroll/transcode"
|
||||||
|
"aroll/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
type analyzeRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
NoiseDb float64 `json:"noiseDb"`
|
||||||
|
MinSilence float64 `json:"minSilence"`
|
||||||
|
Padding float64 `json:"padding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeHandler fetches the video bytes from Redis, writes them to a short-lived
|
||||||
|
// temp file for FFmpeg, runs silence detection, then deletes the temp file.
|
||||||
|
func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req analyzeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := st.Get(context.Background(), req.Filename)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "file not found in store (may have expired)", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(req.Filename)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Write to a temp file — FFmpeg needs a file path, not a byte slice.
|
||||||
|
// This file exists only for the duration of the FFmpeg scan.
|
||||||
|
tmp, err := os.CreateTemp("", "aroll-analyze-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
broadcastError(hub, "create temp: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
broadcastError(hub, "write temp: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
_, err = transcode.DetectSpeechSegments(
|
||||||
|
tmp.Name(),
|
||||||
|
req.NoiseDb,
|
||||||
|
req.MinSilence,
|
||||||
|
req.Padding,
|
||||||
|
hub.Broadcast,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
broadcastError(hub, err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcastError(hub *ws.Hub, msg string) {
|
||||||
|
data, _ := json.Marshal(map[string]string{"type": "error", "message": msg})
|
||||||
|
hub.Broadcast(data)
|
||||||
|
}
|
||||||
40
handlers/download.go
Normal file
40
handlers/download.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"aroll/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadHandler fetches the exported video from Redis, streams it to the
|
||||||
|
// browser, then immediately deletes the key. One download per export.
|
||||||
|
func DownloadHandler(st *store.Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
|
||||||
|
// Only serve keys we created in ExportHandler
|
||||||
|
if !strings.HasPrefix(key, "output-") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data, err := st.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "file not found or already downloaded", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from Redis now — one download only
|
||||||
|
defer st.Delete(ctx, key)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`)
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
handlers/export.go
Normal file
104
handlers/export.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"aroll/store"
|
||||||
|
"aroll/transcode"
|
||||||
|
"aroll/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exportRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Segments []transcode.Segment `json:"segments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportHandler fetches the input video from Redis, runs FFmpeg to cut the
|
||||||
|
// silence, stores the output back in Redis, then cleans up the temp files.
|
||||||
|
// The output key is sent to the frontend via a "done" WebSocket message.
|
||||||
|
func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req exportRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Segments) == 0 {
|
||||||
|
http.Error(w, "no segments", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := st.Get(context.Background(), req.Filename)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "file not found in store (may have expired)", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(req.Filename)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Write input bytes to a temp file for FFmpeg
|
||||||
|
inTmp, err := os.CreateTemp("", "aroll-in-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
broadcastError(hub, "create input temp: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(inTmp.Name())
|
||||||
|
|
||||||
|
if _, err := inTmp.Write(data); err != nil {
|
||||||
|
inTmp.Close()
|
||||||
|
broadcastError(hub, "write input temp: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inTmp.Close()
|
||||||
|
|
||||||
|
// Create an output temp file for FFmpeg to write into
|
||||||
|
outTmp, err := os.CreateTemp("", "aroll-out-*.mp4")
|
||||||
|
if err != nil {
|
||||||
|
broadcastError(hub, "create output temp: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outTmp.Close()
|
||||||
|
defer os.Remove(outTmp.Name())
|
||||||
|
|
||||||
|
// Run FFmpeg — progress streamed via WebSocket
|
||||||
|
if err := transcode.ExportSegments(
|
||||||
|
inTmp.Name(), outTmp.Name(), req.Segments, hub.Broadcast,
|
||||||
|
); err != nil {
|
||||||
|
broadcastError(hub, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the output bytes and store them in Redis
|
||||||
|
outData, err := os.ReadFile(outTmp.Name())
|
||||||
|
if err != nil {
|
||||||
|
broadcastError(hub, "read output: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputKey := "output-" + store.NewID()
|
||||||
|
if err := st.Set(context.Background(), outputKey, outData); err != nil {
|
||||||
|
broadcastError(hub, "store output: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the frontend where to download from
|
||||||
|
msg, _ := json.Marshal(map[string]string{
|
||||||
|
"type": "done",
|
||||||
|
"message": outputKey,
|
||||||
|
})
|
||||||
|
hub.Broadcast(msg)
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
handlers/upload.go
Normal file
58
handlers/upload.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"aroll/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadHandler reads the video into memory and stores it in Redis under a
|
||||||
|
// random key. The key is returned to the frontend as "filename".
|
||||||
|
// Nothing is written to disk — the file lives only in Redis with a TTL.
|
||||||
|
func UploadHandler(st *store.Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 4<<30)
|
||||||
|
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||||
|
http.Error(w, "parse form: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("video")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "missing 'video' field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read file: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key format: "video-<id><ext>" e.g. "video-a3f9...2b.mp4"
|
||||||
|
key := "video-" + store.NewID() + ext
|
||||||
|
|
||||||
|
if err := st.Set(context.Background(), key, data); err != nil {
|
||||||
|
http.Error(w, "store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"filename": key})
|
||||||
|
}
|
||||||
|
}
|
||||||
47
handlers/ws.go
Normal file
47
handlers/ws.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"aroll/ws"
|
||||||
|
|
||||||
|
gws "github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = gws.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
func WSHandler(hub *ws.Hub) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ws upgrade: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &ws.Client{Send: make(chan []byte, 256)}
|
||||||
|
hub.Register <- client
|
||||||
|
|
||||||
|
// Writer goroutine: pumps messages from hub → WebSocket
|
||||||
|
go func() {
|
||||||
|
defer conn.Close()
|
||||||
|
for msg := range client.Send {
|
||||||
|
if err := conn.WriteMessage(gws.TextMessage, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Reader loop: keeps the connection alive and detects close frames
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hub.Unregister <- client
|
||||||
|
}
|
||||||
|
}
|
||||||
43
main.go
Normal file
43
main.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"aroll/handlers"
|
||||||
|
"aroll/store"
|
||||||
|
"aroll/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
redisAddr := "localhost:6379"
|
||||||
|
if addr := os.Getenv("REDIS_ADDR"); addr != "" {
|
||||||
|
redisAddr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.New(redisAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("redis: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Connected to Redis at %s", redisAddr)
|
||||||
|
|
||||||
|
hub := ws.NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/ws", handlers.WSHandler(hub))
|
||||||
|
mux.HandleFunc("/upload", handlers.UploadHandler(st))
|
||||||
|
mux.HandleFunc("/analyze", handlers.AnalyzeHandler(st, hub))
|
||||||
|
mux.HandleFunc("/export", handlers.ExportHandler(st, hub))
|
||||||
|
mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st)))
|
||||||
|
|
||||||
|
// Serve the React build in production
|
||||||
|
if _, err := os.Stat("frontend/dist"); err == nil {
|
||||||
|
mux.Handle("/", http.FileServer(http.Dir("frontend/dist")))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Listening on http://localhost:8080")
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||||
|
}
|
||||||
48
store/store.go
Normal file
48
store/store.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TTL is how long any stored file lives in Redis before automatic deletion.
|
||||||
|
const TTL = 2 * time.Hour
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(addr string) (*Store, error) {
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
||||||
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("redis connect %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
return &Store{rdb: rdb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores binary data under key with the global TTL.
|
||||||
|
func (s *Store) Set(ctx context.Context, key string, data []byte) error {
|
||||||
|
return s.rdb.Set(ctx, key, data, TTL).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves binary data by key.
|
||||||
|
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
|
||||||
|
return s.rdb.Get(ctx, key).Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a key immediately (called after download).
|
||||||
|
func (s *Store) Delete(ctx context.Context, key string) {
|
||||||
|
s.rdb.Del(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewID generates a random hex ID for use as a Redis key suffix.
|
||||||
|
func NewID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
206
transcode/transcode.go
Normal file
206
transcode/transcode.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package transcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Segment is a time interval [Start, End) in seconds.
|
||||||
|
type Segment struct {
|
||||||
|
Start float64 `json:"start"`
|
||||||
|
End float64 `json:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Segments []Segment `json:"segments,omitempty"`
|
||||||
|
Duration float64 `json:"duration,omitempty"`
|
||||||
|
Percent float64 `json:"percent,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(msg wsMsg, broadcast func([]byte)) {
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
broadcast(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Silence detection
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// DetectSpeechSegments runs ffmpeg's silencedetect filter and returns the
|
||||||
|
// non-silent (speech) segments. A "segments" WebSocket message is broadcast
|
||||||
|
// when detection finishes.
|
||||||
|
func DetectSpeechSegments(
|
||||||
|
inputPath string,
|
||||||
|
noiseDb, minDuration, padding float64,
|
||||||
|
broadcast func([]byte),
|
||||||
|
) ([]Segment, error) {
|
||||||
|
filter := fmt.Sprintf("silencedetect=noise=%.0fdB:d=%.2f", noiseDb, minDuration)
|
||||||
|
|
||||||
|
cmd := exec.Command("ffmpeg",
|
||||||
|
"-i", inputPath,
|
||||||
|
"-af", filter,
|
||||||
|
"-f", "null", "-",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ffmpeg writes silencedetect output to stderr
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
silences []silenceInterval
|
||||||
|
pendingStart float64
|
||||||
|
totalDuration float64
|
||||||
|
|
||||||
|
durationRe = regexp.MustCompile(`Duration:\s+(\d+):(\d+):([0-9.]+)`)
|
||||||
|
startRe = regexp.MustCompile(`silence_start:\s*([0-9.e+\-]+)`)
|
||||||
|
endRe = regexp.MustCompile(`silence_end:\s*([0-9.e+\-]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stderr)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
if m := durationRe.FindStringSubmatch(line); m != nil {
|
||||||
|
h, _ := strconv.ParseFloat(m[1], 64)
|
||||||
|
min, _ := strconv.ParseFloat(m[2], 64)
|
||||||
|
s, _ := strconv.ParseFloat(m[3], 64)
|
||||||
|
totalDuration = h*3600 + min*60 + s
|
||||||
|
}
|
||||||
|
if m := startRe.FindStringSubmatch(line); m != nil {
|
||||||
|
pendingStart, _ = strconv.ParseFloat(m[1], 64)
|
||||||
|
}
|
||||||
|
if m := endRe.FindStringSubmatch(line); m != nil {
|
||||||
|
end, _ := strconv.ParseFloat(m[1], 64)
|
||||||
|
silences = append(silences, silenceInterval{start: pendingStart, end: end})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffmpeg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := invertSilences(silences, totalDuration, padding)
|
||||||
|
|
||||||
|
send(wsMsg{
|
||||||
|
Type: "segments",
|
||||||
|
Segments: segments,
|
||||||
|
Duration: totalDuration,
|
||||||
|
}, broadcast)
|
||||||
|
|
||||||
|
return segments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type silenceInterval struct{ start, end float64 }
|
||||||
|
|
||||||
|
// invertSilences turns silence regions into speech regions, with a small
|
||||||
|
// padding buffer so words at the edges don't get clipped.
|
||||||
|
func invertSilences(silences []silenceInterval, totalDuration, padding float64) []Segment {
|
||||||
|
if len(silences) == 0 {
|
||||||
|
return []Segment{{Start: 0, End: totalDuration}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments []Segment
|
||||||
|
cursor := 0.0
|
||||||
|
|
||||||
|
for _, s := range silences {
|
||||||
|
segEnd := s.start + padding
|
||||||
|
if segEnd > totalDuration {
|
||||||
|
segEnd = totalDuration
|
||||||
|
}
|
||||||
|
if segEnd-cursor > 0.05 {
|
||||||
|
segments = append(segments, Segment{Start: cursor, End: segEnd})
|
||||||
|
}
|
||||||
|
next := s.end - padding
|
||||||
|
if next < segEnd {
|
||||||
|
next = segEnd
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor < totalDuration-0.01 {
|
||||||
|
segments = append(segments, Segment{Start: cursor, End: totalDuration})
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Export
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ExportSegments concatenates the given segments using ffmpeg's concat demuxer
|
||||||
|
// (stream-copy, no re-encode). Progress is streamed via broadcast.
|
||||||
|
func ExportSegments(
|
||||||
|
inputPath, outputPath string,
|
||||||
|
segments []Segment,
|
||||||
|
broadcast func([]byte),
|
||||||
|
) error {
|
||||||
|
concatPath := outputPath + ".concat.txt"
|
||||||
|
f, err := os.Create(concatPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create concat file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(concatPath)
|
||||||
|
|
||||||
|
totalDuration := 0.0
|
||||||
|
for _, seg := range segments {
|
||||||
|
totalDuration += seg.End - seg.Start
|
||||||
|
fmt.Fprintf(f, "file '%s'\ninpoint %.6f\noutpoint %.6f\n",
|
||||||
|
inputPath, seg.Start, seg.End)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command("ffmpeg",
|
||||||
|
"-f", "concat",
|
||||||
|
"-safe", "0",
|
||||||
|
"-i", concatPath,
|
||||||
|
"-c", "copy",
|
||||||
|
"-progress", "pipe:1",
|
||||||
|
"-y",
|
||||||
|
outputPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeRe := regexp.MustCompile(`out_time_ms=(\d+)`)
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if m := timeRe.FindStringSubmatch(scanner.Text()); m != nil {
|
||||||
|
ms, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil || ms < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pct := (ms / 1e6) / totalDuration * 100
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
send(wsMsg{Type: "progress", Percent: pct}, broadcast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
ws/hub.go
Normal file
66
ws/hub.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Client represents a single WebSocket connection.
|
||||||
|
// The handler owns the actual *websocket.Conn; the hub only needs the send channel.
|
||||||
|
type Client struct {
|
||||||
|
Send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages all active clients and routes broadcast messages to them.
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[*Client]struct{}
|
||||||
|
Register chan *Client
|
||||||
|
Unregister chan *Client
|
||||||
|
broadcast chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*Client]struct{}),
|
||||||
|
Register: make(chan *Client, 8),
|
||||||
|
Unregister: make(chan *Client, 8),
|
||||||
|
broadcast: make(chan []byte, 512),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-h.Register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[c] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
case c := <-h.Unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[c]; ok {
|
||||||
|
delete(h.clients, c)
|
||||||
|
close(c.Send)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
case msg := <-h.broadcast:
|
||||||
|
h.mu.Lock()
|
||||||
|
for c := range h.clients {
|
||||||
|
select {
|
||||||
|
case c.Send <- msg:
|
||||||
|
default:
|
||||||
|
delete(h.clients, c)
|
||||||
|
close(c.Send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends msg to every connected client (non-blocking).
|
||||||
|
func (h *Hub) Broadcast(msg []byte) {
|
||||||
|
select {
|
||||||
|
case h.broadcast <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user