Initial CutScript release - Open-source AI-powered text-based video editor

CutScript is a local-first, Descript-like video editor where you edit video by editing text.
Delete a word from the transcript and it's cut from the video.

Features:
- Word-level transcription with WhisperX
- Text-based video editing with undo/redo
- AI filler word removal (Ollama/OpenAI/Claude)
- AI clip creation for shorts
- Waveform timeline with virtualized transcript
- FFmpeg stream-copy (fast) and re-encode (4K) export
- Caption burn-in and sidecar SRT generation
- Studio Sound audio enhancement (DeepFilterNet)
- Keyboard shortcuts (J/K/L, Space, Delete, Ctrl+Z/S/E)
- Encrypted API key storage
- Project save/load (.aive files)

Architecture:
- Electron + React + Tailwind (frontend)
- FastAPI + Python (backend)
- WhisperX for transcription
- FFmpeg for video processing
- Multi-provider AI support

Performance optimizations:
- RAF-throttled time updates
- Zustand selectors for granular subscriptions
- Dual-canvas waveform rendering
- Virtualized transcript with react-virtuoso

Built on top of DataAnts-AI/VideoTranscriber, completely rewritten as a desktop application.

License: MIT
This commit is contained in:
Your Name
2026-03-03 06:31:04 -05:00
parent d1e1fedcae
commit 33cca5f552
73 changed files with 7463 additions and 3906 deletions

View File

