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
|
## 🔴 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`.
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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,24 +111,71 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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})'")
|
||||||
|
|
||||||
|
# Combine all volume filters
|
||||||
|
if volume_filters:
|
||||||
|
audio_filter = ",".join(volume_filters)
|
||||||
|
else:
|
||||||
|
audio_filter = "anull" # No muting needed
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Original cutting logic
|
||||||
|
if not keep_segments:
|
||||||
|
raise ValueError("No segments to export")
|
||||||
|
|
||||||
filter_parts = []
|
filter_parts = []
|
||||||
for i, seg in enumerate(keep_segments):
|
for i, seg in enumerate(keep_segments):
|
||||||
filter_parts.append(
|
filter_parts.append(
|
||||||
@ -159,6 +212,7 @@ def export_reencode(
|
|||||||
]
|
]
|
||||||
|
|
||||||
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,25 +227,69 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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})'")
|
||||||
|
|
||||||
|
if volume_filters:
|
||||||
|
audio_filter = ",".join(volume_filters)
|
||||||
|
else:
|
||||||
|
audio_filter = "anull"
|
||||||
|
|
||||||
|
# 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}'"
|
||||||
|
|
||||||
|
filter_complex = f"[0:a]{audio_filter}[a];[0:v]{video_filter}[v]"
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Original cutting logic with subtitles
|
||||||
|
if not keep_segments:
|
||||||
|
raise ValueError("No segments to export")
|
||||||
|
|
||||||
filter_parts = []
|
filter_parts = []
|
||||||
for i, seg in enumerate(keep_segments):
|
for i, seg in enumerate(keep_segments):
|
||||||
filter_parts.append(
|
filter_parts.append(
|
||||||
@ -231,6 +329,7 @@ def export_reencode_with_subs(
|
|||||||
]
|
]
|
||||||
|
|
||||||
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:]}")
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 · {deletedRanges.length} cuts
|
{words.length} words · {deletedRanges.length} cuts · {cutRanges.length} cut ranges · {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>
|
||||||
|
|||||||
@ -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,9 +383,63 @@ 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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
isDraggingRef.current = true;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
seekToClientX(e.clientX);
|
seekToClientX(e.clientX);
|
||||||
@ -366,8 +456,9 @@ export default function WaveformTimeline() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('mousemove', onMove);
|
window.addEventListener('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[seekToClientX],
|
[cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!videoUrl) {
|
if (!videoUrl) {
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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) {
|
if (t >= range.start && t < range.end) {
|
||||||
video.currentTime = range.end;
|
t = range.end;
|
||||||
|
shouldSkip = true;
|
||||||
|
skipCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldSkip) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipCount > 0) {
|
||||||
|
video.currentTime = t;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mute/unmute based on mute ranges
|
||||||
|
let shouldMute = false;
|
||||||
|
for (const range of muteRanges) {
|
||||||
|
if (t >= range.start && t < range.end) {
|
||||||
|
shouldMute = true;
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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