added cut and mute zones
This commit is contained in:
@ -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`.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:]}")
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 · {deletedRanges.length} cuts
|
||||
{words.length} words · {deletedRanges.length} cuts · {cutRanges.length} cut ranges · {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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"}
|
||||
Reference in New Issue
Block a user