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:
310
frontend/src/App.tsx
Normal file
310
frontend/src/App.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEditorStore } from './store/editorStore';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import TranscriptEditor from './components/TranscriptEditor';
|
||||
import WaveformTimeline from './components/WaveformTimeline';
|
||||
import AIPanel from './components/AIPanel';
|
||||
import ExportDialog from './components/ExportDialog';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import {
|
||||
Film,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Download,
|
||||
Loader2,
|
||||
FolderSearch,
|
||||
FileInput,
|
||||
} from 'lucide-react';
|
||||
|
||||
const IS_ELECTRON = !!window.electronAPI;
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
videoPath,
|
||||
words,
|
||||
isTranscribing,
|
||||
transcriptionProgress,
|
||||
loadVideo,
|
||||
setBackendUrl,
|
||||
setTranscription,
|
||||
setTranscribing,
|
||||
backendUrl,
|
||||
} = useEditorStore();
|
||||
|
||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||
const [manualPath, setManualPath] = useState('');
|
||||
const [whisperModel, setWhisperModel] = useState('base');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useKeyboardShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_ELECTRON) {
|
||||
window.electronAPI!.getBackendUrl().then(setBackendUrl);
|
||||
}
|
||||
}, [setBackendUrl]);
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
if (!IS_ELECTRON) return;
|
||||
try {
|
||||
const projectPath = await window.electronAPI!.openProject();
|
||||
if (!projectPath) return;
|
||||
const content = await window.electronAPI!.readFile(projectPath);
|
||||
const data = JSON.parse(content);
|
||||
useEditorStore.getState().loadProject(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
alert(`Failed to load project: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFile = async () => {
|
||||
if (IS_ELECTRON) {
|
||||
const path = await window.electronAPI!.openFile();
|
||||
if (path) {
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
} else {
|
||||
// Browser: use the manual path input
|
||||
const path = manualPath.trim();
|
||||
if (path) {
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const path = manualPath.trim();
|
||||
if (!path) return;
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
};
|
||||
|
||||
const transcribeVideo = async (path: string) => {
|
||||
setTranscribing(true, 0);
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: path, model: whisperModel }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Transcription failed: ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
setTranscription(data);
|
||||
} catch (err) {
|
||||
console.error('Transcription error:', err);
|
||||
alert(`Transcription failed. Check the console for details.\n\n${err}`);
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Panel) =>
|
||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||
|
||||
if (!videoPath) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center gap-8 bg-editor-bg px-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Film className="w-14 h-14 text-editor-accent opacity-80" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">CutScript</h1>
|
||||
<p className="text-editor-text-muted text-sm max-w-sm text-center">
|
||||
Open-source text-based video editing powered by AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper model selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-editor-text-muted whitespace-nowrap">Whisper model:</label>
|
||||
<select
|
||||
value={whisperModel}
|
||||
onChange={(e) => setWhisperModel(e.target.value)}
|
||||
className="px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
|
||||
>
|
||||
<option value="tiny">tiny (~75 MB, fastest)</option>
|
||||
<option value="base">base (~140 MB, fast)</option>
|
||||
<option value="small">small (~460 MB, good)</option>
|
||||
<option value="medium">medium (~1.5 GB, better)</option>
|
||||
<option value="large">large (~2.9 GB, best)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{IS_ELECTRON ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button
|
||||
onClick={handleOpenFile}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
Open Video File
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLoadProject}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
||||
>
|
||||
<FileInput className="w-4 h-4" />
|
||||
Load Project (.aive)
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Browser: manual path input */
|
||||
<div className="w-full max-w-lg space-y-3">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-editor-warning/10 border border-editor-warning/30 rounded-lg">
|
||||
<span className="text-editor-warning text-xs">
|
||||
Running in browser — paste the full path to your video file below.
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={handleManualSubmit} className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<FolderSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-editor-text-muted pointer-events-none" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="text"
|
||||
value={manualPath}
|
||||
onChange={(e) => setManualPath(e.target.value)}
|
||||
placeholder="C:\Videos\my-video.mp4"
|
||||
className="w-full pl-9 pr-3 py-2.5 bg-editor-surface border border-editor-border rounded-lg text-sm text-editor-text placeholder:text-editor-text-muted/40 focus:outline-none focus:border-editor-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!manualPath.trim()}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm text-white font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Film className="w-4 h-4" />
|
||||
Load & Transcribe
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-[11px] text-editor-text-muted text-center">
|
||||
Supported: MP4, AVI, MOV, MKV, WebM, M4A
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="h-12 flex items-center justify-between px-4 border-b border-editor-border shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Film className="w-5 h-5 text-editor-accent" />
|
||||
<span className="text-sm font-medium truncate max-w-[300px]">
|
||||
{videoPath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={<FolderOpen className="w-4 h-4" />}
|
||||
label="Open"
|
||||
onClick={IS_ELECTRON ? handleOpenFile : () => useEditorStore.getState().reset()}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label="AI"
|
||||
active={activePanel === 'ai'}
|
||||
onClick={() => togglePanel('ai')}
|
||||
disabled={words.length === 0}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
label="Export"
|
||||
active={activePanel === 'export'}
|
||||
onClick={() => togglePanel('export')}
|
||||
disabled={words.length === 0}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Settings className="w-4 h-4" />}
|
||||
label="Settings"
|
||||
active={activePanel === 'settings'}
|
||||
onClick={() => togglePanel('settings')}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: video + transcript */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Video player */}
|
||||
<div className="w-1/2 p-3 flex items-center justify-center bg-black/20">
|
||||
<VideoPlayer />
|
||||
</div>
|
||||
|
||||
{/* Transcript */}
|
||||
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
||||
{isTranscribing ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 className="w-8 h-8 text-editor-accent animate-spin" />
|
||||
<p className="text-sm text-editor-text-muted">
|
||||
Transcribing... {Math.round(transcriptionProgress)}%
|
||||
</p>
|
||||
</div>
|
||||
) : words.length > 0 ? (
|
||||
<TranscriptEditor />
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-editor-text-muted text-sm">
|
||||
No transcript yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform timeline */}
|
||||
<div className="h-32 border-t border-editor-border shrink-0">
|
||||
<WaveformTimeline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel (AI / Export / Settings) */}
|
||||
{activePanel && (
|
||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-editor-accent text-white'
|
||||
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
|
||||
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
332
frontend/src/components/AIPanel.tsx
Normal file
332
frontend/src/components/AIPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/ExportDialog.tsx
Normal file
229
frontend/src/components/ExportDialog.tsx
Normal 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 — 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>
|
||||
);
|
||||
}
|
||||
192
frontend/src/components/SettingsPanel.tsx
Normal file
192
frontend/src/components/SettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/TranscriptEditor.tsx
Normal file
204
frontend/src/components/TranscriptEditor.tsx
Normal 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 · {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>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/VideoPlayer.tsx
Normal file
133
frontend/src/components/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/WaveformTimeline.tsx
Normal file
220
frontend/src/components/WaveformTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
236
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const deleteSelectedWords = useEditorStore((s) => s.deleteSelectedWords);
|
||||
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
|
||||
|
||||
const playbackRateRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
const getVideo = (): HTMLVideoElement | null => document.querySelector('video');
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
|
||||
const video = getVideo();
|
||||
|
||||
switch (true) {
|
||||
// --- Undo / Redo ---
|
||||
case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: {
|
||||
e.preventDefault();
|
||||
useEditorStore.temporal.getState().redo();
|
||||
return;
|
||||
}
|
||||
case e.key === 'z' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
useEditorStore.temporal.getState().undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Delete / Backspace: delete selected words ---
|
||||
case e.key === 'Delete' || e.key === 'Backspace': {
|
||||
if (selectedWordIndices.length > 0) {
|
||||
e.preventDefault();
|
||||
deleteSelectedWords();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Space: play / pause ---
|
||||
case e.key === ' ' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
if (video.paused) video.play();
|
||||
else video.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- J: reverse / slow down ---
|
||||
case e.key === 'j' || e.key === 'J': {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
|
||||
if (playbackRateRef.current < 0) {
|
||||
// HTML5 video doesn't support negative rates natively; step back
|
||||
video.currentTime = Math.max(0, video.currentTime - 2);
|
||||
} else {
|
||||
video.playbackRate = playbackRateRef.current;
|
||||
if (video.paused) video.play();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- K: pause ---
|
||||
case e.key === 'k' || e.key === 'K': {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
video.pause();
|
||||
playbackRateRef.current = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- L: forward / speed up ---
|
||||
case e.key === 'l' || e.key === 'L': {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5);
|
||||
video.playbackRate = Math.max(0.25, playbackRateRef.current);
|
||||
if (video.paused) video.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Arrow Left: seek back 5s ---
|
||||
case e.key === 'ArrowLeft' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
if (video) video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Arrow Right: seek forward 5s ---
|
||||
case e.key === 'ArrowRight' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- [ mark in-point (home) ---
|
||||
case e.key === '[': {
|
||||
e.preventDefault();
|
||||
if (video) video.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- ] mark out-point (end) ---
|
||||
case e.key === ']': {
|
||||
e.preventDefault();
|
||||
if (video) video.currentTime = video.duration;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ctrl+S: save project ---
|
||||
case e.key === 's' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
saveProject();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ctrl+E: export ---
|
||||
case e.key === 'e' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
// Trigger export panel via DOM click
|
||||
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
|
||||
if (exportBtn) exportBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- ?: show shortcut cheatsheet ---
|
||||
case e.key === '?' || (e.key === '/' && e.shiftKey): {
|
||||
e.preventDefault();
|
||||
toggleCheatsheet();
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [deleteSelectedWords, selectedWordIndices]);
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
const state = useEditorStore.getState();
|
||||
if (!state.videoPath || state.words.length === 0) return;
|
||||
|
||||
try {
|
||||
const projectData = {
|
||||
version: 1,
|
||||
videoPath: state.videoPath,
|
||||
words: state.words,
|
||||
segments: state.segments,
|
||||
deletedRanges: state.deletedRanges,
|
||||
language: state.language,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const outputPath = await window.electronAPI?.saveFile({
|
||||
defaultPath: state.videoPath.replace(/\.[^.]+$/, '.aive'),
|
||||
filters: [{ name: 'CutScript Project', extensions: ['aive'] }],
|
||||
});
|
||||
|
||||
if (outputPath) {
|
||||
if (window.electronAPI?.writeFile) {
|
||||
await window.electronAPI.writeFile(outputPath, JSON.stringify(projectData, null, 2));
|
||||
} else {
|
||||
const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = outputPath.split(/[\\/]/).pop() || 'project.aive';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save project:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let cheatsheetVisible = false;
|
||||
|
||||
function toggleCheatsheet() {
|
||||
const existing = document.getElementById('keyboard-cheatsheet');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
cheatsheetVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
cheatsheetVisible = true;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'keyboard-cheatsheet';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);';
|
||||
overlay.onclick = () => {
|
||||
overlay.remove();
|
||||
cheatsheetVisible = false;
|
||||
};
|
||||
|
||||
const shortcuts = [
|
||||
['Space', 'Play / Pause'],
|
||||
['J', 'Reverse / Slow down'],
|
||||
['K', 'Pause'],
|
||||
['L', 'Forward / Speed up'],
|
||||
['\u2190 / \u2192', 'Seek \u00b15 seconds'],
|
||||
['Delete', 'Delete selected words'],
|
||||
['Ctrl+Z', 'Undo'],
|
||||
['Ctrl+Shift+Z', 'Redo'],
|
||||
['Ctrl+S', 'Save project'],
|
||||
['Ctrl+E', 'Export'],
|
||||
['?', 'This cheatsheet'],
|
||||
];
|
||||
|
||||
const rows = shortcuts
|
||||
.map(
|
||||
([key, desc]) =>
|
||||
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600">${key}</td><td style="padding:6px 0;color:#e2e8f0">${desc}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:400px;" onclick="event.stopPropagation()">
|
||||
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
||||
<table style="font-size:13px">${rows}</table>
|
||||
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Press ? or click outside to close</p>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
69
frontend/src/hooks/useVideoSync.ts
Normal file
69
frontend/src/hooks/useVideoSync.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
|
||||
export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>) {
|
||||
const rafRef = useRef<number>(0);
|
||||
const {
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setIsPlaying,
|
||||
deletedRanges,
|
||||
} = useEditorStore();
|
||||
|
||||
const seekTo = useCallback(
|
||||
(time: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
},
|
||||
[videoRef, setCurrentTime],
|
||||
);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const t = video.currentTime;
|
||||
for (const range of deletedRanges) {
|
||||
if (t >= range.start && t < range.end) {
|
||||
video.currentTime = range.end;
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentTime(t);
|
||||
});
|
||||
};
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onLoadedMetadata = () => setDuration(video.duration);
|
||||
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [videoRef, deletedRanges, setCurrentTime, setIsPlaying, setDuration]);
|
||||
|
||||
return { seekTo, togglePlay };
|
||||
}
|
||||
37
frontend/src/index.css
Normal file
37
frontend/src/index.css
Normal file
@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a2d3a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3d4a;
|
||||
}
|
||||
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
129
frontend/src/store/aiStore.ts
Normal file
129
frontend/src/store/aiStore.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { AIProvider, AIProviderConfig, FillerWordResult, ClipSuggestion } from '../types/project';
|
||||
|
||||
const ENCRYPTED_KEY_PREFIX = 'aive_enc_';
|
||||
|
||||
interface AIState {
|
||||
providers: Record<AIProvider, AIProviderConfig>;
|
||||
defaultProvider: AIProvider;
|
||||
customFillerWords: string;
|
||||
fillerResult: FillerWordResult | null;
|
||||
clipSuggestions: ClipSuggestion[];
|
||||
isProcessing: boolean;
|
||||
processingMessage: string;
|
||||
_keysHydrated: boolean;
|
||||
}
|
||||
|
||||
interface AIActions {
|
||||
setProviderConfig: (provider: AIProvider, config: Partial<AIProviderConfig>) => void;
|
||||
setDefaultProvider: (provider: AIProvider) => void;
|
||||
setCustomFillerWords: (words: string) => void;
|
||||
setFillerResult: (result: FillerWordResult | null) => void;
|
||||
setClipSuggestions: (suggestions: ClipSuggestion[]) => void;
|
||||
setProcessing: (active: boolean, message?: string) => void;
|
||||
hydrateKeys: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function encryptAndStore(key: string, value: string): Promise<void> {
|
||||
if (!value) {
|
||||
localStorage.removeItem(ENCRYPTED_KEY_PREFIX + key);
|
||||
return;
|
||||
}
|
||||
if (window.electronAPI) {
|
||||
const encrypted = await window.electronAPI.encryptString(value);
|
||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, encrypted);
|
||||
} else {
|
||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, btoa(value));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndDecrypt(key: string): Promise<string> {
|
||||
const stored = localStorage.getItem(ENCRYPTED_KEY_PREFIX + key);
|
||||
if (!stored) return '';
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
return await window.electronAPI.decryptString(stored);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
try {
|
||||
return atob(stored);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const useAIStore = create<AIState & AIActions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
providers: {
|
||||
ollama: { provider: 'ollama', baseUrl: 'http://localhost:11434', model: 'llama3' },
|
||||
openai: { provider: 'openai', apiKey: '', model: 'gpt-4o' },
|
||||
claude: { provider: 'claude', apiKey: '', model: 'claude-sonnet-4-20250514' },
|
||||
},
|
||||
defaultProvider: 'ollama',
|
||||
customFillerWords: '',
|
||||
fillerResult: null,
|
||||
clipSuggestions: [],
|
||||
isProcessing: false,
|
||||
processingMessage: '',
|
||||
_keysHydrated: false,
|
||||
|
||||
setProviderConfig: (provider, config) => {
|
||||
set((state) => ({
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: { ...state.providers[provider], ...config },
|
||||
},
|
||||
}));
|
||||
|
||||
if (config.apiKey !== undefined) {
|
||||
encryptAndStore(`${provider}_apiKey`, config.apiKey);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultProvider: (provider) => set({ defaultProvider: provider }),
|
||||
|
||||
setCustomFillerWords: (words) => set({ customFillerWords: words }),
|
||||
|
||||
setFillerResult: (result) => set({ fillerResult: result }),
|
||||
|
||||
setClipSuggestions: (suggestions) => set({ clipSuggestions: suggestions }),
|
||||
|
||||
setProcessing: (active, message) =>
|
||||
set({ isProcessing: active, processingMessage: message ?? '' }),
|
||||
|
||||
hydrateKeys: async () => {
|
||||
const [openaiKey, claudeKey] = await Promise.all([
|
||||
loadAndDecrypt('openai_apiKey'),
|
||||
loadAndDecrypt('claude_apiKey'),
|
||||
]);
|
||||
const state = get();
|
||||
set({
|
||||
providers: {
|
||||
...state.providers,
|
||||
openai: { ...state.providers.openai, apiKey: openaiKey },
|
||||
claude: { ...state.providers.claude, apiKey: claudeKey },
|
||||
},
|
||||
_keysHydrated: true,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'aive-ai-settings',
|
||||
partialize: (state) => ({
|
||||
providers: {
|
||||
ollama: { ...state.providers.ollama, apiKey: undefined },
|
||||
openai: { ...state.providers.openai, apiKey: '' },
|
||||
claude: { ...state.providers.claude, apiKey: '' },
|
||||
},
|
||||
defaultProvider: state.defaultProvider,
|
||||
customFillerWords: state.customFillerWords,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
useAIStore.getState().hydrateKeys();
|
||||
232
frontend/src/store/editorStore.ts
Normal file
232
frontend/src/store/editorStore.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { create } from 'zustand';
|
||||
import { temporal } from 'zundo';
|
||||
import type { Word, Segment, DeletedRange, TranscriptionResult } from '../types/project';
|
||||
|
||||
interface EditorState {
|
||||
videoPath: string | null;
|
||||
videoUrl: string | null;
|
||||
words: Word[];
|
||||
segments: Segment[];
|
||||
deletedRanges: DeletedRange[];
|
||||
language: string;
|
||||
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
|
||||
selectedWordIndices: number[];
|
||||
hoveredWordIndex: number | null;
|
||||
|
||||
isTranscribing: boolean;
|
||||
transcriptionProgress: number;
|
||||
isExporting: boolean;
|
||||
exportProgress: number;
|
||||
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
interface EditorActions {
|
||||
setBackendUrl: (url: string) => void;
|
||||
loadVideo: (path: string) => void;
|
||||
setTranscription: (result: TranscriptionResult) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setSelectedWordIndices: (indices: number[]) => void;
|
||||
setHoveredWordIndex: (index: number | null) => void;
|
||||
deleteSelectedWords: () => void;
|
||||
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
||||
restoreRange: (rangeId: string) => void;
|
||||
setTranscribing: (active: boolean, progress?: number) => void;
|
||||
setExporting: (active: boolean, progress?: number) => void;
|
||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||
getWordAtTime: (time: number) => number;
|
||||
loadProject: (projectData: any) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: EditorState = {
|
||||
videoPath: null,
|
||||
videoUrl: null,
|
||||
words: [],
|
||||
segments: [],
|
||||
deletedRanges: [],
|
||||
language: '',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isPlaying: false,
|
||||
selectedWordIndices: [],
|
||||
hoveredWordIndex: null,
|
||||
isTranscribing: false,
|
||||
transcriptionProgress: 0,
|
||||
isExporting: false,
|
||||
exportProgress: 0,
|
||||
backendUrl: 'http://localhost:8642',
|
||||
};
|
||||
|
||||
let nextRangeId = 1;
|
||||
|
||||
export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
temporal(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setBackendUrl: (url) => set({ backendUrl: url }),
|
||||
|
||||
loadVideo: (path) => {
|
||||
const backend = get().backendUrl;
|
||||
const url = `${backend}/file?path=${encodeURIComponent(path)}`;
|
||||
set({
|
||||
...initialState,
|
||||
backendUrl: backend,
|
||||
videoPath: path,
|
||||
videoUrl: url,
|
||||
});
|
||||
},
|
||||
|
||||
setTranscription: (result) => {
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = result.segments.map((seg) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
globalIdx += seg.words.length;
|
||||
return annotated;
|
||||
});
|
||||
set({
|
||||
words: result.words,
|
||||
segments: annotatedSegments,
|
||||
language: result.language,
|
||||
deletedRanges: [],
|
||||
selectedWordIndices: [],
|
||||
});
|
||||
},
|
||||
|
||||
setCurrentTime: (time) => set({ currentTime: time }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||
|
||||
deleteSelectedWords: () => {
|
||||
const { selectedWordIndices, words, deletedRanges } = get();
|
||||
if (selectedWordIndices.length === 0) return;
|
||||
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
|
||||
const newRange: DeletedRange = {
|
||||
id: `dr_${nextRangeId++}`,
|
||||
start: startWord.start,
|
||||
end: endWord.end,
|
||||
wordIndices: sorted,
|
||||
};
|
||||
|
||||
set({
|
||||
deletedRanges: [...deletedRanges, newRange],
|
||||
selectedWordIndices: [],
|
||||
});
|
||||
},
|
||||
|
||||
deleteWordRange: (startIndex, endIndex) => {
|
||||
const { words, deletedRanges } = get();
|
||||
const indices = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) indices.push(i);
|
||||
|
||||
const newRange: DeletedRange = {
|
||||
id: `dr_${nextRangeId++}`,
|
||||
start: words[startIndex].start,
|
||||
end: words[endIndex].end,
|
||||
wordIndices: indices,
|
||||
};
|
||||
|
||||
set({ deletedRanges: [...deletedRanges, newRange] });
|
||||
},
|
||||
|
||||
restoreRange: (rangeId) => {
|
||||
const { deletedRanges } = get();
|
||||
set({ deletedRanges: deletedRanges.filter((r) => r.id !== rangeId) });
|
||||
},
|
||||
|
||||
setTranscribing: (active, progress) =>
|
||||
set({
|
||||
isTranscribing: active,
|
||||
transcriptionProgress: progress ?? (active ? 0 : 100),
|
||||
}),
|
||||
|
||||
setExporting: (active, progress) =>
|
||||
set({
|
||||
isExporting: active,
|
||||
exportProgress: progress ?? (active ? 0 : 100),
|
||||
}),
|
||||
|
||||
getKeepSegments: () => {
|
||||
const { words, deletedRanges, duration } = get();
|
||||
if (words.length === 0) return [{ start: 0, end: duration }];
|
||||
|
||||
const deletedSet = new Set<number>();
|
||||
for (const range of deletedRanges) {
|
||||
for (const idx of range.wordIndices) deletedSet.add(idx);
|
||||
}
|
||||
|
||||
const segments: Array<{ start: number; end: number }> = [];
|
||||
let segStart: number | null = null;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (!deletedSet.has(i)) {
|
||||
if (segStart === null) segStart = words[i].start;
|
||||
} else {
|
||||
if (segStart !== null) {
|
||||
segments.push({ start: segStart, end: words[i - 1].end });
|
||||
segStart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (segStart !== null) {
|
||||
segments.push({ start: segStart, end: words[words.length - 1].end });
|
||||
}
|
||||
|
||||
return segments;
|
||||
},
|
||||
|
||||
getWordAtTime: (time) => {
|
||||
const { words } = get();
|
||||
let lo = 0;
|
||||
let hi = words.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (words[mid].end < time) lo = mid + 1;
|
||||
else if (words[mid].start > time) hi = mid - 1;
|
||||
else return mid;
|
||||
}
|
||||
return lo < words.length ? lo : words.length - 1;
|
||||
},
|
||||
|
||||
loadProject: (data) => {
|
||||
const backend = get().backendUrl;
|
||||
const url = `${backend}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
globalIdx += seg.words.length;
|
||||
return annotated;
|
||||
});
|
||||
|
||||
set({
|
||||
...initialState,
|
||||
backendUrl: backend,
|
||||
videoPath: data.videoPath,
|
||||
videoUrl: url,
|
||||
words: data.words || [],
|
||||
segments: annotatedSegments,
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
language: data.language || '',
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{ limit: 100 },
|
||||
),
|
||||
);
|
||||
86
frontend/src/types/project.ts
Normal file
86
frontend/src/types/project.ts
Normal file
@ -0,0 +1,86 @@
|
||||
export interface Word {
|
||||
word: string;
|
||||
start: number;
|
||||
end: number;
|
||||
confidence: number;
|
||||
speaker?: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
words: Word[];
|
||||
speaker?: string;
|
||||
globalStartIndex: number;
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface DeletedRange extends TimeRange {
|
||||
id: string;
|
||||
wordIndices: number[];
|
||||
}
|
||||
|
||||
export interface ProjectFile {
|
||||
version: 1;
|
||||
videoPath: string;
|
||||
words: Word[];
|
||||
segments: Segment[];
|
||||
deletedRanges: DeletedRange[];
|
||||
language: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
words: Word[];
|
||||
segments: Segment[];
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
outputPath: string;
|
||||
mode: 'fast' | 'reencode';
|
||||
resolution: '720p' | '1080p' | '4k';
|
||||
format: 'mp4' | 'mov' | 'webm';
|
||||
enhanceAudio: boolean;
|
||||
captions: 'none' | 'burn-in' | 'sidecar';
|
||||
captionStyle?: CaptionStyle;
|
||||
}
|
||||
|
||||
export interface CaptionStyle {
|
||||
fontName: string;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
backgroundColor: string;
|
||||
position: 'bottom' | 'top' | 'center';
|
||||
bold: boolean;
|
||||
}
|
||||
|
||||
export type AIProvider = 'ollama' | 'openai' | 'claude';
|
||||
|
||||
export interface AIProviderConfig {
|
||||
provider: AIProvider;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface FillerWordResult {
|
||||
wordIndices: number[];
|
||||
fillerWords: Array<{ index: number; word: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface ClipSuggestion {
|
||||
title: string;
|
||||
startWordIndex: number;
|
||||
endWordIndex: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
reason: string;
|
||||
}
|
||||
16
frontend/src/vite-env.d.ts
vendored
Normal file
16
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ElectronAPI {
|
||||
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||
saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||
openProject: () => Promise<string | null>;
|
||||
getBackendUrl: () => Promise<string>;
|
||||
encryptString: (data: string) => Promise<string>;
|
||||
decryptString: (encrypted: string) => Promise<string>;
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
}
|
||||
Reference in New Issue
Block a user