2026-01-31 00:20:04 +01:00
|
|
|
import type { EditorCore } from "@/core";
|
|
|
|
|
import type {
|
|
|
|
|
TProject,
|
|
|
|
|
TProjectMetadata,
|
|
|
|
|
TProjectSortKey,
|
|
|
|
|
TProjectSortOption,
|
|
|
|
|
TProjectSettings,
|
|
|
|
|
TTimelineViewState,
|
|
|
|
|
} from "@/types/project";
|
|
|
|
|
import type { ExportOptions, ExportResult } from "@/types/export";
|
2026-01-31 22:19:11 +01:00
|
|
|
import { storageService } from "@/services/storage/service";
|
2026-01-31 00:20:04 +01:00
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { generateUUID } from "@/utils/id";
|
|
|
|
|
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
|
|
|
|
import {
|
|
|
|
|
DEFAULT_FPS,
|
|
|
|
|
DEFAULT_CANVAS_SIZE,
|
|
|
|
|
DEFAULT_COLOR,
|
|
|
|
|
} from "@/constants/project-constants";
|
|
|
|
|
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
|
|
|
|
|
import { buildScene } from "@/services/renderer/scene-builder";
|
|
|
|
|
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
|
|
|
|
import {
|
2026-02-05 12:53:56 +01:00
|
|
|
CURRENT_PROJECT_VERSION,
|
2026-01-31 00:20:04 +01:00
|
|
|
migrations,
|
|
|
|
|
runStorageMigrations,
|
|
|
|
|
} from "@/services/storage/migrations";
|
|
|
|
|
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
|
|
|
|
|
|
|
|
|
|
export interface MigrationState {
|
|
|
|
|
isMigrating: boolean;
|
|
|
|
|
fromVersion: number | null;
|
|
|
|
|
toVersion: number | null;
|
|
|
|
|
projectName: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ProjectManager {
|
|
|
|
|
private active: TProject | null = null;
|
|
|
|
|
private savedProjects: TProjectMetadata[] = [];
|
|
|
|
|
private isLoading = true;
|
|
|
|
|
private isInitialized = false;
|
|
|
|
|
private invalidProjectIds = new Set<string>();
|
|
|
|
|
private storageMigrationPromise: Promise<void> | null = null;
|
|
|
|
|
private listeners = new Set<() => void>();
|
|
|
|
|
private migrationState: MigrationState = {
|
|
|
|
|
isMigrating: false,
|
|
|
|
|
fromVersion: null,
|
|
|
|
|
toVersion: null,
|
|
|
|
|
projectName: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
constructor(private editor: EditorCore) {}
|
|
|
|
|
|
|
|
|
|
private async ensureStorageMigrations(): Promise<void> {
|
|
|
|
|
if (this.storageMigrationPromise) {
|
|
|
|
|
await this.storageMigrationPromise;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.storageMigrationPromise = (async () => {
|
2026-02-05 12:53:56 +01:00
|
|
|
await runStorageMigrations({ migrations });
|
2026-01-31 00:20:04 +01:00
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
await this.storageMigrationPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createNewProject({ name }: { name: string }): Promise<string> {
|
|
|
|
|
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
|
|
|
|
|
const newProject: TProject = {
|
|
|
|
|
metadata: {
|
|
|
|
|
id: generateUUID(),
|
|
|
|
|
name,
|
|
|
|
|
duration: getProjectDurationFromScenes({ scenes: [mainScene] }),
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
scenes: [mainScene],
|
|
|
|
|
currentSceneId: mainScene.id,
|
|
|
|
|
settings: {
|
|
|
|
|
fps: DEFAULT_FPS,
|
|
|
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
|
|
|
originalCanvasSize: null,
|
|
|
|
|
background: {
|
|
|
|
|
type: "color",
|
|
|
|
|
color: DEFAULT_COLOR,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-05 12:53:56 +01:00
|
|
|
version: CURRENT_PROJECT_VERSION,
|
2026-01-31 00:20:04 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.active = newProject;
|
|
|
|
|
this.notify();
|
|
|
|
|
|
|
|
|
|
this.editor.media.clearAllAssets();
|
|
|
|
|
this.editor.scenes.initializeScenes({
|
|
|
|
|
scenes: newProject.scenes,
|
|
|
|
|
currentSceneId: newProject.currentSceneId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await storageService.saveProject({ project: newProject });
|
|
|
|
|
this.updateMetadata(newProject);
|
|
|
|
|
|
|
|
|
|
return newProject.metadata.id;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error("Failed to save new project");
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadProject({ id }: { id: string }): Promise<void> {
|
|
|
|
|
if (!this.isInitialized) {
|
|
|
|
|
this.isLoading = true;
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.editor.save.pause();
|
|
|
|
|
await this.ensureStorageMigrations();
|
|
|
|
|
this.editor.media.clearAllAssets();
|
|
|
|
|
this.editor.scenes.clearScenes();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await storageService.loadProject({ id });
|
|
|
|
|
if (!result) {
|
|
|
|
|
throw new Error(`Project with id ${id} not found`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = result.project;
|
|
|
|
|
|
|
|
|
|
this.active = project;
|
|
|
|
|
this.notify();
|
|
|
|
|
|
|
|
|
|
if (project.scenes && project.scenes.length > 0) {
|
|
|
|
|
this.editor.scenes.initializeScenes({
|
|
|
|
|
scenes: project.scenes,
|
|
|
|
|
currentSceneId: project.currentSceneId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.editor.media.loadProjectMedia({ projectId: id });
|
|
|
|
|
|
|
|
|
|
if (!project.metadata.thumbnail) {
|
|
|
|
|
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
|
|
|
|
|
if (didUpdateThumbnail) {
|
|
|
|
|
await this.saveCurrentProject();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to load project:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
this.notify();
|
|
|
|
|
this.editor.save.resume();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveCurrentProject(): Promise<void> {
|
|
|
|
|
if (!this.active) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const scenes = this.editor.scenes.getScenes();
|
|
|
|
|
const updatedProject = {
|
|
|
|
|
...this.active,
|
|
|
|
|
scenes,
|
|
|
|
|
metadata: {
|
|
|
|
|
...this.active.metadata,
|
|
|
|
|
duration: getProjectDurationFromScenes({ scenes }),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await storageService.saveProject({ project: updatedProject });
|
|
|
|
|
this.active = updatedProject;
|
|
|
|
|
this.updateMetadata(updatedProject);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to save project:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
|
|
|
|
|
return this.editor.renderer.exportProject({ options });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadAllProjects(): Promise<void> {
|
|
|
|
|
if (!this.isInitialized) {
|
|
|
|
|
this.isLoading = true;
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.ensureStorageMigrations();
|
|
|
|
|
try {
|
|
|
|
|
const metadata = await storageService.loadAllProjectsMetadata();
|
|
|
|
|
this.savedProjects = metadata;
|
|
|
|
|
this.notify();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to load projects:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
this.isInitialized = true;
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteProjects({ ids }: { ids: string[] }): Promise<void> {
|
|
|
|
|
const uniqueIds = Array.from(new Set(ids));
|
|
|
|
|
if (uniqueIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(
|
|
|
|
|
uniqueIds.map((id) =>
|
|
|
|
|
Promise.all([
|
|
|
|
|
storageService.deleteProjectMedia({ projectId: id }),
|
|
|
|
|
storageService.deleteProject({ id }),
|
|
|
|
|
]),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const idSet = new Set(uniqueIds);
|
|
|
|
|
this.savedProjects = this.savedProjects.filter(
|
|
|
|
|
(project) => !idSet.has(project.id),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const shouldClearActive =
|
|
|
|
|
this.active && idSet.has(this.active.metadata.id);
|
|
|
|
|
|
|
|
|
|
if (shouldClearActive) {
|
|
|
|
|
this.active = null;
|
|
|
|
|
this.editor.media.clearAllAssets();
|
|
|
|
|
this.editor.scenes.clearScenes();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.notify();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to delete projects:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeProject(): void {
|
|
|
|
|
this.active = null;
|
|
|
|
|
this.notify();
|
|
|
|
|
|
|
|
|
|
this.editor.media.clearAllAssets();
|
|
|
|
|
this.editor.scenes.clearScenes();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async renameProject({
|
|
|
|
|
id,
|
|
|
|
|
name,
|
|
|
|
|
}: {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await storageService.loadProject({ id });
|
|
|
|
|
if (!result) {
|
|
|
|
|
toast.error("Project not found", {
|
|
|
|
|
description: "Please try again",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updatedProject: TProject = {
|
|
|
|
|
...result.project,
|
|
|
|
|
metadata: {
|
|
|
|
|
...result.project.metadata,
|
|
|
|
|
name,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await storageService.saveProject({ project: updatedProject });
|
|
|
|
|
|
|
|
|
|
if (this.active?.metadata.id === id) {
|
|
|
|
|
this.active = updatedProject;
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.updateMetadata(updatedProject);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to rename project:", error);
|
|
|
|
|
toast.error("Failed to rename project", {
|
|
|
|
|
description:
|
|
|
|
|
error instanceof Error ? error.message : "Please try again",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async duplicateProjects({ ids }: { ids: string[] }): Promise<string[]> {
|
|
|
|
|
const uniqueIds = Array.from(new Set(ids));
|
|
|
|
|
if (uniqueIds.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const getDuplicateBaseName = ({ name }: { name: string }) => {
|
|
|
|
|
const match = name.match(/^\((\d+)\)\s+(.+)$/);
|
|
|
|
|
const number = match ? Number.parseInt(match[1], 10) : null;
|
|
|
|
|
const baseName = match ? match[2] : name;
|
|
|
|
|
return { baseName, number };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadResults = await Promise.all(
|
|
|
|
|
uniqueIds.map(async (projectId) => {
|
|
|
|
|
const result = await storageService.loadProject({ id: projectId });
|
|
|
|
|
return { projectId, project: result?.project ?? null };
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const missingProjectIds = loadResults
|
|
|
|
|
.filter((result) => !result.project)
|
|
|
|
|
.map((result) => result.projectId);
|
|
|
|
|
|
|
|
|
|
if (missingProjectIds.length > 0) {
|
|
|
|
|
toast.error(
|
|
|
|
|
missingProjectIds.length === 1
|
|
|
|
|
? "Project not found"
|
|
|
|
|
: "Projects not found",
|
|
|
|
|
{
|
|
|
|
|
description:
|
|
|
|
|
missingProjectIds.length === 1
|
|
|
|
|
? "Please try again"
|
|
|
|
|
: "Some projects could not be found",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
throw new Error(`Projects not found: ${missingProjectIds.join(", ")}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projectsToDuplicate = loadResults.flatMap((result) =>
|
|
|
|
|
result.project ? [result.project] : [],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const maxNumberByBaseName = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
for (const project of this.savedProjects) {
|
|
|
|
|
const { baseName, number } = getDuplicateBaseName({
|
|
|
|
|
name: project.name,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (number === null) continue;
|
|
|
|
|
|
|
|
|
|
const currentMax = maxNumberByBaseName.get(baseName);
|
|
|
|
|
if (currentMax === undefined || number > currentMax) {
|
|
|
|
|
maxNumberByBaseName.set(baseName, number);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextNumberByBaseName = new Map<string, number>();
|
|
|
|
|
for (const [baseName, maxNumber] of maxNumberByBaseName) {
|
|
|
|
|
nextNumberByBaseName.set(baseName, maxNumber + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const duplicationPlans = projectsToDuplicate.map((project) => {
|
|
|
|
|
const { baseName } = getDuplicateBaseName({
|
|
|
|
|
name: project.metadata.name,
|
|
|
|
|
});
|
|
|
|
|
const nextNumber = nextNumberByBaseName.get(baseName) ?? 1;
|
|
|
|
|
nextNumberByBaseName.set(baseName, nextNumber + 1);
|
|
|
|
|
|
|
|
|
|
const newProjectId = generateUUID();
|
|
|
|
|
const newProject: TProject = {
|
|
|
|
|
...project,
|
|
|
|
|
metadata: {
|
|
|
|
|
...project.metadata,
|
|
|
|
|
id: newProjectId,
|
|
|
|
|
name: `(${nextNumber}) ${baseName}`,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
newProjectId,
|
|
|
|
|
newProject,
|
|
|
|
|
sourceProjectId: project.metadata.id,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
duplicationPlans.map(({ newProject }) =>
|
|
|
|
|
storageService.saveProject({ project: newProject }),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
duplicationPlans.map(async ({ sourceProjectId, newProjectId }) => {
|
|
|
|
|
const sourceMediaAssets = await storageService.loadAllMediaAssets({
|
|
|
|
|
projectId: sourceProjectId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
sourceMediaAssets.map((mediaAsset) =>
|
|
|
|
|
storageService.saveMediaAsset({
|
|
|
|
|
projectId: newProjectId,
|
|
|
|
|
mediaAsset,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const { newProject } of duplicationPlans) {
|
|
|
|
|
this.updateMetadata(newProject);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return duplicationPlans.map((plan) => plan.newProjectId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to duplicate projects:", error);
|
|
|
|
|
toast.error("Failed to duplicate projects", {
|
|
|
|
|
description:
|
|
|
|
|
error instanceof Error ? error.message : "Please try again",
|
|
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateSettings({
|
|
|
|
|
settings,
|
|
|
|
|
pushHistory = true,
|
|
|
|
|
}: {
|
|
|
|
|
settings: Partial<TProjectSettings>;
|
|
|
|
|
pushHistory?: boolean;
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
if (!this.active) return;
|
|
|
|
|
|
|
|
|
|
const command = new UpdateProjectSettingsCommand(settings);
|
|
|
|
|
if (pushHistory) {
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
command.execute();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
|
|
|
|
|
if (!this.active) return;
|
|
|
|
|
|
|
|
|
|
const updatedProject: TProject = {
|
|
|
|
|
...this.active,
|
|
|
|
|
metadata: { ...this.active.metadata, thumbnail, updatedAt: new Date() },
|
|
|
|
|
};
|
|
|
|
|
this.active = updatedProject;
|
|
|
|
|
this.notify();
|
|
|
|
|
this.updateMetadata(updatedProject);
|
|
|
|
|
this.editor.save.markDirty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async prepareExit(): Promise<void> {
|
|
|
|
|
if (!this.active) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
|
|
|
|
|
if (didUpdateThumbnail) {
|
|
|
|
|
await this.editor.save.flush();
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to generate project thumbnail on exit:", error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getFilteredAndSortedProjects({
|
|
|
|
|
searchQuery,
|
|
|
|
|
sortOption,
|
|
|
|
|
}: {
|
|
|
|
|
searchQuery: string;
|
|
|
|
|
sortOption: TProjectSortOption;
|
|
|
|
|
}): TProjectMetadata[] {
|
|
|
|
|
const filteredProjects = this.savedProjects.filter((project) =>
|
|
|
|
|
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [key, order] = sortOption.split("-") as [
|
|
|
|
|
TProjectSortKey,
|
|
|
|
|
"asc" | "desc",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
|
|
|
|
const aValue = a[key];
|
|
|
|
|
const bValue = b[key];
|
|
|
|
|
|
|
|
|
|
if (order === "asc") {
|
|
|
|
|
if (aValue < bValue) return -1;
|
|
|
|
|
if (aValue > bValue) return 1;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
if (aValue > bValue) return -1;
|
|
|
|
|
if (aValue < bValue) return 1;
|
|
|
|
|
return 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sortedProjects;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isInvalidProjectId({ id }: { id: string }): boolean {
|
|
|
|
|
return this.invalidProjectIds.has(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markProjectIdAsInvalid({ id }: { id: string }): void {
|
|
|
|
|
this.invalidProjectIds.add(id);
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearInvalidProjectIds(): void {
|
|
|
|
|
this.invalidProjectIds.clear();
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getActive(): TProject {
|
|
|
|
|
if (!this.active) {
|
|
|
|
|
throw new Error("No active project");
|
|
|
|
|
}
|
|
|
|
|
return this.active;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* for agents:
|
|
|
|
|
* in most cases, the project is guaranteed to be active, in which getActive() should be used instead.
|
|
|
|
|
* for very rare cases, this function may be used.
|
|
|
|
|
*/
|
|
|
|
|
getActiveOrNull(): TProject | null {
|
|
|
|
|
return this.active;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTimelineViewState(): TTimelineViewState {
|
|
|
|
|
return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
|
|
|
|
|
if (!this.active) return;
|
|
|
|
|
this.active = {
|
|
|
|
|
...this.active,
|
|
|
|
|
timelineViewState: viewState ?? undefined,
|
|
|
|
|
};
|
|
|
|
|
this.editor.save.markDirty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getSavedProjects(): TProjectMetadata[] {
|
|
|
|
|
return this.savedProjects;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getIsLoading(): boolean {
|
|
|
|
|
return this.isLoading;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getIsInitialized(): boolean {
|
|
|
|
|
return this.isInitialized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getMigrationState(): MigrationState {
|
|
|
|
|
return this.migrationState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setActiveProject({ project }: { project: TProject }): void {
|
|
|
|
|
this.active = project;
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
subscribe(listener: () => void): () => void {
|
|
|
|
|
this.listeners.add(listener);
|
|
|
|
|
return () => this.listeners.delete(listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async updateThumbnailFromTimeline(): Promise<boolean> {
|
|
|
|
|
if (!this.active) return false;
|
|
|
|
|
|
|
|
|
|
const tracks = this.editor.timeline.getTracks();
|
|
|
|
|
const mediaAssets = this.editor.media.getAssets();
|
|
|
|
|
const duration = this.editor.timeline.getTotalDuration();
|
|
|
|
|
|
|
|
|
|
if (duration === 0) return false;
|
|
|
|
|
|
|
|
|
|
const { canvasSize, background } = this.active.settings;
|
|
|
|
|
|
|
|
|
|
const scene = buildScene({
|
|
|
|
|
tracks,
|
|
|
|
|
mediaAssets,
|
|
|
|
|
duration,
|
|
|
|
|
canvasSize,
|
|
|
|
|
background,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const renderer = new CanvasRenderer({
|
|
|
|
|
width: canvasSize.width,
|
|
|
|
|
height: canvasSize.height,
|
|
|
|
|
fps: this.active.settings.fps,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const tempCanvas = document.createElement("canvas");
|
|
|
|
|
tempCanvas.width = canvasSize.width;
|
|
|
|
|
tempCanvas.height = canvasSize.height;
|
|
|
|
|
|
|
|
|
|
await renderer.renderToCanvas({
|
|
|
|
|
node: scene,
|
|
|
|
|
time: 0,
|
|
|
|
|
targetCanvas: tempCanvas,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const thumbnailDataUrl = tempCanvas.toDataURL("image/png");
|
|
|
|
|
|
|
|
|
|
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateMetadata(project: TProject): void {
|
|
|
|
|
const index = this.savedProjects.findIndex(
|
|
|
|
|
(p) => p.id === project.metadata.id,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
this.savedProjects[index] = project.metadata;
|
|
|
|
|
} else {
|
|
|
|
|
this.savedProjects = [project.metadata, ...this.savedProjects];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private notify(): void {
|
|
|
|
|
this.listeners.forEach((fn) => fn());
|
|
|
|
|
}
|
|
|
|
|
}
|