first commit
This commit is contained in:
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}`
|
||||
}
|
||||
Reference in New Issue
Block a user