2026-03-03 06:31:04 -05:00
|
|
|
import { create } from 'zustand';
|
|
|
|
|
import { temporal } from 'zundo';
|
2026-04-03 11:14:31 -06:00
|
|
|
import type { Word, Segment, DeletedRange, CutRange, MuteRange, TranscriptionResult, ProjectFile } from '../types/project';
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
interface EditorState {
|
|
|
|
|
videoPath: string | null;
|
|
|
|
|
videoUrl: string | null;
|
2026-03-30 18:36:41 -06:00
|
|
|
exportedAudioPath: string | null; // path to modified audio from a previous export
|
2026-03-03 06:31:04 -05:00
|
|
|
words: Word[];
|
|
|
|
|
segments: Segment[];
|
|
|
|
|
deletedRanges: DeletedRange[];
|
2026-04-03 11:14:31 -06:00
|
|
|
cutRanges: CutRange[];
|
|
|
|
|
muteRanges: MuteRange[];
|
2026-03-03 06:31:04 -05:00
|
|
|
language: string;
|
|
|
|
|
|
|
|
|
|
currentTime: number;
|
|
|
|
|
duration: number;
|
|
|
|
|
isPlaying: boolean;
|
|
|
|
|
|
|
|
|
|
selectedWordIndices: number[];
|
|
|
|
|
hoveredWordIndex: number | null;
|
|
|
|
|
|
|
|
|
|
isTranscribing: boolean;
|
|
|
|
|
transcriptionProgress: number;
|
2026-03-26 00:58:57 -06:00
|
|
|
transcriptionStatus: string;
|
2026-03-03 06:31:04 -05:00
|
|
|
isExporting: boolean;
|
|
|
|
|
exportProgress: number;
|
|
|
|
|
|
|
|
|
|
backendUrl: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EditorActions {
|
|
|
|
|
setBackendUrl: (url: string) => void;
|
|
|
|
|
loadVideo: (path: string) => void;
|
2026-03-30 18:36:41 -06:00
|
|
|
setExportedAudioPath: (path: string | null) => void;
|
|
|
|
|
saveProject: () => ProjectFile;
|
2026-03-03 06:31:04 -05:00
|
|
|
setTranscription: (result: TranscriptionResult) => void;
|
|
|
|
|
setCurrentTime: (time: number) => void;
|
|
|
|
|
setDuration: (duration: number) => void;
|
|
|
|
|
setIsPlaying: (playing: boolean) => void;
|
|
|
|
|
setSelectedWordIndices: (indices: number[]) => void;
|
|
|
|
|
setHoveredWordIndex: (index: number | null) => void;
|
|
|
|
|
deleteSelectedWords: () => void;
|
|
|
|
|
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
|
|
|
|
restoreRange: (rangeId: string) => void;
|
2026-04-03 11:14:31 -06:00
|
|
|
addCutRange: (start: number, end: number) => void;
|
|
|
|
|
addMuteRange: (start: number, end: number) => void;
|
2026-04-03 11:36:08 -06:00
|
|
|
updateCutRange: (id: string, start: number, end: number) => void;
|
|
|
|
|
updateMuteRange: (id: string, start: number, end: number) => void;
|
2026-04-03 11:14:31 -06:00
|
|
|
removeCutRange: (id: string) => void;
|
|
|
|
|
removeMuteRange: (id: string) => void;
|
2026-03-26 00:58:57 -06:00
|
|
|
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
2026-03-03 06:31:04 -05:00
|
|
|
setExporting: (active: boolean, progress?: number) => void;
|
|
|
|
|
getKeepSegments: () => Array<{ start: number; end: number }>;
|
|
|
|
|
getWordAtTime: (time: number) => number;
|
|
|
|
|
loadProject: (projectData: any) => void;
|
|
|
|
|
reset: () => void;
|
2026-04-08 00:48:05 -06:00
|
|
|
pauseUndo: () => void;
|
|
|
|
|
resumeUndo: () => void;
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initialState: EditorState = {
|
|
|
|
|
videoPath: null,
|
|
|
|
|
videoUrl: null,
|
2026-03-30 18:36:41 -06:00
|
|
|
exportedAudioPath: null,
|
2026-03-03 06:31:04 -05:00
|
|
|
words: [],
|
|
|
|
|
segments: [],
|
|
|
|
|
deletedRanges: [],
|
2026-04-03 11:14:31 -06:00
|
|
|
cutRanges: [],
|
|
|
|
|
muteRanges: [],
|
2026-03-03 06:31:04 -05:00
|
|
|
language: '',
|
|
|
|
|
currentTime: 0,
|
|
|
|
|
duration: 0,
|
|
|
|
|
isPlaying: false,
|
|
|
|
|
selectedWordIndices: [],
|
|
|
|
|
hoveredWordIndex: null,
|
|
|
|
|
isTranscribing: false,
|
|
|
|
|
transcriptionProgress: 0,
|
2026-03-26 00:58:57 -06:00
|
|
|
transcriptionStatus: '',
|
2026-03-03 06:31:04 -05:00
|
|
|
isExporting: false,
|
|
|
|
|
exportProgress: 0,
|
2026-03-28 15:09:56 -06:00
|
|
|
backendUrl: 'http://127.0.0.1:8000',
|
2026-03-03 06:31:04 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let nextRangeId = 1;
|
|
|
|
|
|
|
|
|
|
export const useEditorStore = create<EditorState & EditorActions>()(
|
|
|
|
|
temporal(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
...initialState,
|
|
|
|
|
|
|
|
|
|
setBackendUrl: (url) => set({ backendUrl: url }),
|
|
|
|
|
|
2026-03-30 18:36:41 -06:00
|
|
|
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
|
|
|
|
|
|
|
|
|
saveProject: (): ProjectFile => {
|
2026-04-03 11:14:31 -06:00
|
|
|
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, language, exportedAudioPath } = get();
|
2026-03-30 18:36:41 -06:00
|
|
|
if (!videoPath) throw new Error('No video loaded');
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
// Strip globalStartIndex (runtime-only field) before persisting
|
|
|
|
|
const persistSegments = segments.map(({ globalStartIndex: _drop, ...rest }) => rest);
|
|
|
|
|
return {
|
|
|
|
|
version: 1,
|
|
|
|
|
videoPath,
|
|
|
|
|
exportedAudioPath: exportedAudioPath ?? undefined,
|
|
|
|
|
words,
|
|
|
|
|
segments: persistSegments as unknown as Segment[],
|
|
|
|
|
deletedRanges,
|
2026-04-03 11:14:31 -06:00
|
|
|
cutRanges,
|
|
|
|
|
muteRanges,
|
2026-03-30 18:36:41 -06:00
|
|
|
language,
|
|
|
|
|
createdAt: now, // will be overwritten if we track original creation time later
|
|
|
|
|
modifiedAt: now,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
loadVideo: (path) => {
|
|
|
|
|
const backend = get().backendUrl;
|
2026-04-08 01:04:27 -06:00
|
|
|
const buildMediaUrl = (filePath: string) => {
|
|
|
|
|
const isWav = filePath.toLowerCase().endsWith('.wav');
|
|
|
|
|
return isWav
|
|
|
|
|
? `${backend}/file?path=${encodeURIComponent(filePath)}&format=mp3`
|
|
|
|
|
: `${backend}/file?path=${encodeURIComponent(filePath)}`;
|
|
|
|
|
};
|
|
|
|
|
const url = buildMediaUrl(path);
|
2026-03-03 06:31:04 -05:00
|
|
|
set({
|
|
|
|
|
...initialState,
|
|
|
|
|
backendUrl: backend,
|
|
|
|
|
videoPath: path,
|
|
|
|
|
videoUrl: url,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setTranscription: (result) => {
|
|
|
|
|
let globalIdx = 0;
|
|
|
|
|
const annotatedSegments = result.segments.map((seg) => {
|
|
|
|
|
const annotated = { ...seg, globalStartIndex: globalIdx };
|
|
|
|
|
globalIdx += seg.words.length;
|
|
|
|
|
return annotated;
|
|
|
|
|
});
|
|
|
|
|
set({
|
|
|
|
|
words: result.words,
|
|
|
|
|
segments: annotatedSegments,
|
|
|
|
|
language: result.language,
|
|
|
|
|
deletedRanges: [],
|
|
|
|
|
selectedWordIndices: [],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setCurrentTime: (time) => set({ currentTime: time }),
|
|
|
|
|
setDuration: (duration) => set({ duration }),
|
|
|
|
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
|
|
|
|
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
|
|
|
|
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
|
|
|
|
|
|
|
|
|
deleteSelectedWords: () => {
|
|
|
|
|
const { selectedWordIndices, words, deletedRanges } = 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: [],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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) });
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
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] });
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-03 11:36:08 -06:00
|
|
|
updateCutRange: (id, start, end) => {
|
|
|
|
|
const { cutRanges } = get();
|
|
|
|
|
set({
|
|
|
|
|
cutRanges: cutRanges.map((r) =>
|
|
|
|
|
r.id === id ? { ...r, start, end } : r
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateMuteRange: (id, start, end) => {
|
|
|
|
|
const { muteRanges } = get();
|
|
|
|
|
set({
|
|
|
|
|
muteRanges: muteRanges.map((r) =>
|
|
|
|
|
r.id === id ? { ...r, start, end } : r
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
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) });
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-26 00:58:57 -06:00
|
|
|
setTranscribing: (active, progress, status) =>
|
2026-03-03 06:31:04 -05:00
|
|
|
set({
|
|
|
|
|
isTranscribing: active,
|
|
|
|
|
transcriptionProgress: progress ?? (active ? 0 : 100),
|
2026-04-03 11:14:31 -06:00
|
|
|
transcriptionStatus: status ?? (active ? 'Transcribing...' : ''),
|
2026-03-03 06:31:04 -05:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
setExporting: (active, progress) =>
|
|
|
|
|
set({
|
|
|
|
|
isExporting: active,
|
|
|
|
|
exportProgress: progress ?? (active ? 0 : 100),
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
getKeepSegments: () => {
|
2026-04-03 11:14:31 -06:00
|
|
|
const { words, deletedRanges, cutRanges, duration } = get();
|
2026-03-03 06:31:04 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:14:31 -06:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const segments: Array<{ start: number; end: number }> = [];
|
|
|
|
|
let segStart: number | null = null;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
|
|
|
if (!deletedSet.has(i)) {
|
|
|
|
|
if (segStart === null) segStart = words[i].start;
|
|
|
|
|
} else {
|
|
|
|
|
if (segStart !== null) {
|
|
|
|
|
segments.push({ start: segStart, end: words[i - 1].end });
|
|
|
|
|
segStart = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (segStart !== null) {
|
|
|
|
|
segments.push({ start: segStart, end: words[words.length - 1].end });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return segments;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getWordAtTime: (time) => {
|
|
|
|
|
const { words } = get();
|
|
|
|
|
let lo = 0;
|
|
|
|
|
let hi = words.length - 1;
|
|
|
|
|
while (lo <= hi) {
|
|
|
|
|
const mid = (lo + hi) >>> 1;
|
|
|
|
|
if (words[mid].end < time) lo = mid + 1;
|
|
|
|
|
else if (words[mid].start > time) hi = mid - 1;
|
|
|
|
|
else return mid;
|
|
|
|
|
}
|
|
|
|
|
return lo < words.length ? lo : words.length - 1;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
loadProject: (data) => {
|
|
|
|
|
const backend = get().backendUrl;
|
2026-04-08 01:04:27 -06:00
|
|
|
const isWav = data.videoPath.toLowerCase().endsWith('.wav');
|
|
|
|
|
const url = isWav
|
|
|
|
|
? `${backend}/file?path=${encodeURIComponent(data.videoPath)}&format=mp3`
|
|
|
|
|
: `${backend}/file?path=${encodeURIComponent(data.videoPath)}`;
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
let globalIdx = 0;
|
|
|
|
|
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
|
|
|
|
const annotated = { ...seg, globalStartIndex: globalIdx };
|
|
|
|
|
globalIdx += seg.words.length;
|
|
|
|
|
return annotated;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
set({
|
|
|
|
|
...initialState,
|
|
|
|
|
backendUrl: backend,
|
|
|
|
|
videoPath: data.videoPath,
|
|
|
|
|
videoUrl: url,
|
|
|
|
|
words: data.words || [],
|
|
|
|
|
segments: annotatedSegments,
|
|
|
|
|
deletedRanges: data.deletedRanges || [],
|
2026-04-03 11:14:31 -06:00
|
|
|
cutRanges: data.cutRanges || [],
|
|
|
|
|
muteRanges: data.muteRanges || [],
|
2026-03-03 06:31:04 -05:00
|
|
|
language: data.language || '',
|
2026-03-30 18:36:41 -06:00
|
|
|
exportedAudioPath: data.exportedAudioPath ?? null,
|
2026-03-03 06:31:04 -05:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
reset: () => set(initialState),
|
2026-04-08 00:48:05 -06:00
|
|
|
|
|
|
|
|
pauseUndo: () => {
|
|
|
|
|
// Access the temporal store through the useEditorStore
|
|
|
|
|
const temporalStore = (useEditorStore as any).temporal;
|
|
|
|
|
if (temporalStore) {
|
|
|
|
|
temporalStore.getState().pause();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
resumeUndo: () => {
|
|
|
|
|
// Access the temporal store through the useEditorStore
|
|
|
|
|
const temporalStore = (useEditorStore as any).temporal;
|
|
|
|
|
if (temporalStore) {
|
|
|
|
|
temporalStore.getState().resume();
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-03 06:31:04 -05:00
|
|
|
}),
|
|
|
|
|
{ limit: 100 },
|
|
|
|
|
),
|
|
|
|
|
);
|