speed zones work now
This commit is contained in:
@ -37,7 +37,6 @@ export default function App() {
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
@ -61,6 +60,7 @@ export default function App() {
|
||||
} = useEditorStore();
|
||||
|
||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||
const [projectName, setProjectName] = useState<string | null>(null);
|
||||
const [whisperModel, setWhisperModel] = useState('base');
|
||||
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
||||
const [cutMode, setCutMode] = useState(false);
|
||||
@ -81,7 +81,6 @@ export default function App() {
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
@ -96,7 +95,6 @@ export default function App() {
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
@ -116,8 +114,7 @@ export default function App() {
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
words: data.words || [],
|
||||
segments: data.segments || [],
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
cutRanges: [...(data.cutRanges || []), ...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }))],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
speedRanges: data.speedRanges || [],
|
||||
@ -183,6 +180,7 @@ export default function App() {
|
||||
const content = await window.electronAPI!.readFile(projectPath);
|
||||
const data = JSON.parse(content);
|
||||
loadProjectFromData(data);
|
||||
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
||||
} catch (err) {
|
||||
console.error('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 path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
||||
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
||||
setProjectName(path.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
||||
if (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">
|
||||
{videoPath && (
|
||||
<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 · {cutRanges.length} cuts · {muteRanges.length} mutes · {gainRanges.length} gains · {speedRanges.length} speeds
|
||||
</span>
|
||||
{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">
|
||||
{transcriptionModel}
|
||||
|
||||
@ -4,10 +4,10 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
|
||||
import type { ExportOptions } from '../types/project';
|
||||
|
||||
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();
|
||||
|
||||
const hasCuts = deletedRanges.length > 0;
|
||||
const hasCuts = cutRanges.length > 0;
|
||||
|
||||
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
|
||||
mode: 'fast',
|
||||
@ -35,8 +35,11 @@ export default function ExportDialog() {
|
||||
const keepSegments = getKeepSegments();
|
||||
|
||||
const deletedSet = new Set<number>();
|
||||
for (const range of deletedRanges) {
|
||||
for (const idx of range.wordIndices) deletedSet.add(idx);
|
||||
for (const range of cutRanges) {
|
||||
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`, {
|
||||
@ -61,7 +64,7 @@ export default function ExportDialog() {
|
||||
console.error('Export error:', err);
|
||||
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 (
|
||||
<div className="p-4 space-y-5">
|
||||
|
||||
@ -22,7 +22,6 @@ export default function TranscriptEditor({
|
||||
}: TranscriptEditorProps) {
|
||||
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 gainRanges = useEditorStore((s) => s.gainRanges);
|
||||
@ -31,7 +30,6 @@ export default function TranscriptEditor({
|
||||
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
|
||||
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
|
||||
const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex);
|
||||
const restoreRange = useEditorStore((s) => s.restoreRange);
|
||||
const removeCutRange = useEditorStore((s) => s.removeCutRange);
|
||||
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
||||
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
||||
@ -48,14 +46,6 @@ export default function TranscriptEditor({
|
||||
const zoneDragStart = useRef<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 [activeWordIndex, setActiveWordIndex] = useState(-1);
|
||||
@ -170,11 +160,6 @@ export default function TranscriptEditor({
|
||||
[setSelectedWordIndices],
|
||||
);
|
||||
|
||||
const getRangeForWord = useCallback(
|
||||
(wordIndex: number) => deletedRanges.find((r) => r.wordIndices.includes(wordIndex)),
|
||||
[deletedRanges],
|
||||
);
|
||||
|
||||
const cutSelectedWords = useCallback(() => {
|
||||
if (selectedWordIndices.length === 0) return;
|
||||
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">
|
||||
{segment.words.map((word, localIndex) => {
|
||||
const globalIndex = (segment.globalStartIndex ?? 0) + localIndex;
|
||||
const isDeleted = deletedSet.has(globalIndex);
|
||||
const isSelected = selectedSet.has(globalIndex);
|
||||
const isActive = globalIndex === activeWordIndex;
|
||||
const isHovered = globalIndex === hoveredWordIndex;
|
||||
const isZoneDragSelected = zoneDragRange
|
||||
? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end
|
||||
: false;
|
||||
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
|
||||
const cutRange = getCutRangeForWord(globalIndex);
|
||||
const muteRange = getMuteRangeForWord(globalIndex);
|
||||
const gainRange = getGainRangeForWord(globalIndex);
|
||||
@ -281,7 +264,6 @@ export default function TranscriptEditor({
|
||||
onMouseLeave={() => setHoveredWordIndex(null)}
|
||||
className={`
|
||||
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' : ''}
|
||||
${muteRange ? 'bg-blue-500/20 text-blue-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 && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-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' : ''}
|
||||
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
||||
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
||||
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
||||
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
||||
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
||||
`}
|
||||
>
|
||||
{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 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@ -328,16 +299,12 @@ export default function TranscriptEditor({
|
||||
</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 (
|
||||
<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 · {cutRanges.length} cut ranges · {muteRanges.length} mute ranges · {gainRanges.length} gain ranges
|
||||
· {speedRanges.length} speed ranges
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-editor-border shrink-0">
|
||||
{selectedWordIndices.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
|
||||
@ -127,7 +127,6 @@ export default function WaveformTimeline({
|
||||
const videoPath = useEditorStore((s) => s.videoPath);
|
||||
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 gainRanges = useEditorStore((s) => s.gainRanges);
|
||||
@ -322,13 +321,6 @@ export default function WaveformTimeline({
|
||||
const waveTop = RULER_H + 1;
|
||||
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)
|
||||
for (const range of showCutZones ? cutRanges : []) {
|
||||
const x1 = (range.start - scroll) * pxPerSec;
|
||||
@ -430,9 +422,9 @@ export default function WaveformTimeline({
|
||||
ctx.fillStyle = '#d1fae5';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textBaseline = 'top';
|
||||
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.textBaseline = 'alphabetic';
|
||||
@ -483,7 +475,6 @@ export default function WaveformTimeline({
|
||||
}
|
||||
ctx.stroke();
|
||||
}, [
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
@ -506,7 +497,7 @@ export default function WaveformTimeline({
|
||||
drawStaticWaveformRef.current = drawStaticWaveform;
|
||||
}, [drawStaticWaveform]);
|
||||
|
||||
// Redraw static layer when deletedRanges change
|
||||
// Redraw static layer when cutRanges change
|
||||
useEffect(() => {
|
||||
drawStaticWaveform();
|
||||
}, [drawStaticWaveform]);
|
||||
|
||||
@ -160,7 +160,6 @@ async function saveProject() {
|
||||
videoPath: state.videoPath,
|
||||
words: state.words,
|
||||
segments: state.segments,
|
||||
deletedRanges: state.deletedRanges,
|
||||
language: state.language,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
|
||||
@ -7,18 +7,75 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setIsPlaying,
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
speedRanges,
|
||||
} = 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(
|
||||
(time: number) => {
|
||||
if (videoRef.current) {
|
||||
let targetTime = time;
|
||||
|
||||
// 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;
|
||||
const maxSkips = 10; // Prevent infinite loops
|
||||
|
||||
@ -36,10 +93,11 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = targetTime;
|
||||
videoRef.current.playbackRate = getPlaybackRateAtTime(targetTime);
|
||||
setCurrentTime(targetTime);
|
||||
}
|
||||
},
|
||||
[videoRef, deletedRanges, cutRanges, setCurrentTime],
|
||||
[videoRef, cutRanges, getPlaybackRateAtTime, setCurrentTime],
|
||||
);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
@ -55,65 +113,52 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const updateWhilePlaying = () => {
|
||||
applyVideoEffects(video);
|
||||
if (!video.paused && !video.ended) {
|
||||
rafRef.current = requestAnimationFrame(updateWhilePlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
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) {
|
||||
shouldMute = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
video.muted = shouldMute;
|
||||
|
||||
setCurrentTime(t);
|
||||
applyVideoEffects(video);
|
||||
});
|
||||
};
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onLoadedMetadata = () => setDuration(video.duration);
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
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('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
video.playbackRate = 1;
|
||||
};
|
||||
}, [videoRef, deletedRanges, cutRanges, muteRanges, setCurrentTime, setIsPlaying, setDuration]);
|
||||
}, [videoRef, applyVideoEffects, setIsPlaying, setDuration]);
|
||||
|
||||
return { seekTo, togglePlay };
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import { temporal } from 'zundo';
|
||||
import type {
|
||||
Word,
|
||||
Segment,
|
||||
DeletedRange,
|
||||
CutRange,
|
||||
MuteRange,
|
||||
GainRange,
|
||||
@ -21,7 +20,6 @@ interface EditorState {
|
||||
exportedAudioPath: string | null; // path to modified audio from a previous export
|
||||
words: Word[];
|
||||
segments: Segment[];
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
gainRanges: GainRange[];
|
||||
@ -61,7 +59,6 @@ interface EditorActions {
|
||||
setHoveredWordIndex: (index: number | null) => void;
|
||||
deleteSelectedWords: () => void;
|
||||
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
||||
restoreRange: (rangeId: string) => void;
|
||||
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
|
||||
addMuteRange: (start: number, end: number) => void;
|
||||
addGainRange: (start: number, end: number, gainDb: number) => void;
|
||||
@ -97,7 +94,6 @@ const initialState: EditorState = {
|
||||
exportedAudioPath: null,
|
||||
words: [],
|
||||
segments: [],
|
||||
deletedRanges: [],
|
||||
cutRanges: [],
|
||||
muteRanges: [],
|
||||
gainRanges: [],
|
||||
@ -159,7 +155,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
|
||||
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');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting.
|
||||
@ -175,7 +171,6 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
transcriptionModel: transcriptionModel ?? undefined,
|
||||
words,
|
||||
segments: persistSegments as unknown as Segment[],
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
@ -210,7 +205,6 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
words: result.words,
|
||||
segments: annotatedSegments,
|
||||
language: result.language,
|
||||
deletedRanges: [],
|
||||
selectedWordIndices: [],
|
||||
});
|
||||
},
|
||||
@ -222,44 +216,20 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||
|
||||
deleteSelectedWords: () => {
|
||||
const { selectedWordIndices, words, deletedRanges } = get();
|
||||
const { selectedWordIndices, words } = get();
|
||||
if (selectedWordIndices.length === 0) return;
|
||||
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
|
||||
const newRange: DeletedRange = {
|
||||
id: `dr_${nextRangeId++}`,
|
||||
start: startWord.start,
|
||||
end: endWord.end,
|
||||
wordIndices: sorted,
|
||||
};
|
||||
|
||||
set({
|
||||
deletedRanges: [...deletedRanges, newRange],
|
||||
selectedWordIndices: [],
|
||||
});
|
||||
get().addCutRange(startWord.start, endWord.end);
|
||||
set({ selectedWordIndices: [] });
|
||||
},
|
||||
|
||||
deleteWordRange: (startIndex, endIndex) => {
|
||||
const { words, deletedRanges } = get();
|
||||
const indices = [];
|
||||
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) });
|
||||
const { words } = get();
|
||||
get().addCutRange(words[startIndex].start, words[endIndex].end);
|
||||
},
|
||||
|
||||
addCutRange: (start, end, trimGroupId) => {
|
||||
@ -438,15 +408,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
}),
|
||||
|
||||
getKeepSegments: () => {
|
||||
const { words, deletedRanges, cutRanges, duration } = get();
|
||||
const { words, cutRanges, duration } = get();
|
||||
if (words.length === 0) return [{ start: 0, end: duration }];
|
||||
|
||||
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 (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
@ -508,8 +473,11 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
videoUrl: url,
|
||||
words: data.words || [],
|
||||
segments: annotatedSegments,
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
||||
cutRanges: [
|
||||
...(data.cutRanges || []),
|
||||
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
|
||||
],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
speedRanges: data.speedRanges || [],
|
||||
|
||||
@ -21,11 +21,6 @@ export interface TimeRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface DeletedRange extends TimeRange {
|
||||
id: string;
|
||||
wordIndices: number[];
|
||||
}
|
||||
|
||||
export interface CutRange extends TimeRange {
|
||||
id: string;
|
||||
trimGroupId?: string;
|
||||
@ -71,7 +66,6 @@ export interface ProjectFile {
|
||||
transcriptionModel?: string;
|
||||
words: Word[];
|
||||
segments: Segment[];
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
gainRanges?: GainRange[];
|
||||
|
||||
Reference in New Issue
Block a user