+ {/* ── Video preview ── */}
+
+
+
+
+
+ {fmtTime(currentTime)} / {fmtTime(effectiveDuration)}
+
+
+
+
+ {/* ── Scrollable timeline ── */}
+
+ {/* Ruler sits inside scroll so it tracks with the track */}
+
+ {ticks.map((t, i) => (
+
+ {t.label}
+
+ ))}
+
+
+
+ {/* ① Thumbnail strip */}
+
+ {Array.from({ length: thumbCount }, (_, i) => (
+
+ {thumbnails[i] ? (
+

+ ) : (
+
+ )}
+
+ ))}
+
+
+ {/* ② Silence highlights — overlaid on thumbnails */}
+ {silences.map((r, i) => (
+
+ ))}
+
+ {/* ③ Speech segment handles */}
+ {segments.map((seg, i) => (
+
{ 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 */}
+
+
+
+
+ {/* ── Legend ── */}
+
+ ■ Speech kept ({keptCount})
+ ■ Toggled off
+ ▨ Silence — will be removed
+ {!disabled && (
+ Click a speech region to include / exclude it
+ )}
+
+
+ )
+}
+
+// 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}`
+}
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..e5cd5b4
--- /dev/null
+++ b/frontend/src/hooks/useWebSocket.ts
@@ -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
(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 }
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..e2ce75a
--- /dev/null
+++ b/frontend/src/index.css
@@ -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; }
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..520b520
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+)
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..a4c834a
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..7d248fa
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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 },
+ },
+ },
+})
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7ea9b41
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..049c9b9
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/handlers/analyze.go b/handlers/analyze.go
new file mode 100644
index 0000000..59f4e22
--- /dev/null
+++ b/handlers/analyze.go
@@ -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)
+}
diff --git a/handlers/download.go b/handlers/download.go
new file mode 100644
index 0000000..cf34edf
--- /dev/null
+++ b/handlers/download.go
@@ -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)
+ }
+}
diff --git a/handlers/export.go b/handlers/export.go
new file mode 100644
index 0000000..59886b9
--- /dev/null
+++ b/handlers/export.go
@@ -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)
+ }
+}
diff --git a/handlers/upload.go b/handlers/upload.go
new file mode 100644
index 0000000..91fa7af
--- /dev/null
+++ b/handlers/upload.go
@@ -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-" 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})
+ }
+}
diff --git a/handlers/ws.go b/handlers/ws.go
new file mode 100644
index 0000000..c956db1
--- /dev/null
+++ b/handlers/ws.go
@@ -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
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..39bb802
--- /dev/null
+++ b/main.go
@@ -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))
+}
diff --git a/store/store.go b/store/store.go
new file mode 100644
index 0000000..43aecba
--- /dev/null
+++ b/store/store.go
@@ -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)
+}
diff --git a/transcode/transcode.go b/transcode/transcode.go
new file mode 100644
index 0000000..2ecdcb0
--- /dev/null
+++ b/transcode/transcode.go
@@ -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
+}
diff --git a/ws/hub.go b/ws/hub.go
new file mode 100644
index 0000000..d6ddea9
--- /dev/null
+++ b/ws/hub.go
@@ -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:
+ }
+}