mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
chore: normalize line endings
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,49 @@
|
||||
import type { Command } from "@/lib/commands";
|
||||
|
||||
export class CommandManager {
|
||||
private history: Command[] = [];
|
||||
private redoStack: Command[] = [];
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
command.execute();
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
return command;
|
||||
}
|
||||
|
||||
push({ command }: { command: Command }): void {
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.history.length === 0) return;
|
||||
const command = this.history.pop();
|
||||
command?.undo();
|
||||
if (command) {
|
||||
this.redoStack.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.redoStack.length === 0) return;
|
||||
const command = this.redoStack.pop();
|
||||
command?.redo();
|
||||
if (command) {
|
||||
this.history.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.history.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
import type { Command } from "@/lib/commands";
|
||||
|
||||
export class CommandManager {
|
||||
private history: Command[] = [];
|
||||
private redoStack: Command[] = [];
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
command.execute();
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
return command;
|
||||
}
|
||||
|
||||
push({ command }: { command: Command }): void {
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.history.length === 0) return;
|
||||
const command = this.history.pop();
|
||||
command?.undo();
|
||||
if (command) {
|
||||
this.redoStack.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.redoStack.length === 0) return;
|
||||
const command = this.redoStack.pop();
|
||||
command?.redo();
|
||||
if (command) {
|
||||
this.history.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.history.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export class MediaManager {
|
||||
|
||||
try {
|
||||
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
|
||||
this.editor.project.ratchetFpsForImportedMedia({
|
||||
importedAssets: [newAsset],
|
||||
});
|
||||
return newAsset;
|
||||
} catch (error) {
|
||||
console.error("Failed to save media asset:", error);
|
||||
|
||||
@@ -13,19 +13,19 @@ export class PlaybackManager {
|
||||
|
||||
constructor(private editor: EditorCore) {
|
||||
this.editor.timeline.subscribe(() => {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
if (this.currentTime > duration && duration > 0) {
|
||||
this.currentTime = duration;
|
||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
||||
if (this.currentTime > maxTime && maxTime > 0) {
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
play(): void {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
||||
|
||||
if (duration > 0) {
|
||||
if (this.currentTime >= duration) {
|
||||
if (maxTime > 0) {
|
||||
if (this.currentTime >= maxTime) {
|
||||
this.seek({ time: 0 });
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,8 @@ export class PlaybackManager {
|
||||
}
|
||||
|
||||
seek({ time }: { time: number }): void {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
this.currentTime = Math.max(0, Math.min(duration, time));
|
||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
||||
this.currentTime = Math.max(0, Math.min(maxTime, time));
|
||||
this.notify();
|
||||
|
||||
window.dispatchEvent(
|
||||
@@ -154,16 +154,16 @@ export class PlaybackManager {
|
||||
this.lastUpdate = now;
|
||||
|
||||
const newTime = this.currentTime + delta;
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
||||
|
||||
if (duration > 0 && newTime >= duration) {
|
||||
if (maxTime > 0 && newTime >= maxTime) {
|
||||
this.pause();
|
||||
this.currentTime = duration;
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: duration },
|
||||
detail: { time: maxTime },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
import { loadFonts } from "@/lib/fonts/google-fonts";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
|
||||
import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
|
||||
export interface MigrationState {
|
||||
isMigrating: boolean;
|
||||
@@ -492,6 +494,23 @@ export class ProjectManager {
|
||||
command.execute();
|
||||
}
|
||||
|
||||
ratchetFpsForImportedMedia({
|
||||
importedAssets,
|
||||
}: {
|
||||
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
|
||||
}): number | null {
|
||||
if (!this.active) return null;
|
||||
|
||||
const nextFps = getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: this.active.settings.fps,
|
||||
importedAssets,
|
||||
});
|
||||
if (nextFps === null) return null;
|
||||
|
||||
new UpdateProjectSettingsCommand({ fps: nextFps }).execute();
|
||||
return nextFps;
|
||||
}
|
||||
|
||||
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
|
||||
if (!this.active) return;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
||||
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { downloadBlob } from "@/utils/browser";
|
||||
|
||||
type SnapshotResult =
|
||||
@@ -81,9 +81,7 @@ export class RendererManager {
|
||||
}
|
||||
|
||||
const { canvasSize, fps } = activeProject.settings;
|
||||
const currentTime = this.editor.playback.getCurrentTime();
|
||||
const lastFrameTime = getLastFrameTime({ duration, fps });
|
||||
const renderTime = Math.min(currentTime, lastFrameTime);
|
||||
const renderTime = this.editor.playback.getCurrentTime();
|
||||
|
||||
const renderer = new CanvasRenderer({
|
||||
width: canvasSize.width,
|
||||
|
||||
@@ -1,107 +1,107 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
|
||||
type SaveManagerOptions = {
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
export class SaveManager {
|
||||
private debounceMs: number;
|
||||
private isPaused = false;
|
||||
private isSaving = false;
|
||||
private hasPendingSave = false;
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeHandlers: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
private editor: EditorCore,
|
||||
{ debounceMs = 800 }: SaveManagerOptions = {},
|
||||
) {
|
||||
this.debounceMs = debounceMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.unsubscribeHandlers.length > 0) return;
|
||||
|
||||
this.unsubscribeHandlers = [
|
||||
this.editor.scenes.subscribe(() => {
|
||||
this.markDirty();
|
||||
}),
|
||||
this.editor.timeline.subscribe(() => {
|
||||
this.markDirty();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const unsubscribe of this.unsubscribeHandlers) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.unsubscribeHandlers = [];
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.isPaused = false;
|
||||
if (this.hasPendingSave) {
|
||||
this.queueSave();
|
||||
}
|
||||
}
|
||||
|
||||
markDirty({ force = false }: { force?: boolean } = {}): void {
|
||||
if (this.isPaused && !force) return;
|
||||
this.hasPendingSave = true;
|
||||
this.queueSave();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.hasPendingSave = true;
|
||||
await this.saveNow();
|
||||
}
|
||||
|
||||
getIsDirty(): boolean {
|
||||
return this.hasPendingSave || this.isSaving;
|
||||
}
|
||||
|
||||
private queueSave(): void {
|
||||
if (this.isSaving) return;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
this.saveTimer = setTimeout(() => {
|
||||
void this.saveNow();
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
private async saveNow(): Promise<void> {
|
||||
if (this.isSaving) return;
|
||||
if (!this.hasPendingSave) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
if (this.editor.project.getIsLoading()) return;
|
||||
if (this.editor.project.getMigrationState().isMigrating) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.hasPendingSave = false;
|
||||
this.clearTimer();
|
||||
|
||||
try {
|
||||
await this.editor.project.saveCurrentProject();
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
if (this.hasPendingSave) {
|
||||
this.queueSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (!this.saveTimer) return;
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
}
|
||||
import type { EditorCore } from "@/core";
|
||||
|
||||
type SaveManagerOptions = {
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
export class SaveManager {
|
||||
private debounceMs: number;
|
||||
private isPaused = false;
|
||||
private isSaving = false;
|
||||
private hasPendingSave = false;
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeHandlers: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
private editor: EditorCore,
|
||||
{ debounceMs = 800 }: SaveManagerOptions = {},
|
||||
) {
|
||||
this.debounceMs = debounceMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.unsubscribeHandlers.length > 0) return;
|
||||
|
||||
this.unsubscribeHandlers = [
|
||||
this.editor.scenes.subscribe(() => {
|
||||
this.markDirty();
|
||||
}),
|
||||
this.editor.timeline.subscribe(() => {
|
||||
this.markDirty();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const unsubscribe of this.unsubscribeHandlers) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.unsubscribeHandlers = [];
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.isPaused = false;
|
||||
if (this.hasPendingSave) {
|
||||
this.queueSave();
|
||||
}
|
||||
}
|
||||
|
||||
markDirty({ force = false }: { force?: boolean } = {}): void {
|
||||
if (this.isPaused && !force) return;
|
||||
this.hasPendingSave = true;
|
||||
this.queueSave();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.hasPendingSave = true;
|
||||
await this.saveNow();
|
||||
}
|
||||
|
||||
getIsDirty(): boolean {
|
||||
return this.hasPendingSave || this.isSaving;
|
||||
}
|
||||
|
||||
private queueSave(): void {
|
||||
if (this.isSaving) return;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
this.saveTimer = setTimeout(() => {
|
||||
void this.saveNow();
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
private async saveNow(): Promise<void> {
|
||||
if (this.isSaving) return;
|
||||
if (!this.hasPendingSave) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
if (this.editor.project.getIsLoading()) return;
|
||||
if (this.editor.project.getMigrationState().isMigrating) return;
|
||||
|
||||
this.isSaving = true;
|
||||
this.hasPendingSave = false;
|
||||
this.clearTimer();
|
||||
|
||||
try {
|
||||
await this.editor.project.saveCurrentProject();
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
if (this.hasPendingSave) {
|
||||
this.queueSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (!this.saveTimer) return;
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,363 +1,363 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, TScene } from "@/lib/timeline";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import {
|
||||
getMainScene,
|
||||
ensureMainScene,
|
||||
canDeleteScene,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scenes";
|
||||
import {
|
||||
getBookmarkAtTime,
|
||||
getFrameTime,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmarks";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
import {
|
||||
CreateSceneCommand,
|
||||
DeleteSceneCommand,
|
||||
MoveBookmarkCommand,
|
||||
RemoveBookmarkCommand,
|
||||
RenameSceneCommand,
|
||||
ToggleBookmarkCommand,
|
||||
UpdateBookmarkCommand,
|
||||
} from "@/lib/commands/scene";
|
||||
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
private list: TScene[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async createScene({
|
||||
name,
|
||||
isMain = false,
|
||||
}: {
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}): Promise<string> {
|
||||
if (!this.editor.project.getActive()) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new CreateSceneCommand(name, isMain);
|
||||
this.editor.command.execute({ command });
|
||||
return command.getSceneId();
|
||||
}
|
||||
|
||||
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const sceneToDelete = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
|
||||
if (!canDelete) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
if (!this.editor.project.getActive()) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new DeleteSceneCommand(sceneId);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async renameScene({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
if (!this.editor.project.getActive()) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new RenameSceneCommand(sceneId, name);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const targetScene = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
|
||||
this.active = targetScene;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
const command = new ToggleBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isBookmarked({ time }: { time: number }): boolean {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !this.active || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
const command = new RemoveBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async updateBookmark({
|
||||
time,
|
||||
updates,
|
||||
}: {
|
||||
time: number;
|
||||
updates: Partial<{ note: string; color: string; duration: number }>;
|
||||
}): Promise<void> {
|
||||
const command = new UpdateBookmarkCommand(time, updates);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async moveBookmark({
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
}): Promise<void> {
|
||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
getBookmarkAtTime({ time }: { time: number }) {
|
||||
const activeScene = this.active;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !activeProject) return null;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
|
||||
return getBookmarkAtTime({
|
||||
bookmarks: activeScene.bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
}
|
||||
|
||||
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
|
||||
try {
|
||||
const result = await storageService.loadProject({ id: projectId });
|
||||
if (result?.project.scenes) {
|
||||
const { scenes: ensuredScenes, hasAddedMainTrack } =
|
||||
this.ensureScenesHaveMainTrack({
|
||||
scenes: result.project.scenes ?? [],
|
||||
});
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: ensuredScenes,
|
||||
currentSceneId: result.project.currentSceneId,
|
||||
});
|
||||
|
||||
this.list = ensuredScenes;
|
||||
this.active = currentScene;
|
||||
this.notify();
|
||||
|
||||
if (hasAddedMainTrack) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.editor.save.markDirty({ force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
initializeScenes({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}): void {
|
||||
const ensuredScenes = ensureMainScene({ scenes });
|
||||
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
|
||||
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
|
||||
const currentScene = currentSceneId
|
||||
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
|
||||
|
||||
this.list = scenesWithMainTracks;
|
||||
this.active = currentScene || fallbackScene;
|
||||
this.notify();
|
||||
|
||||
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
||||
if (hasAddedMainScene || hasAddedMainTrack) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: scenesWithMainTracks,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.editor.save.markDirty({ force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearScenes(): void {
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getActiveScene(): TScene {
|
||||
if (!this.active) {
|
||||
throw new Error("No active scene.");
|
||||
}
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getScenes(): TScene[] {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
setScenes({
|
||||
scenes,
|
||||
activeSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
activeSceneId?: string;
|
||||
}): void {
|
||||
this.list = scenes;
|
||||
const nextActiveSceneId = activeSceneId ?? this.active?.id ?? null;
|
||||
this.active = nextActiveSceneId
|
||||
? (scenes.find((scene) => scene.id === nextActiveSceneId) ?? null)
|
||||
: null;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
|
||||
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedScene: TScene = {
|
||||
...this.active,
|
||||
tracks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.list = this.list.map((s) =>
|
||||
s.id === this.active?.id ? updatedScene : s,
|
||||
);
|
||||
this.active = updatedScene;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: this.list,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
|
||||
scenes: TScene[];
|
||||
hasAddedMainTrack: boolean;
|
||||
} {
|
||||
let hasAddedMainTrack = false;
|
||||
const ensuredScenes: TScene[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
const existingTracks = scene.tracks ?? [];
|
||||
const updatedTracks = ensureMainTrack({ tracks: existingTracks });
|
||||
if (updatedTracks !== existingTracks) {
|
||||
hasAddedMainTrack = true;
|
||||
ensuredScenes.push({
|
||||
...scene,
|
||||
tracks: updatedTracks,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
ensuredScenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
return { scenes: ensuredScenes, hasAddedMainTrack };
|
||||
}
|
||||
}
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, TScene } from "@/lib/timeline";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import {
|
||||
getMainScene,
|
||||
ensureMainScene,
|
||||
canDeleteScene,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scenes";
|
||||
import {
|
||||
getBookmarkAtTime,
|
||||
getFrameTime,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmarks";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
import {
|
||||
CreateSceneCommand,
|
||||
DeleteSceneCommand,
|
||||
MoveBookmarkCommand,
|
||||
RemoveBookmarkCommand,
|
||||
RenameSceneCommand,
|
||||
ToggleBookmarkCommand,
|
||||
UpdateBookmarkCommand,
|
||||
} from "@/lib/commands/scene";
|
||||
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
private list: TScene[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async createScene({
|
||||
name,
|
||||
isMain = false,
|
||||
}: {
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}): Promise<string> {
|
||||
if (!this.editor.project.getActive()) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new CreateSceneCommand(name, isMain);
|
||||
this.editor.command.execute({ command });
|
||||
return command.getSceneId();
|
||||
}
|
||||
|
||||
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const sceneToDelete = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
|
||||
if (!canDelete) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
if (!this.editor.project.getActive()) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new DeleteSceneCommand(sceneId);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async renameScene({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
if (!this.editor.project.getActive()) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new RenameSceneCommand(sceneId, name);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const targetScene = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
|
||||
this.active = targetScene;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
const command = new ToggleBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isBookmarked({ time }: { time: number }): boolean {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !this.active || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
const command = new RemoveBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async updateBookmark({
|
||||
time,
|
||||
updates,
|
||||
}: {
|
||||
time: number;
|
||||
updates: Partial<{ note: string; color: string; duration: number }>;
|
||||
}): Promise<void> {
|
||||
const command = new UpdateBookmarkCommand(time, updates);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
async moveBookmark({
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
}): Promise<void> {
|
||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
getBookmarkAtTime({ time }: { time: number }) {
|
||||
const activeScene = this.active;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !activeProject) return null;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
|
||||
return getBookmarkAtTime({
|
||||
bookmarks: activeScene.bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
}
|
||||
|
||||
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
|
||||
try {
|
||||
const result = await storageService.loadProject({ id: projectId });
|
||||
if (result?.project.scenes) {
|
||||
const { scenes: ensuredScenes, hasAddedMainTrack } =
|
||||
this.ensureScenesHaveMainTrack({
|
||||
scenes: result.project.scenes ?? [],
|
||||
});
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: ensuredScenes,
|
||||
currentSceneId: result.project.currentSceneId,
|
||||
});
|
||||
|
||||
this.list = ensuredScenes;
|
||||
this.active = currentScene;
|
||||
this.notify();
|
||||
|
||||
if (hasAddedMainTrack) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.editor.save.markDirty({ force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
initializeScenes({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}): void {
|
||||
const ensuredScenes = ensureMainScene({ scenes });
|
||||
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
|
||||
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
|
||||
const currentScene = currentSceneId
|
||||
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
|
||||
|
||||
this.list = scenesWithMainTracks;
|
||||
this.active = currentScene || fallbackScene;
|
||||
this.notify();
|
||||
|
||||
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
||||
if (hasAddedMainScene || hasAddedMainTrack) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: scenesWithMainTracks,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.editor.save.markDirty({ force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearScenes(): void {
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getActiveScene(): TScene {
|
||||
if (!this.active) {
|
||||
throw new Error("No active scene.");
|
||||
}
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getScenes(): TScene[] {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
setScenes({
|
||||
scenes,
|
||||
activeSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
activeSceneId?: string;
|
||||
}): void {
|
||||
this.list = scenes;
|
||||
const nextActiveSceneId = activeSceneId ?? this.active?.id ?? null;
|
||||
this.active = nextActiveSceneId
|
||||
? (scenes.find((scene) => scene.id === nextActiveSceneId) ?? null)
|
||||
: null;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
|
||||
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedScene: TScene = {
|
||||
...this.active,
|
||||
tracks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.list = this.list.map((s) =>
|
||||
s.id === this.active?.id ? updatedScene : s,
|
||||
);
|
||||
this.active = updatedScene;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: this.list,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
|
||||
scenes: TScene[];
|
||||
hasAddedMainTrack: boolean;
|
||||
} {
|
||||
let hasAddedMainTrack = false;
|
||||
const ensuredScenes: TScene[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
const existingTracks = scene.tracks ?? [];
|
||||
const updatedTracks = ensureMainTrack({ tracks: existingTracks });
|
||||
if (updatedTracks !== existingTracks) {
|
||||
hasAddedMainTrack = true;
|
||||
ensuredScenes.push({
|
||||
...scene,
|
||||
tracks: updatedTracks,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
ensuredScenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
return { scenes: ensuredScenes, hasAddedMainTrack };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
void editor;
|
||||
}
|
||||
|
||||
getSelectedElements(): ElementRef[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
||||
return this.selectedKeyframes;
|
||||
}
|
||||
|
||||
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
||||
return this.keyframeSelectionAnchor;
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSelectedKeyframes({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef | null;
|
||||
}): void {
|
||||
this.selectedKeyframes = keyframes;
|
||||
if (anchorKeyframe !== undefined) {
|
||||
this.keyframeSelectionAnchor = anchorKeyframe;
|
||||
} else if (keyframes.length === 0) {
|
||||
this.keyframeSelectionAnchor = null;
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedElements = [];
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearKeyframeSelection(): void {
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export class SelectionManager {
|
||||
private selectedElements: ElementRef[] = [];
|
||||
private selectedKeyframes: SelectedKeyframeRef[] = [];
|
||||
private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(editor: EditorCore) {
|
||||
void editor;
|
||||
}
|
||||
|
||||
getSelectedElements(): ElementRef[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getSelectedKeyframes(): SelectedKeyframeRef[] {
|
||||
return this.selectedKeyframes;
|
||||
}
|
||||
|
||||
getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
|
||||
return this.keyframeSelectionAnchor;
|
||||
}
|
||||
|
||||
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||
this.selectedElements = elements;
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSelectedKeyframes({
|
||||
keyframes,
|
||||
anchorKeyframe,
|
||||
}: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
anchorKeyframe?: SelectedKeyframeRef | null;
|
||||
}): void {
|
||||
this.selectedKeyframes = keyframes;
|
||||
if (anchorKeyframe !== undefined) {
|
||||
this.keyframeSelectionAnchor = anchorKeyframe;
|
||||
} else if (keyframes.length === 0) {
|
||||
this.keyframeSelectionAnchor = null;
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedElements = [];
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearKeyframeSelection(): void {
|
||||
this.selectedKeyframes = [];
|
||||
this.keyframeSelectionAnchor = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
@@ -224,6 +225,13 @@ export class TimelineManager {
|
||||
return calculateTotalDuration({ tracks: this.getTracks() });
|
||||
}
|
||||
|
||||
getLastSeekableTime(): number {
|
||||
const duration = this.getTotalDuration();
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
if (!fps || duration <= 0) return duration;
|
||||
return getLastFrameTime({ duration, fps });
|
||||
}
|
||||
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
return this.getTracks().find((track) => track.id === trackId) ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user