757 lines
26 KiB
TypeScript
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 },
|
|
),
|
|
);
|