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

1647 lines
46 KiB
TypeScript

import { create } from "zustand";
import {
TrackType,
TimelineElement,
CreateTimelineElement,
TimelineTrack,
TextElement,
DragData,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
import { MediaFile } from "@/types/media";
import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
originalName: string,
suffix: string
): string => {
// Remove existing suffixes to prevent accumulation
const baseName = originalName
.replace(/ \(left\)$/i, "")
.replace(/ \(right\)$/i, "")
.replace(/ \(audio\)$/i, "")
.replace(/ \(split \d+\)$/i, "");
return `${baseName} (${suffix})`;
};
interface TimelineStore {
// Private track storage
_tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
// Clipboard buffer
clipboard: {
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
} | null;
// Always returns properly ordered tracks with main track ensured
tracks: TimelineTrack[];
// Manual method if you need to force recomputation
getSortedTracks: () => TimelineTrack[];
// Snapping settings
snappingEnabled: boolean;
// Snapping actions
toggleSnapping: () => void;
// Ripple editing mode
rippleEditingEnabled: boolean;
toggleRippleEditing: () => void;
// Multi-selection
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;
elementId: string | null;
trackId: string | null;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
};
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
startDrag: (
elementId: string,
trackId: string,
startMouseX: number,
startElementTime: number,
clickOffsetTime: number
) => void;
updateDragTime: (currentTime: number) => void;
endDrag: () => void;
// Actions
addTrack: (type: TrackType) => string;
insertTrackAt: (type: TrackType, index: number) => string;
removeTrack: (trackId: string) => void;
removeTrackWithRipple: (trackId: string) => void;
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
removeElementFromTrack: (
trackId: string,
elementId: string,
pushHistory?: boolean
) => void;
moveElementToTrack: (
fromTrackId: string,
toTrackId: string,
elementId: string
) => void;
updateElementTrim: (
trackId: string,
elementId: string,
trimStart: number,
trimEnd: number,
pushHistory?: boolean
) => void;
updateElementDuration: (
trackId: string,
elementId: string,
duration: number,
pushHistory?: boolean
) => void;
updateElementStartTime: (
trackId: string,
elementId: string,
startTime: number,
pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
toggleElementHidden: (trackId: string, elementId: string) => void;
toggleElementMuted: (trackId: string, elementId: string) => void;
// Split operations for elements
splitElement: (
trackId: string,
elementId: string,
splitTime: number
) => string | null;
splitAndKeepLeft: (
trackId: string,
elementId: string,
splitTime: number
) => void;
splitAndKeepRight: (
trackId: string,
elementId: string,
splitTime: number
) => void;
separateAudio: (trackId: string, elementId: string) => string | null;
// Replace media for an element
replaceElementMedia: (
trackId: string,
elementId: string,
newFile: File
) => Promise<{ success: boolean; error?: string }>;
// Ripple editing functions
updateElementStartTimeWithRipple: (
trackId: string,
elementId: string,
newStartTime: number
) => void;
removeElementFromTrackWithRipple: (
trackId: string,
elementId: string,
pushHistory?: boolean
) => void;
// Computed values
getTotalDuration: () => number;
getProjectThumbnail: (projectId: string) => Promise<string | null>;
// History actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
// Persistence actions
loadProjectTimeline: ({
projectId,
sceneId,
}: {
projectId: string;
sceneId?: string;
}) => Promise<void>;
saveProjectTimeline: ({
projectId,
sceneId,
}: {
projectId: string;
sceneId?: string;
}) => Promise<void>;
clearTimeline: () => void;
// Clipboard actions
copySelected: () => void;
pasteAtTime: (time: number) => void;
updateTextElement: (
trackId: string,
elementId: string,
updates: Partial<
Pick<
TextElement,
| "content"
| "fontSize"
| "fontFamily"
| "color"
| "backgroundColor"
| "textAlign"
| "fontWeight"
| "fontStyle"
| "textDecoration"
| "x"
| "y"
| "rotation"
| "opacity"
>
>
) => void;
checkElementOverlap: (
trackId: string,
startTime: number,
duration: number,
excludeElementId?: string
) => boolean;
findOrCreateTrack: (trackType: TrackType) => string;
addElementAtTime: (
item: MediaFile | TextElement,
currentTime?: number
) => boolean;
addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
// Helper to update tracks and maintain ordering
const updateTracks = (newTracks: TimelineTrack[]) => {
const tracksWithMain = ensureMainTrack(newTracks);
const sortedTracks = sortTracksByOrder(tracksWithMain);
set({
_tracks: tracksWithMain,
tracks: sortedTracks,
});
};
// Helper to auto-save timeline changes
const autoSaveTimeline = async () => {
const activeProject = useProjectStore.getState().activeProject;
const currentScene = useSceneStore.getState().currentScene;
if (activeProject && currentScene) {
try {
await storageService.saveTimeline({
projectId: activeProject.id,
tracks: get()._tracks,
sceneId: currentScene.id,
});
} catch (error) {
console.error("Failed to auto-save timeline:", error);
}
} else {
console.warn(
"Auto-save skipped - missing activeProject or currentScene:",
{
hasProject: !!activeProject,
hasScene: !!currentScene,
sceneName: currentScene?.name,
}
);
}
};
// Helper to update tracks and auto-save
const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
updateTracks(newTracks);
// Auto-save in background
setTimeout(autoSaveTimeline, 100);
};
// Initialize with proper track ordering
const initialTracks = ensureMainTrack([]);
const sortedInitialTracks = sortTracksByOrder(initialTracks);
return {
_tracks: initialTracks,
tracks: sortedInitialTracks,
history: [],
redoStack: [],
selectedElements: [],
rippleEditingEnabled: false,
clipboard: null,
// Snapping settings defaults
snappingEnabled: true,
getSortedTracks: () => {
const { _tracks } = get();
const tracksWithMain = ensureMainTrack(_tracks);
return sortTracksByOrder(tracksWithMain);
},
pushHistory: () => {
const { _tracks, history } = get();
set({
history: [...history, JSON.parse(JSON.stringify(_tracks))],
redoStack: [],
});
},
undo: () => {
const { history, redoStack, _tracks } = get();
if (history.length === 0) return;
const prev = history[history.length - 1];
updateTracksAndSave(prev);
set({
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
});
},
selectElement: (trackId, elementId, multi = false) => {
set((state) => {
const exists = state.selectedElements.some(
(c) => c.trackId === trackId && c.elementId === elementId
);
if (multi) {
return exists
? {
selectedElements: state.selectedElements.filter(
(c) => !(c.trackId === trackId && c.elementId === elementId)
),
}
: {
selectedElements: [
...state.selectedElements,
{ trackId, elementId },
],
};
}
return { selectedElements: [{ trackId, elementId }] };
});
},
deselectElement: (trackId, elementId) => {
set((state) => ({
selectedElements: state.selectedElements.filter(
(c) => !(c.trackId === trackId && c.elementId === elementId)
),
}));
},
clearSelectedElements: () => {
set({ selectedElements: [] });
},
setSelectedElements: (elements) => set({ selectedElements: elements }),
addTrack: (type) => {
get().pushHistory();
const trackName =
type === "media"
? "Media Track"
: type === "text"
? "Text Track"
: type === "audio"
? "Audio Track"
: "Track";
const newTrack: TimelineTrack = {
id: generateUUID(),
name: trackName,
type,
elements: [],
muted: false,
};
updateTracksAndSave([...get()._tracks, newTrack]);
return newTrack.id;
},
insertTrackAt: (type, index) => {
get().pushHistory();
const trackName =
type === "media"
? "Media Track"
: type === "text"
? "Text Track"
: type === "audio"
? "Audio Track"
: "Track";
const newTrack: TimelineTrack = {
id: generateUUID(),
name: trackName,
type,
elements: [],
muted: false,
};
const newTracks = [...get()._tracks];
newTracks.splice(index, 0, newTrack);
updateTracksAndSave(newTracks);
return newTrack.id;
},
removeTrack: (trackId) => {
const { rippleEditingEnabled } = get();
if (rippleEditingEnabled) {
get().removeTrackWithRipple(trackId);
} else {
get().pushHistory();
updateTracksAndSave(
get()._tracks.filter((track) => track.id !== trackId)
);
}
},
removeTrackWithRipple: (trackId) => {
const { _tracks } = get();
const trackToRemove = _tracks.find((t) => t.id === trackId);
if (!trackToRemove) return;
get().pushHistory();
const occupiedRanges = trackToRemove.elements.map((element) => ({
startTime: element.startTime,
endTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd),
}));
occupiedRanges.sort((a, b) => a.startTime - b.startTime);
const mergedRanges: Array<{
startTime: number;
endTime: number;
duration: number;
}> = [];
for (const range of occupiedRanges) {
if (mergedRanges.length === 0) {
mergedRanges.push({
startTime: range.startTime,
endTime: range.endTime,
duration: range.endTime - range.startTime,
});
} else {
const lastRange = mergedRanges[mergedRanges.length - 1];
if (range.startTime <= lastRange.endTime) {
lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
lastRange.duration = lastRange.endTime - lastRange.startTime;
} else {
mergedRanges.push({
startTime: range.startTime,
endTime: range.endTime,
duration: range.endTime - range.startTime,
});
}
}
}
const updatedTracks = _tracks
.filter((track) => track.id !== trackId)
.map((track) => {
const updatedElements = track.elements.map((element) => {
let newStartTime = element.startTime;
for (let i = mergedRanges.length - 1; i >= 0; i--) {
const gap = mergedRanges[i];
if (newStartTime >= gap.endTime) {
newStartTime -= gap.duration;
}
}
return {
...element,
startTime: Math.max(0, newStartTime),
};
});
const hasOverlaps = checkElementOverlaps(updatedElements);
if (hasOverlaps) {
const resolvedElements = resolveElementOverlaps(updatedElements);
return { ...track, elements: resolvedElements };
}
return { ...track, elements: updatedElements };
});
updateTracksAndSave(updatedTracks);
},
addElementToTrack: (trackId, elementData) => {
get().pushHistory();
const track = get()._tracks.find((t) => t.id === trackId);
if (!track) {
console.error("Track not found:", trackId);
return;
}
const validation = validateElementTrackCompatibility(elementData, track);
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
if (elementData.type === "media" && !elementData.mediaId) {
console.error("Media element must have mediaId");
return;
}
if (elementData.type === "text" && !elementData.content) {
console.error("Text element must have content");
return;
}
const currentState = get();
const totalElementsInTimeline = currentState._tracks.reduce(
(total, track) => total + track.elements.length,
0
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement: TimelineElement = {
...elementData,
id: generateUUID(),
startTime: elementData.startTime,
trimStart: elementData.trimStart ?? 0,
trimEnd: elementData.trimEnd ?? 0,
...(elementData.type === "media"
? { muted: elementData.muted ?? false }
: {}),
} as TimelineElement;
if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState();
const mediaItem = mediaStore.mediaFiles.find(
(item) => item.id === newElement.mediaId
);
if (
mediaItem &&
(mediaItem.type === "image" || mediaItem.type === "video")
) {
const projectStore = useProjectStore.getState();
projectStore.updateCanvasSize(
findBestCanvasPreset(getMediaAspectRatio(mediaItem)),
"original"
);
}
if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
const projectStore = useProjectStore.getState();
if (projectStore.activeProject) {
projectStore.updateProjectFps(mediaItem.fps);
}
}
}
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? { ...track, elements: [...track.elements, newElement] }
: track
)
);
get().selectElement(trackId, newElement.id);
},
removeElementFromTrack: (trackId, elementId, pushHistory = true) => {
const { rippleEditingEnabled } = get();
if (rippleEditingEnabled) {
get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory);
} else {
if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()
._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.filter(
(element) => element.id !== elementId
),
}
: track
)
.filter((track) => track.elements.length > 0)
);
}
},
removeElementFromTrackWithRipple: (
trackId,
elementId,
pushHistory = true
) => {
const { _tracks, rippleEditingEnabled } = get();
if (!rippleEditingEnabled) {
get().removeElementFromTrack(trackId, elementId, pushHistory);
return;
}
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!element || !track) return;
if (pushHistory) get().pushHistory();
const elementStartTime = element.startTime;
const elementDuration =
element.duration - element.trimStart - element.trimEnd;
const elementEndTime = elementStartTime + elementDuration;
const updatedTracks = _tracks
.map((currentTrack) => {
const shouldApplyRipple = currentTrack.id === trackId;
const updatedElements = currentTrack.elements
.filter((currentElement) => {
if (
currentElement.id === elementId &&
currentTrack.id === trackId
) {
return false;
}
return true;
})
.map((currentElement) => {
if (!shouldApplyRipple) {
return currentElement;
}
if (currentElement.startTime >= elementEndTime) {
return {
...currentElement,
startTime: Math.max(
0,
currentElement.startTime - elementDuration
),
};
}
return currentElement;
});
const hasOverlaps = checkElementOverlaps(updatedElements);
if (hasOverlaps) {
const resolvedElements = resolveElementOverlaps(updatedElements);
return { ...currentTrack, elements: resolvedElements };
}
return { ...currentTrack, elements: updatedElements };
})
.filter((track) => track.elements.length > 0 || track.isMain);
updateTracksAndSave(updatedTracks);
},
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
get().pushHistory();
const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
const toTrack = get()._tracks.find((track) => track.id === toTrackId);
const elementToMove = fromTrack?.elements.find(
(element) => element.id === elementId
);
if (!elementToMove || !toTrack) return;
const validation = validateElementTrackCompatibility(
elementToMove,
toTrack
);
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
const newTracks = get()
._tracks.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
elements: track.elements.filter(
(element) => element.id !== elementId
),
};
}
if (track.id === toTrackId) {
return {
...track,
elements: [...track.elements, elementToMove],
};
}
return track;
})
.filter((track) => track.elements.length > 0);
updateTracksAndSave(newTracks);
},
updateElementTrim: (
trackId,
elementId,
trimStart,
trimEnd,
pushHistory = true
) => {
if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId
? { ...element, trimStart, trimEnd }
: element
),
}
: track
)
);
},
updateElementDuration: (
trackId,
elementId,
duration,
pushHistory = true
) => {
if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId ? { ...element, duration } : element
),
}
: track
)
);
},
updateElementStartTime: (
trackId,
elementId,
startTime,
pushHistory = true
) => {
if (pushHistory) get().pushHistory();
const clampedStartTime = Math.max(0, startTime);
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId
? { ...element, startTime: clampedStartTime }
: element
),
}
: track
)
);
},
updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
const { _tracks, rippleEditingEnabled } = get();
if (!rippleEditingEnabled) {
get().updateElementStartTime(trackId, elementId, newStartTime);
return;
}
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!element || !track) return;
get().pushHistory();
const oldStartTime = element.startTime;
const oldEndTime =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
const newEndTime =
newStartTime + (element.duration - element.trimStart - element.trimEnd);
const timeDelta = newStartTime - oldStartTime;
const updatedTracks = _tracks.map((currentTrack) => {
const shouldApplyRipple = currentTrack.id === trackId;
const updatedElements = currentTrack.elements.map((currentElement) => {
if (currentElement.id === elementId && currentTrack.id === trackId) {
return { ...currentElement, startTime: Math.max(0, newStartTime) };
}
if (!shouldApplyRipple) {
return currentElement;
}
const currentElementStart = currentElement.startTime;
const currentElementEnd =
currentElement.startTime +
(currentElement.duration -
currentElement.trimStart -
currentElement.trimEnd);
if (timeDelta > 0) {
if (currentElementStart >= oldEndTime) {
return {
...currentElement,
startTime: currentElementStart + timeDelta,
};
}
} else if (timeDelta < 0) {
if (
currentElementStart >= newEndTime &&
currentElementStart >= oldStartTime
) {
return {
...currentElement,
startTime: Math.max(0, currentElementStart + timeDelta),
};
}
}
return currentElement;
});
const hasOverlaps = checkElementOverlaps(updatedElements);
if (hasOverlaps) {
const resolvedElements = resolveElementOverlaps(updatedElements);
return { ...currentTrack, elements: resolvedElements };
}
return { ...currentTrack, elements: updatedElements };
});
updateTracksAndSave(updatedTracks);
},
toggleTrackMute: (trackId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
)
);
},
toggleElementHidden: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId
? { ...element, hidden: !element.hidden }
: element
),
}
: track
)
);
},
toggleElementMuted: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId && element.type === "media"
? { ...element, muted: !element.muted }
: element
),
}
: track
)
);
},
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId && element.type === "text"
? { ...element, ...updates }
: element
),
}
: track
)
);
},
splitElement: (trackId, elementId, splitTime) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) return null;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
get().pushHistory();
const relativeTime = splitTime - element.startTime;
const firstDuration = relativeTime;
const secondDuration =
element.duration - element.trimStart - element.trimEnd - relativeTime;
const secondElementId = generateUUID();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.flatMap((c) =>
c.id === elementId
? [
{
...c,
trimEnd: c.trimEnd + secondDuration,
name: getElementNameWithSuffix(c.name, "left"),
},
{
...c,
id: secondElementId,
startTime: splitTime,
trimStart: c.trimStart + firstDuration,
name: getElementNameWithSuffix(c.name, "right"),
},
]
: [c]
),
}
: track
)
);
return secondElementId;
},
// Split element and keep only the left portion
splitAndKeepLeft: (trackId, elementId, splitTime) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) return;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
const relativeTime = splitTime - element.startTime;
const durationToRemove =
element.duration - element.trimStart - element.trimEnd - relativeTime;
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((c) =>
c.id === elementId
? {
...c,
trimEnd: c.trimEnd + durationToRemove,
name: getElementNameWithSuffix(c.name, "left"),
}
: c
),
}
: track
)
);
},
// Split element and keep only the right portion
splitAndKeepRight: (trackId, elementId, splitTime) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) return;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
const relativeTime = splitTime - element.startTime;
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((c) =>
c.id === elementId
? {
...c,
startTime: splitTime,
trimStart: c.trimStart + relativeTime,
name: getElementNameWithSuffix(c.name, "right"),
}
: c
),
}
: track
)
);
},
// Extract audio from video element to an audio track
separateAudio: (trackId, elementId) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element || track?.type !== "media") return null;
get().pushHistory();
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
const audioElementId = generateUUID();
if (existingAudioTrack) {
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === existingAudioTrack.id
? {
...track,
elements: [
...track.elements,
{
...element,
id: audioElementId,
name: getElementNameWithSuffix(element.name, "audio"),
},
],
}
: track
)
);
} else {
const newAudioTrack: TimelineTrack = {
id: generateUUID(),
name: "Audio Track",
type: "audio",
elements: [
{
...element,
id: audioElementId,
name: getElementNameWithSuffix(element.name, "audio"),
},
],
muted: false,
};
updateTracksAndSave([...get()._tracks, newAudioTrack]);
}
return audioElementId;
},
// Replace media for an element
replaceElementMedia: async (
trackId: string,
elementId: string,
newFile: File
): Promise<{ success: boolean; error?: string }> => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) {
return { success: false, error: "Timeline element not found" };
}
if (element.type !== "media") {
return {
success: false,
error: "Replace is only available for media clips",
};
}
try {
const mediaStore = useMediaStore.getState();
const projectStore = useProjectStore.getState();
if (!projectStore.activeProject) {
return { success: false, error: "No active project found" };
}
const {
getFileType,
getImageDimensions,
generateVideoThumbnail,
getMediaDuration,
} = await import("./media-store");
const fileType = getFileType(newFile);
if (!fileType) {
return {
success: false,
error:
"Unsupported file type. Please select a video, audio, or image file.",
};
}
const mediaData: any = {
name: newFile.name,
type: fileType,
file: newFile,
url: URL.createObjectURL(newFile),
};
try {
if (fileType === "image") {
const { width, height } = await getImageDimensions(newFile);
mediaData.width = width;
mediaData.height = height;
} else if (fileType === "video") {
const [duration, { thumbnailUrl, width, height }] =
await Promise.all([
getMediaDuration(newFile),
generateVideoThumbnail(newFile),
]);
mediaData.duration = duration;
mediaData.thumbnailUrl = thumbnailUrl;
mediaData.width = width;
mediaData.height = height;
} else if (fileType === "audio") {
mediaData.duration = await getMediaDuration(newFile);
}
} catch (error) {
return {
success: false,
error: `Failed to process ${fileType} file: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
try {
await mediaStore.addMediaFile(
projectStore.activeProject.id,
mediaData
);
} catch (error) {
return {
success: false,
error: `Failed to add media to project: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
const newMediaItem = mediaStore.mediaFiles.find(
(item) => item.file === newFile
);
if (!newMediaItem) {
return {
success: false,
error: "Failed to create media item in project. Please try again.",
};
}
get().pushHistory();
updateTracksAndSave(
_tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((c) =>
c.id === elementId
? {
...c,
mediaId: newMediaItem.id,
name: newMediaItem.name,
duration: newMediaItem.duration || c.duration,
}
: c
),
}
: track
)
);
return { success: true };
} catch (error) {
console.error("Failed to replace element media:", error);
return {
success: false,
error: `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
getTotalDuration: () => {
const { _tracks } = get();
if (_tracks.length === 0) return 0;
const trackEndTimes = _tracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
return Math.max(maxEnd, elementEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
getProjectThumbnail: async (projectId) => {
try {
const project = await storageService.loadProject({ id: projectId });
if (!project) return null;
// For scene-based projects, use main scene timeline
// For legacy projects, use legacy timeline format
let sceneId: string | undefined;
if (project.scenes && project.scenes.length > 0) {
const mainScene = project.scenes.find((s) => s.isMain);
sceneId = mainScene?.id;
}
const tracks = await storageService.loadTimeline({
projectId,
sceneId,
});
const mediaItems = await storageService.loadAllMediaFiles({
projectId,
});
if (!tracks || !mediaItems.length) return null;
const firstMediaElement = tracks
.flatMap((track) => track.elements)
.filter((element) => element.type === "media")
.sort((a, b) => a.startTime - b.startTime)[0];
if (!firstMediaElement) return null;
const mediaFile = mediaItems.find(
(item) => item.id === firstMediaElement.mediaId
);
if (!mediaFile) return null;
if (mediaFile.type === "video" && mediaFile.file) {
const { generateVideoThumbnail } = await import(
"@/stores/media-store"
);
const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file);
return thumbnailUrl;
}
if (mediaFile.type === "image" && mediaFile.url) {
return mediaFile.url;
}
return null;
} catch (error) {
console.error("Failed to get project thumbnail:", error);
return null;
}
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
updateTracksAndSave(next);
set({ redoStack: redoStack.slice(0, -1) });
},
dragState: {
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
setDragState: (dragState) =>
set((state) => ({
dragState: { ...state.dragState, ...dragState },
})),
startDrag: (
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime
) => {
set({
dragState: {
isDragging: true,
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
currentTime: startElementTime,
},
});
},
updateDragTime: (currentTime) => {
set((state) => ({
dragState: {
...state.dragState,
currentTime,
},
}));
},
endDrag: () => {
set({
dragState: {
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
});
},
loadProjectTimeline: async ({
projectId,
sceneId,
}: {
projectId: string;
sceneId?: string;
}) => {
try {
const tracks = await storageService.loadTimeline({
projectId,
sceneId,
});
if (tracks) {
updateTracks(tracks);
} else {
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
}
set({ history: [], redoStack: [] });
} catch (error) {
console.error("Failed to load timeline:", error);
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
set({ history: [], redoStack: [] });
}
},
saveProjectTimeline: async ({
projectId,
sceneId,
}: {
projectId: string;
sceneId?: string;
}) => {
const { _tracks } = get();
try {
await storageService.saveTimeline({
projectId,
tracks: _tracks,
sceneId,
});
} catch (error) {
console.error("Failed to save timeline:", error);
}
},
clearTimeline: () => {
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
set({
history: [],
redoStack: [],
selectedElements: [],
clipboard: null,
});
},
// Snapping actions
toggleSnapping: () => {
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
},
// Ripple editing functions
toggleRippleEditing: () => {
set((state) => ({
rippleEditingEnabled: !state.rippleEditingEnabled,
}));
},
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
const track = get()._tracks.find((t) => t.id === trackId);
if (!track) return false;
const overlap = track.elements.some((element) => {
const elementEnd =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
if (element.id === excludeElementId) {
return false;
}
return (
(startTime >= element.startTime && startTime < elementEnd) ||
(startTime + duration > element.startTime &&
startTime + duration <= elementEnd) ||
(startTime < element.startTime && startTime + duration > elementEnd)
);
});
return overlap;
},
findOrCreateTrack: (trackType) => {
if (trackType === "text") {
return get().insertTrackAt(trackType, 0);
}
const existingTrack = get()._tracks.find((t) => t.type === trackType);
if (existingTrack) {
return existingTrack.id;
}
return get().addTrack(trackType);
},
addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => {
if (item.type === "text") {
const targetTrackId = get().insertTrackAt("text", 0);
get().addElementToTrack(
targetTrackId,
buildTextElement(item, currentTime)
);
return true;
}
const media = item as MediaFile;
const trackType = media.type === "audio" ? "audio" : "media";
const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: media.id,
name: media.name,
duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
addElementToNewTrack: (item) => {
if (item.type === "text") {
const targetTrackId = get().insertTrackAt("text", 0);
get().addElementToTrack(
targetTrackId,
buildTextElement(item as TextElement | DragData, 0)
);
return true;
}
const media = item as MediaFile;
const trackType = media.type === "audio" ? "audio" : "media";
const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, {
type: "media",
mediaId: media.id,
name: media.name,
duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
muted: false,
});
return true;
},
copySelected: () => {
const { selectedElements, _tracks } = get();
if (selectedElements.length === 0) return;
const items: Array<{
trackType: TrackType;
element: CreateTimelineElement;
}> = [];
for (const { trackId, elementId } of selectedElements) {
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!track || !element) continue;
// Prepare a creation-friendly copy without id
const { id: _id, ...rest } = element as TimelineElement;
items.push({
trackType: track.type,
element: rest as CreateTimelineElement,
});
}
set({ clipboard: { items } });
},
pasteAtTime: (time) => {
const { clipboard } = get();
if (!clipboard || clipboard.items.length === 0) return;
// Determine reference start time offset based on earliest element in clipboard
const minStart = Math.min(
...clipboard.items.map((x) => x.element.startTime)
);
get().pushHistory();
for (const item of clipboard.items) {
const targetTrackId = get().findOrCreateTrack(item.trackType);
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
// Ensure no overlap on target track
const duration =
item.element.duration - item.element.trimStart - item.element.trimEnd;
const hasOverlap = get().checkElementOverlap(
targetTrackId,
startTime,
duration
);
if (hasOverlap) {
// If overlap, nudge forward slightly until free (simple resolve)
let candidate = startTime;
let safety = 0;
while (
get().checkElementOverlap(targetTrackId, candidate, duration) &&
safety < 1000
) {
candidate += 0.01;
safety += 1;
}
get().addElementToTrack(targetTrackId, {
...item.element,
startTime: candidate,
});
} else {
get().addElementToTrack(targetTrackId, {
...item.element,
startTime,
});
}
}
},
};
});
function buildTextElement(
raw: TextElement | DragData,
startTime: number
): CreateTimelineElement {
const t = raw as Partial<TextElement>;
return {
type: "text",
name: t.name ?? DEFAULT_TEXT_ELEMENT.name,
content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
fontSize:
typeof t.fontSize === "number"
? t.fontSize
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x,
y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y,
rotation:
typeof t.rotation === "number"
? t.rotation
: DEFAULT_TEXT_ELEMENT.rotation,
opacity:
typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity,
};
}