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

@ -6,6 +6,8 @@ Features are grouped by priority. Check off items as they are implemented.
## 🔴 High Priority — Core editing gaps ## 🔴 High Priority — Core editing gaps
- [ ] **Cut / Mute sections** — select a time range and choose to cut (remove entirely) or mute (silence audio while video continues). Cut sections show as red overlays, mute sections as transparent blue overlays on the timeline over the transcript text and audio waveform. Backend: `ffmpeg -af volume=0` for mute, time-based cutting for removal.
- [ ] **Silence / pause trimmer** — detect and auto-remove pauses longer than X ms. One backend endpoint (`/audio/remove-silence`) + a button in the UI. Saves enormous time in podcast/interview editing. - [ ] **Silence / pause trimmer** — detect and auto-remove pauses longer than X ms. One backend endpoint (`/audio/remove-silence`) + a button in the UI. Saves enormous time in podcast/interview editing.
- [ ] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`. - [ ] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`.

View File

@ -32,6 +32,7 @@ class ExportRequest(BaseModel):
input_path: str input_path: str
output_path: str output_path: str
keep_segments: List[SegmentModel] keep_segments: List[SegmentModel]
mute_ranges: Optional[List[SegmentModel]] = None
mode: str = "fast" mode: str = "fast"
resolution: str = "1080p" resolution: str = "1080p"
format: str = "mp4" format: str = "mp4"
@ -64,15 +65,16 @@ def _mux_audio(video_path: str, audio_path: str, output_path: str) -> str:
async def export_video(req: ExportRequest): async def export_video(req: ExportRequest):
try: try:
segments = [{"start": s.start, "end": s.end} for s in req.keep_segments] segments = [{"start": s.start, "end": s.end} for s in req.keep_segments]
mute_segments = [{"start": s.start, "end": s.end} for s in req.mute_ranges] if req.mute_ranges else None
if not segments: if not segments and not mute_segments:
raise HTTPException(status_code=400, detail="No segments to export") raise HTTPException(status_code=400, detail="No segments to export")
use_stream_copy = req.mode == "fast" and len(segments) == 1 use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments
needs_reencode_for_subs = req.captions == "burn-in" needs_reencode_for_subs = req.captions == "burn-in"
# Burn-in captions require re-encode # Burn-in captions or mute ranges require re-encode
if needs_reencode_for_subs: if needs_reencode_for_subs or mute_segments:
use_stream_copy = False use_stream_copy = False
words_dicts = [w.model_dump() for w in req.words] if req.words else [] words_dicts = [w.model_dump() for w in req.words] if req.words else []
@ -98,6 +100,7 @@ async def export_video(req: ExportRequest):
ass_path, ass_path,
resolution=req.resolution, resolution=req.resolution,
format_hint=req.format, format_hint=req.format,
mute_ranges=mute_segments,
) )
else: else:
output = export_reencode( output = export_reencode(
@ -106,6 +109,7 @@ async def export_video(req: ExportRequest):
segments, segments,
resolution=req.resolution, resolution=req.resolution,
format_hint=req.format, format_hint=req.format,
mute_ranges=mute_segments,
) )
finally: finally:
if ass_path and os.path.exists(ass_path): if ass_path and os.path.exists(ass_path):

View File

