diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-06 20:45:29 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-06 20:45:29 +0200 |
| commit | 40c7fbb4f86130c83fbd9029e83c5e7550e3219d (patch) | |
| tree | 054d918d282358c084f4fc08462913833de644a5 /apps/web/src/stores/timeline-store.ts | |
| parent | 0e32c732ddc335d35e6a7f8547f4af68bfd9c369 (diff) | |
| download | opencut-40c7fbb4f86130c83fbd9029e83c5e7550e3219d.tar.gz | |
refactor: move to a typed-tracks system and add support for text
Diffstat (limited to 'apps/web/src/stores/timeline-store.ts')
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 387 |
1 files changed, 241 insertions, 146 deletions
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 01f4fea6..ef08fffd 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,10 +1,14 @@ import { create } from "zustand"; -import type { TrackType } from "@/types/timeline"; +import type { + TrackType, + TimelineElement, + CreateTimelineElement, +} from "@/types/timeline"; import { useEditorStore } from "./editor-store"; import { useMediaStore, getMediaAspectRatio } from "./media-store"; -// Helper function to manage clip naming with suffixes -const getClipNameWithSuffix = ( +// Helper function to manage element naming with suffixes +const getElementNameWithSuffix = ( originalName: string, suffix: string ): string => { @@ -18,21 +22,11 @@ const getClipNameWithSuffix = ( return `${baseName} (${suffix})`; }; -export interface TimelineClip { - id: string; - mediaId: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; -} - export interface TimelineTrack { id: string; name: string; type: TrackType; - clips: TimelineClip[]; + elements: TimelineElement[]; muted?: boolean; } @@ -42,28 +36,30 @@ interface TimelineStore { redoStack: TimelineTrack[][]; // Multi-selection - selectedClips: { trackId: string; clipId: string }[]; - selectClip: (trackId: string, clipId: string, multi?: boolean) => void; - deselectClip: (trackId: string, clipId: string) => void; - clearSelectedClips: () => void; - setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; + selectedElements: { trackId: string; elementId: string }[]; + selectElement: (trackId: string, elementId: string, multi?: boolean) => void; + deselectElement: (trackId: string, elementId: string) => void; + clearSelectedElements: () => void; + setSelectedElements: ( + elements: { trackId: string; elementId: string }[] + ) => void; // Drag state dragState: { isDragging: boolean; - clipId: string | null; + elementId: string | null; trackId: string | null; startMouseX: number; - startClipTime: number; + startElementTime: number; clickOffsetTime: number; currentTime: number; }; setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; startDrag: ( - clipId: string, + elementId: string, trackId: string, startMouseX: number, - startClipTime: number, + startElementTime: number, clickOffsetTime: number ) => void; updateDragTime: (currentTime: number) => void; @@ -71,44 +67,45 @@ interface TimelineStore { // Actions addTrack: (type: TrackType) => string; + insertTrackAt: (type: TrackType, index: number) => string; removeTrack: (trackId: string) => void; - addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void; - removeClipFromTrack: (trackId: string, clipId: string) => void; - moveClipToTrack: ( + addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; + removeElementFromTrack: (trackId: string, elementId: string) => void; + moveElementToTrack: ( fromTrackId: string, toTrackId: string, - clipId: string + elementId: string ) => void; - updateClipTrim: ( + updateElementTrim: ( trackId: string, - clipId: string, + elementId: string, trimStart: number, trimEnd: number ) => void; - updateClipStartTime: ( + updateElementStartTime: ( trackId: string, - clipId: string, + elementId: string, startTime: number ) => void; toggleTrackMute: (trackId: string) => void; - // Split operations for clips - splitClip: ( + // Split operations for elements + splitElement: ( trackId: string, - clipId: string, + elementId: string, splitTime: number ) => string | null; splitAndKeepLeft: ( trackId: string, - clipId: string, + elementId: string, splitTime: number ) => void; splitAndKeepRight: ( trackId: string, - clipId: string, + elementId: string, splitTime: number ) => void; - separateAudio: (trackId: string, clipId: string) => string | null; + separateAudio: (trackId: string, elementId: string) => string | null; // Computed values getTotalDuration: () => number; @@ -123,10 +120,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ tracks: [], history: [], redoStack: [], - selectedClips: [], + selectedElements: [], pushHistory: () => { - const { tracks, history, redoStack } = get(); + const { tracks, history } = get(); set({ history: [...history, JSON.parse(JSON.stringify(tracks))], redoStack: [], @@ -144,46 +141,62 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ }); }, - selectClip: (trackId, clipId, multi = false) => { + selectElement: (trackId, elementId, multi = false) => { set((state) => { - const exists = state.selectedClips.some( - (c) => c.trackId === trackId && c.clipId === clipId + const exists = state.selectedElements.some( + (c) => c.trackId === trackId && c.elementId === elementId ); if (multi) { return exists ? { - selectedClips: state.selectedClips.filter( - (c) => !(c.trackId === trackId && c.clipId === clipId) + selectedElements: state.selectedElements.filter( + (c) => !(c.trackId === trackId && c.elementId === elementId) ), } - : { selectedClips: [...state.selectedClips, { trackId, clipId }] }; + : { + selectedElements: [ + ...state.selectedElements, + { trackId, elementId }, + ], + }; } else { - return { selectedClips: [{ trackId, clipId }] }; + return { selectedElements: [{ trackId, elementId }] }; } }); }, - deselectClip: (trackId, clipId) => { + deselectElement: (trackId, elementId) => { set((state) => ({ - selectedClips: state.selectedClips.filter( - (c) => !(c.trackId === trackId && c.clipId === clipId) + selectedElements: state.selectedElements.filter( + (c) => !(c.trackId === trackId && c.elementId === elementId) ), })); }, - clearSelectedClips: () => { - set({ selectedClips: [] }); + clearSelectedElements: () => { + set({ selectedElements: [] }); }, - setSelectedClips: (clips) => set({ selectedClips: clips }), + setSelectedElements: (elements) => set({ selectedElements: elements }), addTrack: (type) => { get().pushHistory(); + + // Generate proper track name based on type + const trackName = + type === "media" + ? "Media Track" + : type === "text" + ? "Text Track" + : type === "audio" + ? "Audio Track" + : "Track"; + const newTrack: TimelineTrack = { id: crypto.randomUUID(), - name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, + name: trackName, type, - clips: [], + elements: [], muted: false, }; set((state) => ({ @@ -192,6 +205,35 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ return newTrack.id; }, + insertTrackAt: (type, index) => { + get().pushHistory(); + + // Generate proper track name based on type + const trackName = + type === "media" + ? "Media Track" + : type === "text" + ? "Text Track" + : type === "audio" + ? "Audio Track" + : "Track"; + + const newTrack: TimelineTrack = { + id: crypto.randomUUID(), + name: trackName, + type, + elements: [], + muted: false, + }; + + set((state) => { + const newTracks = [...state.tracks]; + newTracks.splice(index, 0, newTrack); + return { tracks: newTracks }; + }); + return newTrack.id; + }, + removeTrack: (trackId) => { get().pushHistory(); set((state) => ({ @@ -199,31 +241,64 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, - addClipToTrack: (trackId, clipData) => { + addElementToTrack: (trackId, elementData) => { get().pushHistory(); - // Check if this is the first clip being added to the timeline + // Validate element type matches track type + const track = get().tracks.find((t) => t.id === trackId); + if (!track) { + console.error("Track not found:", trackId); + return; + } + + // Validate element can be added to this track type + if (track.type === "media" && elementData.type !== "media") { + console.error("Media track only accepts media elements"); + return; + } + if (track.type === "text" && elementData.type !== "text") { + console.error("Text track only accepts text elements"); + return; + } + if (track.type === "audio" && elementData.type !== "media") { + console.error("Audio track only accepts media elements"); + return; + } + + // For media elements, validate mediaId exists + if (elementData.type === "media" && !elementData.mediaId) { + console.error("Media element must have mediaId"); + return; + } + + // For text elements, validate required text properties + if (elementData.type === "text" && !elementData.content) { + console.error("Text element must have content"); + return; + } + + // Check if this is the first element being added to the timeline const currentState = get(); - const totalClipsInTimeline = currentState.tracks.reduce( - (total, track) => total + track.clips.length, + const totalElementsInTimeline = currentState.tracks.reduce( + (total, track) => total + track.elements.length, 0 ); - const isFirstClip = totalClipsInTimeline === 0; + const isFirstElement = totalElementsInTimeline === 0; - const newClip: TimelineClip = { - ...clipData, + const newElement: TimelineElement = { + ...elementData, id: crypto.randomUUID(), - startTime: clipData.startTime || 0, + startTime: elementData.startTime || 0, trimStart: 0, trimEnd: 0, - }; + } as TimelineElement; // Type assertion since we trust the caller passes valid data - // If this is the first clip, automatically set the project canvas size + // If this is the first element and it's a media element, automatically set the project canvas size // to match the media's aspect ratio - if (isFirstClip && clipData.mediaId) { + if (isFirstElement && newElement.type === "media") { const mediaStore = useMediaStore.getState(); const mediaItem = mediaStore.mediaItems.find( - (item) => item.id === clipData.mediaId + (item) => item.id === newElement.mediaId ); if ( @@ -240,13 +315,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId - ? { ...track, clips: [...track.clips, newClip] } + ? { ...track, elements: [...track.elements, newElement] } : track ), })); }, - removeClipFromTrack: (trackId, clipId) => { + removeElementFromTrack: (trackId, elementId) => { get().pushHistory(); set((state) => ({ tracks: state.tracks @@ -254,21 +329,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ track.id === trackId ? { ...track, - clips: track.clips.filter((clip) => clip.id !== clipId), + elements: track.elements.filter( + (element) => element.id !== elementId + ), } : track ) - .filter((track) => track.clips.length > 0), + .filter((track) => track.elements.length > 0), })); }, - moveClipToTrack: (fromTrackId, toTrackId, clipId) => { + moveElementToTrack: (fromTrackId, toTrackId, elementId) => { get().pushHistory(); set((state) => { const fromTrack = state.tracks.find((track) => track.id === fromTrackId); - const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); + const elementToMove = fromTrack?.elements.find( + (element) => element.id === elementId + ); - if (!clipToMove) return state; + if (!elementToMove) return state; return { tracks: state.tracks @@ -276,30 +355,34 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ if (track.id === fromTrackId) { return { ...track, - clips: track.clips.filter((clip) => clip.id !== clipId), + elements: track.elements.filter( + (element) => element.id !== elementId + ), }; } else if (track.id === toTrackId) { return { ...track, - clips: [...track.clips, clipToMove], + elements: [...track.elements, elementToMove], }; } return track; }) - .filter((track) => track.clips.length > 0), + .filter((track) => track.elements.length > 0), }; }); }, - updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { + updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { 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 + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, trimStart, trimEnd } + : element ), } : track @@ -307,15 +390,15 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, - updateClipStartTime: (trackId, clipId, startTime) => { + updateElementStartTime: (trackId, elementId, startTime) => { get().pushHistory(); set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId ? { ...track, - clips: track.clips.map((clip) => - clip.id === clipId ? { ...clip, startTime } : clip + elements: track.elements.map((element) => + element.id === elementId ? { ...element, startTime } : element ), } : track @@ -332,47 +415,48 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, - splitClip: (trackId, clipId, splitTime) => { + splitElement: (trackId, elementId, splitTime) => { const { tracks } = get(); const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); + const element = track?.elements.find((c) => c.id === elementId); - if (!clip) return null; + if (!element) return null; - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; get().pushHistory(); - const relativeTime = splitTime - clip.startTime; + const relativeTime = splitTime - element.startTime; const firstDuration = relativeTime; const secondDuration = - clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + element.duration - element.trimStart - element.trimEnd - relativeTime; - const secondClipId = crypto.randomUUID(); + const secondElementId = crypto.randomUUID(); set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId ? { ...track, - clips: track.clips.flatMap((c) => - c.id === clipId + elements: track.elements.flatMap((c) => + c.id === elementId ? [ { ...c, trimEnd: c.trimEnd + secondDuration, - name: getClipNameWithSuffix(c.name, "left"), + name: getElementNameWithSuffix(c.name, "left"), }, { ...c, - id: secondClipId, + id: secondElementId, startTime: splitTime, trimStart: c.trimStart + firstDuration, - name: getClipNameWithSuffix(c.name, "right"), + name: getElementNameWithSuffix(c.name, "right"), }, ] : [c] @@ -382,40 +466,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ ), })); - return secondClipId; + return secondElementId; }, - // Split clip and keep only the left portion - splitAndKeepLeft: (trackId, clipId, splitTime) => { + // Split element and keep only the left portion + splitAndKeepLeft: (trackId, elementId, splitTime) => { const { tracks } = get(); const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); + const element = track?.elements.find((c) => c.id === elementId); - if (!clip) return; + if (!element) return; - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; get().pushHistory(); - const relativeTime = splitTime - clip.startTime; + const relativeTime = splitTime - element.startTime; const durationToRemove = - clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + element.duration - element.trimStart - element.trimEnd - relativeTime; set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId ? { ...track, - clips: track.clips.map((c) => - c.id === clipId + elements: track.elements.map((c) => + c.id === elementId ? { ...c, trimEnd: c.trimEnd + durationToRemove, - name: getClipNameWithSuffix(c.name, "left"), + name: getElementNameWithSuffix(c.name, "left"), } : c ), @@ -425,36 +510,37 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, - // Split clip and keep only the right portion - splitAndKeepRight: (trackId, clipId, splitTime) => { + // Split element and keep only the right portion + splitAndKeepRight: (trackId, elementId, splitTime) => { const { tracks } = get(); const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); + const element = track?.elements.find((c) => c.id === elementId); - if (!clip) return; + if (!element) return; - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; get().pushHistory(); - const relativeTime = splitTime - clip.startTime; + const relativeTime = splitTime - element.startTime; set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId ? { ...track, - clips: track.clips.map((c) => - c.id === clipId + elements: track.elements.map((c) => + c.id === elementId ? { ...c, startTime: splitTime, trimStart: c.trimStart + relativeTime, - name: getClipNameWithSuffix(c.name, "right"), + name: getElementNameWithSuffix(c.name, "right"), } : c ), @@ -464,33 +550,33 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, - // Extract audio from video clip to an audio track - separateAudio: (trackId, clipId) => { + // Extract audio from video element to an audio track + separateAudio: (trackId, elementId) => { const { tracks } = get(); const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); + const element = track?.elements.find((c) => c.id === elementId); - if (!clip || track?.type !== "video") return null; + if (!element || track?.type !== "media") 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(); + const audioElementId = crypto.randomUUID(); if (existingAudioTrack) { - // Add audio clip to existing audio track + // Add audio element to existing audio track set((state) => ({ tracks: state.tracks.map((track) => track.id === existingAudioTrack.id ? { ...track, - clips: [ - ...track.clips, + elements: [ + ...track.elements, { - ...clip, - id: audioClipId, - name: getClipNameWithSuffix(clip.name, "audio"), + ...element, + id: audioElementId, + name: getElementNameWithSuffix(element.name, "audio"), }, ], } @@ -498,16 +584,16 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ ), })); } else { - // Create new audio track with the audio clip in a single atomic update + // Create new audio track with the audio element in a single atomic update const newAudioTrack: TimelineTrack = { id: crypto.randomUUID(), name: "Audio Track", type: "audio", - clips: [ + elements: [ { - ...clip, - id: audioClipId, - name: getClipNameWithSuffix(clip.name, "audio"), + ...element, + id: audioElementId, + name: getElementNameWithSuffix(element.name, "audio"), }, ], muted: false, @@ -518,7 +604,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); } - return audioClipId; + return audioElementId; }, getTotalDuration: () => { @@ -526,10 +612,13 @@ export const useTimelineStore = create<TimelineStore>((set, 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); + track.elements.reduce((maxEnd, element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + return Math.max(maxEnd, elementEnd); }, 0) ); @@ -545,10 +634,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ dragState: { isDragging: false, - clipId: null, + elementId: null, trackId: null, startMouseX: 0, - startClipTime: 0, + startElementTime: 0, clickOffsetTime: 0, currentTime: 0, }, @@ -558,16 +647,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ dragState: { ...state.dragState, ...dragState }, })), - startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => { + startDrag: ( + elementId, + trackId, + startMouseX, + startElementTime, + clickOffsetTime + ) => { set({ dragState: { isDragging: true, - clipId, + elementId, trackId, startMouseX, - startClipTime, + startElementTime, clickOffsetTime, - currentTime: startClipTime, + currentTime: startElementTime, }, }); }, @@ -585,10 +680,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ set({ dragState: { isDragging: false, - clipId: null, + elementId: null, trackId: null, startMouseX: 0, - startClipTime: 0, + startElementTime: 0, clickOffsetTime: 0, currentTime: 0, }, |
