1086 lines
41 KiB
TypeScript
1086 lines
41 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback, 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 DevPanel from './components/DevPanel';
|
|
import MarkersPanel from './components/MarkersPanel';
|
|
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
|
import ZoneEditor from './components/ZoneEditor';
|
|
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
|
import AppendClipPanel from './components/AppendClipPanel';
|
|
import LicenseDialog from './components/LicenseDialog';
|
|
import HelpContent from './components/HelpContent';
|
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
|
import { useLicenseStore } from './store/licenseStore';
|
|
import {
|
|
FolderOpen,
|
|
Settings,
|
|
Sparkles,
|
|
Download,
|
|
FileInput,
|
|
Save,
|
|
Scissors,
|
|
VolumeX,
|
|
SlidersHorizontal,
|
|
Gauge,
|
|
FilePlus2,
|
|
RefreshCw,
|
|
Grid3x3,
|
|
MapPin,
|
|
Music,
|
|
ListVideo,
|
|
Clock,
|
|
AlertTriangle,
|
|
HelpCircle,
|
|
} from 'lucide-react';
|
|
|
|
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
|
|
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
|
|
|
export default function App() {
|
|
const {
|
|
projectFilePath,
|
|
videoPath,
|
|
exportedAudioPath,
|
|
words,
|
|
segments,
|
|
cutRanges,
|
|
muteRanges,
|
|
gainRanges,
|
|
speedRanges,
|
|
globalGainDb,
|
|
silenceTrimGroups,
|
|
transcriptionModel,
|
|
language,
|
|
isTranscribing,
|
|
transcriptionStatus,
|
|
markInTime,
|
|
markOutTime,
|
|
loadVideo,
|
|
setProjectFilePath,
|
|
setBackendUrl,
|
|
clearMarkRange,
|
|
setTranscription,
|
|
setTranscriptionModel,
|
|
setTranscribing,
|
|
selectedWordIndices,
|
|
addCutRange,
|
|
addMuteRange,
|
|
addGainRange,
|
|
addSpeedRange,
|
|
backendUrl,
|
|
} = useEditorStore();
|
|
|
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
|
const [projectName, setProjectName] = useState<string | null>(null);
|
|
const [splitRatio, setSplitRatio] = useState(() => {
|
|
try { return Number(localStorage.getItem('talkedit:splitRatio')) || 0.5; } catch { return 0.5; }
|
|
});
|
|
const splitRef = useRef<HTMLDivElement>(null);
|
|
const isDraggingSplit = useRef(false);
|
|
|
|
const startSplitDrag = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
isDraggingSplit.current = true;
|
|
const container = splitRef.current?.parentElement;
|
|
if (!container) return;
|
|
const rect = container.getBoundingClientRect();
|
|
const onMove = (me: MouseEvent) => {
|
|
if (!isDraggingSplit.current) return;
|
|
const pct = (me.clientX - rect.left) / rect.width;
|
|
const clamped = Math.max(0.15, Math.min(0.85, pct));
|
|
setSplitRatio(clamped);
|
|
localStorage.setItem('talkedit:splitRatio', String(clamped));
|
|
};
|
|
const onUp = () => { isDraggingSplit.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
}, []);
|
|
|
|
// Draggable right sidebar
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
try { return Number(localStorage.getItem('talkedit:sidebarWidth')) || 320; } catch { return 320; }
|
|
});
|
|
const isDraggingSidebar = useRef(false);
|
|
|
|
const startSidebarDrag = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
isDraggingSidebar.current = true;
|
|
const container = document.querySelector('.main-content') as HTMLElement;
|
|
if (!container) return;
|
|
const rect = container.getBoundingClientRect();
|
|
const onMove = (me: MouseEvent) => {
|
|
if (!isDraggingSidebar.current) return;
|
|
const w = rect.right - me.clientX;
|
|
const clamped = Math.max(180, Math.min(600, w));
|
|
setSidebarWidth(clamped);
|
|
localStorage.setItem('talkedit:sidebarWidth', String(clamped));
|
|
};
|
|
const onUp = () => { isDraggingSidebar.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
}, []);
|
|
|
|
const [whisperModel, setWhisperModel] = useState('base');
|
|
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
|
const [cutMode, setCutMode] = useState(false);
|
|
const [muteMode, setMuteMode] = useState(false);
|
|
const [gainMode, setGainMode] = useState(false);
|
|
const [gainModeDb, setGainModeDb] = useState(3);
|
|
const [speedMode, setSpeedMode] = useState(false);
|
|
const [speedModeValue, setSpeedModeValue] = useState(1.25);
|
|
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
|
|
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
|
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
|
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
|
const [showFileMenu, setShowFileMenu] = useState(false);
|
|
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
|
const [recoveryData, setRecoveryData] = useState<any>(null);
|
|
const [recoveryMinutesAgo, setRecoveryMinutesAgo] = useState(0);
|
|
const [backendDown, setBackendDown] = useState(false);
|
|
const canEdit = useLicenseStore((s) => s.canEdit);
|
|
const licenseStatus = useLicenseStore((s) => s.status);
|
|
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
|
|
|
const projectSignature = useMemo(() => {
|
|
if (!videoPath) return null;
|
|
return JSON.stringify({
|
|
videoPath,
|
|
exportedAudioPath,
|
|
words,
|
|
segments,
|
|
cutRanges,
|
|
muteRanges,
|
|
gainRanges,
|
|
speedRanges,
|
|
globalGainDb,
|
|
silenceTrimGroups,
|
|
transcriptionModel,
|
|
language,
|
|
});
|
|
}, [
|
|
videoPath,
|
|
exportedAudioPath,
|
|
words,
|
|
segments,
|
|
cutRanges,
|
|
muteRanges,
|
|
gainRanges,
|
|
speedRanges,
|
|
globalGainDb,
|
|
silenceTrimGroups,
|
|
transcriptionModel,
|
|
language,
|
|
]);
|
|
|
|
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
|
|
|
const loadProjectFromData = (data: any) => {
|
|
const removedCount = useEditorStore.getState().loadProject(data);
|
|
if (removedCount > 0) {
|
|
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
|
|
}
|
|
const loadedSignature = JSON.stringify({
|
|
videoPath: data.videoPath,
|
|
exportedAudioPath: data.exportedAudioPath ?? null,
|
|
words: data.words || [],
|
|
segments: data.segments || [],
|
|
cutRanges: [...(data.cutRanges || []), ...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }))],
|
|
muteRanges: data.muteRanges || [],
|
|
gainRanges: data.gainRanges || [],
|
|
speedRanges: data.speedRanges || [],
|
|
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
|
silenceTrimGroups: data.silenceTrimGroups || [],
|
|
transcriptionModel: data.transcriptionModel ?? null,
|
|
language: data.language || '',
|
|
});
|
|
setLastSavedSignature(loadedSignature);
|
|
};
|
|
|
|
const runGuarded = async (action: () => Promise<void>) => {
|
|
if (!hasUnsavedChanges) {
|
|
await action();
|
|
return;
|
|
}
|
|
setPendingProceedAction(() => action);
|
|
setShowUnsavedPrompt(true);
|
|
};
|
|
|
|
useKeyboardShortcuts();
|
|
|
|
useEffect(() => {
|
|
useLicenseStore.getState().checkStatus();
|
|
window.electronAPI?.readAutosave().then((data) => {
|
|
if (data) {
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const savedAt = parsed.savedAt;
|
|
const minutesAgo = savedAt ? Math.round((Date.now() - savedAt) / 60000) : 0;
|
|
setRecoveryData(parsed);
|
|
setRecoveryMinutesAgo(minutesAgo);
|
|
setShowRecoveryDialog(true);
|
|
} catch {}
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// Handle Escape key to exit timeline zone modes and close menus
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setCutMode(false);
|
|
setMuteMode(false);
|
|
setGainMode(false);
|
|
setSpeedMode(false);
|
|
setShowFileMenu(false);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.electronAPI!.getBackendUrl().then(setBackendUrl);
|
|
}, [setBackendUrl]);
|
|
|
|
useEffect(() => {
|
|
if (videoPath) return;
|
|
const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY);
|
|
if (savedPath) {
|
|
loadVideo(savedPath);
|
|
}
|
|
}, [videoPath, loadVideo]);
|
|
|
|
useEffect(() => {
|
|
if (videoPath) {
|
|
sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath);
|
|
return;
|
|
}
|
|
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
|
}, [videoPath]);
|
|
|
|
useEffect(() => {
|
|
if (!videoPath) return;
|
|
|
|
const interval = setInterval(() => {
|
|
const state = useEditorStore.getState();
|
|
const hasData = state.words.length > 0 || state.cutRanges.length > 0 || state.muteRanges.length > 0 || state.gainRanges.length > 0 || state.speedRanges.length > 0;
|
|
if (!hasData) return;
|
|
const autosaveData = {
|
|
savedAt: Date.now(),
|
|
videoPath: state.videoPath,
|
|
words: state.words,
|
|
segments: state.segments,
|
|
cutRanges: state.cutRanges,
|
|
muteRanges: state.muteRanges,
|
|
gainRanges: state.gainRanges,
|
|
speedRanges: state.speedRanges,
|
|
globalGainDb: state.globalGainDb,
|
|
silenceTrimGroups: state.silenceTrimGroups,
|
|
transcriptionModel: state.transcriptionModel,
|
|
language: state.language,
|
|
markInTime: state.markInTime,
|
|
markOutTime: state.markOutTime,
|
|
timelineMarkers: state.timelineMarkers,
|
|
};
|
|
window.electronAPI.writeAutosave(JSON.stringify(autosaveData));
|
|
}, 60000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [videoPath]);
|
|
|
|
const handleLoadProject = async () => {
|
|
await runGuarded(async () => {
|
|
try {
|
|
const projectPath = await window.electronAPI!.openProject();
|
|
if (!projectPath) return;
|
|
const content = await window.electronAPI!.readFile(projectPath);
|
|
const data = JSON.parse(content);
|
|
setProjectFilePath(projectPath);
|
|
loadProjectFromData(data);
|
|
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
|
} catch (err) {
|
|
console.error('Failed to load project:', err);
|
|
alert(`Failed to load project: ${err}`);
|
|
}
|
|
});
|
|
};
|
|
|
|
const writeProjectToPath = async (path: string): Promise<boolean> => {
|
|
try {
|
|
const data = useEditorStore.getState().saveProject();
|
|
const resolvedPath = path.endsWith('.aive') ? path : `${path}.aive`;
|
|
await window.electronAPI!.writeFile(resolvedPath, JSON.stringify(data, null, 2));
|
|
setProjectFilePath(resolvedPath);
|
|
setProjectName(resolvedPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
|
if (projectSignature) {
|
|
setLastSavedSignature(projectSignature);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Failed to save project:', err);
|
|
alert(`Failed to save project: ${err}`);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleSaveProjectAs = async (): Promise<boolean> => {
|
|
const savePath = await window.electronAPI!.saveProject();
|
|
if (!savePath) return false;
|
|
return writeProjectToPath(savePath);
|
|
};
|
|
|
|
const handleSaveProject = async (): Promise<boolean> => {
|
|
if (!projectFilePath) {
|
|
return handleSaveProjectAs();
|
|
}
|
|
return writeProjectToPath(projectFilePath);
|
|
};
|
|
|
|
const handleOpenFile = async () => {
|
|
await runGuarded(async () => {
|
|
const path = await window.electronAPI!.openFile();
|
|
if (path) {
|
|
setLastSavedSignature(null);
|
|
setProjectFilePath(null);
|
|
setProjectName(null);
|
|
loadVideo(path);
|
|
if (canEdit) {
|
|
await transcribeVideo(path);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleNewProject = () => {
|
|
runGuarded(async () => {
|
|
useEditorStore.getState().reset();
|
|
setLastSavedSignature(null);
|
|
setActivePanel(null);
|
|
setProjectFilePath(null);
|
|
setProjectName(null);
|
|
setCutMode(false);
|
|
setMuteMode(false);
|
|
setGainMode(false);
|
|
setSpeedMode(false);
|
|
});
|
|
};
|
|
|
|
const transcribeVideo = async (path: string) => {
|
|
setTranscribing(true, 0, 'Checking model...');
|
|
try {
|
|
// Step 1: ensure model is downloaded (may take a while on first run)
|
|
const MODEL_SIZES: Record<string, string> = {
|
|
'tiny': '~75 MB',
|
|
'tiny.en': '~75 MB',
|
|
'base': '~140 MB',
|
|
'base.en': '~140 MB',
|
|
'small': '~460 MB',
|
|
'small.en': '~460 MB',
|
|
'medium': '~1.5 GB',
|
|
'medium.en': '~1.5 GB',
|
|
'large': '~2.9 GB',
|
|
'large-v2': '~2.9 GB',
|
|
'large-v3': '~2.9 GB',
|
|
'large-v3-turbo': '~1.6 GB',
|
|
'distil-large-v3': '~1.5 GB',
|
|
'distil-medium.en': '~750 MB',
|
|
'distil-small.en': '~190 MB',
|
|
};
|
|
const modelLabel = MODEL_SIZES[whisperModel] ?? 'unknown size';
|
|
setTranscribing(true, 5, `Downloading ${whisperModel} model (${modelLabel})...`);
|
|
await window.electronAPI.ensureModel(whisperModel);
|
|
|
|
// Step 2: run transcription
|
|
setTranscribing(true, 20, 'Transcribing audio...');
|
|
const data = await window.electronAPI.transcribe(path, whisperModel);
|
|
setTranscription(data);
|
|
setTranscriptionModel(whisperModel);
|
|
} catch (err) {
|
|
console.error('Transcription error:', err);
|
|
alert(`Transcription failed. Check the console for details.\n\n${err}`);
|
|
} finally {
|
|
setTranscribing(false);
|
|
}
|
|
};
|
|
|
|
const handleReprocessProject = async () => {
|
|
if (!videoPath) return;
|
|
|
|
await runGuarded(async () => {
|
|
setShowReprocessConfirm(true);
|
|
});
|
|
};
|
|
|
|
const confirmReprocessProject = async () => {
|
|
if (!videoPath) return;
|
|
setShowReprocessConfirm(false);
|
|
await transcribeVideo(videoPath);
|
|
};
|
|
|
|
const handleUnsavedSaveAndContinue = async () => {
|
|
const action = pendingProceedAction;
|
|
if (!action) {
|
|
setShowUnsavedPrompt(false);
|
|
return;
|
|
}
|
|
const didSave = await handleSaveProject();
|
|
if (!didSave) return;
|
|
setShowUnsavedPrompt(false);
|
|
setPendingProceedAction(null);
|
|
await action();
|
|
};
|
|
|
|
const handleUnsavedDiscardAndContinue = async () => {
|
|
const action = pendingProceedAction;
|
|
setShowUnsavedPrompt(false);
|
|
setPendingProceedAction(null);
|
|
if (action) {
|
|
await action();
|
|
}
|
|
};
|
|
|
|
const handleUnsavedCancel = () => {
|
|
setShowUnsavedPrompt(false);
|
|
setPendingProceedAction(null);
|
|
};
|
|
|
|
const handleRecoverAutosave = () => {
|
|
if (!recoveryData) return;
|
|
const data = recoveryData;
|
|
const removedCount = useEditorStore.getState().loadProject(data);
|
|
if (removedCount > 0) {
|
|
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
|
|
}
|
|
if (data.markInTime != null) useEditorStore.getState().setMarkInTime(data.markInTime);
|
|
if (data.markOutTime != null) useEditorStore.getState().setMarkOutTime(data.markOutTime);
|
|
window.electronAPI.deleteAutosave();
|
|
setShowRecoveryDialog(false);
|
|
setRecoveryData(null);
|
|
};
|
|
|
|
const handleDismissRecovery = () => {
|
|
window.electronAPI.deleteAutosave();
|
|
setShowRecoveryDialog(false);
|
|
setRecoveryData(null);
|
|
};
|
|
|
|
const togglePanel = (panel: Panel) => {
|
|
setActivePanel((prev) => (prev === panel ? null : panel));
|
|
};
|
|
|
|
const handleCut = () => {
|
|
if (markInTime !== null && markOutTime !== null) {
|
|
const startTime = Math.min(markInTime, markOutTime);
|
|
const endTime = Math.max(markInTime, markOutTime);
|
|
if (endTime - startTime >= 0.01) {
|
|
addCutRange(startTime, endTime);
|
|
setActivePanel('zones');
|
|
}
|
|
clearMarkRange();
|
|
return;
|
|
}
|
|
|
|
if (selectedWordIndices.length > 0) {
|
|
// If words are selected, apply cut immediately
|
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
|
const startTime = words[sorted[0]].start;
|
|
const endTime = words[sorted[sorted.length - 1]].end;
|
|
addCutRange(startTime, endTime);
|
|
} else {
|
|
// Toggle cut mode
|
|
setCutMode(!cutMode);
|
|
setMuteMode(false); // Exit mute mode
|
|
setGainMode(false); // Exit gain mode
|
|
setSpeedMode(false); // Exit speed mode
|
|
}
|
|
};
|
|
|
|
const handleMute = () => {
|
|
if (markInTime !== null && markOutTime !== null) {
|
|
const startTime = Math.min(markInTime, markOutTime);
|
|
const endTime = Math.max(markInTime, markOutTime);
|
|
if (endTime - startTime >= 0.01) {
|
|
addMuteRange(startTime, endTime);
|
|
setActivePanel('zones');
|
|
}
|
|
clearMarkRange();
|
|
return;
|
|
}
|
|
|
|
if (selectedWordIndices.length > 0) {
|
|
// If words are selected, apply mute immediately
|
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
|
const startTime = words[sorted[0]].start;
|
|
const endTime = words[sorted[sorted.length - 1]].end;
|
|
addMuteRange(startTime, endTime);
|
|
} else {
|
|
// Toggle mute mode
|
|
setMuteMode(!muteMode);
|
|
setCutMode(false); // Exit cut mode
|
|
setGainMode(false); // Exit gain mode
|
|
setSpeedMode(false); // Exit speed mode
|
|
}
|
|
};
|
|
|
|
const handleGain = () => {
|
|
if (markInTime !== null && markOutTime !== null) {
|
|
const startTime = Math.min(markInTime, markOutTime);
|
|
const endTime = Math.max(markInTime, markOutTime);
|
|
if (endTime - startTime >= 0.01) {
|
|
addGainRange(startTime, endTime, gainModeDb);
|
|
setActivePanel('zones');
|
|
}
|
|
clearMarkRange();
|
|
return;
|
|
}
|
|
|
|
if (selectedWordIndices.length > 0) {
|
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
|
const startTime = words[sorted[0]].start;
|
|
const endTime = words[sorted[sorted.length - 1]].end;
|
|
addGainRange(startTime, endTime, gainModeDb);
|
|
} else {
|
|
setGainMode(!gainMode);
|
|
setCutMode(false);
|
|
setMuteMode(false);
|
|
setSpeedMode(false);
|
|
}
|
|
};
|
|
|
|
const handleSpeed = () => {
|
|
if (markInTime !== null && markOutTime !== null) {
|
|
const startTime = Math.min(markInTime, markOutTime);
|
|
const endTime = Math.max(markInTime, markOutTime);
|
|
if (endTime - startTime >= 0.01) {
|
|
addSpeedRange(startTime, endTime, speedModeValue);
|
|
setActivePanel('zones');
|
|
}
|
|
clearMarkRange();
|
|
return;
|
|
}
|
|
|
|
if (selectedWordIndices.length > 0) {
|
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
|
const startTime = words[sorted[0]].start;
|
|
const endTime = words[sorted[sorted.length - 1]].end;
|
|
addSpeedRange(startTime, endTime, speedModeValue);
|
|
} else {
|
|
setSpeedMode(!speedMode);
|
|
setCutMode(false);
|
|
setMuteMode(false);
|
|
setGainMode(false);
|
|
}
|
|
};
|
|
|
|
// Health check timer
|
|
useEffect(() => {
|
|
const checkHealth = async () => {
|
|
try {
|
|
const res = await fetch(`${backendUrl}/health`);
|
|
setBackendDown(!res.ok);
|
|
} catch {
|
|
setBackendDown(true);
|
|
}
|
|
};
|
|
checkHealth();
|
|
const interval = setInterval(checkHealth, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [videoPath, backendUrl]);
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
|
{/* Top bar */}
|
|
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
|
|
<div className="flex items-center gap-0.5">
|
|
<div className="relative">
|
|
<ToolbarButton
|
|
icon={<FolderOpen className="w-4 h-4" />}
|
|
label="File"
|
|
onClick={() => setShowFileMenu((p) => !p)}
|
|
active={showFileMenu}
|
|
/>
|
|
{showFileMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowFileMenu(false)} />
|
|
<div className="absolute left-0 top-full mt-1 z-50 w-44 rounded-lg border border-editor-border bg-editor-surface shadow-xl py-1">
|
|
<DropdownItem icon={<FilePlus2 className="w-4 h-4" />} label="New Project" title="Start a new empty project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} />
|
|
<DropdownItem icon={<FolderOpen className="w-4 h-4" />} label="Open File" title="Open a video or audio file for transcription" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} />
|
|
<DropdownItem icon={<FileInput className="w-4 h-4" />} label="Load Project" title="Open a saved .aive project file" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
|
|
<div className="border-t border-editor-border my-1" />
|
|
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save" title="Save current project" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} />
|
|
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save As" title="Save a copy of the current project" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<ToolbarButton
|
|
icon={<Scissors className="w-4 h-4" />}
|
|
label="Cut"
|
|
onClick={handleCut}
|
|
active={cutMode}
|
|
disabled={!canEdit}
|
|
title="Cut selected word range or mark in/out area — removes the segment from output"
|
|
/>
|
|
<ToolbarButton
|
|
icon={<VolumeX className="w-4 h-4" />}
|
|
label="Mute"
|
|
onClick={handleMute}
|
|
active={muteMode}
|
|
disabled={!canEdit}
|
|
title="Mute selected word range or mark in/out area — silences audio, keeps video"
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
<ToolbarButton
|
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
|
label="Sound Gain"
|
|
onClick={handleGain}
|
|
active={gainMode}
|
|
disabled={!canEdit}
|
|
title="Add gain zone from selection or mark in/out — adjust volume up or down"
|
|
/>
|
|
<input
|
|
type="number"
|
|
min={-24}
|
|
max={24}
|
|
step={0.5}
|
|
value={gainModeDb}
|
|
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
|
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
|
title="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces"
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<ToolbarButton
|
|
icon={<Gauge className="w-4 h-4" />}
|
|
label="Speed Adjust"
|
|
onClick={handleSpeed}
|
|
active={speedMode}
|
|
disabled={!canEdit}
|
|
title="Add speed zone from selection or mark in/out — change playback speed"
|
|
/>
|
|
<input
|
|
type="number"
|
|
min={0.25}
|
|
max={4}
|
|
step={0.05}
|
|
value={speedModeValue}
|
|
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
|
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
|
title="Playback speed multiplier for new speed zones — 1x is normal, 2x is double speed"
|
|
disabled={!canEdit}
|
|
/>
|
|
</div>
|
|
<div className="w-px h-5 bg-editor-border mx-1" />
|
|
<ToolbarButton
|
|
icon={<Grid3x3 className="w-4 h-4" />}
|
|
label="Edit Zones"
|
|
active={activePanel === 'zones'}
|
|
onClick={() => togglePanel('zones')}
|
|
disabled={!videoPath || !canEdit}
|
|
title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones"
|
|
/>
|
|
<ToolbarButton
|
|
icon={<span className="text-[10px] font-semibold">PA</span>}
|
|
label="Trim Silence"
|
|
active={activePanel === 'silence'}
|
|
onClick={() => togglePanel('silence')}
|
|
disabled={!videoPath || !canEdit}
|
|
title="Detect and remove silent pauses — batch-removes silence above a configurable threshold"
|
|
/>
|
|
<ToolbarButton
|
|
icon={<MapPin className="w-4 h-4" />}
|
|
label="Chapter Marks"
|
|
active={activePanel === 'markers'}
|
|
onClick={() => togglePanel('markers')}
|
|
disabled={!videoPath || !canEdit}
|
|
title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps"
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Music className="w-4 h-4" />}
|
|
label="Bkg. Music"
|
|
active={activePanel === 'music'}
|
|
onClick={() => togglePanel('music')}
|
|
disabled={!videoPath || !canEdit}
|
|
title="Add background music track with auto-ducking — music lowers when someone speaks"
|
|
/>
|
|
<ToolbarButton
|
|
icon={<ListVideo className="w-4 h-4" />}
|
|
label="Add Clips"
|
|
active={activePanel === 'append'}
|
|
onClick={() => togglePanel('append')}
|
|
disabled={!videoPath || !canEdit}
|
|
title="Append additional video clips — concatenate multiple files during export"
|
|
/>
|
|
<div className="w-px h-5 bg-editor-border mx-1" />
|
|
<ToolbarButton
|
|
icon={<Sparkles className="w-4 h-4" />}
|
|
label="AI Tools"
|
|
active={activePanel === 'ai'}
|
|
onClick={() => togglePanel('ai')}
|
|
disabled={words.length === 0 || !canEdit}
|
|
title="AI filler detection, clip suggestions, and transcript analysis"
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Download className="w-4 h-4" />}
|
|
label="Export"
|
|
active={activePanel === 'export'}
|
|
onClick={() => togglePanel('export')}
|
|
disabled={!videoPath}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<Settings className="w-4 h-4" />}
|
|
label="Settings"
|
|
active={activePanel === 'settings'}
|
|
onClick={() => togglePanel('settings')}
|
|
/>
|
|
<ToolbarButton
|
|
icon={<HelpCircle className="w-4 h-4" />}
|
|
label="Help"
|
|
active={activePanel === 'help'}
|
|
onClick={() => togglePanel('help')}
|
|
title="View help and feature documentation"
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<div className="main-content flex-1 flex overflow-hidden">
|
|
{/* Left: video + transcript */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
|
|
{/* Video player */}
|
|
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
|
|
{videoPath ? (
|
|
<VideoPlayer />
|
|
) : (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="w-20 h-20 rounded-[18px] bg-editor-accent/10 border border-editor-accent/20 flex items-center justify-center shadow-lg shadow-editor-accent/5">
|
|
<svg width="48" height="48" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.7"/>
|
|
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
<path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round"/>
|
|
<path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.6"/>
|
|
</svg>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-3">
|
|
<button
|
|
onClick={handleOpenFile}
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white text-sm font-medium transition-all"
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
Open File
|
|
</button>
|
|
<button
|
|
onClick={handleLoadProject}
|
|
className="flex items-center gap-2 px-4 py-1.5 text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-md transition-colors"
|
|
>
|
|
<FileInput className="w-3.5 h-3.5" />
|
|
Load Project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Draggable divider */}
|
|
<div
|
|
tabIndex={0}
|
|
role="separator"
|
|
aria-label="Resize panel"
|
|
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
|
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
|
onMouseDown={startSplitDrag}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'ArrowLeft') setSplitRatio(Math.max(0.15, splitRatio - 0.02));
|
|
if (e.key === 'ArrowRight') setSplitRatio(Math.min(0.85, splitRatio + 0.02));
|
|
}}
|
|
title="Drag to resize"
|
|
/>
|
|
|
|
{/* Transcript */}
|
|
<div className="border-l border-editor-border flex flex-col min-h-0" style={{ width: `${(1 - splitRatio) * 100}%`, minWidth: 0 }}>
|
|
{videoPath && (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
|
{projectName && (
|
|
<span className="text-xs font-semibold text-editor-accent shrink-0">{projectName}</span>
|
|
)}
|
|
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[/\\]/).pop()}</span>
|
|
<span className="text-xs text-editor-text-muted ml-auto shrink-0">
|
|
{words.length} words · {cutRanges.length} cuts · {muteRanges.length} mutes · {gainRanges.length} gains · {speedRanges.length} speeds
|
|
</span>
|
|
{transcriptionModel && (
|
|
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
|
|
{transcriptionModel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isTranscribing ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-5">
|
|
{/* Animated waveform */}
|
|
<div className="flex items-end gap-[3px] h-10">
|
|
{[35, 60, 45, 80, 55, 70, 40, 65, 50, 75, 40, 58].map((h, i) => (
|
|
<div
|
|
key={i}
|
|
className="w-[5px] rounded-full bg-editor-accent wave-bar"
|
|
style={{
|
|
height: `${h}%`,
|
|
animationDelay: `${i * 75}ms`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="text-center space-y-1">
|
|
<p className="text-sm font-medium text-editor-text">Processing audio</p>
|
|
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
|
|
<div className="w-48 h-1.5 bg-editor-border rounded-full overflow-hidden mt-3">
|
|
<div className="h-full bg-editor-accent rounded-full animate-pulse" style={{ width: '60%' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : words.length > 0 ? (
|
|
<TranscriptEditor
|
|
cutMode={cutMode}
|
|
muteMode={muteMode}
|
|
gainMode={gainMode}
|
|
gainModeDb={gainModeDb}
|
|
speedMode={speedMode}
|
|
speedModeValue={speedModeValue}
|
|
/>
|
|
) : (
|
|
<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
|
|
cutMode={cutMode}
|
|
muteMode={muteMode}
|
|
gainMode={gainMode}
|
|
gainModeDb={gainModeDb}
|
|
speedMode={speedMode}
|
|
speedModeValue={speedModeValue}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right panel (AI / Export / Settings) */}
|
|
{activePanel && (
|
|
<div className="flex shrink-0">
|
|
{/* Draggable sidebar divider */}
|
|
<div
|
|
tabIndex={0}
|
|
role="separator"
|
|
aria-label="Resize panel"
|
|
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
|
onMouseDown={startSidebarDrag}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'ArrowUp') setSidebarWidth(Math.max(180, sidebarWidth - sidebarWidth * 0.02));
|
|
if (e.key === 'ArrowDown') setSidebarWidth(Math.min(600, sidebarWidth + sidebarWidth * 0.02));
|
|
}}
|
|
title="Drag to resize"
|
|
/>
|
|
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
|
|
{activePanel === 'zones' && (
|
|
<ZoneEditor />
|
|
)}
|
|
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
|
{activePanel === 'markers' && <MarkersPanel />}
|
|
{activePanel === 'music' && <BackgroundMusicPanel />}
|
|
{activePanel === 'append' && <AppendClipPanel />}
|
|
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
|
|
{activePanel === 'export' && <ExportDialog />}
|
|
{activePanel === 'settings' && <SettingsPanel />}
|
|
{activePanel === 'help' && <HelpContent />}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{import.meta.env.DEV && <DevPanel />}
|
|
|
|
<LicenseDialog />
|
|
|
|
{showReprocessConfirm && (
|
|
<div
|
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
|
onClick={() => setShowReprocessConfirm(false)}
|
|
>
|
|
<div
|
|
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-sm font-semibold">Reprocess transcript?</h3>
|
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
|
This will reprocess the current file with <span className="text-editor-text font-medium">{whisperModel}</span> and replace the current transcript words and timings.
|
|
</p>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
onClick={() => setShowReprocessConfirm(false)}
|
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={confirmReprocessProject}
|
|
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
|
>
|
|
Reprocess Now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showUnsavedPrompt && (
|
|
<div
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 px-4"
|
|
onClick={handleUnsavedCancel}
|
|
>
|
|
<div
|
|
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-sm font-semibold">Save changes first?</h3>
|
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
|
There are unsaved changes in this project. Save before continuing?
|
|
</p>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
onClick={handleUnsavedCancel}
|
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleUnsavedDiscardAndContinue}
|
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text hover:bg-editor-surface"
|
|
>
|
|
Don't Save
|
|
</button>
|
|
<button
|
|
onClick={handleUnsavedSaveAndContinue}
|
|
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showRecoveryDialog && (
|
|
<div
|
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4"
|
|
onClick={handleDismissRecovery}
|
|
>
|
|
<div
|
|
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-sm font-semibold">Recover unsaved work?</h3>
|
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
|
TalkEdit recovered a project from {recoveryMinutesAgo} minute{recoveryMinutesAgo !== 1 ? 's' : ''} ago.
|
|
</p>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
onClick={handleDismissRecovery}
|
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
<button
|
|
onClick={handleRecoverAutosave}
|
|
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
|
>
|
|
Recover
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{backendDown && (
|
|
<div className="fixed bottom-0 left-0 right-0 z-[90] flex items-center justify-center gap-2 px-4 py-2 bg-amber-500/15 border-t border-amber-500/30 text-amber-400 text-xs font-medium">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
Backend disconnected — retrying...
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolbarButton({
|
|
icon,
|
|
label,
|
|
active,
|
|
onClick,
|
|
disabled,
|
|
title,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
active?: boolean;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<span title={title || label}>
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
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>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DropdownItem({
|
|
icon,
|
|
label,
|
|
onClick,
|
|
disabled,
|
|
title,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<span title={title || label}>
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
|
|
disabled
|
|
? 'opacity-40 cursor-not-allowed'
|
|
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-bg'
|
|
}`}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|