@ -0,0 +1,332 @@
import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react';
import type { ClipSuggestion } from '../types/project';
export default function AIPanel() {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
const {
defaultProvider,
providers,
customFillerWords,
fillerResult,
clipSuggestions,
isProcessing,
processingMessage,
setCustomFillerWords,
setFillerResult,
setClipSuggestions,
setProcessing,
} = useAIStore();
const [activeTab, setActiveTab] = useState<'filler' | 'clips'>('filler');
const detectFillers = useCallback(async () => {
if (words.length === 0) return;
setProcessing(true, 'Detecting filler words...');
try {
const config = providers[defaultProvider];
const transcript = words.map((w) => w.word).join(' ');
const res = await fetch(`${backendUrl}/ai/filler-removal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript,
words: words.map((w, i) => ({ index: i, word: w.word })),
provider: defaultProvider,
model: config.model,
api_key: config.apiKey || undefined,
base_url: config.baseUrl || undefined,
custom_filler_words: customFillerWords || undefined,
}),
});
if (!res.ok) throw new Error('Filler detection failed');
const data = await res.json();
setFillerResult(data);
} catch (err) {
console.error(err);
} finally {
setProcessing(false);
}
}, [words, backendUrl, defaultProvider, providers, customFillerWords, setProcessing, setFillerResult]);
const createClips = useCallback(async () => {
if (words.length === 0) return;
setProcessing(true, 'Finding best clip segments...');
try {
const config = providers[defaultProvider];
const transcript = words.map((w) => w.word).join(' ');
const res = await fetch(`${backendUrl}/ai/create-clip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript,
words: words.map((w, i) => ({
index: i,
word: w.word,
start: w.start,
end: w.end,
})),
provider: defaultProvider,
model: config.model,
api_key: config.apiKey || undefined,
base_url: config.baseUrl || undefined,
target_duration: 60,
}),
});
if (!res.ok) throw new Error('Clip creation failed');
const data = await res.json();
setClipSuggestions(data.clips || []);
} catch (err) {
console.error(err);
} finally {
setProcessing(false);
}
}, [words, backendUrl, defaultProvider, providers, setProcessing, setClipSuggestions]);
const applyFillerDeletions = useCallback(() => {
if (!fillerResult) return;
const sorted = [...fillerResult.fillerWords].sort((a, b) => b.index - a.index);
for (const fw of sorted) {
deleteWordRange(fw.index, fw.index);
}
setFillerResult(null);
}, [fillerResult, deleteWordRange, setFillerResult]);
const handlePreviewClip = useCallback(
(clip: ClipSuggestion) => {
setCurrentTime(clip.startTime);
const video = document.querySelector('video');
if (video) {
video.currentTime = clip.startTime;
video.play();
}
},
[setCurrentTime],
);
const [exportingClipIndex, setExportingClipIndex] = useState<number | null>(null);
const handleExportClip = useCallback(
async (clip: ClipSuggestion, index: number) => {
if (!videoPath) return;
setExportingClipIndex(index);
try {
const safeName = clip.title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
const dirSep = videoPath.lastIndexOf('\\') >= 0 ? '\\' : '/';
const dir = videoPath.substring(0, videoPath.lastIndexOf(dirSep));
const outputPath = `${dir}${dirSep}${safeName}_clip.mp4`;
const res = await fetch(`${backendUrl}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input_path: videoPath,
output_path: outputPath,
keep_segments: [{ start: clip.startTime, end: clip.endTime }],
mode: 'fast',
format: 'mp4',
}),
});
if (!res.ok) throw new Error('Export failed');
const data = await res.json();
alert(`Clip exported to: ${data.output_path}`);
} catch (err) {
console.error(err);
alert('Failed to export clip. Check console for details.');
} finally {
setExportingClipIndex(null);
}
},
[videoPath, backendUrl],
);
return (
<div className="flex flex-col h-full">
<div className="flex border-b border-editor-border shrink-0">
<TabButton
active={activeTab === 'filler'}
onClick={() => setActiveTab('filler')}
icon={<Scissors className="w-3.5 h-3.5" />}
label="Filler Words"
/>
<TabButton
active={activeTab === 'clips'}
onClick={() => setActiveTab('clips')}
icon={<Film className="w-3.5 h-3.5" />}
label="Create Clips"
/>
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'filler' && (
<div className="space-y-4">
<p className="text-xs text-editor-text-muted">
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
your transcript.
</p>
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Custom filler words (comma-separated)
</label>
<input
type="text"
value={customFillerWords}
onChange={(e) => setCustomFillerWords(e.target.value)}
placeholder="e.g. okay, alright, anyway"
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
<button
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Detect Filler Words
</>
)}
</button>
{fillerResult && fillerResult.fillerWords.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
Found {fillerResult.fillerWords.length} filler words
</span>
<div className="flex gap-1">
<button
onClick={applyFillerDeletions}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30"
>
<Check className="w-3 h-3" /> Apply All
</button>
<button
onClick={() => setFillerResult(null)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
>
<X className="w-3 h-3" /> Dismiss
</button>
</div>
</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{fillerResult.fillerWords.map((fw) => (
<div
key={fw.index}
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs"
>
<span>
<strong>"{fw.word}"</strong>
<span className="text-editor-text-muted ml-1"> {fw.reason}</span>
</span>
</div>
))}
</div>
</div>
)}
{fillerResult && fillerResult.fillerWords.length === 0 && (
<p className="text-xs text-editor-success">No filler words detected.</p>
)}
</div>
)}
{activeTab === 'clips' && (
<div className="space-y-4">
<p className="text-xs text-editor-text-muted">
AI analyzes your transcript and suggests the most engaging segments for a
YouTube Short or social media clip.
</p>
<button
onClick={createClips}
disabled={isProcessing || words.length === 0}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Film className="w-4 h-4" />
Find Best Clips
</>
)}
</button>
{clipSuggestions.length > 0 && (
<div className="space-y-3">
{clipSuggestions.map((clip, i) => (
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold">{clip.title}</span>
<span className="text-[10px] text-editor-text-muted">
{Math.round(clip.endTime - clip.startTime)}s
</span>
</div>
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p>
<div className="flex gap-2">
<button
onClick={() => handlePreviewClip(clip)}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors"
>
<Play className="w-3 h-3" /> Preview
</button>
<button
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-50 transition-colors"
>
{exportingClipIndex === i ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Export
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function TabButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
onClick={onClick}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 ${
active
? 'border-editor-accent text-editor-accent'
: 'border-transparent text-editor-text-muted hover:text-editor-text'
}`}
>
{icon}
{label}
</button>
);
}

View File

@ -0,0 +1,229 @@
import { useState, useCallback, useMemo } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
import type { ExportOptions } from '../types/project';
export default function ExportDialog() {
const { videoPath, words, deletedRanges, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore();
const hasCuts = deletedRanges.length > 0;
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
mode: 'fast',
resolution: '1080p',
format: 'mp4',
enhanceAudio: false,
captions: 'none',
});
const handleExport = useCallback(async () => {
if (!videoPath) return;
const outputPath = await window.electronAPI?.saveFile({
defaultPath: videoPath.replace(/\.[^.]+$/, '_edited.mp4'),
filters: [
{ name: 'MP4', extensions: ['mp4'] },
{ name: 'MOV', extensions: ['mov'] },
{ name: 'WebM', extensions: ['webm'] },
],
});
if (!outputPath) return;
setExporting(true, 0);
try {
const keepSegments = getKeepSegments();
const deletedSet = new Set<number>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) deletedSet.add(idx);
}
const res = await fetch(`${backendUrl}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input_path: videoPath,
output_path: outputPath,
keep_segments: keepSegments,
words: options.captions !== 'none' ? words : undefined,
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
...options,
}),
});
if (!res.ok) throw new Error(`Export failed: ${res.statusText}`);
setExporting(false, 100);
} catch (err) {
console.error('Export error:', err);
setExporting(false);
}
}, [videoPath, options, backendUrl, setExporting, getKeepSegments]);
return (
<div className="p-4 space-y-5">
<h3 className="text-sm font-semibold">Export Video</h3>
{/* Mode */}
<fieldset className="space-y-2">
<legend className="text-xs text-editor-text-muted font-medium">Export Mode</legend>
<div className="grid grid-cols-2 gap-2">
<ModeCard
active={options.mode === 'fast'}
onClick={() => setOptions((o) => ({ ...o, mode: 'fast' }))}
icon={<Zap className="w-4 h-4" />}
title="Fast"
desc="Stream copy, seconds"
/>
<ModeCard
active={options.mode === 'reencode'}
onClick={() => setOptions((o) => ({ ...o, mode: 'reencode' }))}
icon={<Cog className="w-4 h-4" />}
title="Re-encode"
desc="Custom quality, slower"
/>
</div>
</fieldset>
{/* Resolution (only for re-encode) */}
{options.mode === 'reencode' && (
<SelectField
label="Resolution"
value={options.resolution}
onChange={(v) => setOptions((o) => ({ ...o, resolution: v as ExportOptions['resolution'] }))}
options={[
{ value: '720p', label: '720p (HD)' },
{ value: '1080p', label: '1080p (Full HD)' },
{ value: '4k', label: '4K (Ultra HD)' },
]}
/>
)}
{/* Format */}
<SelectField
label="Format"
value={options.format}
onChange={(v) => setOptions((o) => ({ ...o, format: v as ExportOptions['format'] }))}
options={[
{ value: 'mp4', label: 'MP4 (H.264)' },
{ value: 'mov', label: 'MOV (QuickTime)' },
{ value: 'webm', label: 'WebM (VP9)' },
]}
/>
{/* Audio enhancement */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={options.enhanceAudio}
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
/>
<span className="text-xs">Enhance audio (Studio Sound)</span>
</label>
{/* Captions */}
<SelectField
label="Captions"
value={options.captions}
onChange={(v) => setOptions((o) => ({ ...o, captions: v as ExportOptions['captions'] }))}
options={[
{ value: 'none', label: 'No captions' },
{ value: 'burn-in', label: 'Burn-in (permanent)' },
{ value: 'sidecar', label: 'Sidecar SRT file' },
]}
/>
{/* Export button */}
<button
onClick={handleExport}
disabled={isExporting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Exporting... {Math.round(exportProgress)}%
</>
) : (
<>
<Download className="w-4 h-4" />
Export
</>
)}
</button>
{options.mode === 'fast' && !hasCuts && (
<p className="text-[10px] text-editor-text-muted text-center">
Fast mode uses stream copy &mdash; no quality loss, exports in seconds.
</p>
)}
{options.mode === 'fast' && hasCuts && (
<div className="flex items-start gap-1.5 p-2 bg-editor-accent/10 rounded text-[10px] text-editor-accent">
<Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>
Word-level cuts require re-encoding for frame-accurate output. Export will
automatically use re-encode mode. This takes longer but ensures your cuts are precise.
</span>
</div>
)}
</div>
);
}
function ModeCard({
active,
onClick,
icon,
title,
desc,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
title: string;
desc: string;
}) {
return (
<button
onClick={onClick}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
active
? 'border-editor-accent bg-editor-accent/10'
: 'border-editor-border hover:border-editor-text-muted'
}`}
>
{icon}
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] text-editor-text-muted">{desc}</span>
</button>
);
}
function SelectField({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<div className="space-y-1">
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}

View File

@ -0,0 +1,192 @@
import { useAIStore } from '../store/aiStore';
import { useState, useEffect } from 'react';
import type { AIProvider } from '../types/project';
import { useEditorStore } from '../store/editorStore';
import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react';
export default function SettingsPanel() {
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
const { backendUrl } = useEditorStore();
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const fetchOllamaModels = async () => {
setLoadingModels(true);
try {
const res = await fetch(`${backendUrl}/ai/ollama-models`);
if (res.ok) {
const data = await res.json();
setOllamaModels(data.models || []);
}
} catch {
setOllamaModels([]);
} finally {
setLoadingModels(false);
}
};
useEffect(() => {
fetchOllamaModels();
}, [backendUrl]);
const providerIcons: Record<AIProvider, React.ReactNode> = {
ollama: <Bot className="w-4 h-4" />,
openai: <Cloud className="w-4 h-4" />,
claude: <Brain className="w-4 h-4" />,
};
const providerLabels: Record<AIProvider, string> = {
ollama: 'Ollama (Local)',
openai: 'OpenAI',
claude: 'Claude (Anthropic)',
};
return (
<div className="p-4 space-y-6">
<h3 className="text-sm font-semibold">AI Settings</h3>
{/* Default provider selector */}
<div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>
<div className="grid grid-cols-3 gap-1.5">
{(['ollama', 'openai', 'claude'] as AIProvider[]).map((p) => (
<button
key={p}
onClick={() => setDefaultProvider(p)}
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors text-[10px] ${
defaultProvider === p
? 'border-editor-accent bg-editor-accent/10 text-editor-accent'
: 'border-editor-border text-editor-text-muted hover:text-editor-text'
}`}
>
{providerIcons[p]}
{p.charAt(0).toUpperCase() + p.slice(1)}
</button>
))}
</div>
</div>
{/* Ollama settings */}
<ProviderSection title="Ollama (Local)" icon={providerIcons.ollama}>
<InputField
label="Base URL"
value={providers.ollama.baseUrl || ''}
onChange={(v) => setProviderConfig('ollama', { baseUrl: v })}
placeholder="http://localhost:11434"
/>
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-editor-text-muted">Model</label>
<button
onClick={fetchOllamaModels}
disabled={loadingModels}
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
>
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{ollamaModels.length > 0 ? (
<select
value={providers.ollama.model}
onChange={(e) => setProviderConfig('ollama', { model: e.target.value })}
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
>
{ollamaModels.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
) : (
<InputField
label=""
value={providers.ollama.model}
onChange={(v) => setProviderConfig('ollama', { model: v })}
placeholder="llama3"
/>
)}
</div>
</ProviderSection>
{/* OpenAI settings */}
<ProviderSection title="OpenAI" icon={providerIcons.openai}>
<InputField
label="API Key"
value={providers.openai.apiKey || ''}
onChange={(v) => setProviderConfig('openai', { apiKey: v })}
placeholder="sk-..."
type="password"
/>
<InputField
label="Model"
value={providers.openai.model}
onChange={(v) => setProviderConfig('openai', { model: v })}
placeholder="gpt-4o"
/>
</ProviderSection>
{/* Claude settings */}
<ProviderSection title="Claude (Anthropic)" icon={providerIcons.claude}>
<InputField
label="API Key"
value={providers.claude.apiKey || ''}
onChange={(v) => setProviderConfig('claude', { apiKey: v })}
placeholder="sk-ant-..."
type="password"
/>
<InputField
label="Model"
value={providers.claude.model}
onChange={(v) => setProviderConfig('claude', { model: v })}
placeholder="claude-sonnet-4-20250514"
/>
</ProviderSection>
</div>
);
}
function ProviderSection({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="space-y-3 p-3 bg-editor-surface rounded-lg">
<div className="flex items-center gap-2 text-xs font-medium">
{icon}
{title}
</div>
<div className="space-y-2">{children}</div>
</div>
);
}
function InputField({
label,
value,
onChange,
placeholder,
type = 'text',
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
}) {
return (
<div className="space-y-1">
{label && <label className="text-xs text-editor-text-muted">{label}</label>}
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full px-3 py-2 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent"
/>
</div>
);
}

View File

@ -0,0 +1,204 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Virtuoso } from 'react-virtuoso';
import { Trash2, RotateCcw } from 'lucide-react';
export default function TranscriptEditor() {
const words = useEditorStore((s) => s.words);
const segments = useEditorStore((s) => s.segments);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex);
const deleteSelectedWords = useEditorStore((s) => s.deleteSelectedWords);
const restoreRange = useEditorStore((s) => s.restoreRange);
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const selectionStart = useRef<number | null>(null);
const wasDragging = useRef(false);
const virtuosoRef = useRef<any>(null);
const deletedSet = useMemo(() => {
const s = new Set<number>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) s.add(idx);
}
return s;
}, [deletedRanges]);
const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]);
const [activeWordIndex, setActiveWordIndex] = useState(-1);
useEffect(() => {
if (words.length === 0) return;
const interval = setInterval(() => {
const video = document.querySelector('video') as HTMLVideoElement | null;
if (!video) return;
const idx = getWordAtTime(video.currentTime);
setActiveWordIndex((prev) => (prev === idx ? prev : idx));
}, 250);
return () => clearInterval(interval);
}, [words, getWordAtTime]);
// Auto-scroll to active segment via Virtuoso
useEffect(() => {
if (activeWordIndex < 0 || segments.length === 0) return;
const segIdx = segments.findIndex((seg) => {
const start = seg.globalStartIndex ?? 0;
return activeWordIndex >= start && activeWordIndex < start + seg.words.length;
});
if (segIdx >= 0 && virtuosoRef.current) {
virtuosoRef.current.scrollIntoView({ index: segIdx, behavior: 'smooth', align: 'center' });
}
}, [activeWordIndex, segments]);
const handleWordMouseDown = useCallback(
(index: number, e: React.MouseEvent) => {
e.preventDefault();
wasDragging.current = false;
if (e.shiftKey && selectedWordIndices.length > 0) {
const first = selectedWordIndices[0];
const start = Math.min(first, index);
const end = Math.max(first, index);
const indices = [];
for (let i = start; i <= end; i++) indices.push(i);
setSelectedWordIndices(indices);
} else {
selectionStart.current = index;
setSelectedWordIndices([index]);
}
},
[selectedWordIndices, setSelectedWordIndices],
);
const handleWordMouseEnter = useCallback(
(index: number) => {
setHoveredWordIndex(index);
if (selectionStart.current !== null) {
wasDragging.current = true;
const start = Math.min(selectionStart.current, index);
const end = Math.max(selectionStart.current, index);
const indices = [];
for (let i = start; i <= end; i++) indices.push(i);
setSelectedWordIndices(indices);
}
},
[setHoveredWordIndex, setSelectedWordIndices],
);
const handleMouseUp = useCallback(() => {
selectionStart.current = null;
}, []);
const handleClickOutside = useCallback(
(e: React.MouseEvent) => {
if (wasDragging.current) {
wasDragging.current = false;
return;
}
if ((e.target as HTMLElement).dataset.wordIndex === undefined) {
setSelectedWordIndices([]);
}
},
[setSelectedWordIndices],
);
const getRangeForWord = useCallback(
(wordIndex: number) => deletedRanges.find((r) => r.wordIndices.includes(wordIndex)),
[deletedRanges],
);
const renderSegment = useCallback(
(index: number) => {
const segment = segments[index];
if (!segment) return null;
return (
<div className="mb-3 px-4">
{segment.speaker && (
<div className="text-xs text-editor-accent font-medium mb-1">
{segment.speaker}
</div>
)}
<p className="text-sm leading-relaxed flex flex-wrap">
{segment.words.map((word, localIndex) => {
const globalIndex = (segment.globalStartIndex ?? 0) + localIndex;
const isDeleted = deletedSet.has(globalIndex);
const isSelected = selectedSet.has(globalIndex);
const isActive = globalIndex === activeWordIndex;
const isHovered = globalIndex === hoveredWordIndex;
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
return (
<span
key={globalIndex}
id={`word-${globalIndex}`}
data-word-index={globalIndex}
onMouseDown={(e) => handleWordMouseDown(globalIndex, e)}
onMouseEnter={() => handleWordMouseEnter(globalIndex)}
onMouseLeave={() => setHoveredWordIndex(null)}
className={`
relative px-[2px] py-[1px] rounded cursor-pointer transition-colors
${isDeleted ? 'line-through text-editor-text-muted/40 bg-editor-word-deleted' : ''}
${isSelected && !isDeleted ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isDeleted && !isSelected ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive ? 'bg-editor-word-hover' : ''}
`}
>
{word.word}{' '}
{isDeleted && isHovered && deletedRange && (
<button
onClick={(e) => {
e.stopPropagation();
restoreRange(deletedRange.id);
}}
className="absolute -top-5 left-1/2 -translate-x-1/2 flex items-center gap-0.5 px-1.5 py-0.5 bg-editor-surface border border-editor-border rounded text-[10px] text-editor-success whitespace-nowrap z-10"
>
<RotateCcw className="w-2.5 h-2.5" /> Restore
</button>
)}
</span>
);
})}
</p>
</div>
);
},
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, restoreRange],
);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0">
<span className="text-xs text-editor-text-muted flex-1">
{words.length} words &middot; {deletedRanges.length} cuts
</span>
{selectedWordIndices.length > 0 && (
<button
onClick={deleteSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-danger/20 text-editor-danger rounded hover:bg-editor-danger/30 transition-colors"
>
<Trash2 className="w-3 h-3" />
Delete {selectedWordIndices.length} words
</button>
)}
</div>
<div
className="flex-1 min-h-0 select-none"
onMouseUp={handleMouseUp}
onClick={handleClickOutside}
>
<Virtuoso
ref={virtuosoRef}
totalCount={segments.length}
itemContent={renderSegment}
overscan={200}
className="h-full"
style={{ height: '100%' }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
import { useRef, useCallback, useState, useEffect } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useVideoSync } from '../hooks/useVideoSync';
import { Play, Pause, SkipBack, SkipForward, Volume2 } from 'lucide-react';
export default function VideoPlayer() {
const videoRef = useRef<HTMLVideoElement>(null);
const videoUrl = useEditorStore((s) => s.videoUrl);
const isPlaying = useEditorStore((s) => s.isPlaying);
const duration = useEditorStore((s) => s.duration);
const { seekTo, togglePlay } = useVideoSync(videoRef);
const [displayTime, setDisplayTime] = useState(0);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
let raf = 0;
const tick = () => {
setDisplayTime(video.currentTime);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [videoUrl]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const handleProgressClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
seekTo(ratio * duration);
},
[seekTo, duration],
);
const skip = useCallback(
(delta: number) => {
const video = videoRef.current;
if (!video) return;
seekTo(Math.max(0, Math.min(duration, video.currentTime + delta)));
},
[seekTo, duration],
);
if (!videoUrl) {
return (
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-sm">
No video loaded
</div>
);
}
return (
<div className="w-full h-full flex flex-col">
<div className="flex-1 flex items-center justify-center bg-black rounded-lg overflow-hidden min-h-0">
<video
ref={videoRef}
src={videoUrl}
className="max-w-full max-h-full object-contain"
playsInline
onClick={togglePlay}
/>
</div>
<div className="pt-2 space-y-1.5 shrink-0">
<div
className="h-1.5 bg-editor-border rounded-full cursor-pointer group"
onClick={handleProgressClick}
>
<div
className="h-full bg-editor-accent rounded-full relative transition-all group-hover:h-2"
style={{ width: duration > 0 ? `${(displayTime / duration) * 100}%` : '0%' }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<ControlButton onClick={() => skip(-5)} title="Back 5s">
<SkipBack className="w-4 h-4" />
</ControlButton>
<ControlButton onClick={togglePlay} title={isPlaying ? 'Pause' : 'Play'} primary>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
</ControlButton>
<ControlButton onClick={() => skip(5)} title="Forward 5s">
<SkipForward className="w-4 h-4" />
</ControlButton>
</div>
<div className="flex items-center gap-3 text-xs text-editor-text-muted">
<Volume2 className="w-3.5 h-3.5" />
<span className="font-mono">
{formatTime(displayTime)} / {formatTime(duration)}
</span>
</div>
</div>
</div>
</div>
);
}
function ControlButton({
children,
onClick,
title,
primary,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
primary?: boolean;
}) {
return (
<button
onClick={onClick}
title={title}
className={`p-1.5 rounded-md transition-colors ${
primary
? 'bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
}`}
>
{children}
</button>
);
}

View File

@ -0,0 +1,220 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { ZoomIn, ZoomOut, AlertTriangle } from 'lucide-react';
export default function WaveformTimeline() {
const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [audioError, setAudioError] = useState<string | null>(null);
const videoUrl = useEditorStore((s) => s.videoUrl);
const videoPath = useEditorStore((s) => s.videoPath);
const duration = useEditorStore((s) => s.duration);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
const zoomRef = useRef(1);
const rafRef = useRef(0);
useEffect(() => {
if (!videoUrl || !videoPath) return;
setAudioError(null);
const loadAudio = async () => {
try {
const ctx = new AudioContext();
audioContextRef.current = ctx;
const response = await fetch(videoUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
audioBufferRef.current = audioBuffer;
drawStaticWaveform();
} catch (err) {
console.warn('Could not decode audio for waveform:', err);
setAudioError('Waveform unavailable — audio could not be decoded');
}
};
loadAudio();
return () => {
audioContextRef.current?.close();
};
}, [videoUrl, videoPath]);
const drawStaticWaveform = useCallback(() => {
const canvas = waveCanvasRef.current;
const buffer = audioBufferRef.current;
if (!canvas || !buffer) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
const channelData = buffer.getChannelData(0);
const samplesPerPixel = Math.floor(channelData.length / width);
ctx.clearRect(0, 0, width, height);
for (const range of deletedRanges) {
const x1 = (range.start / buffer.duration) * width;
const x2 = (range.end / buffer.duration) * width;
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)';
ctx.fillRect(x1, 0, x2 - x1, height);
}
const mid = height / 2;
ctx.beginPath();
ctx.strokeStyle = '#4a4d5e';
ctx.lineWidth = 1;
for (let x = 0; x < width; x++) {
const start = x * samplesPerPixel;
const end = Math.min(start + samplesPerPixel, channelData.length);
let min = 0;
let max = 0;
for (let i = start; i < end; i++) {
if (channelData[i] < min) min = channelData[i];
if (channelData[i] > max) max = channelData[i];
}
const yMin = mid + min * mid * 0.9;
const yMax = mid + max * mid * 0.9;
ctx.moveTo(x, yMin);
ctx.lineTo(x, yMax);
}
ctx.stroke();
}, [deletedRanges]);
// Redraw static layer when deletedRanges change
useEffect(() => {
drawStaticWaveform();
}, [drawStaticWaveform]);
// Lightweight RAF loop for playhead only -- reads video.currentTime directly,
// never triggers React re-renders
useEffect(() => {
const headCanvas = headCanvasRef.current;
const waveCanvas = waveCanvasRef.current;
if (!headCanvas || !waveCanvas) return;
const tick = () => {
const ctx = headCanvas.getContext('2d');
if (!ctx) { rafRef.current = requestAnimationFrame(tick); return; }
const buffer = audioBufferRef.current;
const video = document.querySelector('video') as HTMLVideoElement | null;
const dur = buffer?.duration ?? 0;
const dpr = window.devicePixelRatio || 1;
const rect = headCanvas.getBoundingClientRect();
if (headCanvas.width !== waveCanvas.width || headCanvas.height !== waveCanvas.height) {
headCanvas.width = rect.width * dpr;
headCanvas.height = rect.height * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const width = rect.width;
const height = rect.height;
ctx.clearRect(0, 0, width, height);
if (dur > 0 && video) {
const px = (video.currentTime / dur) * width;
ctx.beginPath();
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2;
ctx.moveTo(px, 0);
ctx.lineTo(px, height);
ctx.stroke();
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [videoUrl]);
useEffect(() => {
const observer = new ResizeObserver(() => {
drawStaticWaveform();
});
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, [drawStaticWaveform]);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (!headCanvasRef.current || duration === 0) return;
const rect = headCanvasRef.current.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const newTime = ratio * duration;
setCurrentTime(newTime);
const video = document.querySelector('video');
if (video) video.currentTime = newTime;
},
[duration, setCurrentTime],
);
if (!videoUrl) {
return (
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
Load a video to see the waveform
</div>
);
}
return (
<div ref={containerRef} className="w-full h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-1 shrink-0">
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
Timeline
</span>
<div className="flex items-center gap-1">
<button
onClick={() => { zoomRef.current = Math.max(0.5, zoomRef.current - 0.5); drawStaticWaveform(); }}
className="p-0.5 text-editor-text-muted hover:text-editor-text"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { zoomRef.current = Math.min(10, zoomRef.current + 0.5); drawStaticWaveform(); }}
className="p-0.5 text-editor-text-muted hover:text-editor-text"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
</div>
</div>
{audioError ? (
<div className="flex-1 flex items-center justify-center gap-2 text-editor-text-muted text-xs">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
<span>{audioError}</span>
</div>
) : (
<div className="flex-1 relative">
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
<canvas
ref={headCanvasRef}
className="absolute inset-0 w-full h-full cursor-crosshair"
onClick={handleClick}
/>
</div>
)}
</div>
);
}