volume panel; copilot instructions

This commit is contained in:
2026-04-15 16:10:35 -06:00
parent 0df967507f
commit 4f90750497
11 changed files with 688 additions and 106 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useMemo } from 'react';
import { useEditorStore } from './store/editorStore';
import VideoPlayer from './components/VideoPlayer';
import TranscriptEditor from './components/TranscriptEditor';
@ -8,6 +8,7 @@ import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel';
import DevPanel from './components/DevPanel';
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
import VolumePanel from './components/VolumePanel';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import {
Film,
@ -20,6 +21,7 @@ import {
Save,
Scissors,
VolumeX,
Volume2,
FilePlus2,
RefreshCw,
} from 'lucide-react';
@ -27,13 +29,22 @@ import {
const IS_ELECTRON = !!window.electronAPI;
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
type Panel = 'ai' | 'settings' | 'export' | 'silence' | null;
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
export default function App() {
const {
videoPath,
exportedAudioPath,
words,
segments,
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
isTranscribing,
transcriptionProgress,
transcriptionStatus,
@ -54,8 +65,72 @@ export default function App() {
const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false);
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 fileInputRef = useRef<HTMLInputElement>(null);
const projectSignature = useMemo(() => {
if (!videoPath) return null;
return JSON.stringify({
videoPath,
exportedAudioPath,
words,
segments,
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
});
}, [
videoPath,
exportedAudioPath,
words,
segments,
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
globalGainDb,
silenceTrimGroups,
transcriptionModel,
language,
]);
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
const loadProjectFromData = (data: any) => {
useEditorStore.getState().loadProject(data);
const loadedSignature = JSON.stringify({
videoPath: data.videoPath,
exportedAudioPath: data.exportedAudioPath ?? null,
words: data.words || [],
segments: data.segments || [],
deletedRanges: data.deletedRanges || [],
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
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 (!IS_ELECTRON || !hasUnsavedChanges) {
await action();
return;
}
setPendingProceedAction(() => action);
setShowUnsavedPrompt(true);
};
useKeyboardShortcuts();
// Handle Escape key to exit cut/mute modes
@ -99,58 +174,69 @@ export default function App() {
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}`);
}
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);
loadProjectFromData(data);
} catch (err) {
console.error('Failed to load project:', err);
alert(`Failed to load project: ${err}`);
}
});
};
const handleSaveProject = async () => {
if (!IS_ELECTRON) return;
const handleSaveProject = async (): Promise<boolean> => {
if (!IS_ELECTRON) return false;
try {
const savePath = await window.electronAPI!.saveProject();
if (!savePath) return;
if (!savePath) return false;
const data = useEditorStore.getState().saveProject();
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
if (projectSignature) {
setLastSavedSignature(projectSignature);
}
return true;
} catch (err) {
console.error('Failed to save project:', err);
alert(`Failed to save project: ${err}`);
return false;
}
return false;
};
const handleOpenFile = async () => {
if (IS_ELECTRON) {
const path = await window.electronAPI!.openFile();
if (path) {
loadVideo(path);
await transcribeVideo(path);
await runGuarded(async () => {
if (IS_ELECTRON) {
const path = await window.electronAPI!.openFile();
if (path) {
setLastSavedSignature(null);
loadVideo(path);
await transcribeVideo(path);
}
} else {
// Browser: use the manual path input
const path = manualPath.trim();
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 handleNewProject = () => {
const shouldReset = window.confirm('Start a new project? Unsaved changes in the current session will be lost.');
if (!shouldReset) return;
useEditorStore.getState().reset();
setActivePanel(null);
setManualPath('');
setCutMode(false);
setMuteMode(false);
runGuarded(async () => {
useEditorStore.getState().reset();
setLastSavedSignature(null);
setActivePanel(null);
setManualPath('');
setCutMode(false);
setMuteMode(false);
});
};
const handleManualSubmit = async (e: React.FormEvent) => {
@ -209,7 +295,9 @@ export default function App() {
return;
}
setShowReprocessConfirm(true);
await runGuarded(async () => {
setShowReprocessConfirm(true);
});
};
const confirmReprocessProject = async () => {
@ -218,6 +306,33 @@ export default function App() {
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 togglePanel = (panel: Panel) => {
setActivePanel((prev) => (prev === panel ? null : panel));
};
@ -403,6 +518,13 @@ export default function App() {
onClick={handleMute}
active={muteMode}
/>
<ToolbarButton
icon={<Volume2 className="w-4 h-4" />}
label="Volume"
active={activePanel === 'volume'}
onClick={() => togglePanel('volume')}
disabled={!videoPath}
/>
<ToolbarButton
icon={<span className="text-[10px] font-semibold">PA</span>}
label="Pause Trim"
@ -520,6 +642,7 @@ export default function App() {
{/* Right panel (AI / Export / Settings) */}
{activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
{activePanel === 'volume' && <VolumePanel />}
{activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />}
{activePanel === 'export' && <ExportDialog />}
@ -559,6 +682,43 @@ export default function App() {
</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>
)}
</div>
);
}

View File

@ -4,7 +4,7 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
import type { ExportOptions } from '../types/project';
export default function ExportDialog() {
const { videoPath, words, deletedRanges, cutRanges, muteRanges, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
const { videoPath, words, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore();
const hasCuts = deletedRanges.length > 0;
@ -47,6 +47,8 @@ export default function ExportDialog() {
output_path: outputPath,
keep_segments: keepSegments,
mute_ranges: muteRanges,
gain_ranges: gainRanges,
global_gain_db: globalGainDb,
words: options.captions !== 'none' ? words : undefined,
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
...options,

View File

@ -0,0 +1,134 @@
import { useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Trash2, Volume2 } from 'lucide-react';
export default function VolumePanel() {
const {
words,
selectedWordIndices,
globalGainDb,
gainRanges,
setGlobalGainDb,
addGainRange,
updateGainRange,
removeGainRange,
} = useEditorStore();
const [selectionGainDb, setSelectionGainDb] = useState(3);
const canApplySelection = selectedWordIndices.length > 0;
const selectedRange = useMemo(() => {
if (!canApplySelection) return null;
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startWord = words[sorted[0]];
const endWord = words[sorted[sorted.length - 1]];
if (!startWord || !endWord) return null;
return {
start: startWord.start,
end: endWord.end,
};
}, [canApplySelection, selectedWordIndices, words]);
const applySelectionGain = () => {
if (!selectedRange) return;
addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
};
return (
<div className="p-4 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Volume / Gain
</h3>
<p className="text-xs text-editor-text-muted">
Apply global gain or per-selection gain ranges.
</p>
</div>
<div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Global Gain (dB)</label>
<input
type="range"
min={-24}
max={24}
step={0.5}
value={globalGainDb}
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
className="w-full"
/>
<div className="flex items-center justify-between text-xs">
<span className="text-editor-text-muted">-24 dB</span>
<span className="font-medium text-editor-text">{globalGainDb.toFixed(1)} dB</span>
<span className="text-editor-text-muted">+24 dB</span>
</div>
</div>
<div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={-24}
max={24}
step={0.5}
value={selectionGainDb}
onChange={(e) => setSelectionGainDb(Number(e.target.value) || 0)}
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
<button
onClick={applySelectionGain}
disabled={!canApplySelection || !selectedRange}
className="px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40"
>
Apply To Selection
</button>
</div>
<p className="text-[11px] text-editor-text-muted">
{canApplySelection
? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
: 'Select transcript words to apply a gain range.'}
</p>
</div>
{gainRanges.length > 0 && (
<div className="space-y-2 pt-1 border-t border-editor-border">
<div className="text-xs font-medium">Gain Ranges</div>
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
{gainRanges.map((range) => (
<div
key={range.id}
className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs flex items-center gap-2"
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{range.start.toFixed(2)}s - {range.end.toFixed(2)}s
</div>
<div className="text-editor-text-muted">{range.id}</div>
</div>
<input
type="number"
min={-24}
max={24}
step={0.5}
value={range.gainDb}
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
title="Gain dB"
/>
<button
onClick={() => removeGainRange(range.id)}
className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
title="Delete gain range"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -6,6 +6,7 @@ import type {
DeletedRange,
CutRange,
MuteRange,
GainRange,
TranscriptionResult,
ProjectFile,
SilenceDetectionRange,
@ -22,6 +23,8 @@ interface EditorState {
deletedRanges: DeletedRange[];
cutRanges: CutRange[];
muteRanges: MuteRange[];
gainRanges: GainRange[];
globalGainDb: number;
silenceTrimGroups: SilenceTrimGroup[];
transcriptionModel: string | null;
language: string;
@ -59,10 +62,14 @@ interface EditorActions {
restoreRange: (rangeId: string) => void;
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
addMuteRange: (start: number, end: number) => void;
addGainRange: (start: number, end: number, gainDb: number) => void;
updateCutRange: (id: string, start: number, end: number) => void;
updateMuteRange: (id: string, start: number, end: number) => void;
updateGainRange: (id: string, gainDb: number) => void;
removeCutRange: (id: string) => void;
removeMuteRange: (id: string) => void;
removeGainRange: (id: string) => void;
setGlobalGainDb: (gainDb: number) => void;
applySilenceTrimGroup: (args: {
groupId?: string;
sourceRanges: SilenceDetectionRange[];
@ -86,6 +93,8 @@ const initialState: EditorState = {
deletedRanges: [],
cutRanges: [],
muteRanges: [],
gainRanges: [],
globalGainDb: 0,
silenceTrimGroups: [],
transcriptionModel: null,
language: '',
@ -142,7 +151,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
saveProject: (): ProjectFile => {
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
if (!videoPath) throw new Error('No video loaded');
const now = new Date().toISOString();
// Strip globalStartIndex (runtime-only field) before persisting
@ -157,6 +166,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
globalGainDb,
silenceTrimGroups,
language,
createdAt: now, // will be overwritten if we track original creation time later
@ -259,6 +270,17 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ muteRanges: [...muteRanges, newRange] });
},
addGainRange: (start, end, gainDb) => {
const { gainRanges } = get();
const newRange: GainRange = {
id: `gain_${nextRangeId++}`,
start,
end,
gainDb,
};
set({ gainRanges: [...gainRanges, newRange] });
},
updateCutRange: (id, start, end) => {
const { cutRanges } = get();
set({
@ -277,6 +299,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
});
},
updateGainRange: (id, gainDb) => {
const { gainRanges } = get();
set({
gainRanges: gainRanges.map((r) =>
r.id === id ? { ...r, gainDb } : r
),
});
},
removeCutRange: (id) => {
const { cutRanges } = get();
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
@ -287,6 +318,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ muteRanges: muteRanges.filter((r) => r.id !== id) });
},
removeGainRange: (id) => {
const { gainRanges } = get();
set({ gainRanges: gainRanges.filter((r) => r.id !== id) });
},
setGlobalGainDb: (gainDb) => {
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
},
applySilenceTrimGroup: ({ groupId, sourceRanges, settings }) => {
const { duration, cutRanges, silenceTrimGroups } = get();
const now = new Date().toISOString();
@ -415,6 +455,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
deletedRanges: data.deletedRanges || [],
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [],
transcriptionModel: data.transcriptionModel ?? null,
language: data.language || '',

View File

@ -35,6 +35,11 @@ export interface MuteRange extends TimeRange {
id: string;
}
export interface GainRange extends TimeRange {
id: string;
gainDb: number;
}
export interface SilenceDetectionRange extends TimeRange {
duration: number;
}
@ -64,6 +69,8 @@ export interface ProjectFile {
deletedRanges: DeletedRange[];
cutRanges: CutRange[];
muteRanges: MuteRange[];
gainRanges?: GainRange[];
globalGainDb?: number;
silenceTrimGroups?: SilenceTrimGroup[];
language: string;
createdAt: string;