summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-06 22:51:11 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-06 22:51:11 +0200
commit6edd5b36cf7f09b8b146f57a880eb919c5fdad7f (patch)
treef41f166b71715010684b7e9769e557f6902aed02 /apps
parent40c7fbb4f86130c83fbd9029e83c5e7550e3219d (diff)
downloadopencut-6edd5b36cf7f09b8b146f57a880eb919c5fdad7f.tar.gz
feat: main track and ordering tracks
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/components/development-debug.tsx3
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx34
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx101
-rw-r--r--apps/web/src/stores/timeline-store.ts1022
-rw-r--r--apps/web/src/types/timeline.ts48
5 files changed, 660 insertions, 548 deletions
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx
index f407e2e0..3b891e38 100644
--- a/apps/web/src/components/development-debug.tsx
+++ b/apps/web/src/components/development-debug.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
+import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { TimelineTrack } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button";
import { useState } from "react";
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 9a02560b..bbd112c1 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -1,10 +1,7 @@
"use client";
-import {
- useTimelineStore,
- type TimelineTrack,
-} from "@/stores/timeline-store";
-import { TimelineElement } from "@/types/timeline";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { TimelineElement, TimelineTrack } from "@/types/timeline";
import {
useMediaStore,
type MediaItem,
@@ -112,16 +109,19 @@ export function PreviewPanel() {
track.elements.forEach((element) => {
const elementStart = element.startTime;
const elementEnd =
- element.startTime + (element.duration - element.trimStart - element.trimEnd);
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
if (currentTime >= elementStart && currentTime < elementEnd) {
let mediaItem = null;
-
+
// Only get media item for media elements
if (element.type === "media") {
- mediaItem = element.mediaId === "test"
- ? null // Test elements don't have a real media item
- : mediaItems.find((item) => item.id === element.mediaId) || null;
+ mediaItem =
+ element.mediaId === "test"
+ ? null // Test elements don't have a real media item
+ : mediaItems.find((item) => item.id === element.mediaId) ||
+ null;
}
activeElements.push({ element, track, mediaItem });
@@ -165,9 +165,9 @@ export function PreviewPanel() {
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textDecoration: element.textDecoration,
- padding: '4px 8px',
- borderRadius: '2px',
- whiteSpace: 'pre-wrap',
+ padding: "4px 8px",
+ borderRadius: "2px",
+ whiteSpace: "pre-wrap",
}}
>
{element.content}
@@ -262,7 +262,9 @@ export function PreviewPanel() {
No elements at current time
</div>
) : (
- activeElements.map((elementData, index) => renderElement(elementData, index))
+ activeElements.map((elementData, index) =>
+ renderElement(elementData, index)
+ )
)}
</div>
) : (
@@ -305,7 +307,9 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ const mediaItem = mediaItems.find(
+ (item) => item.id === element.mediaId
+ );
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index dd4219f9..eb58e1ee 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -13,7 +13,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
-import { TimelineTrack } from "@/stores/timeline-store";
+import {
+ TimelineTrack,
+ sortTracksByOrder,
+ ensureMainTrack,
+ getMainTrack
+} from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import type {
TimelineElement as TimelineElementType,
@@ -555,15 +560,23 @@ export function TimelineTrackContent({
// Handle position-aware track creation for text
if (track.type !== "text" || dropPosition !== "on") {
- // Determine where to insert the new text track
+ // Text tracks should go above the main track
+ const mainTrack = getMainTrack(tracks);
let insertIndex: number;
+
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// dropPosition === "on" but track is not text type
- insertIndex = currentTrackIndex + 1;
+ // Insert above main track if main track exists, otherwise at top
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
+ insertIndex = mainTrackIndex;
+ } else {
+ insertIndex = 0; // Top of timeline
+ }
}
targetTrackId = insertTrackAt("text", insertIndex);
@@ -646,42 +659,72 @@ export function TimelineTrackContent({
const needsNewTrack = !isCompatible || dropPosition !== "on";
if (needsNewTrack) {
- // Determine where to insert the new track
- let insertIndex: number;
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // dropPosition === "on" but track is incompatible
- insertIndex = currentTrackIndex + 1;
- }
-
if (isVideoOrImage) {
- // For video/image, check if main media track is empty and at the right position
- const mainMediaTrack = tracks.find((t) => t.type === "media");
- if (
- mainMediaTrack &&
- mainMediaTrack.elements.length === 0 &&
- dropPosition === "on"
- ) {
- targetTrackId = mainMediaTrack.id;
- targetTrack = mainMediaTrack;
+ // For video/image, check if we need a main track or additional media track
+ const mainTrack = getMainTrack(tracks);
+
+ if (!mainTrack) {
+ // No main track exists, create it
+ const updatedTracks = ensureMainTrack(tracks);
+ const newMainTrack = getMainTrack(updatedTracks);
+ if (newMainTrack && newMainTrack.elements.length === 0) {
+ targetTrackId = newMainTrack.id;
+ targetTrack = newMainTrack;
+ } else {
+ // Main track was created but somehow has elements, create new media track
+ const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id);
+ targetTrackId = insertTrackAt("media", mainTrackIndex);
+ const updatedTracksAfterInsert = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId);
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ } else if (mainTrack.elements.length === 0 && dropPosition === "on") {
+ // Main track exists and is empty, use it
+ targetTrackId = mainTrack.id;
+ targetTrack = mainTrack;
} else {
+ // Create new media track above main track
+ const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert above main track
+ insertIndex = mainTrackIndex;
+ }
+
targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
+ const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
} else if (isAudio) {
+ // Audio tracks go at the bottom
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert after main track (bottom area)
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
+ insertIndex = mainTrackIndex + 1;
+ } else {
+ insertIndex = tracks.length; // Bottom of timeline
+ }
+ }
+
targetTrackId = insertTrackAt("audio", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
+ const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index ef08fffd..c4e4e0e3 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,8 +1,11 @@
import { create } from "zustand";
-import type {
+import {
TrackType,
TimelineElement,
CreateTimelineElement,
+ TimelineTrack,
+ sortTracksByOrder,
+ ensureMainTrack,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
@@ -22,19 +25,18 @@ const getElementNameWithSuffix = (
return `${baseName} (${suffix})`;
};
-export interface TimelineTrack {
- id: string;
- name: string;
- type: TrackType;
- elements: TimelineElement[];
- muted?: boolean;
-}
-
interface TimelineStore {
- tracks: TimelineTrack[];
+ // Private track storage
+ _tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
+ // Always returns properly ordered tracks with main track ensured
+ tracks: TimelineTrack[];
+
+ // Manual method if you need to force recomputation
+ getSortedTracks: () => TimelineTrack[];
+
// Multi-selection
selectedElements: { trackId: string; elementId: string }[];
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
@@ -116,577 +118,593 @@ interface TimelineStore {
pushHistory: () => void;
}
-export const useTimelineStore = create<TimelineStore>((set, get) => ({
- tracks: [],
- history: [],
- redoStack: [],
- selectedElements: [],
-
- pushHistory: () => {
- const { tracks, history } = get();
+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({
- history: [...history, JSON.parse(JSON.stringify(tracks))],
- redoStack: [],
+ _tracks: tracksWithMain,
+ tracks: sortedTracks,
});
- },
+ };
- 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),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
- });
- },
+ // Initialize with proper track ordering
+ const initialTracks = ensureMainTrack([]);
+ const sortedInitialTracks = sortTracksByOrder(initialTracks);
- 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 },
- ],
- };
- } else {
- return { selectedElements: [{ trackId, elementId }] };
- }
- });
- },
+ return {
+ _tracks: initialTracks,
+ tracks: sortedInitialTracks,
+ history: [],
+ redoStack: [],
+ selectedElements: [],
+
+ 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];
+ updateTracks(prev);
+ set({
+ history: history.slice(0, -1),
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
+ });
+ },
- deselectElement: (trackId, elementId) => {
- set((state) => ({
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId)
- ),
- }));
- },
+ 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 },
+ ],
+ };
+ } else {
+ return { selectedElements: [{ trackId, elementId }] };
+ }
+ });
+ },
- clearSelectedElements: () => {
- set({ selectedElements: [] });
- },
+ deselectElement: (trackId, elementId) => {
+ set((state) => ({
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }));
+ },
- setSelectedElements: (elements) => set({ selectedElements: elements }),
+ clearSelectedElements: () => {
+ set({ selectedElements: [] });
+ },
- addTrack: (type) => {
- get().pushHistory();
+ setSelectedElements: (elements) => set({ selectedElements: elements }),
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ addTrack: (type) => {
+ get().pushHistory();
- const newTrack: TimelineTrack = {
- id: crypto.randomUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
- set((state) => ({
- tracks: [...state.tracks, newTrack],
- }));
- return newTrack.id;
- },
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- insertTrackAt: (type, index) => {
- get().pushHistory();
+ const newTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ updateTracks([...get()._tracks, newTrack]);
+ return newTrack.id;
+ },
- const newTrack: TimelineTrack = {
- id: crypto.randomUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ insertTrackAt: (type, index) => {
+ get().pushHistory();
- set((state) => {
- const newTracks = [...state.tracks];
- newTracks.splice(index, 0, newTrack);
- return { tracks: newTracks };
- });
- return newTrack.id;
- },
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- removeTrack: (trackId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.filter((track) => track.id !== trackId),
- }));
- },
+ const newTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- addElementToTrack: (trackId, elementData) => {
- get().pushHistory();
+ const newTracks = [...get()._tracks];
+ newTracks.splice(index, 0, newTrack);
+ updateTracks(newTracks);
+ return newTrack.id;
+ },
- // Validate element type matches track type
- const track = get().tracks.find((t) => t.id === trackId);
- if (!track) {
- console.error("Track not found:", trackId);
- return;
- }
+ removeTrack: (trackId) => {
+ get().pushHistory();
+ updateTracks(get()._tracks.filter((track) => track.id !== trackId));
+ },
- // 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;
- }
+ addElementToTrack: (trackId, elementData) => {
+ get().pushHistory();
- // For media elements, validate mediaId exists
- if (elementData.type === "media" && !elementData.mediaId) {
- console.error("Media element must have mediaId");
- return;
- }
+ // Validate element type matches track type
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) {
+ console.error("Track not found:", trackId);
+ return;
+ }
- // For text elements, validate required text properties
- if (elementData.type === "text" && !elementData.content) {
- console.error("Text element must have content");
- 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;
+ }
- // Check if this is the first element being added to the timeline
- const currentState = get();
- const totalElementsInTimeline = currentState.tracks.reduce(
- (total, track) => total + track.elements.length,
- 0
- );
- const isFirstElement = totalElementsInTimeline === 0;
+ // For media elements, validate mediaId exists
+ if (elementData.type === "media" && !elementData.mediaId) {
+ console.error("Media element must have mediaId");
+ return;
+ }
- const newElement: TimelineElement = {
- ...elementData,
- id: crypto.randomUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- } as TimelineElement; // Type assertion since we trust the caller passes valid data
+ // For text elements, validate required text properties
+ if (elementData.type === "text" && !elementData.content) {
+ console.error("Text element must have content");
+ return;
+ }
- // 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 (isFirstElement && newElement.type === "media") {
- const mediaStore = useMediaStore.getState();
- const mediaItem = mediaStore.mediaItems.find(
- (item) => item.id === newElement.mediaId
+ // Check if this is the first element being added to the timeline
+ const currentState = get();
+ const totalElementsInTimeline = currentState._tracks.reduce(
+ (total, track) => total + track.elements.length,
+ 0
);
+ const isFirstElement = totalElementsInTimeline === 0;
- if (
- mediaItem &&
- (mediaItem.type === "image" || mediaItem.type === "video")
- ) {
- const editorStore = useEditorStore.getState();
- editorStore.setCanvasSizeFromAspectRatio(
- getMediaAspectRatio(mediaItem)
+ const newElement: TimelineElement = {
+ ...elementData,
+ id: crypto.randomUUID(),
+ 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 element and it's a media element, automatically set the project canvas size
+ // to match the media's aspect ratio
+ if (isFirstElement && newElement.type === "media") {
+ const mediaStore = useMediaStore.getState();
+ const mediaItem = mediaStore.mediaItems.find(
+ (item) => item.id === newElement.mediaId
);
- }
- }
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? { ...track, elements: [...track.elements, newElement] }
- : track
- ),
- }));
- },
+ if (
+ mediaItem &&
+ (mediaItem.type === "image" || mediaItem.type === "video")
+ ) {
+ const editorStore = useEditorStore.getState();
+ editorStore.setCanvasSizeFromAspectRatio(
+ getMediaAspectRatio(mediaItem)
+ );
+ }
+ }
- removeElementFromTrack: (trackId, elementId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks
- .map((track) =>
+ updateTracks(
+ get()._tracks.map((track) =>
track.id === trackId
- ? {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId
- ),
- }
+ ? { ...track, elements: [...track.elements, newElement] }
: track
)
- .filter((track) => track.elements.length > 0),
- }));
- },
+ );
+ },
- moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
- get().pushHistory();
- set((state) => {
- const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
+ removeElementFromTrack: (trackId, elementId) => {
+ get().pushHistory();
+ updateTracks(
+ get()
+ ._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ }
+ : track
+ )
+ .filter((track) => track.elements.length > 0)
+ );
+ },
+
+ moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
+ get().pushHistory();
+
+ const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
const elementToMove = fromTrack?.elements.find(
(element) => element.id === elementId
);
- if (!elementToMove) return state;
-
- return {
- tracks: state.tracks
- .map((track) => {
- if (track.id === fromTrackId) {
- return {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId
- ),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- elements: [...track.elements, elementToMove],
- };
- }
- return track;
- })
- .filter((track) => track.elements.length > 0),
- };
- });
- },
+ if (!elementToMove) return;
- updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? {
+ const newTracks = get()
+ ._tracks.map((track) => {
+ if (track.id === fromTrackId) {
+ return {
...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, trimStart, trimEnd }
- : element
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
),
- }
- : track
- ),
- }));
- },
-
- updateElementStartTime: (trackId, elementId, startTime) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? {
+ };
+ } else if (track.id === toTrackId) {
+ return {
...track,
- elements: track.elements.map((element) =>
- element.id === elementId ? { ...element, startTime } : element
- ),
- }
- : track
- ),
- }));
- },
-
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : 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);
+ elements: [...track.elements, elementToMove],
+ };
+ }
+ return track;
+ })
+ .filter((track) => track.elements.length > 0);
- if (!element) return null;
+ updateTracks(newTracks);
+ },
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
+ get().pushHistory();
+ updateTracks(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, trimStart, trimEnd }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
+ updateElementStartTime: (trackId, elementId, startTime) => {
+ get().pushHistory();
+ updateTracks(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, startTime } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- get().pushHistory();
+ toggleTrackMute: (trackId) => {
+ get().pushHistory();
+ updateTracks(
+ get()._tracks.map((track) =>
+ track.id === trackId ? { ...track, muted: !track.muted } : track
+ )
+ );
+ },
- const relativeTime = splitTime - element.startTime;
- const firstDuration = relativeTime;
- const secondDuration =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ splitElement: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- const secondElementId = crypto.randomUUID();
+ if (!element) return null;
- set((state) => ({
- tracks: state.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
- ),
- }));
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- return secondElementId;
- },
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
- // 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);
+ get().pushHistory();
- if (!element) return;
+ const relativeTime = splitTime - element.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const secondElementId = crypto.randomUUID();
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ updateTracks(
+ 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
+ )
+ );
- get().pushHistory();
+ return secondElementId;
+ },
- const relativeTime = splitTime - element.startTime;
- const durationToRemove =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ // 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);
- set((state) => ({
- tracks: state.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
- ),
- }));
- },
+ if (!element) return;
- // 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);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (!element) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ get().pushHistory();
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ const relativeTime = splitTime - element.startTime;
+ const durationToRemove =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- get().pushHistory();
+ updateTracks(
+ 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
+ )
+ );
+ },
- const relativeTime = splitTime - element.startTime;
+ // 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);
- set((state) => ({
- tracks: state.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
- ),
- }));
- },
+ if (!element) return;
- // 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);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (!element || track?.type !== "media") return null;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- // Find existing audio track or prepare to create one
- const existingAudioTrack = tracks.find((t) => t.type === "audio");
- const audioElementId = crypto.randomUUID();
+ const relativeTime = splitTime - element.startTime;
- if (existingAudioTrack) {
- // Add audio element to existing audio track
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === existingAudioTrack.id
+ updateTracks(
+ get()._tracks.map((track) =>
+ track.id === trackId
? {
...track,
- elements: [
- ...track.elements,
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ startTime: splitTime,
+ trimStart: c.trimStart + relativeTime,
+ name: getElementNameWithSuffix(c.name, "right"),
+ }
+ : c
+ ),
}
: track
- ),
- }));
- } else {
- // Create new audio track with the audio element in a single atomic update
- const newAudioTrack: TimelineTrack = {
- id: crypto.randomUUID(),
- name: "Audio Track",
- type: "audio",
- elements: [
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- muted: false,
- };
+ )
+ );
+ },
- set((state) => ({
- tracks: [...state.tracks, newAudioTrack],
- }));
- }
+ // 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);
- return audioElementId;
- },
+ if (!element || track?.type !== "media") return null;
- getTotalDuration: () => {
- const { tracks } = get();
- if (tracks.length === 0) return 0;
+ get().pushHistory();
- 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)
- );
+ // Find existing audio track or prepare to create one
+ const existingAudioTrack = _tracks.find((t) => t.type === "audio");
+ const audioElementId = crypto.randomUUID();
- return Math.max(...trackEndTimes, 0);
- },
+ if (existingAudioTrack) {
+ // Add audio element to existing audio track
+ updateTracks(
+ get()._tracks.map((track) =>
+ track.id === existingAudioTrack.id
+ ? {
+ ...track,
+ elements: [
+ ...track.elements,
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ }
+ : track
+ )
+ );
+ } else {
+ // Create new audio track with the audio element in a single atomic update
+ const newAudioTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: "Audio Track",
+ type: "audio",
+ elements: [
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ muted: false,
+ };
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- set({ tracks: next, redoStack: redoStack.slice(0, -1) });
- },
+ updateTracks([...get()._tracks, newAudioTrack]);
+ }
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
+ return audioElementId;
+ },
- setDragState: (dragState) =>
- set((state) => ({
- dragState: { ...state.dragState, ...dragState },
- })),
+ getTotalDuration: () => {
+ const { _tracks } = get();
+ if (_tracks.length === 0) return 0;
- startDrag: (
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime
- ) => {
- set({
- dragState: {
- isDragging: true,
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- currentTime: startElementTime,
- },
- });
- },
+ 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)
+ );
- updateDragTime: (currentTime) => {
- set((state) => ({
- dragState: {
- ...state.dragState,
- currentTime,
- },
- }));
- },
+ return Math.max(...trackEndTimes, 0);
+ },
- endDrag: () => {
- set({
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
- });
- },
-}));
+ redo: () => {
+ const { redoStack } = get();
+ if (redoStack.length === 0) return;
+ const next = redoStack[redoStack.length - 1];
+ updateTracks(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,
+ },
+ });
+ },
+ };
+});
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index daada718..7079f091 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,5 +1,4 @@
import { MediaType } from "@/stores/media-store";
-import { TimelineTrack } from "@/stores/timeline-store";
export type TrackType = "media" | "text" | "audio";
@@ -77,3 +76,50 @@ export interface TextItemDragData {
}
export type DragData = MediaItemDragData | TextItemDragData;
+
+export interface TimelineTrack {
+ id: string;
+ name: string;
+ type: TrackType;
+ elements: TimelineElement[];
+ muted?: boolean;
+ isMain?: boolean;
+}
+
+export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] {
+ return [...tracks].sort((a, b) => {
+ // Audio tracks always go to bottom
+ if (a.type === "audio" && b.type !== "audio") return 1;
+ if (b.type === "audio" && a.type !== "audio") return -1;
+
+ // Main track goes above audio but below other tracks
+ if (a.isMain && !b.isMain && b.type !== "audio") return 1;
+ if (b.isMain && !a.isMain && a.type !== "audio") return -1;
+
+ // Within same category, maintain creation order
+ return 0;
+ });
+}
+
+export function getMainTrack(tracks: TimelineTrack[]): TimelineTrack | null {
+ return tracks.find((track) => track.isMain) || null;
+}
+
+export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
+ const hasMainTrack = tracks.some((track) => track.isMain);
+
+ if (!hasMainTrack) {
+ // Create main track if it doesn't exist
+ const mainTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: "Main Track",
+ type: "media",
+ elements: [],
+ muted: false,
+ isMain: true,
+ };
+ return [mainTrack, ...tracks];
+ }
+
+ return tracks;
+}