Files
TalkEdit/frontend/src/store/editorStore.ts

757 lines
26 KiB
TypeScript

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<TimelineMarker>) => 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<ZoomConfig>) => 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<BackgroundMusicConfig>) => 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<EditorState & EditorActions>()(
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<Segment>).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<number>();
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 = <T extends { start: number; end: number }>(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<CutRange>([...(data.cutRanges || []), ...legacyCuts]);
const cleanedMuteRanges = filterZones<MuteRange>(data.muteRanges || []);
const cleanedGainRanges = filterZones<GainRange>(data.gainRanges || []);
const cleanedSpeedRanges = filterZones<SpeedRange>(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 },
),
);