@ -28,19 +28,25 @@ def export_stream_copy(
input_path: str, input_path: str,
output_path: str, output_path: str,
keep_segments: List[dict], keep_segments: List[dict],
mute_ranges: List[dict] = None,
) -> str: ) -> str:
""" """
Export video using FFmpeg concat demuxer with stream copy. Export video using FFmpeg concat demuxer with stream copy.
~100x faster than re-encoding. No quality loss. ~100x faster than re-encoding. No quality loss.
Falls back to re-encoding if mute_ranges are provided.
Args: Args:
input_path: source video file input_path: source video file
output_path: destination file output_path: destination file
keep_segments: list of {"start": float, "end": float} to keep keep_segments: list of {"start": float, "end": float} to keep
mute_ranges: list of {"start": float, "end": float} to mute (optional)
Returns: Returns:
output_path on success output_path on success
""" """
if mute_ranges:
# Mute ranges require audio filtering, so fall back to re-encoding
return export_reencode(input_path, output_path, keep_segments, "1080p", "mp4", mute_ranges)
ffmpeg = _find_ffmpeg() ffmpeg = _find_ffmpeg()
input_path = str(Path(input_path).resolve()) input_path = str(Path(input_path).resolve())
output_path = str(Path(output_path).resolve()) output_path = str(Path(output_path).resolve())
@ -105,60 +111,108 @@ def export_reencode(
keep_segments: List[dict], keep_segments: List[dict],
resolution: str = "1080p", resolution: str = "1080p",
format_hint: str = "mp4", format_hint: str = "mp4",
mute_ranges: List[dict] = None,
) -> str: ) -> str:
""" """
Export video with full re-encode. Slower but supports resolution changes, Export video with full re-encode. Slower but supports resolution changes,
format conversion, and avoids stream-copy edge cases. format conversion, and avoids stream-copy edge cases.
If mute_ranges are provided, applies audio muting instead of cutting.
""" """
ffmpeg = _find_ffmpeg() ffmpeg = _find_ffmpeg()
input_path = str(Path(input_path).resolve()) input_path = str(Path(input_path).resolve())
output_path = str(Path(output_path).resolve()) output_path = str(Path(output_path).resolve())
if not keep_segments:
raise ValueError("No segments to export")
scale_map = { scale_map = {
"720p": "scale=-2:720", "720p": "scale=-2:720",
"1080p": "scale=-2:1080", "1080p": "scale=-2:1080",
"4k": "scale=-2:2160", "4k": "scale=-2:2160",
} }
filter_parts = [] # Handle muting case - keep full video but silence audio ranges
for i, seg in enumerate(keep_segments): if mute_ranges and len(mute_ranges) > 0:
filter_parts.append( # Build volume filter for muting
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];" volume_filters = []
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];" for i, mute_range in enumerate(mute_ranges):
) start = mute_range['start']
end = mute_range['end']
# Use volume=0 to mute, enable to specify time range
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
n = len(keep_segments) # Combine all volume filters
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n)) if volume_filters:
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]") audio_filter = ",".join(volume_filters)
else:
audio_filter = "anull" # No muting needed
filter_complex = "".join(filter_parts) # Video filter - just scaling if needed
scale = scale_map.get(resolution, "")
if scale:
video_filter = scale
video_map = "[v]"
else:
video_filter = "null"
video_map = "0:v"
scale = scale_map.get(resolution, "") filter_complex = f"[0:a]{audio_filter}[a];[0:v]{video_filter}{video_map}"
if scale:
filter_complex += f";[outv]{scale}[outv_scaled]" codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
video_map = "[outv_scaled]" if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[a]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding with {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
else: else:
video_map = "[outv]" # Original cutting logic
if not keep_segments:
raise ValueError("No segments to export")
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"] filter_parts = []
if format_hint == "webm": for i, seg in enumerate(keep_segments):
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"] filter_parts.append(
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
cmd = [ n = len(keep_segments)
ffmpeg, "-y", concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
"-i", input_path, filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
"-filter_complex", filter_complex,
"-map", video_map, filter_complex = "".join(filter_parts)
"-map", "[outa]",
*codec_args, scale = scale_map.get(resolution, "")
"-movflags", "+faststart", if scale:
output_path, filter_complex += f";[outv]{scale}[outv_scaled]"
] video_map = "[outv_scaled]"
else:
video_map = "[outv]"
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[outa]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding {n} segments -> {output_path} ({resolution})")
logger.info(f"Re-encoding {n} segments -> {output_path} ({resolution})")
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(f"FFmpeg re-encode failed: {result.stderr[-500:]}") raise RuntimeError(f"FFmpeg re-encode failed: {result.stderr[-500:]}")
@ -173,64 +227,109 @@ def export_reencode_with_subs(
subtitle_path: str, subtitle_path: str,
resolution: str = "1080p", resolution: str = "1080p",
format_hint: str = "mp4", format_hint: str = "mp4",
mute_ranges: List[dict] = None,
) -> str: ) -> str:
""" """
Export video with re-encode and burn-in subtitles (ASS format). Export video with re-encode and burn-in subtitles (ASS format).
Applies trim+concat first, then overlays the subtitle file. Applies trim+concat first, then overlays the subtitle file.
If mute_ranges are provided, applies audio muting instead of cutting.
""" """
ffmpeg = _find_ffmpeg() ffmpeg = _find_ffmpeg()
input_path = str(Path(input_path).resolve()) input_path = str(Path(input_path).resolve())
output_path = str(Path(output_path).resolve()) output_path = str(Path(output_path).resolve())
subtitle_path = str(Path(subtitle_path).resolve()) subtitle_path = str(Path(subtitle_path).resolve())
if not keep_segments:
raise ValueError("No segments to export")
scale_map = { scale_map = {
"720p": "scale=-2:720", "720p": "scale=-2:720",
"1080p": "scale=-2:1080", "1080p": "scale=-2:1080",
"4k": "scale=-2:2160", "4k": "scale=-2:2160",
} }
filter_parts = [] # Handle muting case - keep full video but silence audio ranges
for i, seg in enumerate(keep_segments): if mute_ranges and len(mute_ranges) > 0:
filter_parts.append( # Build volume filter for muting
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];" volume_filters = []
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];" for i, mute_range in enumerate(mute_ranges):
) start = mute_range['start']
end = mute_range['end']
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
n = len(keep_segments) if volume_filters:
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n)) audio_filter = ",".join(volume_filters)
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]") else:
audio_filter = "anull"
filter_complex = "".join(filter_parts) # Video filter with subtitles
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
scale = scale_map.get(resolution, "")
if scale:
video_filter = f"{scale},ass='{escaped_sub}'"
else:
video_filter = f"ass='{escaped_sub}'"
# Escape path for FFmpeg subtitle filter (Windows backslashes need escaping) filter_complex = f"[0:a]{audio_filter}[a];[0:v]{video_filter}[v]"
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
scale = scale_map.get(resolution, "") codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if scale: if format_hint == "webm":
filter_complex += f";[outv]{scale},ass='{escaped_sub}'[outv_final]" codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]",
"-map", "[a]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding with subtitles and {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
else: else:
filter_complex += f";[outv]ass='{escaped_sub}'[outv_final]" # Original cutting logic with subtitles
video_map = "[outv_final]" if not keep_segments:
raise ValueError("No segments to export")
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"] filter_parts = []
if format_hint == "webm": for i, seg in enumerate(keep_segments):
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"] filter_parts.append(
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
cmd = [ n = len(keep_segments)
ffmpeg, "-y", concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
"-i", input_path, filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
"-filter_complex", filter_complex,
"-map", video_map, filter_complex = "".join(filter_parts)
"-map", "[outa]",
*codec_args, # Escape path for FFmpeg subtitle filter (Windows backslashes need escaping)
"-movflags", "+faststart", escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
output_path,
] scale = scale_map.get(resolution, "")
if scale:
filter_complex += f";[outv]{scale},ass='{escaped_sub}'[outv_final]"
else:
filter_complex += f";[outv]ass='{escaped_sub}'[outv_final]"
video_map = "[outv_final]"
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[outa]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding {n} segments with subtitles -> {output_path} ({resolution})")
logger.info(f"Re-encoding {n} segments with subtitles -> {output_path} ({resolution})")
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(f"FFmpeg re-encode with subs failed: {result.stderr[-500:]}") raise RuntimeError(f"FFmpeg re-encode with subs failed: {result.stderr[-500:]}")

