2025-06-26 00:37:35 +02:00
|
|
|
import { create } from "zustand";
|
2025-07-06 20:45:29 +02:00
|
|
|
import type {
|
|
|
|
|
TrackType,
|
|
|
|
|
TimelineElement,
|
|
|
|
|
CreateTimelineElement,
|
|
|
|
|
} from "@/types/timeline";
|
2025-06-30 23:19:37 +02:00
|
|
|
import { useEditorStore } from "./editor-store";
|
2025-07-01 01:13:14 +02:00
|
|
|
import { useMediaStore, getMediaAspectRatio } from "./media-store";
|
2025-06-26 00:37:35 +02:00
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// Helper function to manage element naming with suffixes
|
|
|
|
|
const getElementNameWithSuffix = (
|
2025-06-27 00:07:47 +05:30
|
|
|
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})`;
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-26 00:37:35 +02:00
|
|
|
export interface TimelineTrack {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
2025-06-27 01:02:01 +02:00
|
|
|
type: TrackType;
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: TimelineElement[];
|
2025-06-26 00:37:35 +02:00
|
|
|
muted?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TimelineStore {
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
history: TimelineTrack[][];
|
|
|
|
|
redoStack: TimelineTrack[][];
|
|
|
|
|
|
|
|
|
|
// Multi-selection
|
2025-07-06 20:45:29 +02:00
|
|
|
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;
|
2025-06-26 00:37:35 +02:00
|
|
|
|
|
|
|
|
// Drag state
|
|
|
|
|
dragState: {
|
|
|
|
|
isDragging: boolean;
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string | null;
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId: string | null;
|
|
|
|
|
startMouseX: number;
|
2025-07-06 20:45:29 +02:00
|
|
|
startElementTime: number;
|
2025-06-26 00:37:35 +02:00
|
|
|
clickOffsetTime: number;
|
|
|
|
|
currentTime: number;
|
|
|
|
|
};
|
|
|
|
|
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
|
|
|
|
|
startDrag: (
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string,
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId: string,
|
|
|
|
|
startMouseX: number,
|
2025-07-06 20:45:29 +02:00
|
|
|
startElementTime: number,
|
2025-06-26 00:37:35 +02:00
|
|
|
clickOffsetTime: number
|
|
|
|
|
) => void;
|
|
|
|
|
updateDragTime: (currentTime: number) => void;
|
|
|
|
|
endDrag: () => void;
|
|
|
|
|
|
|
|
|
|
// Actions
|
2025-06-27 01:02:01 +02:00
|
|
|
addTrack: (type: TrackType) => string;
|
2025-07-06 20:45:29 +02:00
|
|
|
insertTrackAt: (type: TrackType, index: number) => string;
|
2025-06-26 00:37:35 +02:00
|
|
|
removeTrack: (trackId: string) => void;
|
2025-07-06 20:45:29 +02:00
|
|
|
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
|
|
|
|
|
removeElementFromTrack: (trackId: string, elementId: string) => void;
|
|
|
|
|
moveElementToTrack: (
|
2025-06-26 00:37:35 +02:00
|
|
|
fromTrackId: string,
|
|
|
|
|
toTrackId: string,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string
|
2025-06-26 00:37:35 +02:00
|
|
|
) => void;
|
2025-07-06 20:45:29 +02:00
|
|
|
updateElementTrim: (
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId: string,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string,
|
2025-06-26 00:37:35 +02:00
|
|
|
trimStart: number,
|
|
|
|
|
trimEnd: number
|
|
|
|
|
) => void;
|
2025-07-06 20:45:29 +02:00
|
|
|
updateElementStartTime: (
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId: string,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string,
|
2025-06-26 00:37:35 +02:00
|
|
|
startTime: number
|
|
|
|
|
) => void;
|
|
|
|
|
toggleTrackMute: (trackId: string) => void;
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// Split operations for elements
|
|
|
|
|
splitElement: (
|
2025-06-26 23:40:50 +05:30
|
|
|
trackId: string,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string,
|
2025-06-26 23:40:50 +05:30
|
|
|
splitTime: number
|
|
|
|
|
) => string | null;
|
|
|
|
|
splitAndKeepLeft: (
|
|
|
|
|
trackId: string,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string,
|
2025-06-26 23:40:50 +05:30
|
|
|
splitTime: number
|
|
|
|
|
) => void;
|
|
|
|
|
splitAndKeepRight: (
|
|
|
|
|
trackId: string,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: string,
|
2025-06-26 23:40:50 +05:30
|
|
|
splitTime: number
|
|
|
|
|
) => void;
|
2025-07-06 20:45:29 +02:00
|
|
|
separateAudio: (trackId: string, elementId: string) => string | null;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-06-26 00:37:35 +02:00
|
|
|
// Computed values
|
|
|
|
|
getTotalDuration: () => number;
|
|
|
|
|
|
2025-06-26 23:40:50 +05:30
|
|
|
// History actions
|
2025-06-26 00:37:35 +02:00
|
|
|
undo: () => void;
|
|
|
|
|
redo: () => void;
|
|
|
|
|
pushHistory: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|
|
|
|
tracks: [],
|
|
|
|
|
history: [],
|
|
|
|
|
redoStack: [],
|
2025-07-06 20:45:29 +02:00
|
|
|
selectedElements: [],
|
2025-06-26 00:37:35 +02:00
|
|
|
|
|
|
|
|
pushHistory: () => {
|
2025-07-06 20:45:29 +02:00
|
|
|
const { tracks, history } = get();
|
2025-06-26 00:37:35 +02:00
|
|
|
set({
|
|
|
|
|
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
2025-06-26 23:40:50 +05:30
|
|
|
redoStack: [],
|
2025-06-26 00:37:35 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
undo: () => {
|
|
|
|
|
const { history, redoStack, tracks } = get();
|
|
|
|
|
if (history.length === 0) return;
|
|
|
|
|
const prev = history[history.length - 1];
|
|
|
|
|
set({
|
|
|
|
|
tracks: prev,
|
|
|
|
|
history: history.slice(0, -1),
|
2025-06-26 23:40:50 +05:30
|
|
|
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
|
2025-06-26 00:37:35 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
selectElement: (trackId, elementId, multi = false) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
set((state) => {
|
2025-07-06 20:45:29 +02:00
|
|
|
const exists = state.selectedElements.some(
|
|
|
|
|
(c) => c.trackId === trackId && c.elementId === elementId
|
2025-06-26 00:37:35 +02:00
|
|
|
);
|
|
|
|
|
if (multi) {
|
|
|
|
|
return exists
|
|
|
|
|
? {
|
2025-07-06 20:45:29 +02:00
|
|
|
selectedElements: state.selectedElements.filter(
|
|
|
|
|
(c) => !(c.trackId === trackId && c.elementId === elementId)
|
2025-06-26 00:37:35 +02:00
|
|
|
),
|
|
|
|
|
}
|
2025-07-06 20:45:29 +02:00
|
|
|
: {
|
|
|
|
|
selectedElements: [
|
|
|
|
|
...state.selectedElements,
|
|
|
|
|
{ trackId, elementId },
|
|
|
|
|
],
|
|
|
|
|
};
|
2025-06-26 00:37:35 +02:00
|
|
|
} else {
|
2025-07-06 20:45:29 +02:00
|
|
|
return { selectedElements: [{ trackId, elementId }] };
|
2025-06-26 00:37:35 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
deselectElement: (trackId, elementId) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
set((state) => ({
|
2025-07-06 20:45:29 +02:00
|
|
|
selectedElements: state.selectedElements.filter(
|
|
|
|
|
(c) => !(c.trackId === trackId && c.elementId === elementId)
|
2025-06-26 00:37:35 +02:00
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
clearSelectedElements: () => {
|
|
|
|
|
set({ selectedElements: [] });
|
2025-06-26 00:37:35 +02:00
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
setSelectedElements: (elements) => set({ selectedElements: elements }),
|
2025-06-26 00:37:35 +02:00
|
|
|
|
|
|
|
|
addTrack: (type) => {
|
|
|
|
|
get().pushHistory();
|
2025-07-06 20:45:29 +02:00
|
|
|
|
|
|
|
|
// Generate proper track name based on type
|
|
|
|
|
const trackName =
|
|
|
|
|
type === "media"
|
|
|
|
|
? "Media Track"
|
|
|
|
|
: type === "text"
|
|
|
|
|
? "Text Track"
|
|
|
|
|
: type === "audio"
|
|
|
|
|
? "Audio Track"
|
|
|
|
|
: "Track";
|
|
|
|
|
|
2025-06-26 00:37:35 +02:00
|
|
|
const newTrack: TimelineTrack = {
|
|
|
|
|
id: crypto.randomUUID(),
|
2025-07-06 20:45:29 +02:00
|
|
|
name: trackName,
|
2025-06-26 00:37:35 +02:00
|
|
|
type,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: [],
|
2025-06-26 00:37:35 +02:00
|
|
|
muted: false,
|
|
|
|
|
};
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: [...state.tracks, newTrack],
|
|
|
|
|
}));
|
|
|
|
|
return newTrack.id;
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
|
2025-06-26 00:37:35 +02:00
|
|
|
removeTrack: (trackId) => {
|
|
|
|
|
get().pushHistory();
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.filter((track) => track.id !== trackId),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
addElementToTrack: (trackId, elementData) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
get().pushHistory();
|
2025-06-30 23:19:37 +02:00
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// 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
|
2025-06-30 23:19:37 +02:00
|
|
|
const currentState = get();
|
2025-07-06 20:45:29 +02:00
|
|
|
const totalElementsInTimeline = currentState.tracks.reduce(
|
|
|
|
|
(total, track) => total + track.elements.length,
|
2025-06-30 23:19:37 +02:00
|
|
|
0
|
|
|
|
|
);
|
2025-07-06 20:45:29 +02:00
|
|
|
const isFirstElement = totalElementsInTimeline === 0;
|
2025-06-30 23:19:37 +02:00
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const newElement: TimelineElement = {
|
|
|
|
|
...elementData,
|
2025-06-26 00:37:35 +02:00
|
|
|
id: crypto.randomUUID(),
|
2025-07-06 20:45:29 +02:00
|
|
|
startTime: elementData.startTime || 0,
|
2025-06-26 00:37:35 +02:00
|
|
|
trimStart: 0,
|
|
|
|
|
trimEnd: 0,
|
2025-07-06 20:45:29 +02:00
|
|
|
} as TimelineElement; // Type assertion since we trust the caller passes valid data
|
2025-06-26 00:37:35 +02:00
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// If this is the first element and it's a media element, automatically set the project canvas size
|
2025-06-30 23:19:37 +02:00
|
|
|
// to match the media's aspect ratio
|
2025-07-06 20:45:29 +02:00
|
|
|
if (isFirstElement && newElement.type === "media") {
|
2025-06-30 23:19:37 +02:00
|
|
|
const mediaStore = useMediaStore.getState();
|
|
|
|
|
const mediaItem = mediaStore.mediaItems.find(
|
2025-07-06 20:45:29 +02:00
|
|
|
(item) => item.id === newElement.mediaId
|
2025-06-30 23:19:37 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
mediaItem &&
|
|
|
|
|
(mediaItem.type === "image" || mediaItem.type === "video")
|
|
|
|
|
) {
|
|
|
|
|
const editorStore = useEditorStore.getState();
|
2025-07-01 01:13:14 +02:00
|
|
|
editorStore.setCanvasSizeFromAspectRatio(
|
|
|
|
|
getMediaAspectRatio(mediaItem)
|
|
|
|
|
);
|
2025-06-30 23:19:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-26 00:37:35 +02:00
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId
|
2025-07-06 20:45:29 +02:00
|
|
|
? { ...track, elements: [...track.elements, newElement] }
|
2025-06-26 00:37:35 +02:00
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
removeElementFromTrack: (trackId, elementId) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
get().pushHistory();
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks
|
|
|
|
|
.map((track) =>
|
|
|
|
|
track.id === trackId
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.filter(
|
|
|
|
|
(element) => element.id !== elementId
|
|
|
|
|
),
|
2025-06-26 00:37:35 +02:00
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
)
|
2025-07-06 20:45:29 +02:00
|
|
|
.filter((track) => track.elements.length > 0),
|
2025-06-26 00:37:35 +02:00
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
get().pushHistory();
|
|
|
|
|
set((state) => {
|
|
|
|
|
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
2025-07-06 20:45:29 +02:00
|
|
|
const elementToMove = fromTrack?.elements.find(
|
|
|
|
|
(element) => element.id === elementId
|
|
|
|
|
);
|
2025-06-26 00:37:35 +02:00
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
if (!elementToMove) return state;
|
2025-06-26 00:37:35 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
tracks: state.tracks
|
|
|
|
|
.map((track) => {
|
|
|
|
|
if (track.id === fromTrackId) {
|
|
|
|
|
return {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.filter(
|
|
|
|
|
(element) => element.id !== elementId
|
|
|
|
|
),
|
2025-06-26 00:37:35 +02:00
|
|
|
};
|
|
|
|
|
} else if (track.id === toTrackId) {
|
|
|
|
|
return {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: [...track.elements, elementToMove],
|
2025-06-26 00:37:35 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return track;
|
|
|
|
|
})
|
2025-07-06 20:45:29 +02:00
|
|
|
.filter((track) => track.elements.length > 0),
|
2025-06-26 00:37:35 +02:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
get().pushHistory();
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.map((element) =>
|
|
|
|
|
element.id === elementId
|
|
|
|
|
? { ...element, trimStart, trimEnd }
|
|
|
|
|
: element
|
2025-06-26 00:37:35 +02:00
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
updateElementStartTime: (trackId, elementId, startTime) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
get().pushHistory();
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.map((element) =>
|
|
|
|
|
element.id === elementId ? { ...element, startTime } : element
|
2025-06-26 00:37:35 +02:00
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleTrackMute: (trackId) => {
|
|
|
|
|
get().pushHistory();
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId ? { ...track, muted: !track.muted } : track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
splitElement: (trackId, elementId, splitTime) => {
|
2025-06-26 23:40:50 +05:30
|
|
|
const { tracks } = get();
|
|
|
|
|
const track = tracks.find((t) => t.id === trackId);
|
2025-07-06 20:45:29 +02:00
|
|
|
const element = track?.elements.find((c) => c.id === elementId);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
if (!element) return null;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const effectiveStart = element.startTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
const effectiveEnd =
|
2025-07-06 20:45:29 +02:00
|
|
|
element.startTime +
|
|
|
|
|
(element.duration - element.trimStart - element.trimEnd);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
|
|
|
|
|
|
|
|
|
|
get().pushHistory();
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const relativeTime = splitTime - element.startTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
const firstDuration = relativeTime;
|
|
|
|
|
const secondDuration =
|
2025-07-06 20:45:29 +02:00
|
|
|
element.duration - element.trimStart - element.trimEnd - relativeTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const secondElementId = crypto.randomUUID();
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.flatMap((c) =>
|
|
|
|
|
c.id === elementId
|
2025-06-26 23:40:50 +05:30
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
...c,
|
|
|
|
|
trimEnd: c.trimEnd + secondDuration,
|
2025-07-06 20:45:29 +02:00
|
|
|
name: getElementNameWithSuffix(c.name, "left"),
|
2025-06-26 23:40:50 +05:30
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
...c,
|
2025-07-06 20:45:29 +02:00
|
|
|
id: secondElementId,
|
2025-06-26 23:40:50 +05:30
|
|
|
startTime: splitTime,
|
|
|
|
|
trimStart: c.trimStart + firstDuration,
|
2025-07-06 20:45:29 +02:00
|
|
|
name: getElementNameWithSuffix(c.name, "right"),
|
2025-06-26 23:40:50 +05:30
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: [c]
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
return secondElementId;
|
2025-06-26 23:40:50 +05:30
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// Split element and keep only the left portion
|
|
|
|
|
splitAndKeepLeft: (trackId, elementId, splitTime) => {
|
2025-06-26 23:40:50 +05:30
|
|
|
const { tracks } = get();
|
|
|
|
|
const track = tracks.find((t) => t.id === trackId);
|
2025-07-06 20:45:29 +02:00
|
|
|
const element = track?.elements.find((c) => c.id === elementId);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
if (!element) return;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const effectiveStart = element.startTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
const effectiveEnd =
|
2025-07-06 20:45:29 +02:00
|
|
|
element.startTime +
|
|
|
|
|
(element.duration - element.trimStart - element.trimEnd);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
|
|
|
|
|
|
|
|
|
|
get().pushHistory();
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const relativeTime = splitTime - element.startTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
const durationToRemove =
|
2025-07-06 20:45:29 +02:00
|
|
|
element.duration - element.trimStart - element.trimEnd - relativeTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.map((c) =>
|
|
|
|
|
c.id === elementId
|
2025-06-26 23:40:50 +05:30
|
|
|
? {
|
|
|
|
|
...c,
|
|
|
|
|
trimEnd: c.trimEnd + durationToRemove,
|
2025-07-06 20:45:29 +02:00
|
|
|
name: getElementNameWithSuffix(c.name, "left"),
|
2025-06-26 23:40:50 +05:30
|
|
|
}
|
|
|
|
|
: c
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// Split element and keep only the right portion
|
|
|
|
|
splitAndKeepRight: (trackId, elementId, splitTime) => {
|
2025-06-26 23:40:50 +05:30
|
|
|
const { tracks } = get();
|
|
|
|
|
const track = tracks.find((t) => t.id === trackId);
|
2025-07-06 20:45:29 +02:00
|
|
|
const element = track?.elements.find((c) => c.id === elementId);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
if (!element) return;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const effectiveStart = element.startTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
const effectiveEnd =
|
2025-07-06 20:45:29 +02:00
|
|
|
element.startTime +
|
|
|
|
|
(element.duration - element.trimStart - element.trimEnd);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
|
|
|
|
|
|
|
|
|
|
get().pushHistory();
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
const relativeTime = splitTime - element.startTime;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === trackId
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: track.elements.map((c) =>
|
|
|
|
|
c.id === elementId
|
2025-06-26 23:40:50 +05:30
|
|
|
? {
|
|
|
|
|
...c,
|
|
|
|
|
startTime: splitTime,
|
|
|
|
|
trimStart: c.trimStart + relativeTime,
|
2025-07-06 20:45:29 +02:00
|
|
|
name: getElementNameWithSuffix(c.name, "right"),
|
2025-06-26 23:40:50 +05:30
|
|
|
}
|
|
|
|
|
: c
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
// Extract audio from video element to an audio track
|
|
|
|
|
separateAudio: (trackId, elementId) => {
|
2025-06-26 23:40:50 +05:30
|
|
|
const { tracks } = get();
|
|
|
|
|
const track = tracks.find((t) => t.id === trackId);
|
2025-07-06 20:45:29 +02:00
|
|
|
const element = track?.elements.find((c) => c.id === elementId);
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
if (!element || track?.type !== "media") return null;
|
2025-06-26 23:40:50 +05:30
|
|
|
|
|
|
|
|
get().pushHistory();
|
|
|
|
|
|
2025-06-27 00:14:29 +05:30
|
|
|
// Find existing audio track or prepare to create one
|
|
|
|
|
const existingAudioTrack = tracks.find((t) => t.type === "audio");
|
2025-07-06 20:45:29 +02:00
|
|
|
const audioElementId = crypto.randomUUID();
|
2025-06-26 23:40:50 +05:30
|
|
|
|
2025-06-27 00:14:29 +05:30
|
|
|
if (existingAudioTrack) {
|
2025-07-06 20:45:29 +02:00
|
|
|
// Add audio element to existing audio track
|
2025-06-27 00:14:29 +05:30
|
|
|
set((state) => ({
|
|
|
|
|
tracks: state.tracks.map((track) =>
|
|
|
|
|
track.id === existingAudioTrack.id
|
|
|
|
|
? {
|
|
|
|
|
...track,
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: [
|
|
|
|
|
...track.elements,
|
2025-06-27 00:14:29 +05:30
|
|
|
{
|
2025-07-06 20:45:29 +02:00
|
|
|
...element,
|
|
|
|
|
id: audioElementId,
|
|
|
|
|
name: getElementNameWithSuffix(element.name, "audio"),
|
2025-06-27 00:14:29 +05:30
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
: track
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
2025-07-06 20:45:29 +02:00
|
|
|
// Create new audio track with the audio element in a single atomic update
|
2025-06-26 23:40:50 +05:30
|
|
|
const newAudioTrack: TimelineTrack = {
|
2025-06-27 00:14:29 +05:30
|
|
|
id: crypto.randomUUID(),
|
2025-06-26 23:40:50 +05:30
|
|
|
name: "Audio Track",
|
|
|
|
|
type: "audio",
|
2025-07-06 20:45:29 +02:00
|
|
|
elements: [
|
2025-06-27 00:14:29 +05:30
|
|
|
{
|
2025-07-06 20:45:29 +02:00
|
|
|
...element,
|
|
|
|
|
id: audioElementId,
|
|
|
|
|
name: getElementNameWithSuffix(element.name, "audio"),
|
2025-06-27 00:14:29 +05:30
|
|
|
},
|
|
|
|
|
],
|
2025-06-26 23:40:50 +05:30
|
|
|
muted: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
tracks: [...state.tracks, newAudioTrack],
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
return audioElementId;
|
2025-06-26 23:40:50 +05:30
|
|
|
},
|
|
|
|
|
|
2025-06-26 00:37:35 +02:00
|
|
|
getTotalDuration: () => {
|
|
|
|
|
const { tracks } = get();
|
|
|
|
|
if (tracks.length === 0) return 0;
|
|
|
|
|
|
|
|
|
|
const trackEndTimes = tracks.map((track) =>
|
2025-07-06 20:45:29 +02:00
|
|
|
track.elements.reduce((maxEnd, element) => {
|
|
|
|
|
const elementEnd =
|
|
|
|
|
element.startTime +
|
|
|
|
|
element.duration -
|
|
|
|
|
element.trimStart -
|
|
|
|
|
element.trimEnd;
|
|
|
|
|
return Math.max(maxEnd, elementEnd);
|
2025-06-26 00:37:35 +02:00
|
|
|
}, 0)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return Math.max(...trackEndTimes, 0);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
redo: () => {
|
|
|
|
|
const { redoStack } = get();
|
|
|
|
|
if (redoStack.length === 0) return;
|
|
|
|
|
const next = redoStack[redoStack.length - 1];
|
|
|
|
|
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dragState: {
|
|
|
|
|
isDragging: false,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: null,
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId: null,
|
|
|
|
|
startMouseX: 0,
|
2025-07-06 20:45:29 +02:00
|
|
|
startElementTime: 0,
|
2025-06-26 00:37:35 +02:00
|
|
|
clickOffsetTime: 0,
|
|
|
|
|
currentTime: 0,
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setDragState: (dragState) =>
|
|
|
|
|
set((state) => ({
|
|
|
|
|
dragState: { ...state.dragState, ...dragState },
|
|
|
|
|
})),
|
|
|
|
|
|
2025-07-06 20:45:29 +02:00
|
|
|
startDrag: (
|
|
|
|
|
elementId,
|
|
|
|
|
trackId,
|
|
|
|
|
startMouseX,
|
|
|
|
|
startElementTime,
|
|
|
|
|
clickOffsetTime
|
|
|
|
|
) => {
|
2025-06-26 00:37:35 +02:00
|
|
|
set({
|
|
|
|
|
dragState: {
|
|
|
|
|
isDragging: true,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId,
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId,
|
|
|
|
|
startMouseX,
|
2025-07-06 20:45:29 +02:00
|
|
|
startElementTime,
|
2025-06-26 00:37:35 +02:00
|
|
|
clickOffsetTime,
|
2025-07-06 20:45:29 +02:00
|
|
|
currentTime: startElementTime,
|
2025-06-26 00:37:35 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateDragTime: (currentTime) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
dragState: {
|
|
|
|
|
...state.dragState,
|
|
|
|
|
currentTime,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
endDrag: () => {
|
|
|
|
|
set({
|
|
|
|
|
dragState: {
|
|
|
|
|
isDragging: false,
|
2025-07-06 20:45:29 +02:00
|
|
|
elementId: null,
|
2025-06-26 00:37:35 +02:00
|
|
|
trackId: null,
|
|
|
|
|
startMouseX: 0,
|
2025-07-06 20:45:29 +02:00
|
|
|
startElementTime: 0,
|
2025-06-26 00:37:35 +02:00
|
|
|
clickOffsetTime: 0,
|
|
|
|
|
currentTime: 0,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
}));
|