mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'staging' into feat/timeline-return-to-start
This commit is contained in:
@@ -1,25 +1,32 @@
|
||||
import { create } from "zustand";
|
||||
import { CanvasSize, CanvasPreset } from "@/types/editor";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { CanvasPreset } from "@/types/editor";
|
||||
|
||||
type CanvasMode = "preset" | "original" | "custom";
|
||||
export type PlatformLayout = "tiktok";
|
||||
|
||||
export const PLATFORM_LAYOUTS: Record<PlatformLayout, string> = {
|
||||
tiktok: "TikTok",
|
||||
};
|
||||
|
||||
interface LayoutGuideSettings {
|
||||
platform: PlatformLayout | null;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
// Loading states
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
|
||||
// Canvas/Project settings
|
||||
canvasSize: CanvasSize;
|
||||
canvasMode: CanvasMode;
|
||||
// Editor UI settings
|
||||
canvasPresets: CanvasPreset[];
|
||||
layoutGuide: LayoutGuideSettings;
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setCanvasSize: (size: CanvasSize) => void;
|
||||
setCanvasSizeToOriginal: (aspectRatio: number) => void;
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
|
||||
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
|
||||
toggleLayoutGuide: (platform: PlatformLayout) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
|
||||
@@ -29,76 +36,56 @@ const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
|
||||
{ name: "4:3", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
// Helper function to find the best matching canvas preset for an aspect ratio
|
||||
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
|
||||
// Calculate aspect ratio for each preset and find the closest match
|
||||
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
|
||||
let smallestDifference = Math.abs(
|
||||
aspectRatio - bestMatch.width / bestMatch.height
|
||||
);
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial states
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
layoutGuide: {
|
||||
platform: null,
|
||||
},
|
||||
|
||||
for (const preset of DEFAULT_CANVAS_PRESETS) {
|
||||
const presetAspectRatio = preset.width / preset.height;
|
||||
const difference = Math.abs(aspectRatio - presetAspectRatio);
|
||||
// Actions
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
if (difference < smallestDifference) {
|
||||
smallestDifference = difference;
|
||||
bestMatch = preset;
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setLayoutGuide: (settings) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
...state.layoutGuide,
|
||||
...settings,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
toggleLayoutGuide: (platform) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
platform: state.layoutGuide.platform === platform ? null : platform,
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "editor-settings",
|
||||
partialize: (state) => ({
|
||||
layoutGuide: state.layoutGuide,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// If the difference is still significant (> 0.1), create a custom size
|
||||
// based on the media aspect ratio with a reasonable resolution
|
||||
const bestAspectRatio = bestMatch.width / bestMatch.height;
|
||||
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
|
||||
// Create custom dimensions based on the aspect ratio
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape - use 1920 width
|
||||
return { width: 1920, height: Math.round(1920 / aspectRatio) };
|
||||
}
|
||||
// Portrait or square - use 1080 height
|
||||
return { width: Math.round(1080 * aspectRatio), height: 1080 };
|
||||
}
|
||||
|
||||
return { width: bestMatch.width, height: bestMatch.height };
|
||||
};
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
// Initial states
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
|
||||
canvasMode: "preset" as CanvasMode,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setCanvasSize: (size) => {
|
||||
set({ canvasSize: size, canvasMode: "preset" });
|
||||
},
|
||||
|
||||
setCanvasSizeToOriginal: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "original" });
|
||||
},
|
||||
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
|
||||
},
|
||||
}));
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,48 +1,222 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const DEFAULT_PANEL_SIZES = {
|
||||
toolsPanel: 45,
|
||||
previewPanel: 75,
|
||||
propertiesPanel: 20,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
} as const;
|
||||
export type PanelPreset =
|
||||
| "default"
|
||||
| "media"
|
||||
| "inspector"
|
||||
| "vertical-preview";
|
||||
|
||||
interface PanelState {
|
||||
// Panel sizes as percentages
|
||||
interface PanelSizes {
|
||||
toolsPanel: number;
|
||||
previewPanel: number;
|
||||
propertiesPanel: number;
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
}
|
||||
|
||||
export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes> = {
|
||||
default: {
|
||||
toolsPanel: 25,
|
||||
previewPanel: 50,
|
||||
propertiesPanel: 25,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
},
|
||||
media: {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 45,
|
||||
propertiesPanel: 25,
|
||||
mainContent: 100,
|
||||
timeline: 25,
|
||||
},
|
||||
inspector: {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 70,
|
||||
propertiesPanel: 30,
|
||||
mainContent: 75,
|
||||
timeline: 25,
|
||||
},
|
||||
"vertical-preview": {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 40,
|
||||
propertiesPanel: 30,
|
||||
mainContent: 75,
|
||||
timeline: 25,
|
||||
},
|
||||
};
|
||||
|
||||
interface PanelState extends PanelSizes {
|
||||
activePreset: PanelPreset;
|
||||
presetCustomSizes: Record<PanelPreset, Partial<PanelSizes>>;
|
||||
resetCounter: number;
|
||||
|
||||
mediaViewMode: "grid" | "list";
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
setMediaViewMode: (mode: "grid" | "list") => void;
|
||||
|
||||
setActivePreset: (preset: PanelPreset) => void;
|
||||
resetPreset: (preset: PanelPreset) => void;
|
||||
getCurrentPresetSizes: () => PanelSizes;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Default sizes - optimized for responsiveness
|
||||
...DEFAULT_PANEL_SIZES,
|
||||
(set, get) => ({
|
||||
...PRESET_CONFIGS.default,
|
||||
activePreset: "default" as PanelPreset,
|
||||
presetCustomSizes: {
|
||||
default: {},
|
||||
media: {},
|
||||
inspector: {},
|
||||
"vertical-preview": {},
|
||||
},
|
||||
resetCounter: 0,
|
||||
|
||||
mediaViewMode: "grid" as const,
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||
setPreviewPanel: (size) => set({ previewPanel: size }),
|
||||
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
||||
setMainContent: (size) => set({ mainContent: size }),
|
||||
setTimeline: (size) => set({ timeline: size }),
|
||||
setToolsPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
toolsPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
toolsPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setPreviewPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
previewPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
previewPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setPropertiesPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
propertiesPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
propertiesPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setMainContent: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
mainContent: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
mainContent: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setTimeline: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
timeline: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
timeline: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
|
||||
setActivePreset: (preset) => {
|
||||
const {
|
||||
activePreset: currentPreset,
|
||||
presetCustomSizes,
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
} = get();
|
||||
|
||||
const updatedPresetCustomSizes = {
|
||||
...presetCustomSizes,
|
||||
[currentPreset]: {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultSizes = PRESET_CONFIGS[preset];
|
||||
const customSizes = updatedPresetCustomSizes[preset] || {};
|
||||
const finalSizes = { ...defaultSizes, ...customSizes } as PanelSizes;
|
||||
|
||||
set({
|
||||
activePreset: preset,
|
||||
presetCustomSizes: updatedPresetCustomSizes,
|
||||
...finalSizes,
|
||||
});
|
||||
},
|
||||
|
||||
resetPreset: (preset) => {
|
||||
const { presetCustomSizes, activePreset, resetCounter } = get();
|
||||
const defaultSizes = PRESET_CONFIGS[preset];
|
||||
|
||||
const newPresetCustomSizes = {
|
||||
...presetCustomSizes,
|
||||
[preset]: {},
|
||||
};
|
||||
|
||||
const updates: Partial<PanelState> = {
|
||||
presetCustomSizes: newPresetCustomSizes,
|
||||
resetCounter: resetCounter + 1,
|
||||
};
|
||||
|
||||
if (preset === activePreset) {
|
||||
Object.assign(updates, defaultSizes);
|
||||
}
|
||||
|
||||
set(updates);
|
||||
},
|
||||
|
||||
getCurrentPresetSizes: () => {
|
||||
const {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
} = get();
|
||||
return {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
};
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
|
||||
@@ -82,6 +82,24 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
const state = get();
|
||||
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (effectiveDuration > 0) {
|
||||
const fps = useProjectStore.getState().activeProject?.fps ?? 30;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
if (state.currentTime >= endThreshold) {
|
||||
get().seek(0);
|
||||
}
|
||||
}
|
||||
|
||||
set({ isPlaying: true });
|
||||
startTimer(get);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,24 @@ import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { CanvasSize, CanvasMode } from "@/types/editor";
|
||||
|
||||
export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 };
|
||||
|
||||
const DEFAULT_PROJECT: TProject = {
|
||||
id: generateUUID(),
|
||||
name: "Untitled",
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
bookmarks: [],
|
||||
fps: 30,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
canvasMode: "preset",
|
||||
};
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
@@ -28,6 +46,7 @@ interface ProjectStore {
|
||||
options?: { backgroundColor?: string; blurIntensity?: number }
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
|
||||
|
||||
// Bookmark methods
|
||||
toggleBookmark: (time: number) => Promise<void>;
|
||||
@@ -144,18 +163,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
},
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
bookmarks: [],
|
||||
fps: 30,
|
||||
};
|
||||
const newProject: TProject = { ...DEFAULT_PROJECT, name };
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
@@ -428,6 +436,29 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
updateCanvasSize: async (size: CanvasSize, mode: CanvasMode) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
canvasSize: size,
|
||||
canvasMode: mode,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update canvas size:", error);
|
||||
toast.error("Failed to update canvas size", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
|
||||
@@ -10,17 +10,16 @@ import {
|
||||
ensureMainTrack,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/types/timeline";
|
||||
import { useEditorStore } from "./editor-store";
|
||||
import {
|
||||
useMediaStore,
|
||||
getMediaAspectRatio,
|
||||
type MediaItem,
|
||||
} from "./media-store";
|
||||
import { findBestCanvasPreset } from "@/lib/editor-utils";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { toast } from "sonner";
|
||||
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
|
||||
|
||||
// Helper function to manage element naming with suffixes
|
||||
@@ -30,10 +29,10 @@ const getElementNameWithSuffix = (
|
||||
): string => {
|
||||
// Remove existing suffixes to prevent accumulation
|
||||
const baseName = originalName
|
||||
.replace(/ \(left\)$/, "")
|
||||
.replace(/ \(right\)$/, "")
|
||||
.replace(/ \(audio\)$/, "")
|
||||
.replace(/ \(split \d+\)$/, "");
|
||||
.replace(/ \(left\)$/i, "")
|
||||
.replace(/ \(right\)$/i, "")
|
||||
.replace(/ \(audio\)$/i, "")
|
||||
.replace(/ \(split \d+\)$/i, "");
|
||||
|
||||
return `${baseName} (${suffix})`;
|
||||
};
|
||||
@@ -127,6 +126,7 @@ interface TimelineStore {
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
toggleElementHidden: (trackId: string, elementId: string) => void;
|
||||
toggleElementMuted: (trackId: string, elementId: string) => void;
|
||||
|
||||
// Split operations for elements
|
||||
splitElement: (
|
||||
@@ -323,7 +323,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
addTrack: (type) => {
|
||||
get().pushHistory();
|
||||
|
||||
// Generate proper track name based on type
|
||||
const trackName =
|
||||
type === "media"
|
||||
? "Media Track"
|
||||
@@ -348,7 +347,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
insertTrackAt: (type, index) => {
|
||||
get().pushHistory();
|
||||
|
||||
// Generate proper track name based on type
|
||||
const trackName =
|
||||
type === "media"
|
||||
? "Media Track"
|
||||
@@ -393,13 +391,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
// If track has no elements, just remove it normally
|
||||
if (trackToRemove.elements.length === 0) {
|
||||
updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all the time ranges occupied by elements in the track being removed
|
||||
const occupiedRanges = trackToRemove.elements.map((element) => ({
|
||||
startTime: element.startTime,
|
||||
endTime:
|
||||
@@ -407,10 +398,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
(element.duration - element.trimStart - element.trimEnd),
|
||||
}));
|
||||
|
||||
// Sort ranges by start time
|
||||
occupiedRanges.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Merge overlapping ranges to get consolidated gaps
|
||||
const mergedRanges: Array<{
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -427,11 +416,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
} else {
|
||||
const lastRange = mergedRanges[mergedRanges.length - 1];
|
||||
if (range.startTime <= lastRange.endTime) {
|
||||
// Overlapping or adjacent ranges, merge them
|
||||
lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
|
||||
lastRange.duration = lastRange.endTime - lastRange.startTime;
|
||||
} else {
|
||||
// Non-overlapping range, add as new
|
||||
mergedRanges.push({
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
@@ -441,17 +428,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the track and apply ripple effects to remaining tracks
|
||||
const updatedTracks = _tracks
|
||||
.filter((track) => track.id !== trackId)
|
||||
.map((track) => {
|
||||
const updatedElements = track.elements.map((element) => {
|
||||
let newStartTime = element.startTime;
|
||||
|
||||
// Process gaps from right to left (latest to earliest) to avoid cumulative shifts
|
||||
for (let i = mergedRanges.length - 1; i >= 0; i--) {
|
||||
const gap = mergedRanges[i];
|
||||
// If this element starts after the gap, shift it left by the gap duration
|
||||
if (newStartTime >= gap.endTime) {
|
||||
newStartTime -= gap.duration;
|
||||
}
|
||||
@@ -463,7 +447,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Check for overlaps and resolve them if necessary
|
||||
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||
if (hasOverlaps) {
|
||||
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||
@@ -479,33 +462,28 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
addElementToTrack: (trackId, elementData) => {
|
||||
get().pushHistory();
|
||||
|
||||
// Validate element type matches track type
|
||||
const track = get()._tracks.find((t) => t.id === trackId);
|
||||
if (!track) {
|
||||
console.error("Track not found:", trackId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use utility function for validation
|
||||
const validation = validateElementTrackCompatibility(elementData, track);
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// For media elements, validate mediaId exists
|
||||
if (elementData.type === "media" && !elementData.mediaId) {
|
||||
console.error("Media element must have mediaId");
|
||||
return;
|
||||
}
|
||||
|
||||
// For text elements, validate required text properties
|
||||
if (elementData.type === "text" && !elementData.content) {
|
||||
console.error("Text element must have content");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is the first element being added to the timeline
|
||||
const currentState = get();
|
||||
const totalElementsInTimeline = currentState._tracks.reduce(
|
||||
(total, track) => total + track.elements.length,
|
||||
@@ -519,10 +497,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
startTime: elementData.startTime || 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
} as TimelineElement; // Type assertion since we trust the caller passes valid data
|
||||
...(elementData.type === "media" ? { muted: false } : {}),
|
||||
} as TimelineElement;
|
||||
|
||||
// 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 and FPS (for videos)
|
||||
if (isFirstElement && newElement.type === "media") {
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const mediaItem = mediaStore.mediaItems.find(
|
||||
@@ -533,13 +510,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
mediaItem &&
|
||||
(mediaItem.type === "image" || mediaItem.type === "video")
|
||||
) {
|
||||
const editorStore = useEditorStore.getState();
|
||||
editorStore.setCanvasSizeFromAspectRatio(
|
||||
getMediaAspectRatio(mediaItem)
|
||||
const projectStore = useProjectStore.getState();
|
||||
projectStore.updateCanvasSize(
|
||||
findBestCanvasPreset(getMediaAspectRatio(mediaItem)),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
// Set project FPS from the first video element
|
||||
if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
|
||||
const projectStore = useProjectStore.getState();
|
||||
if (projectStore.activeProject) {
|
||||
@@ -591,7 +568,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
const { _tracks, rippleEditingEnabled } = get();
|
||||
|
||||
if (!rippleEditingEnabled) {
|
||||
// If ripple editing is disabled, use regular removal
|
||||
get().removeElementFromTrack(trackId, elementId, pushHistory);
|
||||
return;
|
||||
}
|
||||
@@ -608,15 +584,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
const elementEndTime = elementStartTime + elementDuration;
|
||||
|
||||
// Remove the element and shift all elements that come after it
|
||||
const updatedTracks = _tracks
|
||||
.map((currentTrack) => {
|
||||
// Only apply ripple effects to the same track unless multi-track ripple is enabled
|
||||
const shouldApplyRipple = currentTrack.id === trackId;
|
||||
|
||||
const updatedElements = currentTrack.elements
|
||||
.filter((currentElement) => {
|
||||
// Remove the target element
|
||||
if (
|
||||
currentElement.id === elementId &&
|
||||
currentTrack.id === trackId
|
||||
@@ -626,12 +599,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return true;
|
||||
})
|
||||
.map((currentElement) => {
|
||||
// Only apply ripple effects if we should process this track
|
||||
if (!shouldApplyRipple) {
|
||||
return currentElement;
|
||||
}
|
||||
|
||||
// Shift elements that start after the removed element
|
||||
if (currentElement.startTime >= elementEndTime) {
|
||||
return {
|
||||
...currentElement,
|
||||
@@ -644,10 +615,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return currentElement;
|
||||
});
|
||||
|
||||
// Check for overlaps and resolve them if necessary
|
||||
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||
if (hasOverlaps) {
|
||||
// Resolve overlaps by adjusting element positions
|
||||
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||
return { ...currentTrack, elements: resolvedElements };
|
||||
}
|
||||
@@ -670,7 +639,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
if (!elementToMove || !toTrack) return;
|
||||
|
||||
// Validate element type compatibility with target track
|
||||
const validation = validateElementTrackCompatibility(
|
||||
elementToMove,
|
||||
toTrack
|
||||
@@ -776,7 +744,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
const { _tracks, rippleEditingEnabled } = get();
|
||||
|
||||
if (!rippleEditingEnabled) {
|
||||
// If ripple editing is disabled, use regular update
|
||||
get().updateElementStartTime(trackId, elementId, newStartTime);
|
||||
return;
|
||||
}
|
||||
@@ -796,9 +763,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
newStartTime + (element.duration - element.trimStart - element.trimEnd);
|
||||
const timeDelta = newStartTime - oldStartTime;
|
||||
|
||||
// Update tracks based on multi-track ripple setting
|
||||
const updatedTracks = _tracks.map((currentTrack) => {
|
||||
// Only apply ripple effects to the same track unless multi-track ripple is enabled
|
||||
const shouldApplyRipple = currentTrack.id === trackId;
|
||||
|
||||
const updatedElements = currentTrack.elements.map((currentElement) => {
|
||||
@@ -806,12 +771,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return { ...currentElement, startTime: Math.max(0, newStartTime) };
|
||||
}
|
||||
|
||||
// Only apply ripple effects if we should process this track
|
||||
if (!shouldApplyRipple) {
|
||||
return currentElement;
|
||||
}
|
||||
|
||||
// For ripple editing, we need to move elements that come after the moved element
|
||||
const currentElementStart = currentElement.startTime;
|
||||
const currentElementEnd =
|
||||
currentElement.startTime +
|
||||
@@ -819,19 +782,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
currentElement.trimStart -
|
||||
currentElement.trimEnd);
|
||||
|
||||
// If moving element to the right (positive delta)
|
||||
if (timeDelta > 0) {
|
||||
// Move elements that start after the original position of the moved element
|
||||
if (currentElementStart >= oldEndTime) {
|
||||
return {
|
||||
...currentElement,
|
||||
startTime: currentElementStart + timeDelta,
|
||||
};
|
||||
}
|
||||
}
|
||||
// If moving element to the left (negative delta)
|
||||
else if (timeDelta < 0) {
|
||||
// Move elements that start after the new position of the moved element
|
||||
} else if (timeDelta < 0) {
|
||||
if (
|
||||
currentElementStart >= newEndTime &&
|
||||
currentElementStart >= oldStartTime
|
||||
@@ -846,10 +804,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return currentElement;
|
||||
});
|
||||
|
||||
// Check for overlaps and resolve them if necessary
|
||||
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||
if (hasOverlaps) {
|
||||
// Resolve overlaps by adjusting element positions
|
||||
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||
return { ...currentTrack, elements: resolvedElements };
|
||||
}
|
||||
@@ -887,6 +843,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
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(
|
||||
@@ -1050,12 +1024,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
// Find existing audio track or prepare to create one
|
||||
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
|
||||
const audioElementId = generateUUID();
|
||||
|
||||
if (existingAudioTrack) {
|
||||
// Add audio element to existing audio track
|
||||
updateTracksAndSave(
|
||||
get()._tracks.map((track) =>
|
||||
track.id === existingAudioTrack.id
|
||||
@@ -1074,7 +1046,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Create new audio track with the audio element in a single atomic update
|
||||
const newAudioTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
name: "Audio Track",
|
||||
@@ -1124,7 +1095,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return { success: false, error: "No active project found" };
|
||||
}
|
||||
|
||||
// Import required media processing functions
|
||||
const {
|
||||
getFileType,
|
||||
getImageDimensions,
|
||||
@@ -1141,7 +1111,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Process the new media file
|
||||
const mediaData: any = {
|
||||
name: newFile.name,
|
||||
type: fileType,
|
||||
@@ -1150,7 +1119,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
};
|
||||
|
||||
try {
|
||||
// Get media-specific metadata
|
||||
if (fileType === "image") {
|
||||
const { width, height } = await getImageDimensions(newFile);
|
||||
mediaData.width = width;
|
||||
@@ -1175,7 +1143,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Add new media item to store
|
||||
try {
|
||||
await mediaStore.addMediaItem(
|
||||
projectStore.activeProject.id,
|
||||
@@ -1188,7 +1155,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Find the newly created media item
|
||||
const newMediaItem = mediaStore.mediaItems.find(
|
||||
(item) => item.file === newFile
|
||||
);
|
||||
@@ -1202,7 +1168,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
// Update the timeline element to reference the new media
|
||||
updateTracksAndSave(
|
||||
_tracks.map((track) =>
|
||||
track.id === trackId
|
||||
@@ -1214,7 +1179,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
...c,
|
||||
mediaId: newMediaItem.id,
|
||||
name: newMediaItem.name,
|
||||
// Update duration if the new media has a different duration
|
||||
duration: newMediaItem.duration || c.duration,
|
||||
}
|
||||
: c
|
||||
@@ -1355,22 +1319,18 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
});
|
||||
},
|
||||
|
||||
// Persistence methods
|
||||
loadProjectTimeline: async (projectId) => {
|
||||
try {
|
||||
const tracks = await storageService.loadTimeline(projectId);
|
||||
if (tracks) {
|
||||
updateTracks(tracks);
|
||||
} else {
|
||||
// No timeline saved yet, initialize with default
|
||||
const defaultTracks = ensureMainTrack([]);
|
||||
updateTracks(defaultTracks);
|
||||
}
|
||||
// Clear history when loading a project
|
||||
set({ history: [], redoStack: [] });
|
||||
} catch (error) {
|
||||
console.error("Failed to load timeline:", error);
|
||||
// Initialize with default on error
|
||||
const defaultTracks = ensureMainTrack([]);
|
||||
updateTracks(defaultTracks);
|
||||
set({ history: [], redoStack: [] });
|
||||
@@ -1429,8 +1389,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
},
|
||||
|
||||
findOrCreateTrack: (trackType) => {
|
||||
// Always create new text track to allow multiple text elements
|
||||
// Insert text tracks at the top
|
||||
if (trackType === "text") {
|
||||
return get().insertTrackAt(trackType, 0);
|
||||
}
|
||||
@@ -1448,10 +1406,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
const duration =
|
||||
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
|
||||
|
||||
// Get all tracks of the right type
|
||||
const tracks = get()._tracks.filter((t) => t.type === trackType);
|
||||
|
||||
// Try to find a track with no overlap
|
||||
let targetTrackId = null;
|
||||
for (const track of tracks) {
|
||||
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
|
||||
@@ -1460,7 +1416,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
}
|
||||
}
|
||||
|
||||
// If no free track found, create a new one
|
||||
if (!targetTrackId) {
|
||||
targetTrackId = get().addTrack(trackType);
|
||||
}
|
||||
@@ -1473,12 +1428,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
muted: false,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
addTextAtTime: (item, currentTime = 0) => {
|
||||
const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
|
||||
const targetTrackId = get().insertTrackAt("text", 0);
|
||||
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
type: "text",
|
||||
@@ -1516,12 +1472,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
muted: false,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
addTextToNewTrack: (item) => {
|
||||
const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
|
||||
const targetTrackId = get().insertTrackAt("text", 0);
|
||||
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
type: "text",
|
||||
|
||||
Reference in New Issue
Block a user