View File

@ -44494,7 +44494,26 @@
} }
], ],
"deletedRanges": [], "deletedRanges": [],
"cutRanges": [
{
"id": "cut_1",
"start": 554.9,
"end": 561.6
}
],
"muteRanges": [
{
"id": "mute_2",
"start": 563.72,
"end": 571.5
},
{
"id": "mute_3",
"start": 34.98,
"end": 50.14
}
],
"language": "en", "language": "en",
"createdAt": "2026-03-31T00:34:46.603Z", "createdAt": "2026-04-03T17:11:13.537Z",
"modifiedAt": "2026-03-31T00:34:46.603Z" "modifiedAt": "2026-04-03T17:11:13.537Z"
} }

View File

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

View File

@ -7,12 +7,17 @@ export default function TranscriptEditor() {
const words = useEditorStore((s) => s.words); const words = useEditorStore((s) => s.words);
const segments = useEditorStore((s) => s.segments); const segments = useEditorStore((s) => s.segments);
const deletedRanges = useEditorStore((s) => s.deletedRanges); 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 selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex); const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices); const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex); const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex);
const deleteSelectedWords = useEditorStore((s) => s.deleteSelectedWords); const deleteSelectedWords = useEditorStore((s) => s.deleteSelectedWords);
const restoreRange = useEditorStore((s) => s.restoreRange); 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 getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const selectionStart = useRef<number | null>(null); const selectionStart = useRef<number | null>(null);
@ -119,6 +124,32 @@ export default function TranscriptEditor() {
[deletedRanges], [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( const renderSegment = useCallback(
(index: number) => { (index: number) => {
const segment = segments[index]; const segment = segments[index];
@ -138,6 +169,8 @@ export default function TranscriptEditor() {
const isActive = globalIndex === activeWordIndex; const isActive = globalIndex === activeWordIndex;
const isHovered = globalIndex === hoveredWordIndex; const isHovered = globalIndex === hoveredWordIndex;
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null; const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
const cutRange = getCutRangeForWord(globalIndex);
const muteRange = getMuteRangeForWord(globalIndex);
return ( return (
<span <span
@ -151,9 +184,11 @@ export default function TranscriptEditor() {
className={` className={`
relative px-[2px] py-[1px] rounded cursor-pointer transition-colors relative px-[2px] py-[1px] rounded cursor-pointer transition-colors
${isDeleted ? 'line-through text-editor-text-muted/40 bg-editor-word-deleted' : ''} ${isDeleted ? 'line-through text-editor-text-muted/40 bg-editor-word-deleted' : ''}
${isSelected && !isDeleted ? 'bg-editor-word-selected text-white' : ''} ${cutRange ? 'bg-red-500/20 text-red-100' : ''}
${isActive && !isDeleted && !isSelected ? 'bg-editor-accent/20 text-editor-accent' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
${isHovered && !isDeleted && !isSelected && !isActive ? 'bg-editor-word-hover' : ''} ${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}{' '} {word.word}{' '}
@ -168,6 +203,18 @@ export default function TranscriptEditor() {
<RotateCcw className="w-2.5 h-2.5" /> Restore <RotateCcw className="w-2.5 h-2.5" /> Restore
</button> </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> </span>
); );
})} })}
@ -175,22 +222,22 @@ export default function TranscriptEditor() {
</div> </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 ( return (
<div className="flex-1 flex flex-col min-h-0"> <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"> <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"> <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> </span>
{selectedWordIndices.length > 0 && ( {selectedWordIndices.length > 0 && (
<button <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" 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" /> <Trash2 className="w-3 h-3" />
Delete {selectedWordIndices.length} words Cut {selectedWordIndices.length} words
</button> </button>
)} )}
</div> </div>

View File

@ -24,7 +24,7 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
return { major, minor }; return { major, minor };
} }
export default function WaveformTimeline() { export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boolean; muteMode: boolean }) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null); const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null); const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -35,7 +35,11 @@ export default function WaveformTimeline() {
const backendUrl = useEditorStore((s) => s.backendUrl); const backendUrl = useEditorStore((s) => s.backendUrl);
const duration = useEditorStore((s) => s.duration); const duration = useEditorStore((s) => s.duration);
const deletedRanges = useEditorStore((s) => s.deletedRanges); 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 setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange);
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null); const audioBufferRef = useRef<AudioBuffer | null>(null);
@ -46,6 +50,9 @@ export default function WaveformTimeline() {
const drawStaticWaveformRef = useRef<() => void>(() => {}); const drawStaticWaveformRef = useRef<() => void>(() => {});
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const [isDragging, setIsDragging] = useState(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(() => { useEffect(() => {
if (!videoUrl || !videoPath) return; if (!videoUrl || !videoPath) return;
@ -205,6 +212,35 @@ export default function WaveformTimeline() {
ctx.fillRect(x1, waveTop, x2 - x1, waveH); 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; const mid = waveTop + waveH / 2;
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = '#4a4d5e'; ctx.strokeStyle = '#4a4d5e';
@ -228,7 +264,7 @@ export default function WaveformTimeline() {
ctx.lineTo(x, mid + max * amp); ctx.lineTo(x, mid + max * amp);
} }
ctx.stroke(); ctx.stroke();
}, [deletedRanges]); }, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode]);
// Keep the ref in sync with the latest drawStaticWaveform closure // Keep the ref in sync with the latest drawStaticWaveform closure
useEffect(() => { useEffect(() => {
@ -347,27 +383,82 @@ export default function WaveformTimeline() {
if (video) video.currentTime = newTime; if (video) video.currentTime = newTime;
}, [setCurrentTime]); }, [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( const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault(); e.preventDefault();
isDraggingRef.current = true;
setIsDragging(true); if (cutMode || muteMode) {
seekToClientX(e.clientX); // Range selection mode
const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime;
setSelectionStart(startTime);
setSelectionEnd(startTime);
isDraggingRef.current = true;
setIsDragging(true);
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
if (!isDraggingRef.current) return; if (!isDraggingRef.current) return;
seekToClientX(ev.clientX); const currentTime = clientXToTime(ev.clientX);
}; setSelectionEnd(currentTime);
const onUp = () => { };
isDraggingRef.current = false;
setIsDragging(false); const onUp = () => {
window.removeEventListener('mousemove', onMove); isDraggingRef.current = false;
window.removeEventListener('mouseup', onUp); setIsDragging(false);
};
window.addEventListener('mousemove', onMove); if (selectionStartRef.current !== null && selectionEnd !== null) {
window.addEventListener('mouseup', onUp); 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) { if (!videoUrl) {

View File

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

View File

@ -8,16 +8,38 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
setDuration, setDuration,
setIsPlaying, setIsPlaying,
deletedRanges, deletedRanges,
cutRanges,
muteRanges,
} = useEditorStore(); } = useEditorStore();
const seekTo = useCallback( const seekTo = useCallback(
(time: number) => { (time: number) => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.currentTime = time; let targetTime = time;
setCurrentTime(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(() => { const togglePlay = useCallback(() => {
@ -36,13 +58,41 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
const onTimeUpdate = () => { const onTimeUpdate = () => {
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
const t = video.currentTime; let t = video.currentTime;
for (const range of deletedRanges) {
// 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) { if (t >= range.start && t < range.end) {
video.currentTime = range.end; shouldMute = true;
return; break;
} }
} }
video.muted = shouldMute;
setCurrentTime(t); setCurrentTime(t);
}); });
}; };
@ -63,7 +113,7 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('loadedmetadata', onLoadedMetadata);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
}; };
}, [videoRef, deletedRanges, setCurrentTime, setIsPlaying, setDuration]); }, [videoRef, deletedRanges, cutRanges, muteRanges, setCurrentTime, setIsPlaying, setDuration]);
return { seekTo, togglePlay }; return { seekTo, togglePlay };
} }

View File

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

View File

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

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/WaveformTimeline.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/WaveformTimeline.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.ts","./src/types/project.ts"],"errors":true,"version":"5.9.3"}