import { create } from 'zustand'; import { temporal } from 'zundo'; import { assert } from '../lib/assert'; import type { Word, Segment, CutRange, MuteRange, GainRange, SpeedRange, TranscriptionResult, ProjectFile, SilenceDetectionRange, SilenceTrimSettings, SilenceTrimGroup, TimelineMarker, Chapter, ZoomConfig, ClipInfo, BackgroundMusicConfig, } from '../types/project'; interface EditorState { projectFilePath: string | null; videoPath: string | null; videoUrl: string | null; exportedAudioPath: string | null; // path to modified audio from a previous export words: Word[]; segments: Segment[]; cutRanges: CutRange[]; muteRanges: MuteRange[]; gainRanges: GainRange[]; speedRanges: SpeedRange[]; globalGainDb: number; silenceTrimGroups: SilenceTrimGroup[]; timelineMarkers: TimelineMarker[]; transcriptionModel: string | null; language: string; currentTime: number; duration: number; isPlaying: boolean; markInTime: number | null; markOutTime: number | null; selectedWordIndices: number[]; hoveredWordIndex: number | null; isTranscribing: boolean; transcriptionProgress: number; transcriptionStatus: string; isExporting: boolean; exportProgress: number; backendUrl: string; zonePreviewPaddingSeconds: number; zoomConfig: ZoomConfig; additionalClips: ClipInfo[]; backgroundMusic: BackgroundMusicConfig | null; } interface EditorActions { setBackendUrl: (url: string) => void; setProjectFilePath: (path: string | null) => void; loadVideo: (path: string) => void; setExportedAudioPath: (path: string | null) => void; setTranscriptionModel: (model: string | null) => void; saveProject: () => ProjectFile; setTranscription: (result: TranscriptionResult) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; setIsPlaying: (playing: boolean) => void; setMarkInTime: (time: number | null) => void; setMarkOutTime: (time: number | null) => void; clearMarkRange: () => void; setSelectedWordIndices: (indices: number[]) => void; setHoveredWordIndex: (index: number | null) => void; deleteSelectedWords: () => void; deleteWordRange: (startIndex: number, endIndex: number) => void; updateWordText: (index: number, text: string) => void; addCutRange: (start: number, end: number, trimGroupId?: string) => void; addMuteRange: (start: number, end: number) => void; addGainRange: (start: number, end: number, gainDb: number) => void; addSpeedRange: (start: number, end: number, speed: number) => void; updateCutRange: (id: string, start: number, end: number) => void; updateMuteRange: (id: string, start: number, end: number) => void; updateGainRangeBounds: (id: string, start: number, end: number) => void; updateGainRange: (id: string, gainDb: number) => void; updateSpeedRangeBounds: (id: string, start: number, end: number) => void; updateSpeedRange: (id: string, speed: number) => void; removeCutRange: (id: string) => void; removeMuteRange: (id: string) => void; removeGainRange: (id: string) => void; removeSpeedRange: (id: string) => void; setGlobalGainDb: (gainDb: number) => void; applySilenceTrimGroup: (args: { groupId?: string; sourceRanges: SilenceDetectionRange[]; settings: SilenceTrimSettings; }) => { groupId: string; appliedCount: number }; removeSilenceTrimGroup: (groupId: string) => void; addTimelineMarker: (time: number, label?: string, color?: string) => void; updateTimelineMarker: (id: string, updates: Partial) => void; removeTimelineMarker: (id: string) => void; getChapters: () => Chapter[]; setTranscribing: (active: boolean, progress?: number, status?: string) => void; setExporting: (active: boolean, progress?: number) => void; setZonePreviewPaddingSeconds: (seconds: number) => void; replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void; getKeepSegments: () => Array<{ start: number; end: number }>; getWordAtTime: (time: number) => number; loadProject: (projectData: any) => number; reset: () => void; setZoomConfig: (config: Partial) => void; addAdditionalClip: (path: string, label?: string) => void; removeAdditionalClip: (id: string) => void; reorderAdditionalClip: (id: string, direction: -1 | 1) => void; setBackgroundMusic: (config: BackgroundMusicConfig | null) => void; updateBackgroundMusic: (updates: Partial) => void; } const ZONE_PREVIEW_PADDING_KEY = 'talkedit-zone-preview-padding-seconds'; function getStoredZonePreviewPaddingSeconds() { if (typeof window === 'undefined') return 1; const stored = window.localStorage.getItem(ZONE_PREVIEW_PADDING_KEY); const parsed = stored ? Number(stored) : 1; if (!Number.isFinite(parsed)) return 1; return Math.max(0, Math.min(10, parsed)); } const initialState: EditorState = { projectFilePath: null, videoPath: null, videoUrl: null, exportedAudioPath: null, words: [], segments: [], cutRanges: [], muteRanges: [], gainRanges: [], speedRanges: [], globalGainDb: 0, silenceTrimGroups: [], timelineMarkers: [], transcriptionModel: null, language: '', currentTime: 0, duration: 0, isPlaying: false, markInTime: null, markOutTime: null, selectedWordIndices: [], hoveredWordIndex: null, isTranscribing: false, transcriptionProgress: 0, transcriptionStatus: '', isExporting: false, exportProgress: 0, backendUrl: 'http://127.0.0.1:8000', zonePreviewPaddingSeconds: getStoredZonePreviewPaddingSeconds(), zoomConfig: { enabled: false, zoomFactor: 1, panX: 0, panY: 0 }, additionalClips: [], backgroundMusic: null, }; let nextRangeId = 1; let nextTrimGroupId = 1; function buildTrimCutRanges( sourceRanges: SilenceDetectionRange[], settings: SilenceTrimSettings, maxEnd: number, trimGroupId: string, ): CutRange[] { const preBufferSeconds = settings.preBufferMs / 1000; const postBufferSeconds = settings.postBufferMs / 1000; const capEnd = maxEnd > 0 ? maxEnd : Number.POSITIVE_INFINITY; const built: CutRange[] = []; for (const range of sourceRanges) { const start = Math.max(0, range.start + preBufferSeconds); const end = Math.min(capEnd, range.end - postBufferSeconds); if (end - start < 0.01) continue; built.push({ id: `cut_${nextRangeId++}`, start, end, trimGroupId, }); } return built; } export const useEditorStore = create()( temporal( (set, get) => ({ ...initialState, setBackendUrl: (url) => set({ backendUrl: url }), setProjectFilePath: (path) => set({ projectFilePath: path }), setExportedAudioPath: (path) => set({ exportedAudioPath: path }), setTranscriptionModel: (model) => { if (model === null || model === '') return; set({ transcriptionModel: model }); }, saveProject: (): ProjectFile => { const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get(); if (!videoPath) throw new Error('No video loaded'); const now = new Date().toISOString(); // Strip globalStartIndex (runtime-only field) before persisting. const persistSegments = segments.map((seg) => { const rest = { ...seg }; delete (rest as Partial).globalStartIndex; return rest; }); return { version: 1, videoPath, exportedAudioPath: exportedAudioPath ?? undefined, transcriptionModel: transcriptionModel ?? undefined, words, segments: persistSegments as unknown as Segment[], cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, language, createdAt: now, modifiedAt: now, zoomConfig, additionalClips, backgroundMusic: backgroundMusic ?? undefined, }; }, loadVideo: (path) => { if (!path) return; const { backendUrl, zonePreviewPaddingSeconds } = get(); const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`; set({ ...initialState, backendUrl, zonePreviewPaddingSeconds, projectFilePath: null, videoPath: path, videoUrl: url, }); }, setTranscription: (result) => { if (!result.words || result.words.length === 0) { set({ words: [], segments: [], selectedWordIndices: [] }); return; } 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, selectedWordIndices: [], }); }, setCurrentTime: (time) => set({ currentTime: time }), setDuration: (duration) => set({ duration }), setIsPlaying: (playing) => set({ isPlaying: playing }), setMarkInTime: (time) => { if (time !== null && !isFinite(time)) return; set({ markInTime: time }); }, setMarkOutTime: (time) => { if (time !== null && !isFinite(time)) return; set({ markOutTime: time }); }, clearMarkRange: () => set({ markInTime: null, markOutTime: null }), setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }), setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }), deleteSelectedWords: () => { 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]]; get().addCutRange(startWord.start, endWord.end); set({ selectedWordIndices: [] }); }, deleteWordRange: (startIndex, endIndex) => { const { words } = get(); get().addCutRange(words[startIndex].start, words[endIndex].end); }, updateWordText: (index, text) => { const { words, segments } = get(); if (index < 0 || index >= words.length || !text) return; const newWords = words.map((w, i) => i === index ? { ...w, word: text } : w ); // Also update the corresponding segment's words and text let globalIdx = 0; const newSegments = segments.map((seg) => { const start = globalIdx; globalIdx += seg.words.length; if (index >= start && index < start + seg.words.length) { const localIdx = index - start; const updatedSegWords = seg.words.map((w, i) => i === localIdx ? { ...w, word: text } : w ); return { ...seg, words: updatedSegWords, text: updatedSegWords.map((w) => w.word).join(' '), }; } return seg; }); set({ words: newWords, segments: newSegments }); }, addCutRange: (start, end, trimGroupId) => { const { cutRanges, duration } = get(); if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return; const newRange: CutRange = { id: `cut_${nextRangeId++}`, start, end, trimGroupId, }; set({ cutRanges: [...cutRanges, newRange] }); }, addMuteRange: (start, end) => { const { muteRanges, duration } = get(); if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return; const newRange: MuteRange = { id: `mute_${nextRangeId++}`, start, end, }; set({ muteRanges: [...muteRanges, newRange] }); }, addGainRange: (start, end, gainDb) => { const { gainRanges, duration } = get(); if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return; const newRange: GainRange = { id: `gain_${nextRangeId++}`, start, end, gainDb, }; set({ gainRanges: [...gainRanges, newRange] }); }, addSpeedRange: (start, end, speed) => { const { speedRanges, duration } = get(); if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return; const newRange: SpeedRange = { id: `speed_${nextRangeId++}`, start, end, speed: Math.max(0.25, Math.min(4, speed)), }; set({ speedRanges: [...speedRanges, newRange] }); }, 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 ), }); }, updateGainRangeBounds: (id, start, end) => { const { gainRanges } = get(); set({ gainRanges: gainRanges.map((r) => r.id === id ? { ...r, start, end } : r ), }); }, updateGainRange: (id, gainDb) => { const { gainRanges } = get(); set({ gainRanges: gainRanges.map((r) => r.id === id ? { ...r, gainDb } : r ), }); }, updateSpeedRangeBounds: (id, start, end) => { const { speedRanges } = get(); set({ speedRanges: speedRanges.map((r) => r.id === id ? { ...r, start, end } : r ), }); }, updateSpeedRange: (id, speed) => { const { speedRanges } = get(); set({ speedRanges: speedRanges.map((r) => r.id === id ? { ...r, speed: Math.max(0.25, Math.min(4, speed)) } : r ), }); }, 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) }); }, removeGainRange: (id) => { const { gainRanges } = get(); set({ gainRanges: gainRanges.filter((r) => r.id !== id) }); }, removeSpeedRange: (id) => { const { speedRanges } = get(); set({ speedRanges: speedRanges.filter((r) => r.id !== id) }); }, setGlobalGainDb: (gainDb) => { if (!isFinite(gainDb)) { set({ globalGainDb: 0 }); return; } set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) }); }, applySilenceTrimGroup: ({ groupId, sourceRanges, settings }) => { const { duration, cutRanges, silenceTrimGroups } = get(); const now = new Date().toISOString(); const existingGroup = groupId ? silenceTrimGroups.find((group) => group.id === groupId) : null; const resolvedGroupId = existingGroup?.id ?? `trimgrp_${nextTrimGroupId++}`; const nextGroup: SilenceTrimGroup = { id: resolvedGroupId, sourceRanges, settings, createdAt: existingGroup?.createdAt ?? now, updatedAt: now, }; const withoutGroupCuts = cutRanges.filter((range) => range.trimGroupId !== resolvedGroupId); const generatedCuts = buildTrimCutRanges(sourceRanges, settings, duration, resolvedGroupId); const nextGroups = existingGroup ? silenceTrimGroups.map((group) => (group.id === resolvedGroupId ? nextGroup : group)) : [...silenceTrimGroups, nextGroup]; set({ cutRanges: [...withoutGroupCuts, ...generatedCuts], silenceTrimGroups: nextGroups, }); return { groupId: resolvedGroupId, appliedCount: generatedCuts.length, }; }, removeSilenceTrimGroup: (groupId) => { const { cutRanges, silenceTrimGroups } = get(); set({ cutRanges: cutRanges.filter((range) => range.trimGroupId !== groupId), silenceTrimGroups: silenceTrimGroups.filter((group) => group.id !== groupId), }); }, addTimelineMarker: (time, label, color) => { if (!isFinite(time) || time < 0) return; const { timelineMarkers, duration } = get(); if (time > duration) return; const newMarker: TimelineMarker = { id: `marker_${nextRangeId++}`, time, label: label || 'Marker', color: color || '#6366f1', }; set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) }); }, updateTimelineMarker: (id, updates) => { const { timelineMarkers } = get(); set({ timelineMarkers: timelineMarkers .map((m) => (m.id === id ? { ...m, ...updates } : m)) .sort((a, b) => a.time - b.time), }); }, removeTimelineMarker: (id) => { const { timelineMarkers } = get(); set({ timelineMarkers: timelineMarkers.filter((m) => m.id !== id) }); }, getChapters: () => { const { timelineMarkers } = get(); return timelineMarkers.map((m) => ({ markerId: m.id, label: m.label, startTime: m.time, })); }, setTranscribing: (active, progress, status) => set({ isTranscribing: active, transcriptionProgress: progress ?? (active ? 0 : 100), transcriptionStatus: status ?? (active ? 'Transcribing...' : ''), }), setExporting: (active, progress) => set({ isExporting: active, exportProgress: progress ?? (active ? 0 : 100), }), setZonePreviewPaddingSeconds: (seconds) => { if (!isFinite(seconds)) return; const nextSeconds = Math.max(0, Math.min(10, seconds)); if (typeof window !== 'undefined') { window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds)); } set({ zonePreviewPaddingSeconds: nextSeconds }); }, replaceWordRange: (startIndex, endIndex, newWords) => { const { words } = get(); assert(startIndex >= 0 && startIndex < words.length, 'replaceWordRange: startIndex out of bounds'); assert(endIndex >= 0 && endIndex < words.length, 'replaceWordRange: endIndex out of bounds'); assert(startIndex <= endIndex, 'replaceWordRange: startIndex > endIndex'); if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return; // Replace words in the range with new words const before = words.slice(0, startIndex); const after = words.slice(endIndex + 1); const updatedWords = [...before, ...newWords, ...after]; // Rebuild segments from updated words, grouping by speaker const rebuiltSegments: Segment[] = []; let wordIdx = 0; let cumIdx = 0; while (wordIdx < updatedWords.length) { const currentSpeaker = updatedWords[wordIdx].speaker; const groupWords: Word[] = []; while (wordIdx < updatedWords.length && updatedWords[wordIdx].speaker === currentSpeaker) { groupWords.push(updatedWords[wordIdx]); wordIdx++; } rebuiltSegments.push({ id: rebuiltSegments.length, start: groupWords[0].start, end: groupWords[groupWords.length - 1].end, text: groupWords.map((w) => w.word).join(' '), words: groupWords, speaker: currentSpeaker, globalStartIndex: cumIdx, }); cumIdx += groupWords.length; } set({ words: updatedWords, segments: rebuiltSegments, selectedWordIndices: [] }); }, getKeepSegments: () => { const { words, cutRanges, duration } = get(); if (words.length === 0) return [{ start: 0, end: duration }]; const deletedSet = new Set(); for (const cutRange of cutRanges) { for (let i = 0; i < words.length; i++) { const word = words[i]; if (word.start >= cutRange.start && word.end <= cutRange.end) { deletedSet.add(i); } } } const segments: Array<{ start: number; end: number }> = []; let segStart: number | null = null; 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; }, setZoomConfig: (config) => { const { zoomConfig } = get(); set({ zoomConfig: { ...zoomConfig, ...config } }); }, addAdditionalClip: (path, label) => { const { additionalClips } = get(); const id = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; set({ additionalClips: [...additionalClips, { id, path, label: label || path.split(/[/\\]/).pop() || 'Clip' }] }); }, removeAdditionalClip: (id) => { const { additionalClips } = get(); set({ additionalClips: additionalClips.filter((c) => c.id !== id) }); }, reorderAdditionalClip: (id, direction) => { const { additionalClips } = get(); const idx = additionalClips.findIndex((c) => c.id === id); if (idx === -1) return; const target = idx + direction; if (target < 0 || target >= additionalClips.length) return; const reordered = [...additionalClips]; [reordered[idx], reordered[target]] = [reordered[target], reordered[idx]]; set({ additionalClips: reordered }); }, setBackgroundMusic: (config) => { if (!config || !config.path) { set({ backgroundMusic: null }); return; } set({ backgroundMusic: config }); }, updateBackgroundMusic: (updates) => { const { backgroundMusic } = get(); if (!backgroundMusic) return; set({ backgroundMusic: { ...backgroundMusic, ...updates } }); }, loadProject: (data) => { const { backendUrl, zonePreviewPaddingSeconds, projectFilePath, duration } = get(); const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`; const isValidZone = (r: { start: number; end: number }) => isFinite(r.start) && isFinite(r.end) && r.start >= 0 && r.end - r.start >= 0.01 && (duration <= 0 || r.end <= duration); let removed = 0; const filterZones = (ranges: T[]): T[] => { const result: T[] = []; for (const r of ranges) { if (isValidZone(r)) { result.push(r); } else { removed++; } } return result; }; // Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts const legacyCuts = (data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })); const cleanedCutRanges = filterZones([...(data.cutRanges || []), ...legacyCuts]); const cleanedMuteRanges = filterZones(data.muteRanges || []); const cleanedGainRanges = filterZones(data.gainRanges || []); const cleanedSpeedRanges = filterZones(data.speedRanges || []); let globalIdx = 0; const annotatedSegments = (data.segments || []).map((seg: Segment) => { const annotated = { ...seg, globalStartIndex: globalIdx }; globalIdx += seg.words.length; return annotated; }); set({ ...initialState, backendUrl, zonePreviewPaddingSeconds, projectFilePath, videoPath: data.videoPath, videoUrl: url, words: data.words || [], segments: annotatedSegments, cutRanges: cleanedCutRanges, muteRanges: cleanedMuteRanges, gainRanges: cleanedGainRanges, speedRanges: cleanedSpeedRanges, globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, silenceTrimGroups: data.silenceTrimGroups || [], timelineMarkers: data.timelineMarkers || [], transcriptionModel: data.transcriptionModel ?? null, language: data.language || '', exportedAudioPath: data.exportedAudioPath ?? null, zoomConfig: data.zoomConfig || { enabled: false, zoomFactor: 1, panX: 0, panY: 0 }, additionalClips: data.additionalClips || [], backgroundMusic: data.backgroundMusic || null, }); return removed; }, reset: () => { const { zonePreviewPaddingSeconds } = get(); set({ ...initialState, zonePreviewPaddingSeconds, projectFilePath: null }); }, }), { limit: 100 }, ), );