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
- [ ] **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.
- [ ] **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
output_path: str
keep_segments: List[SegmentModel]
mute_ranges: Optional[List[SegmentModel]] = None
mode: str = "fast"
resolution: str = "1080p"
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):
try:
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")
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"
# Burn-in captions require re-encode
if needs_reencode_for_subs:
# Burn-in captions or mute ranges require re-encode
if needs_reencode_for_subs or mute_segments:
use_stream_copy = False
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,
resolution=req.resolution,
format_hint=req.format,
mute_ranges=mute_segments,
)
else:
output = export_reencode(
@ -106,6 +109,7 @@ async def export_video(req: ExportRequest):
segments,
resolution=req.resolution,
format_hint=req.format,
mute_ranges=mute_segments,
)
finally:
if ass_path and os.path.exists(ass_path):

View File

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

View File

@ -44494,7 +44494,26 @@
}
],
"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",
"createdAt": "2026-03-31T00:34:46.603Z",
"modifiedAt": "2026-03-31T00:34:46.603Z"
"createdAt": "2026-04-03T17:11:13.537Z",
"modifiedAt": "2026-04-03T17:11:13.537Z"
}

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;

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"}