diff options
Diffstat (limited to 'apps/web/src/stores')
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 234 |
1 files changed, 227 insertions, 7 deletions
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 4018c301..73628497 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,6 +1,21 @@ import { create } from "zustand"; import type { TrackType } from "@/types/timeline"; +// Helper function to manage clip naming with suffixes +const getClipNameWithSuffix = ( + originalName: string, + suffix: string +): string => { + // Remove existing suffixes to prevent accumulation + const baseName = originalName + .replace(/ \(left\)$/, "") + .replace(/ \(right\)$/, "") + .replace(/ \(audio\)$/, "") + .replace(/ \(split \d+\)$/, ""); + + return `${baseName} (${suffix})`; +}; + export interface TimelineClip { id: string; mediaId: string; @@ -75,10 +90,28 @@ interface TimelineStore { ) => void; toggleTrackMute: (trackId: string) => void; + // Split operations for clips + splitClip: ( + trackId: string, + clipId: string, + splitTime: number + ) => string | null; + splitAndKeepLeft: ( + trackId: string, + clipId: string, + splitTime: number + ) => void; + splitAndKeepRight: ( + trackId: string, + clipId: string, + splitTime: number + ) => void; + separateAudio: (trackId: string, clipId: string) => string | null; + // Computed values getTotalDuration: () => number; - // New actions + // History actions undo: () => void; redo: () => void; pushHistory: () => void; @@ -92,10 +125,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ pushHistory: () => { const { tracks, history, redoStack } = get(); - // Deep copy tracks set({ history: [...history, JSON.parse(JSON.stringify(tracks))], - redoStack: [], // Clear redo stack when new action is performed + redoStack: [], }); }, @@ -106,7 +138,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ set({ tracks: prev, history: history.slice(0, -1), - redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack + redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], }); }, @@ -116,7 +148,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ (c) => c.trackId === trackId && c.clipId === clipId ); if (multi) { - // Toggle selection return exists ? { selectedClips: state.selectedClips.filter( @@ -129,6 +160,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ } }); }, + deselectClip: (trackId, clipId) => { set((state) => ({ selectedClips: state.selectedClips.filter( @@ -136,6 +168,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ ), })); }, + clearSelectedClips: () => { set({ selectedClips: [] }); }, @@ -195,7 +228,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ } : track ) - // Remove track if it becomes empty .filter((track) => track.clips.length > 0), })); }, @@ -224,7 +256,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ } return track; }) - // Remove track if it becomes empty .filter((track) => track.clips.length > 0), }; }); @@ -271,6 +302,195 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, + splitClip: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return null; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + const firstDuration = relativeTime; + const secondDuration = + clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + + const secondClipId = crypto.randomUUID(); + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.flatMap((c) => + c.id === clipId + ? [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: getClipNameWithSuffix(c.name, "left"), + }, + { + ...c, + id: secondClipId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: getClipNameWithSuffix(c.name, "right"), + }, + ] + : [c] + ), + } + : track + ), + })); + + return secondClipId; + }, + + // Split clip and keep only the left portion + splitAndKeepLeft: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + const durationToRemove = + clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((c) => + c.id === clipId + ? { + ...c, + trimEnd: c.trimEnd + durationToRemove, + name: getClipNameWithSuffix(c.name, "left"), + } + : c + ), + } + : track + ), + })); + }, + + // Split clip and keep only the right portion + splitAndKeepRight: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((c) => + c.id === clipId + ? { + ...c, + startTime: splitTime, + trimStart: c.trimStart + relativeTime, + name: getClipNameWithSuffix(c.name, "right"), + } + : c + ), + } + : track + ), + })); + }, + + // Extract audio from video clip to an audio track + separateAudio: (trackId, clipId) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip || track?.type !== "video") return null; + + get().pushHistory(); + + // Find existing audio track or prepare to create one + const existingAudioTrack = tracks.find((t) => t.type === "audio"); + const audioClipId = crypto.randomUUID(); + + if (existingAudioTrack) { + // Add audio clip to existing audio track + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === existingAudioTrack.id + ? { + ...track, + clips: [ + ...track.clips, + { + ...clip, + id: audioClipId, + name: getClipNameWithSuffix(clip.name, "audio"), + }, + ], + } + : track + ), + })); + } else { + // Create new audio track with the audio clip in a single atomic update + const newAudioTrack: TimelineTrack = { + id: crypto.randomUUID(), + name: "Audio Track", + type: "audio", + clips: [ + { + ...clip, + id: audioClipId, + name: getClipNameWithSuffix(clip.name, "audio"), + }, + ], + muted: false, + }; + + set((state) => ({ + tracks: [...state.tracks, newAudioTrack], + })); + } + + return audioClipId; + }, + getTotalDuration: () => { const { tracks } = get(); if (tracks.length === 0) return 0; |
