speed zones work now

This commit is contained in:
2026-04-15 20:17:05 -06:00
parent b7a795f986
commit 4d3d8a2218
8 changed files with 127 additions and 155 deletions

View File

@ -37,7 +37,6 @@ export default function App() {
exportedAudioPath, exportedAudioPath,
words, words,
segments, segments,
deletedRanges,
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
@ -61,6 +60,7 @@ export default function App() {
} = useEditorStore(); } = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null); const [activePanel, setActivePanel] = useState<Panel>(null);
const [projectName, setProjectName] = useState<string | null>(null);
const [whisperModel, setWhisperModel] = useState('base'); const [whisperModel, setWhisperModel] = useState('base');
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]); useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
const [cutMode, setCutMode] = useState(false); const [cutMode, setCutMode] = useState(false);
@ -81,7 +81,6 @@ export default function App() {
exportedAudioPath, exportedAudioPath,
words, words,
segments, segments,
deletedRanges,
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
@ -96,7 +95,6 @@ export default function App() {
exportedAudioPath, exportedAudioPath,
words, words,
segments, segments,
deletedRanges,
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
@ -116,8 +114,7 @@ export default function App() {
exportedAudioPath: data.exportedAudioPath ?? null, exportedAudioPath: data.exportedAudioPath ?? null,
words: data.words || [], words: data.words || [],
segments: data.segments || [], segments: data.segments || [],
deletedRanges: data.deletedRanges || [], cutRanges: [...(data.cutRanges || []), ...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }))],
cutRanges: data.cutRanges || [],
muteRanges: data.muteRanges || [], muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [], gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [], speedRanges: data.speedRanges || [],
@ -183,6 +180,7 @@ export default function App() {
const content = await window.electronAPI!.readFile(projectPath); const content = await window.electronAPI!.readFile(projectPath);
const data = JSON.parse(content); const data = JSON.parse(content);
loadProjectFromData(data); loadProjectFromData(data);
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
} catch (err) { } catch (err) {
console.error('Failed to load project:', err); console.error('Failed to load project:', err);
alert(`Failed to load project: ${err}`); alert(`Failed to load project: ${err}`);
@ -197,6 +195,7 @@ export default function App() {
const data = useEditorStore.getState().saveProject(); const data = useEditorStore.getState().saveProject();
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`; const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2)); await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
setProjectName(path.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
if (projectSignature) { if (projectSignature) {
setLastSavedSignature(projectSignature); setLastSavedSignature(projectSignature);
} }
@ -599,7 +598,13 @@ export default function App() {
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0"> <div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
{videoPath && ( {videoPath && (
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50"> <div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[\/]/).pop()}</span> {projectName && (
<span className="text-xs font-semibold text-editor-accent shrink-0">{projectName}</span>
)}
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[/\\]/).pop()}</span>
<span className="text-xs text-editor-text-muted ml-auto shrink-0">
{words.length} words &middot; {cutRanges.length} cuts &middot; {muteRanges.length} mutes &middot; {gainRanges.length} gains &middot; {speedRanges.length} speeds
</span>
{transcriptionModel && ( {transcriptionModel && (
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0"> <span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
{transcriptionModel} {transcriptionModel}

View File

@ -4,10 +4,10 @@ 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, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } = const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore(); useEditorStore();
const hasCuts = deletedRanges.length > 0; const hasCuts = cutRanges.length > 0;
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({ const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
mode: 'fast', mode: 'fast',
@ -35,8 +35,11 @@ export default function ExportDialog() {
const keepSegments = getKeepSegments(); const keepSegments = getKeepSegments();
const deletedSet = new Set<number>(); const deletedSet = new Set<number>();
for (const range of deletedRanges) { for (const range of cutRanges) {
for (const idx of range.wordIndices) deletedSet.add(idx); for (let i = 0; i < words.length; i++) {
const w = words[i];
if (w.start >= range.start && w.end <= range.end) deletedSet.add(i);
}
} }
const res = await fetch(`${backendUrl}/export`, { const res = await fetch(`${backendUrl}/export`, {
@ -61,7 +64,7 @@ export default function ExportDialog() {
console.error('Export error:', err); console.error('Export error:', err);
setExporting(false); setExporting(false);
} }
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]); }, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
return ( return (
<div className="p-4 space-y-5"> <div className="p-4 space-y-5">

View File

@ -22,7 +22,6 @@ export default function TranscriptEditor({
}: TranscriptEditorProps) { }: TranscriptEditorProps) {
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 cutRanges = useEditorStore((s) => s.cutRanges); const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges); const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges); const gainRanges = useEditorStore((s) => s.gainRanges);
@ -31,7 +30,6 @@ export default function TranscriptEditor({
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 restoreRange = useEditorStore((s) => s.restoreRange);
const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeGainRange = useEditorStore((s) => s.removeGainRange);
@ -48,14 +46,6 @@ export default function TranscriptEditor({
const zoneDragStart = useRef<number | null>(null); const zoneDragStart = useRef<number | null>(null);
const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null); const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null);
const deletedSet = useMemo(() => {
const s = new Set<number>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) s.add(idx);
}
return s;
}, [deletedRanges]);
const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]); const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]);
const [activeWordIndex, setActiveWordIndex] = useState(-1); const [activeWordIndex, setActiveWordIndex] = useState(-1);
@ -170,11 +160,6 @@ export default function TranscriptEditor({
[setSelectedWordIndices], [setSelectedWordIndices],
); );
const getRangeForWord = useCallback(
(wordIndex: number) => deletedRanges.find((r) => r.wordIndices.includes(wordIndex)),
[deletedRanges],
);
const cutSelectedWords = useCallback(() => { const cutSelectedWords = useCallback(() => {
if (selectedWordIndices.length === 0) return; if (selectedWordIndices.length === 0) return;
const sorted = [...selectedWordIndices].sort((a, b) => a - b); const sorted = [...selectedWordIndices].sort((a, b) => a - b);
@ -257,14 +242,12 @@ export default function TranscriptEditor({
<p className="text-sm leading-relaxed flex flex-wrap"> <p className="text-sm leading-relaxed flex flex-wrap">
{segment.words.map((word, localIndex) => { {segment.words.map((word, localIndex) => {
const globalIndex = (segment.globalStartIndex ?? 0) + localIndex; const globalIndex = (segment.globalStartIndex ?? 0) + localIndex;
const isDeleted = deletedSet.has(globalIndex);
const isSelected = selectedSet.has(globalIndex); const isSelected = selectedSet.has(globalIndex);
const isActive = globalIndex === activeWordIndex; const isActive = globalIndex === activeWordIndex;
const isHovered = globalIndex === hoveredWordIndex; const isHovered = globalIndex === hoveredWordIndex;
const isZoneDragSelected = zoneDragRange const isZoneDragSelected = zoneDragRange
? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end ? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end
: false; : false;
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
const cutRange = getCutRangeForWord(globalIndex); const cutRange = getCutRangeForWord(globalIndex);
const muteRange = getMuteRangeForWord(globalIndex); const muteRange = getMuteRangeForWord(globalIndex);
const gainRange = getGainRangeForWord(globalIndex); const gainRange = getGainRangeForWord(globalIndex);
@ -281,7 +264,6 @@ export default function TranscriptEditor({
onMouseLeave={() => setHoveredWordIndex(null)} onMouseLeave={() => setHoveredWordIndex(null)}
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' : ''}
${cutRange ? 'bg-red-500/20 text-red-100' : ''} ${cutRange ? 'bg-red-500/20 text-red-100' : ''}
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''} ${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
${gainRange ? 'bg-amber-500/20 text-amber-100' : ''} ${gainRange ? 'bg-amber-500/20 text-amber-100' : ''}
@ -290,23 +272,12 @@ export default function TranscriptEditor({
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''} ${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''} ${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''} ${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''} ${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''} ${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
`} `}
> >
{word.word}{' '} {word.word}{' '}
{isDeleted && isHovered && deletedRange && (
<button
onClick={(e) => {
e.stopPropagation();
restoreRange(deletedRange.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>
)}
{(cutRange || muteRange || gainRange || speedRange) && isHovered && ( {(cutRange || muteRange || gainRange || speedRange) && isHovered && (
<button <button
onClick={(e) => { onClick={(e) => {
@ -328,16 +299,12 @@ export default function TranscriptEditor({
</div> </div>
); );
}, },
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode], [segments, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode],
); );
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 justify-end gap-2 px-4 py-2 border-b border-editor-border shrink-0">
<span className="text-xs text-editor-text-muted flex-1">
{words.length} words &middot; {deletedRanges.length} cuts &middot; {cutRanges.length} cut ranges &middot; {muteRanges.length} mute ranges &middot; {gainRanges.length} gain ranges
&middot; {speedRanges.length} speed ranges
</span>
{selectedWordIndices.length > 0 && ( {selectedWordIndices.length > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button

View File

@ -127,7 +127,6 @@ export default function WaveformTimeline({
const videoPath = useEditorStore((s) => s.videoPath); const videoPath = useEditorStore((s) => s.videoPath);
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 cutRanges = useEditorStore((s) => s.cutRanges); const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges); const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges); const gainRanges = useEditorStore((s) => s.gainRanges);
@ -322,13 +321,6 @@ export default function WaveformTimeline({
const waveTop = RULER_H + 1; const waveTop = RULER_H + 1;
const waveH = height - waveTop; const waveH = height - waveTop;
for (const range of deletedRanges) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
}
// Draw cut ranges (red overlays) // Draw cut ranges (red overlays)
for (const range of showCutZones ? cutRanges : []) { for (const range of showCutZones ? cutRanges : []) {
const x1 = (range.start - scroll) * pxPerSec; const x1 = (range.start - scroll) * pxPerSec;
@ -430,9 +422,9 @@ export default function WaveformTimeline({
ctx.fillStyle = '#d1fae5'; ctx.fillStyle = '#d1fae5';
ctx.font = '10px sans-serif'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'top';
if (centerX > 12 && centerX < width - 12) { if (centerX > 12 && centerX < width - 12) {
ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + waveH / 2); ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + 4);
} }
ctx.textAlign = 'start'; ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
@ -483,7 +475,6 @@ export default function WaveformTimeline({
} }
ctx.stroke(); ctx.stroke();
}, [ }, [
deletedRanges,
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
@ -506,7 +497,7 @@ export default function WaveformTimeline({
drawStaticWaveformRef.current = drawStaticWaveform; drawStaticWaveformRef.current = drawStaticWaveform;
}, [drawStaticWaveform]); }, [drawStaticWaveform]);
// Redraw static layer when deletedRanges change // Redraw static layer when cutRanges change
useEffect(() => { useEffect(() => {
drawStaticWaveform(); drawStaticWaveform();
}, [drawStaticWaveform]); }, [drawStaticWaveform]);

View File

@ -160,7 +160,6 @@ async function saveProject() {
videoPath: state.videoPath, videoPath: state.videoPath,
words: state.words, words: state.words,
segments: state.segments, segments: state.segments,
deletedRanges: state.deletedRanges,
language: state.language, language: state.language,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(), modifiedAt: new Date().toISOString(),

View File

@ -7,18 +7,75 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
setCurrentTime, setCurrentTime,
setDuration, setDuration,
setIsPlaying, setIsPlaying,
deletedRanges,
cutRanges, cutRanges,
muteRanges, muteRanges,
speedRanges,
} = useEditorStore(); } = useEditorStore();
const getPlaybackRateAtTime = useCallback(
(time: number) => {
for (const range of speedRanges) {
if (time >= range.start && time < range.end) {
return range.speed;
}
}
return 1;
},
[speedRanges],
);
const applyVideoEffects = useCallback(
(video: HTMLVideoElement) => {
let t = video.currentTime;
const allSkipRanges = [...cutRanges];
let skipCount = 0;
const maxSkips = 10;
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) {
video.currentTime = t;
}
let shouldMute = false;
for (const range of muteRanges) {
if (t >= range.start && t < range.end) {
shouldMute = true;
break;
}
}
video.muted = shouldMute;
const playbackRate = getPlaybackRateAtTime(t);
if (video.playbackRate !== playbackRate) {
video.playbackRate = playbackRate;
}
setCurrentTime(t);
return t;
},
[cutRanges, muteRanges, getPlaybackRateAtTime, setCurrentTime],
);
const seekTo = useCallback( const seekTo = useCallback(
(time: number) => { (time: number) => {
if (videoRef.current) { if (videoRef.current) {
let targetTime = time; let targetTime = time;
// If seeking into cut or deleted ranges, skip to the end (handle overlapping/chained ranges) // If seeking into cut or deleted ranges, skip to the end (handle overlapping/chained ranges)
const allSkipRanges = [...deletedRanges, ...cutRanges]; const allSkipRanges = [...cutRanges];
let skipCount = 0; let skipCount = 0;
const maxSkips = 10; // Prevent infinite loops const maxSkips = 10; // Prevent infinite loops
@ -36,10 +93,11 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
} }
videoRef.current.currentTime = targetTime; videoRef.current.currentTime = targetTime;
videoRef.current.playbackRate = getPlaybackRateAtTime(targetTime);
setCurrentTime(targetTime); setCurrentTime(targetTime);
} }
}, },
[videoRef, deletedRanges, cutRanges, setCurrentTime], [videoRef, cutRanges, getPlaybackRateAtTime, setCurrentTime],
); );
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
@ -55,65 +113,52 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
const updateWhilePlaying = () => {
applyVideoEffects(video);
if (!video.paused && !video.ended) {
rafRef.current = requestAnimationFrame(updateWhilePlaying);
}
};
const onTimeUpdate = () => { const onTimeUpdate = () => {
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
let t = video.currentTime; applyVideoEffects(video);
// 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) {
shouldMute = true;
break;
}
}
video.muted = shouldMute;
setCurrentTime(t);
}); });
}; };
const onPlay = () => setIsPlaying(true); const onPlay = () => {
const onPause = () => setIsPlaying(false); setIsPlaying(true);
const onLoadedMetadata = () => setDuration(video.duration); cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateWhilePlaying);
};
const onPause = () => {
setIsPlaying(false);
cancelAnimationFrame(rafRef.current);
applyVideoEffects(video);
};
const onLoadedMetadata = () => {
setDuration(video.duration);
applyVideoEffects(video);
};
const onSeeked = () => applyVideoEffects(video);
video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('play', onPlay); video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause); video.addEventListener('pause', onPause);
video.addEventListener('loadedmetadata', onLoadedMetadata); video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('seeked', onSeeked);
return () => { return () => {
video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('play', onPlay); video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause); video.removeEventListener('pause', onPause);
video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('seeked', onSeeked);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
video.playbackRate = 1;
}; };
}, [videoRef, deletedRanges, cutRanges, muteRanges, setCurrentTime, setIsPlaying, setDuration]); }, [videoRef, applyVideoEffects, setIsPlaying, setDuration]);
return { seekTo, togglePlay }; return { seekTo, togglePlay };
} }

View File

@ -3,7 +3,6 @@ import { temporal } from 'zundo';
import type { import type {
Word, Word,
Segment, Segment,
DeletedRange,
CutRange, CutRange,
MuteRange, MuteRange,
GainRange, GainRange,
@ -21,7 +20,6 @@ interface EditorState {
exportedAudioPath: string | null; // path to modified audio from a previous export exportedAudioPath: string | null; // path to modified audio from a previous export
words: Word[]; words: Word[];
segments: Segment[]; segments: Segment[];
deletedRanges: DeletedRange[];
cutRanges: CutRange[]; cutRanges: CutRange[];
muteRanges: MuteRange[]; muteRanges: MuteRange[];
gainRanges: GainRange[]; gainRanges: GainRange[];
@ -61,7 +59,6 @@ interface EditorActions {
setHoveredWordIndex: (index: number | null) => void; setHoveredWordIndex: (index: number | null) => void;
deleteSelectedWords: () => void; deleteSelectedWords: () => void;
deleteWordRange: (startIndex: number, endIndex: number) => void; deleteWordRange: (startIndex: number, endIndex: number) => void;
restoreRange: (rangeId: string) => void;
addCutRange: (start: number, end: number, trimGroupId?: string) => void; addCutRange: (start: number, end: number, trimGroupId?: string) => void;
addMuteRange: (start: number, end: number) => void; addMuteRange: (start: number, end: number) => void;
addGainRange: (start: number, end: number, gainDb: number) => void; addGainRange: (start: number, end: number, gainDb: number) => void;
@ -97,7 +94,6 @@ const initialState: EditorState = {
exportedAudioPath: null, exportedAudioPath: null,
words: [], words: [],
segments: [], segments: [],
deletedRanges: [],
cutRanges: [], cutRanges: [],
muteRanges: [], muteRanges: [],
gainRanges: [], gainRanges: [],
@ -159,7 +155,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setTranscriptionModel: (model) => set({ transcriptionModel: model }), setTranscriptionModel: (model) => set({ transcriptionModel: model }),
saveProject: (): ProjectFile => { saveProject: (): ProjectFile => {
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get(); const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, 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.
@ -175,7 +171,6 @@ export const useEditorStore = create<EditorState & EditorActions>()(
transcriptionModel: transcriptionModel ?? undefined, transcriptionModel: transcriptionModel ?? undefined,
words, words,
segments: persistSegments as unknown as Segment[], segments: persistSegments as unknown as Segment[],
deletedRanges,
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
@ -210,7 +205,6 @@ export const useEditorStore = create<EditorState & EditorActions>()(
words: result.words, words: result.words,
segments: annotatedSegments, segments: annotatedSegments,
language: result.language, language: result.language,
deletedRanges: [],
selectedWordIndices: [], selectedWordIndices: [],
}); });
}, },
@ -222,44 +216,20 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }), setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
deleteSelectedWords: () => { deleteSelectedWords: () => {
const { selectedWordIndices, words, deletedRanges } = get(); const { selectedWordIndices, words } = get();
if (selectedWordIndices.length === 0) return; if (selectedWordIndices.length === 0) return;
const sorted = [...selectedWordIndices].sort((a, b) => a - b); const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startWord = words[sorted[0]]; const startWord = words[sorted[0]];
const endWord = words[sorted[sorted.length - 1]]; const endWord = words[sorted[sorted.length - 1]];
const newRange: DeletedRange = { get().addCutRange(startWord.start, endWord.end);
id: `dr_${nextRangeId++}`, set({ selectedWordIndices: [] });
start: startWord.start,
end: endWord.end,
wordIndices: sorted,
};
set({
deletedRanges: [...deletedRanges, newRange],
selectedWordIndices: [],
});
}, },
deleteWordRange: (startIndex, endIndex) => { deleteWordRange: (startIndex, endIndex) => {
const { words, deletedRanges } = get(); const { words } = get();
const indices = []; get().addCutRange(words[startIndex].start, words[endIndex].end);
for (let i = startIndex; i <= endIndex; i++) indices.push(i);
const newRange: DeletedRange = {
id: `dr_${nextRangeId++}`,
start: words[startIndex].start,
end: words[endIndex].end,
wordIndices: indices,
};
set({ deletedRanges: [...deletedRanges, newRange] });
},
restoreRange: (rangeId) => {
const { deletedRanges } = get();
set({ deletedRanges: deletedRanges.filter((r) => r.id !== rangeId) });
}, },
addCutRange: (start, end, trimGroupId) => { addCutRange: (start, end, trimGroupId) => {
@ -438,15 +408,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}), }),
getKeepSegments: () => { getKeepSegments: () => {
const { words, deletedRanges, cutRanges, duration } = get(); const { words, 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>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) deletedSet.add(idx);
}
// Also exclude words that fall within cut ranges
for (const cutRange of cutRanges) { for (const cutRange of cutRanges) {
for (let i = 0; i < words.length; i++) { for (let i = 0; i < words.length; i++) {
const word = words[i]; const word = words[i];
@ -508,8 +473,11 @@ export const useEditorStore = create<EditorState & EditorActions>()(
videoUrl: url, videoUrl: url,
words: data.words || [], words: data.words || [],
segments: annotatedSegments, segments: annotatedSegments,
deletedRanges: data.deletedRanges || [], // Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
cutRanges: data.cutRanges || [], cutRanges: [
...(data.cutRanges || []),
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
],
muteRanges: data.muteRanges || [], muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [], gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [], speedRanges: data.speedRanges || [],

View File

@ -21,11 +21,6 @@ export interface TimeRange {
end: number; end: number;
} }
export interface DeletedRange extends TimeRange {
id: string;
wordIndices: number[];
}
export interface CutRange extends TimeRange { export interface CutRange extends TimeRange {
id: string; id: string;
trimGroupId?: string; trimGroupId?: string;
@ -71,7 +66,6 @@ export interface ProjectFile {
transcriptionModel?: string; transcriptionModel?: string;
words: Word[]; words: Word[];
segments: Segment[]; segments: Segment[];
deletedRanges: DeletedRange[];
cutRanges: CutRange[]; cutRanges: CutRange[];
muteRanges: MuteRange[]; muteRanges: MuteRange[];
gainRanges?: GainRange[]; gainRanges?: GainRange[];