added cut and mute zones

This commit is contained in:
2026-04-03 11:14:31 -06:00
parent d7bc6ea74d
commit 585262c3e7
13 changed files with 554 additions and 117 deletions

View File

@ -17,6 +17,8 @@ import {
FolderSearch,
FileInput,
Save,
Scissors,
VolumeX,
} from 'lucide-react';
const IS_ELECTRON = !!window.electronAPI;
@ -35,15 +37,33 @@ export default function App() {
setTranscription,
setTranscribing,
backendUrl,
selectedWordIndices,
addCutRange,
addMuteRange,
} = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null);
const [manualPath, setManualPath] = useState('');
const [whisperModel, setWhisperModel] = useState('base');
const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useKeyboardShortcuts();
// Handle Escape key to exit cut/mute modes
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setCutMode(false);
setMuteMode(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
useEffect(() => {
if (IS_ELECTRON) {
window.electronAPI!.getBackendUrl().then(setBackendUrl);
@ -146,8 +166,33 @@ export default function App() {
}
};
const togglePanel = (panel: Panel) =>
setActivePanel((prev) => (prev === panel ? null : panel));
const handleCut = () => {
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
}
};
const handleMute = () => {
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
}
};
if (!videoPath) {
return (
@ -280,6 +325,18 @@ export default function App() {
onClick={handleLoadProject}
/>
)}
<ToolbarButton
icon={<Scissors className="w-4 h-4" />}
label="Cut"
onClick={handleCut}
active={cutMode}
/>
<ToolbarButton
icon={<VolumeX className="w-4 h-4" />}
label="Mute"
onClick={handleMute}
active={muteMode}
/>
<ToolbarButton
icon={<Sparkles className="w-4 h-4" />}
label="AI"
@ -347,7 +404,7 @@ export default function App() {
{/* Waveform timeline */}
<div className="h-32 border-t border-editor-border shrink-0">
<WaveformTimeline />
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} />
</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, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
const { videoPath, words, deletedRanges, cutRanges, muteRanges, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore();
const hasCuts = deletedRanges.length > 0;
@ -46,6 +46,7 @@ export default function ExportDialog() {
input_path: videoPath,
output_path: outputPath,
keep_segments: keepSegments,
mute_ranges: muteRanges,
words: options.captions !== 'none' ? words : undefined,
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
...options,

View File

@ -7,12 +7,17 @@ export default function TranscriptEditor() {
const words = useEditorStore((s) => s.words);
const segments = useEditorStore((s) => s.segments);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges);
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 removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const addCutRange = useEditorStore((s) => s.addCutRange);
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const selectionStart = useRef<number | null>(null);
@ -119,6 +124,32 @@ export default function TranscriptEditor() {
[deletedRanges],
);
const cutSelectedWords = useCallback(() => {
if (selectedWordIndices.length === 0) return;
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);
}, [selectedWordIndices, words, addCutRange]);
const getCutRangeForWord = useCallback(
(wordIndex: number) => {
const word = words[wordIndex];
if (!word) return null;
return cutRanges.find((r) => word.start >= r.start && word.end <= r.end);
},
[words, cutRanges],
);
const getMuteRangeForWord = useCallback(
(wordIndex: number) => {
const word = words[wordIndex];
if (!word) return null;
return muteRanges.find((r) => word.start >= r.start && word.end <= r.end);
},
[words, muteRanges],
);
const renderSegment = useCallback(
(index: number) => {
const segment = segments[index];
@ -138,6 +169,8 @@ export default function TranscriptEditor() {
const isActive = globalIndex === activeWordIndex;
const isHovered = globalIndex === hoveredWordIndex;
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
const cutRange = getCutRangeForWord(globalIndex);
const muteRange = getMuteRangeForWord(globalIndex);
return (
<span
@ -151,9 +184,11 @@ export default function TranscriptEditor() {
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' : ''}
${cutRange ? 'bg-red-500/20 text-red-100' : ''}
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
${isSelected && !isDeleted && !cutRange && !muteRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange ? 'bg-editor-word-hover' : ''}
`}
>
{word.word}{' '}
@ -168,6 +203,18 @@ export default function TranscriptEditor() {
<RotateCcw className="w-2.5 h-2.5" /> Restore
</button>
)}
{(cutRange || muteRange) && isHovered && (
<button
onClick={(e) => {
e.stopPropagation();
if (cutRange) removeCutRange(cutRange.id);
if (muteRange) removeMuteRange(muteRange.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>
);
})}
@ -175,22 +222,22 @@ export default function TranscriptEditor() {
</div>
);
},
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, restoreRange],
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, restoreRange, removeCutRange, removeMuteRange],
);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0">
<span className="text-xs text-editor-text-muted flex-1">
{words.length} words &middot; {deletedRanges.length} cuts
{words.length} words &middot; {deletedRanges.length} cuts &middot; {cutRanges.length} cut ranges &middot; {muteRanges.length} mute ranges
</span>
{selectedWordIndices.length > 0 && (
<button
onClick={deleteSelectedWords}
onClick={cutSelectedWords}
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
Cut {selectedWordIndices.length} words
</button>
)}
</div>

View File

@ -24,7 +24,7 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
return { major, minor };
}
export default function WaveformTimeline() {
export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boolean; muteMode: boolean }) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -35,7 +35,11 @@ export default function WaveformTimeline() {
const backendUrl = useEditorStore((s) => s.backendUrl);
const duration = useEditorStore((s) => s.duration);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange);
const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
@ -46,6 +50,9 @@ export default function WaveformTimeline() {
const drawStaticWaveformRef = useRef<() => void>(() => {});
const isDraggingRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const selectionStartRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
useEffect(() => {
if (!videoUrl || !videoPath) return;
@ -205,6 +212,35 @@ export default function WaveformTimeline() {
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw cut ranges (red overlays)
for (const range of cutRanges) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
ctx.fillStyle = 'rgba(239, 68, 68, 0.3)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw mute ranges (blue overlays)
for (const range of muteRanges) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
ctx.fillStyle = 'rgba(59, 130, 246, 0.3)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw selection overlay (when in cut/mute mode)
if ((cutMode || muteMode) && selectionStart !== null && selectionEnd !== null) {
const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec;
const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec;
ctx.fillStyle = cutMode ? 'rgba(239, 68, 68, 0.5)' : 'rgba(59, 130, 246, 0.5)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
// Add border
ctx.strokeStyle = cutMode ? '#ef4444' : '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
}
const mid = waveTop + waveH / 2;
ctx.beginPath();
ctx.strokeStyle = '#4a4d5e';
@ -228,7 +264,7 @@ export default function WaveformTimeline() {
ctx.lineTo(x, mid + max * amp);
}
ctx.stroke();
}, [deletedRanges]);
}, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode]);
// Keep the ref in sync with the latest drawStaticWaveform closure
useEffect(() => {
@ -347,27 +383,82 @@ export default function WaveformTimeline() {
if (video) video.currentTime = newTime;
}, [setCurrentTime]);
const clientXToTime = useCallback((clientX: number): number => {
const buffer = audioBufferRef.current;
const canvas = headCanvasRef.current;
if (!canvas || !buffer) return 0;
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const pxPerSec = (rect.width * zoomRef.current) / buffer.duration;
return Math.max(0, Math.min(buffer.duration, scrollSecsRef.current + x / pxPerSec));
}, []);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
isDraggingRef.current = true;
setIsDragging(true);
seekToClientX(e.clientX);
if (cutMode || muteMode) {
// Range selection mode
const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime;
setSelectionStart(startTime);
setSelectionEnd(startTime);
isDraggingRef.current = true;
setIsDragging(true);
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return;
seekToClientX(ev.clientX);
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return;
const currentTime = clientXToTime(ev.clientX);
setSelectionEnd(currentTime);
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
if (selectionStartRef.current !== null && selectionEnd !== null) {
const start = Math.min(selectionStartRef.current, selectionEnd);
const end = Math.max(selectionStartRef.current, selectionEnd);
if (cutMode) {
addCutRange(start, end);
} else if (muteMode) {
addMuteRange(start, end);
}
}
// Reset selection
selectionStartRef.current = null;
setSelectionStart(null);
setSelectionEnd(null);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
} else {
// Normal seek mode
isDraggingRef.current = true;
setIsDragging(true);
seekToClientX(e.clientX);
const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return;
seekToClientX(ev.clientX);
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
},
[seekToClientX],
[cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd],
);
if (!videoUrl) {

View File

@ -3,7 +3,9 @@ import { useEditorStore } from '../store/editorStore';
export function useKeyboardShortcuts() {
const deleteSelectedWords = useEditorStore((s) => s.deleteSelectedWords);
const addCutRange = useEditorStore((s) => s.addCutRange);
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
const words = useEditorStore((s) => s.words);
const playbackRateRef = useRef(1);
@ -29,11 +31,14 @@ export function useKeyboardShortcuts() {
return;
}
// --- Delete / Backspace: delete selected words ---
// --- Delete / Backspace: cut selected words ---
case e.key === 'Delete' || e.key === 'Backspace': {
if (selectedWordIndices.length > 0) {
e.preventDefault();
deleteSelectedWords();
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);
}
return;
}
@ -211,7 +216,7 @@ function toggleCheatsheet() {
['K', 'Pause'],
['L', 'Forward / Speed up'],
['\u2190 / \u2192', 'Seek \u00b15 seconds'],
['Delete', 'Delete selected words'],
['Delete', 'Cut selected words'],
['Ctrl+Z', 'Undo'],
['Ctrl+Shift+Z', 'Redo'],
['Ctrl+S', 'Save project'],

View File

@ -8,16 +8,38 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
setDuration,
setIsPlaying,
deletedRanges,
cutRanges,
muteRanges,
} = useEditorStore();
const seekTo = useCallback(
(time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
let targetTime = time;
// If seeking into cut or deleted ranges, skip to the end (handle overlapping/chained ranges)
const allSkipRanges = [...deletedRanges, ...cutRanges];
let skipCount = 0;
const maxSkips = 10; // Prevent infinite loops
while (skipCount < maxSkips) {
let shouldSkip = false;
for (const range of allSkipRanges) {
if (targetTime >= range.start && targetTime < range.end) {
targetTime = range.end;
shouldSkip = true;
skipCount++;
break;
}
}
if (!shouldSkip) break;
}
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
}
},
[videoRef, setCurrentTime],
[videoRef, deletedRanges, cutRanges, setCurrentTime],
);
const togglePlay = useCallback(() => {
@ -36,13 +58,41 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
const onTimeUpdate = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const t = video.currentTime;
for (const range of deletedRanges) {
let t = video.currentTime;
// Skip over deleted ranges and cut ranges (handle overlapping/chained ranges)
const allSkipRanges = [...deletedRanges, ...cutRanges];
let skipCount = 0;
const maxSkips = 10; // Prevent infinite loops
while (skipCount < maxSkips) {
let shouldSkip = false;
for (const range of allSkipRanges) {
if (t >= range.start && t < range.end) {
t = range.end;
shouldSkip = true;
skipCount++;
break;
}
}
if (!shouldSkip) break;
}
if (skipCount > 0) {
video.currentTime = t;
return;
}
// Mute/unmute based on mute ranges
let shouldMute = false;
for (const range of muteRanges) {
if (t >= range.start && t < range.end) {
video.currentTime = range.end;
return;
shouldMute = true;
break;
}
}
video.muted = shouldMute;
setCurrentTime(t);
});
};
@ -63,7 +113,7 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
video.removeEventListener('loadedmetadata', onLoadedMetadata);
cancelAnimationFrame(rafRef.current);
};
}, [videoRef, deletedRanges, setCurrentTime, setIsPlaying, setDuration]);
}, [videoRef, deletedRanges, cutRanges, muteRanges, setCurrentTime, setIsPlaying, setDuration]);
return { seekTo, togglePlay };
}

View File

@ -1,6 +1,6 @@
import { create } from 'zustand';
import { temporal } from 'zundo';
import type { Word, Segment, DeletedRange, TranscriptionResult, ProjectFile } from '../types/project';
import type { Word, Segment, DeletedRange, CutRange, MuteRange, TranscriptionResult, ProjectFile } from '../types/project';
interface EditorState {
videoPath: string | null;
@ -9,6 +9,8 @@ interface EditorState {
words: Word[];
segments: Segment[];
deletedRanges: DeletedRange[];
cutRanges: CutRange[];
muteRanges: MuteRange[];
language: string;
currentTime: number;
@ -41,6 +43,10 @@ interface EditorActions {
deleteSelectedWords: () => void;
deleteWordRange: (startIndex: number, endIndex: number) => void;
restoreRange: (rangeId: string) => void;
addCutRange: (start: number, end: number) => void;
addMuteRange: (start: number, end: number) => void;
removeCutRange: (id: string) => void;
removeMuteRange: (id: string) => void;
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
setExporting: (active: boolean, progress?: number) => void;
getKeepSegments: () => Array<{ start: number; end: number }>;
@ -56,6 +62,8 @@ const initialState: EditorState = {
words: [],
segments: [],
deletedRanges: [],
cutRanges: [],
muteRanges: [],
language: '',
currentTime: 0,
duration: 0,
@ -82,7 +90,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
saveProject: (): ProjectFile => {
const { videoPath, words, segments, deletedRanges, language, exportedAudioPath } = get();
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, language, exportedAudioPath } = get();
if (!videoPath) throw new Error('No video loaded');
const now = new Date().toISOString();
// Strip globalStartIndex (runtime-only field) before persisting
@ -94,6 +102,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
words,
segments: persistSegments as unknown as Segment[],
deletedRanges,
cutRanges,
muteRanges,
language,
createdAt: now, // will be overwritten if we track original creation time later
modifiedAt: now,
@ -174,11 +184,41 @@ export const useEditorStore = create<EditorState & EditorActions>()(
set({ deletedRanges: deletedRanges.filter((r) => r.id !== rangeId) });
},
addCutRange: (start, end) => {
const { cutRanges } = get();
const newRange: CutRange = {
id: `cut_${nextRangeId++}`,
start,
end,
};
set({ cutRanges: [...cutRanges, newRange] });
},
addMuteRange: (start, end) => {
const { muteRanges } = get();
const newRange: MuteRange = {
id: `mute_${nextRangeId++}`,
start,
end,
};
set({ muteRanges: [...muteRanges, newRange] });
},
removeCutRange: (id) => {
const { cutRanges } = get();
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
},
removeMuteRange: (id) => {
const { muteRanges } = get();
set({ muteRanges: muteRanges.filter((r) => r.id !== id) });
},
setTranscribing: (active, progress, status) =>
set({
isTranscribing: active,
transcriptionProgress: progress ?? (active ? 0 : 100),
transcriptionStatus: status ?? (active ? '' : ''),
transcriptionStatus: status ?? (active ? 'Transcribing...' : ''),
}),
setExporting: (active, progress) =>
@ -188,7 +228,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}),
getKeepSegments: () => {
const { words, deletedRanges, duration } = get();
const { words, deletedRanges, cutRanges, duration } = get();
if (words.length === 0) return [{ start: 0, end: duration }];
const deletedSet = new Set<number>();
@ -196,6 +236,16 @@ export const useEditorStore = create<EditorState & EditorActions>()(
for (const idx of range.wordIndices) deletedSet.add(idx);
}
// Also exclude words that fall within cut ranges
for (const cutRange of cutRanges) {
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (word.start >= cutRange.start && word.end <= cutRange.end) {
deletedSet.add(i);
}
}
}
const segments: Array<{ start: number; end: number }> = [];
let segStart: number | null = null;
@ -249,6 +299,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
words: data.words || [],
segments: annotatedSegments,
deletedRanges: data.deletedRanges || [],
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [],
language: data.language || '',
exportedAudioPath: data.exportedAudioPath ?? null,
});

View File

@ -26,6 +26,14 @@ export interface DeletedRange extends TimeRange {
wordIndices: number[];
}
export interface CutRange extends TimeRange {
id: string;
}
export interface MuteRange extends TimeRange {
id: string;
}
export interface ProjectFile {
version: 1;
videoPath: string;
@ -33,6 +41,8 @@ export interface ProjectFile {
words: Word[];
segments: Segment[];
deletedRanges: DeletedRange[];
cutRanges: CutRange[];
muteRanges: MuteRange[];
language: string;
createdAt: string;
modifiedAt: string;