Files
OpenCut/apps/web/src/stores/timeline-store.ts
T

265 lines
7.1 KiB
TypeScript
Raw Normal View History

2025-06-22 13:07:02 +02:00
import { create } from "zustand";
export interface TimelineClip {
id: string;
mediaId: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
2025-06-22 13:07:02 +02:00
}
export interface TimelineTrack {
id: string;
name: string;
type: "video" | "audio" | "effects";
clips: TimelineClip[];
muted?: boolean;
2025-06-22 13:07:02 +02:00
}
interface TimelineStore {
tracks: TimelineTrack[];
2025-06-24 09:26:31 +05:30
history: TimelineTrack[][];
2025-06-24 09:31:53 +05:30
redoStack: TimelineTrack[][];
2025-06-22 13:07:02 +02:00
2025-06-23 20:51:36 +05:30
// Multi-selection
selectedClips: { trackId: string; clipId: string }[];
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
deselectClip: (trackId: string, clipId: string) => void;
clearSelectedClips: () => void;
2025-06-23 21:16:21 +05:30
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
2025-06-23 20:05:49 +05:30
2025-06-22 13:07:02 +02:00
// Actions
2025-06-22 19:28:03 +02:00
addTrack: (type: "video" | "audio" | "effects") => string;
2025-06-22 13:07:02 +02:00
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
2025-06-22 19:28:03 +02:00
removeClipFromTrack: (trackId: string, clipId: string) => void;
moveClipToTrack: (
fromTrackId: string,
toTrackId: string,
clipId: string
2025-06-22 19:28:03 +02:00
) => void;
updateClipTrim: (
trackId: string,
clipId: string,
trimStart: number,
trimEnd: number
) => void;
updateClipStartTime: (
trackId: string,
clipId: string,
startTime: number
) => void;
toggleTrackMute: (trackId: string) => void;
2025-06-22 22:10:50 +02:00
// Computed values
getTotalDuration: () => number;
2025-06-24 09:26:31 +05:30
// New actions
undo: () => void;
2025-06-24 09:31:53 +05:30
redo: () => void;
2025-06-24 09:26:31 +05:30
pushHistory: () => void;
2025-06-22 13:07:02 +02:00
}
2025-06-22 22:10:50 +02:00
export const useTimelineStore = create<TimelineStore>((set, get) => ({
2025-06-22 13:07:02 +02:00
tracks: [],
2025-06-24 09:26:31 +05:30
history: [],
2025-06-24 09:31:53 +05:30
redoStack: [],
2025-06-23 20:51:36 +05:30
selectedClips: [],
2025-06-23 20:05:49 +05:30
2025-06-24 09:26:31 +05:30
pushHistory: () => {
2025-06-24 09:31:53 +05:30
const { tracks, history, redoStack } = get();
2025-06-24 09:26:31 +05:30
// Deep copy tracks
2025-06-24 09:31:53 +05:30
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [] // Clear redo stack when new action is performed
});
2025-06-24 09:26:31 +05:30
},
undo: () => {
2025-06-24 09:31:53 +05:30
const { history, redoStack, tracks } = get();
2025-06-24 09:26:31 +05:30
if (history.length === 0) return;
const prev = history[history.length - 1];
2025-06-24 09:31:53 +05:30
set({
tracks: prev,
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
});
2025-06-24 09:26:31 +05:30
},
2025-06-23 20:51:36 +05:30
selectClip: (trackId, clipId, multi = false) => {
set((state) => {
const exists = state.selectedClips.some(
(c) => c.trackId === trackId && c.clipId === clipId
);
if (multi) {
// Toggle selection
return exists
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else {
return { selectedClips: [{ trackId, clipId }] };
}
});
2025-06-23 20:05:49 +05:30
},
2025-06-23 20:51:36 +05:30
deselectClip: (trackId, clipId) => {
set((state) => ({
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
}));
},
clearSelectedClips: () => {
set({ selectedClips: [] });
2025-06-23 20:05:49 +05:30
},
2025-06-22 13:07:02 +02:00
2025-06-23 21:16:21 +05:30
setSelectedClips: (clips) => set({ selectedClips: clips }),
2025-06-22 13:07:02 +02:00
addTrack: (type) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
2025-06-22 13:07:02 +02:00
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
type,
clips: [],
muted: false,
2025-06-22 13:07:02 +02:00
};
set((state) => ({
tracks: [...state.tracks, newTrack],
}));
2025-06-22 19:28:03 +02:00
return newTrack.id;
2025-06-22 13:07:02 +02:00
},
removeTrack: (trackId) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
2025-06-22 13:07:02 +02:00
set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
},
addClipToTrack: (trackId, clipData) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
2025-06-22 13:07:02 +02:00
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
trimStart: 0,
trimEnd: 0,
2025-06-22 13:07:02 +02:00
};
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
: track
),
}));
},
2025-06-22 19:28:03 +02:00
removeClipFromTrack: (trackId, clipId) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
2025-06-22 19:28:03 +02:00
set((state) => ({
2025-06-24 09:26:31 +05:30
tracks: state.tracks
.map((track) =>
track.id === trackId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
: track
)
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
2025-06-22 19:28:03 +02:00
}));
},
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
2025-06-22 19:28:03 +02:00
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
if (!clipToMove) return state;
return {
2025-06-24 09:26:31 +05:30
tracks: state.tracks
.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
})
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
2025-06-22 19:28:03 +02:00
};
});
},
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
),
}
: track
),
}));
},
updateClipStartTime: (trackId, clipId, startTime) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, startTime } : clip
),
}
: track
),
}));
},
toggleTrackMute: (trackId) => {
2025-06-24 09:26:31 +05:30
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
),
}));
},
2025-06-22 22:10:50 +02:00
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd =
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
2025-06-22 22:10:50 +02:00
);
return Math.max(...trackEndTimes, 0);
2025-06-22 22:10:50 +02:00
},
2025-06-24 09:31:53 +05:30
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
2025-06-22 13:07:02 +02:00
}));