refactor not done

This commit is contained in:
Maze Winther
2025-11-26 08:47:03 +01:00
commit efbebd13b8
431 changed files with 51577 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { PlaybackManager } from "./managers/playback-manager";
import { TimelineManager } from "./managers/timeline-manager";
import { SceneManager } from "./managers/scene-manager";
import { ProjectManager } from "./managers/project-manager";
import { MediaManager } from "./managers/media-manager";
import { RendererManager } from "./managers/renderer-manager";
import { buildScene } from "@/services/renderer/scene-builder";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import type { ExportOptions } from "@/types/export";
import { DEFAULT_FPS } from "@/constants/editor-constants";
export class EditorCore {
private static instance: EditorCore | null = null;
public readonly playback: PlaybackManager;
public readonly timeline: TimelineManager;
public readonly scene: SceneManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
public readonly renderer: RendererManager;
private constructor() {
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
this.scene = new SceneManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
this.renderer = new RendererManager(this);
}
static getInstance(): EditorCore {
if (!EditorCore.instance) {
EditorCore.instance = new EditorCore();
}
return EditorCore.instance;
}
static reset(): void {
EditorCore.instance = null;
}
async export({
format,
quality,
includeAudio = true,
onProgress,
}: ExportOptions): Promise<{
success: boolean;
buffer?: ArrayBuffer;
error?: string;
}> {
const project = this.project.getActive();
if (!project) {
return { success: false, error: "No active project" };
}
const duration = this.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Timeline is empty" };
}
try {
const sceneGraph = buildScene({
tracks: this.timeline.getTracks(),
mediaFiles: this.media.getMediaFiles(),
duration,
canvasSize: project.canvasSize,
backgroundColor: project.backgroundColor,
});
const exporter = new SceneExporter({
width: project.canvasSize.width,
height: project.canvasSize.height,
fps: project.fps ?? DEFAULT_FPS,
format,
quality,
includeAudio,
});
if (onProgress) {
exporter.on("progress", onProgress);
}
const buffer = await exporter.export(sceneGraph);
if (onProgress) {
exporter.off("progress", onProgress);
}
if (!buffer) {
return { success: false, error: "Export was cancelled" };
}
return { success: true, buffer };
} catch (error) {
const message = error instanceof Error ? error.message : "Export failed";
return { success: false, error: message };
}
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { EditorCore } from "@/core";
import type { MediaFile } from "@/types/media";
import { storageService } from "@/lib/storage/storage-service";
import { generateUUID } from "@/lib/utils";
import { videoCache } from "@/lib/video-cache";
import { generateThumbnail } from "@/lib/media-processing-utils";
export class MediaManager {
private mediaFiles: MediaFile[] = [];
private isLoading = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
async addMediaFile({
projectId,
file,
}: {
projectId: string;
file: Omit<MediaFile, "id">;
}): Promise<void> {
const newItem: MediaFile = {
...file,
id: generateUUID(),
};
this.mediaFiles = [...this.mediaFiles, newItem];
this.notify();
try {
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
} catch (error) {
console.error("Failed to save media item:", error);
this.mediaFiles = this.mediaFiles.filter(
(media) => media.id !== newItem.id,
);
this.notify();
}
}
async removeMediaFile({
projectId,
id,
}: {
projectId: string;
id: string;
}): Promise<void> {
const item = this.mediaFiles.find((media) => media.id === id);
videoCache.clearVideo(id);
if (item?.url) {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
}
this.mediaFiles = this.mediaFiles.filter((media) => media.id !== id);
this.notify();
const tracks = this.editor.timeline.getTracks();
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of tracks) {
for (const el of track.elements) {
if (el.type === "media" && el.mediaId === id) {
elementsToRemove.push({ trackId: track.id, elementId: el.id });
}
}
}
if (elementsToRemove.length > 0) {
this.editor.timeline.setSelectedElements({ elements: elementsToRemove });
this.editor.timeline.deleteSelected({});
}
try {
await storageService.deleteMediaFile({ projectId, id });
} catch (error) {
console.error("Failed to delete media item:", error);
}
}
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
this.isLoading = true;
this.notify();
try {
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
const updatedMediaItems = await Promise.all(
mediaItems.map(async (item) => {
if (item.type === "video" && item.file) {
try {
const thumbnailUrl = await generateThumbnail({
videoFile: item.file,
timeInSeconds: 1,
});
return {
...item,
thumbnailUrl,
};
} catch (error) {
console.error(
`Failed to regenerate thumbnail for video ${item.id}:`,
error,
);
return item;
}
}
return item;
}),
);
this.mediaFiles = updatedMediaItems;
this.notify();
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
this.isLoading = false;
this.notify();
}
}
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
this.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
const mediaIds = this.mediaFiles.map((item) => item.id);
this.mediaFiles = [];
this.notify();
try {
await Promise.all(
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })),
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
}
}
clearAllMedia(): void {
videoCache.clearAll();
this.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
this.mediaFiles = [];
this.notify();
}
getMediaFiles(): MediaFile[] {
return this.mediaFiles;
}
isLoadingMedia(): boolean {
return this.isLoading;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
@@ -0,0 +1,187 @@
import type { EditorCore } from "@/core";
import { DEFAULT_FPS } from "@/constants/editor-constants";
export class PlaybackManager {
private isPlaying = false;
private currentTime = 0;
private volume = 1;
private muted = false;
private previousVolume = 1;
private speed = 1.0;
private listeners = new Set<() => void>();
private playbackTimer: number | null = null;
private lastUpdate = 0;
constructor(private editor: EditorCore) {}
play(): void {
const duration = this.editor.timeline.getTotalDuration();
if (duration > 0) {
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
const frameOffset = 1 / fps;
const endThreshold = Math.max(0, duration - frameOffset);
if (this.currentTime >= endThreshold) {
this.seek({ time: 0 });
}
}
this.isPlaying = true;
this.startTimer();
this.notify();
}
pause(): void {
this.isPlaying = false;
this.stopTimer();
this.notify();
}
toggle(): void {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
seek({ time }: { time: number }): void {
const duration = this.editor.timeline.getTotalDuration();
this.currentTime = Math.max(0, Math.min(duration, time));
this.notify();
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: this.currentTime },
}),
);
}
setVolume({ volume }: { volume: number }): void {
const clampedVolume = Math.max(0, Math.min(1, volume));
this.volume = clampedVolume;
this.muted = clampedVolume === 0;
if (clampedVolume > 0) {
this.previousVolume = clampedVolume;
}
this.notify();
}
setSpeed({ speed }: { speed: number }): void {
this.speed = Math.max(0.1, Math.min(2.0, speed));
this.notify();
window.dispatchEvent(
new CustomEvent("playback-speed", {
detail: { speed: this.speed },
}),
);
}
mute(): void {
if (this.volume > 0) {
this.previousVolume = this.volume;
}
this.muted = true;
this.volume = 0;
this.notify();
}
unmute(): void {
this.muted = false;
this.volume = this.previousVolume;
this.notify();
}
toggleMute(): void {
if (this.muted) {
this.unmute();
} else {
this.mute();
}
}
getIsPlaying(): boolean {
return this.isPlaying;
}
getCurrentTime(): number {
return this.currentTime;
}
getVolume(): number {
return this.volume;
}
isMuted(): boolean {
return this.muted;
}
getSpeed(): number {
return this.speed;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private startTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
}
this.lastUpdate = performance.now();
this.updateTime();
}
private stopTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
this.playbackTimer = null;
}
}
private updateTime = (): void => {
if (!this.isPlaying) return;
const now = performance.now();
const delta = (now - this.lastUpdate) / 1000;
this.lastUpdate = now;
const newTime = this.currentTime + delta * this.speed;
const duration = this.editor.timeline.getTotalDuration();
if (duration > 0 && newTime >= duration) {
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
const frameOffset = 1 / fps;
const stopTime = Math.max(0, duration - frameOffset);
this.pause();
this.currentTime = stopTime;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: stopTime },
}),
);
} else {
this.currentTime = newTime;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
detail: { time: newTime },
}),
);
}
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
}
@@ -0,0 +1,448 @@
import type { EditorCore } from "@/core";
import type { TProject } from "@/types/project";
import type { TCanvasSize } from "@/types/editor";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { generateUUID } from "@/lib/utils";
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE, DEFAULT_BLUR_INTENSITY } from "@/constants/editor-constants";
import { buildDefaultScene } from "@/lib/scene-utils";
export class ProjectManager {
private activeProject: TProject | null = null;
private savedProjects: TProject[] = [];
private isLoading = true;
private isInitialized = false;
private invalidProjectIds = new Set<string>();
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
async createNewProject({ name }: { name: string }): Promise<string> {
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
const newProject: TProject = {
id: generateUUID(),
name,
createdAt: new Date(),
updatedAt: new Date(),
scenes: [mainScene],
currentSceneId: mainScene.id,
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: DEFAULT_BLUR_INTENSITY,
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
};
this.activeProject = newProject;
this.notify();
this.editor.media.clearAllMedia();
this.editor.timeline.clearTimeline();
this.editor.scene.initializeScenes({
scenes: newProject.scenes,
currentSceneId: newProject.currentSceneId,
});
try {
await storageService.saveProject({ project: newProject });
await this.loadAllProjects();
return newProject.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.media.clearAllMedia();
this.editor.timeline.clearTimeline();
this.editor.scene.clearScenes();
try {
const project = await storageService.loadProject({ id });
if (!project) {
throw new Error(`Project with id ${id} not found`);
}
this.activeProject = project;
this.notify();
let currentScene = null;
if (project.scenes && project.scenes.length > 0) {
this.editor.scene.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
currentScene =
project.scenes.find((s) => s.id === project.currentSceneId) ||
project.scenes.find((s) => s.isMain) ||
project.scenes[0];
}
await Promise.all([
this.editor.media.loadProjectMedia({ projectId: id }),
this.editor.timeline.loadProjectTimeline({
projectId: id,
sceneId: currentScene?.id,
}),
]);
} catch (error) {
console.error("Failed to load project:", error);
throw error;
} finally {
this.isLoading = false;
this.notify();
}
}
async saveCurrentProject(): Promise<void> {
if (!this.activeProject) return;
try {
const currentScene = this.editor.scene.getCurrentScene();
await Promise.all([
storageService.saveProject({ project: this.activeProject }),
this.editor.timeline.saveProjectTimeline({
projectId: this.activeProject.id,
sceneId: currentScene?.id,
}),
]);
await this.loadAllProjects();
} catch (error) {
console.error("Failed to save project:", error);
}
}
async loadAllProjects(): Promise<void> {
if (!this.isInitialized) {
this.isLoading = true;
this.notify();
}
try {
const projects = await storageService.loadAllProjects();
this.savedProjects = projects;
this.notify();
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
this.isLoading = false;
this.isInitialized = true;
this.notify();
}
}
async deleteProject({ id }: { id: string }): Promise<void> {
try {
await Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProjectTimeline({ projectId: id }),
storageService.deleteProject({ id }),
]);
await this.loadAllProjects();
if (this.activeProject?.id === id) {
this.activeProject = null;
this.notify();
this.editor.media.clearAllMedia();
this.editor.timeline.clearTimeline();
this.editor.scene.clearScenes();
}
} catch (error) {
console.error("Failed to delete project:", error);
}
}
closeProject(): void {
this.activeProject = null;
this.notify();
this.editor.media.clearAllMedia();
this.editor.timeline.clearTimeline();
this.editor.scene.clearScenes();
}
async renameProject({
id,
name,
}: {
id: string;
name: string;
}): Promise<void> {
const projectToRename = this.savedProjects.find((p) => p.id === id);
if (!projectToRename) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject = {
...projectToRename,
name,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
await this.loadAllProjects();
if (this.activeProject?.id === id) {
this.activeProject = updatedProject;
this.notify();
}
} 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 duplicateProject({
projectId,
}: {
projectId: string;
}): Promise<string> {
try {
const project = await storageService.loadProject({ id: projectId });
if (!project) {
toast.error("Project not found", {
description: "Please try again",
});
throw new Error("Project not found");
}
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
const baseName = numberMatch ? numberMatch[2] : project.name;
const existingNumbers: number[] = [];
this.savedProjects.forEach((p) => {
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) {
existingNumbers.push(parseInt(match[1], 10));
}
});
const nextNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
...project,
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
};
await storageService.saveProject({ project: newProject });
await this.loadAllProjects();
return newProject.id;
} catch (error) {
console.error("Failed to duplicate project:", error);
toast.error("Failed to duplicate project", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
}
async updateProjectBackground({
backgroundColor,
}: {
backgroundColor: string;
}): Promise<void> {
if (!this.activeProject) return;
const updatedProject = {
...this.activeProject,
backgroundColor,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
this.activeProject = updatedProject;
this.notify();
await this.loadAllProjects();
} catch (error) {
console.error("Failed to update project background:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
}
async updateBackgroundType({
type,
options,
}: {
type: "color" | "blur";
options?: { backgroundColor?: string; blurIntensity?: number };
}): Promise<void> {
if (!this.activeProject) return;
const updatedProject = {
...this.activeProject,
backgroundType: type,
...(options?.backgroundColor && {
backgroundColor: options.backgroundColor,
}),
...(options?.blurIntensity !== undefined && {
blurIntensity: options.blurIntensity,
}),
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
this.activeProject = updatedProject;
this.notify();
await this.loadAllProjects();
} catch (error) {
console.error("Failed to update background type:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
}
async updateProjectFps({ fps }: { fps: number }): Promise<void> {
if (!this.activeProject) return;
const updatedProject = {
...this.activeProject,
fps,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
this.activeProject = updatedProject;
this.notify();
await this.loadAllProjects();
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
description: "Please try again",
});
}
}
async updateCanvasSize({ size }: { size: TCanvasSize }): Promise<void> {
if (!this.activeProject) return;
const updatedProject: TProject = {
...this.activeProject,
canvasSize: size,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
this.activeProject = updatedProject;
this.notify();
await this.loadAllProjects();
} catch (error) {
console.error("Failed to update canvas size:", error);
toast.error("Failed to update canvas size", {
description: "Please try again",
});
}
}
getFilteredAndSortedProjects({
searchQuery,
sortOption,
}: {
searchQuery: string;
sortOption: string;
}): TProject[] {
const filteredProjects = this.savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
if (key !== "createdAt" && key !== "name") {
console.warn(`Invalid sort key: ${key}`);
return 0;
}
const aValue = a[key];
const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
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 | null {
return this.activeProject;
}
getActiveFps(): number | undefined {
return this.activeProject?.fps;
}
getSavedProjects(): TProject[] {
return this.savedProjects;
}
getIsLoading(): boolean {
return this.isLoading;
}
getIsInitialized(): boolean {
return this.isInitialized;
}
setActiveProject({ project }: { project: TProject }): void {
this.activeProject = project;
this.notify();
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
@@ -0,0 +1,260 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/types/export";
import type { TimelineTrack } from "@/types/timeline";
import type { MediaFile } from "@/types/media";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
format: "mp4",
quality: "high",
includeAudio: true,
};
interface AudioElement {
buffer: AudioBuffer;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
muted: boolean;
}
export class RendererManager {
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree;
this.notify();
}
getRenderTree(): RootNode | null {
return this.renderTree;
}
async exportProject({
options,
}: {
options: ExportOptions;
}): Promise<ExportResult> {
const { format, quality, fps, includeAudio, onProgress, onCancel } =
options;
try {
const tracks = this.editor.timeline.getTracks();
const mediaFiles = this.editor.media.getMediaFiles();
const activeProject = this.editor.project.getActive();
if (!activeProject) {
return { success: false, error: "No active project" };
}
const duration = this.editor.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Project is empty" };
}
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.(0.05);
audioBuffer = await this.createTimelineAudioBuffer({
tracks,
mediaFiles,
duration,
});
}
const scene = buildScene({
tracks,
mediaFiles,
duration,
canvasSize,
backgroundColor:
activeProject.backgroundType === "blur"
? "transparent"
: activeProject.backgroundColor || "#000000",
backgroundType: activeProject.backgroundType,
blurIntensity: activeProject.blurIntensity,
});
const exporter = new SceneExporter({
width: canvasSize.width,
height: canvasSize.height,
fps: exportFps,
format,
quality,
includeAudio: !!includeAudio,
audioBuffer: audioBuffer || undefined,
});
exporter.on("progress", (progress) => {
const adjustedProgress = includeAudio
? 0.05 + progress * 0.95
: progress;
onProgress?.(adjustedProgress);
});
let cancelled = false;
const checkCancel = () => {
if (onCancel?.()) {
cancelled = true;
exporter.cancel();
}
};
const cancelInterval = setInterval(checkCancel, 100);
try {
const buffer = await exporter.export(scene);
clearInterval(cancelInterval);
if (cancelled) {
return { success: false, cancelled: true };
}
if (!buffer) {
return { success: false, error: "Export failed to produce buffer" };
}
return {
success: true,
buffer,
};
} finally {
clearInterval(cancelInterval);
}
} catch (error) {
console.error("Export failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown export error",
};
}
}
private async createTimelineAudioBuffer({
tracks,
mediaFiles,
duration,
sampleRate = 44100,
}: {
tracks: TimelineTrack[];
mediaFiles: MediaFile[];
duration: number;
sampleRate?: number;
}): Promise<AudioBuffer | null> {
const AudioContextClass =
window.AudioContext ||
(window as typeof window & { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
const audioContext = new AudioContextClass();
const audioElements: AudioElement[] = [];
const mediaMap = new Map<string, MediaFile>(
mediaFiles.map((m) => [m.id, m]),
);
for (const track of tracks) {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type !== "media") continue;
const mediaElement = element;
const mediaItem = mediaMap.get(mediaElement.mediaId);
if (!mediaItem || mediaItem.type !== "audio") continue;
const visibleDuration =
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
if (visibleDuration <= 0) continue;
try {
const arrayBuffer = await mediaItem.file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(
arrayBuffer.slice(0),
);
audioElements.push({
buffer: audioBuffer,
startTime: mediaElement.startTime,
duration: mediaElement.duration,
trimStart: mediaElement.trimStart,
trimEnd: mediaElement.trimEnd,
muted: mediaElement.muted || track.muted || false,
});
} catch (error) {
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
}
}
}
if (audioElements.length === 0) {
return null;
}
const outputChannels = 2;
const outputLength = Math.ceil(duration * sampleRate);
const outputBuffer = audioContext.createBuffer(
outputChannels,
outputLength,
sampleRate,
);
for (const element of audioElements) {
if (element.muted) continue;
const {
buffer,
startTime,
trimStart,
trimEnd,
duration: elementDuration,
} = element;
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceDuration = elementDuration - trimStart - trimEnd;
const sourceLengthSamples = Math.floor(
sourceDuration * buffer.sampleRate,
);
const outputStartSample = Math.floor(startTime * sampleRate);
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
for (let channel = 0; channel < outputChannels; channel++) {
const outputData = outputBuffer.getChannelData(channel);
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
const sourceData = buffer.getChannelData(sourceChannel);
for (let i = 0; i < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
outputData[outputIndex] += sourceData[sourceIndex];
}
}
}
return outputBuffer;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
+389
View File
@@ -0,0 +1,389 @@
import type { EditorCore } from "@/core";
import type { TScene } from "@/types/project";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import {
getActiveScene,
updateSceneInArray,
getMainScene as getMainSceneUtil,
ensureMainScene,
buildDefaultScene,
canDeleteScene,
getFallbackSceneAfterDelete,
normalizeScenes,
findCurrentScene,
} from "@/lib/scene-utils";
import {
getFrameTime,
toggleBookmarkInArray,
removeBookmarkFromArray,
isBookmarkAtTime,
} from "@/lib/timeline/bookmark-utils";
export class SceneManager {
private currentScene: TScene | null = null;
private scenes: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
async createScene({
name,
isMain = false,
}: {
name: string;
isMain: boolean;
}): Promise<string> {
const newScene = buildDefaultScene({ name, isMain });
const updatedScenes = [...this.scenes, newScene];
try {
await this.updateProjectWithScenes({ updatedScenes });
return newScene.id;
} catch (error) {
console.error("Failed to create scene:", error);
throw error;
}
}
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
const sceneToDelete = this.scenes.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);
}
const updatedScenes = this.scenes.filter((s) => s.id !== sceneId);
const newCurrentScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: sceneId,
currentSceneId: this.currentScene?.id || null,
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: newCurrentScene?.id,
});
if (newCurrentScene && newCurrentScene.id !== this.currentScene?.id) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
await this.editor.timeline.loadProjectTimeline({
projectId: activeProject.id,
sceneId: newCurrentScene.id,
});
}
}
} catch (error) {
console.error("Failed to delete scene:", error);
throw error;
}
}
async renameScene({
sceneId,
name,
}: {
sceneId: string;
name: string;
}): Promise<void> {
const updatedScenes = updateSceneInArray({
scenes: this.scenes,
sceneId,
updates: { name, updatedAt: new Date() },
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: sceneId,
});
} catch (error) {
console.error("Failed to rename scene:", error);
throw error;
}
}
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
const targetScene = this.scenes.find((s) => s.id === sceneId);
if (!targetScene) {
throw new Error("Scene not found");
}
const activeProject = this.editor.project.getActive();
if (activeProject && this.currentScene) {
await this.editor.timeline.saveProjectTimeline({
projectId: activeProject.id,
sceneId: this.currentScene.id,
});
}
if (activeProject) {
await this.editor.timeline.loadProjectTimeline({
projectId: activeProject.id,
sceneId,
});
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
updatedAt: new Date(),
};
await storageService.saveProject({ project: updatedProject });
this.editor.project.setActiveProject({ project: updatedProject });
}
this.currentScene = targetScene;
this.notify();
}
async toggleBookmark({ time }: { time: number }): Promise<void> {
const activeScene = this.getActiveScene();
if (!activeScene || !this.currentScene) return;
const activeProject = this.editor.project.getActive();
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
const updatedBookmarks = toggleBookmarkInArray({
bookmarks,
frameTime,
});
const updatedScenes = updateSceneInArray({
scenes: this.scenes,
sceneId: activeScene.id,
updates: {
timeline: {
...activeScene.timeline,
bookmarks: updatedBookmarks,
},
},
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
refreshProjectList: true,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to update bookmarks", {
description: "Please try again",
});
}
}
isBookmarked({ time }: { time: number }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
if (!activeScene || !this.currentScene || !activeProject) return false;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
return isBookmarkAtTime({ bookmarks, frameTime });
}
async removeBookmark({ time }: { time: number }): Promise<void> {
const activeScene = this.getActiveScene();
if (!activeScene || !this.currentScene) return;
const activeProject = this.editor.project.getActive();
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
const updatedBookmarks = removeBookmarkFromArray({
bookmarks,
frameTime,
});
if (updatedBookmarks.length === bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes: this.scenes,
sceneId: activeScene.id,
updates: {
timeline: {
...activeScene.timeline,
bookmarks: updatedBookmarks,
},
},
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
refreshProjectList: true,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to remove bookmark", {
description: "Please try again",
});
}
}
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
try {
const project = await storageService.loadProject({ id: projectId });
if (project?.scenes) {
const normalizedScenes = normalizeScenes({ scenes: project.scenes });
const currentScene = findCurrentScene({
scenes: normalizedScenes,
currentSceneId: project.currentSceneId,
});
this.scenes = normalizedScenes;
this.currentScene = currentScene;
this.notify();
}
} catch (error) {
console.error("Failed to load project scenes:", error);
this.scenes = [];
this.currentScene = null;
this.notify();
}
}
initializeScenes({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}): void {
const ensuredScenes = ensureMainScene({ scenes });
const currentScene = currentSceneId
? ensuredScenes.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
this.scenes = ensuredScenes;
this.currentScene = currentScene || fallbackScene;
this.notify();
if (ensuredScenes.length > scenes.length) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
updatedAt: new Date(),
};
storageService
.saveProject({ project: updatedProject })
.then(() => {
this.editor.project.setActiveProject({ project: updatedProject });
})
.catch((error) => {
console.error(
"Failed to save project with background scene:",
error,
);
});
}
}
}
clearScenes(): void {
this.scenes = [];
this.currentScene = null;
this.notify();
}
getMainScene(): TScene | null {
return getMainSceneUtil({ scenes: this.scenes });
}
getCurrentScene(): TScene | null {
return this.currentScene;
}
getActiveScene(): TScene | null {
return getActiveScene({
scenes: this.scenes,
currentSceneId: this.currentScene?.id || "",
});
}
getScenes(): TScene[] {
return this.scenes;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private async updateProjectWithScenes({
updatedScenes,
updatedSceneId,
refreshProjectList = false,
}: {
updatedScenes: TScene[];
updatedSceneId?: string;
refreshProjectList?: boolean;
}): Promise<void> {
const activeProject = this.editor.project.getActive();
if (!activeProject) {
throw new Error("No active project");
}
const updatedScene = updatedSceneId
? updatedScenes.find((s) => s.id === updatedSceneId)
: this.currentScene;
const updatedProject = {
...activeProject,
scenes: updatedScenes,
updatedAt: new Date(),
};
await storageService.saveProject({ project: updatedProject });
this.editor.project.setActiveProject({ project: updatedProject });
this.scenes = updatedScenes;
this.currentScene = updatedScene || null;
this.notify();
if (refreshProjectList) {
await this.editor.project.loadAllProjects();
}
}
}
@@ -0,0 +1,528 @@
import type { EditorCore } from "@/core";
import type {
TrackType,
CreateTimelineElement,
TimelineTrack,
TextElement,
DragData,
} from "@/types/timeline";
import type { MediaFile } from "@/types/media";
import { calculateTotalDuration } from "@/lib/timeline/calculation-utils";
export class TimelineManager {
private _tracks: TimelineTrack[] = [];
private history: TimelineTrack[][] = [];
private redoStack: TimelineTrack[][] = [];
private clipboard: {
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
} | null = null;
private tracks: TimelineTrack[] = [];
private snappingEnabled = true;
private rippleEditingEnabled = false;
private selectedElements: { trackId: string; elementId: string }[] = [];
private dragState = {
isDragging: false,
elementId: null as string | null,
trackId: null as string | null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
};
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {
this.initializeTracks();
}
private initializeTracks(): void {
this._tracks = [];
this.tracks = [];
}
getSortedTracks(): TimelineTrack[] {
throw new Error("Not implemented");
}
toggleSnapping(): void {
throw new Error("Not implemented");
}
toggleRippleEditing(): void {
throw new Error("Not implemented");
}
selectElement({
trackId,
elementId,
multi = false,
}: {
trackId: string;
elementId: string;
multi?: boolean;
}): void {
throw new Error("Not implemented");
}
deselectElement({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
throw new Error("Not implemented");
}
clearSelectedElements(): void {
throw new Error("Not implemented");
}
setSelectedElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
throw new Error("Not implemented");
}
setDragState({
dragState,
}: {
dragState: Partial<{
isDragging: boolean;
elementId: string | null;
trackId: string | null;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
}>;
}): void {
throw new Error("Not implemented");
}
startDrag({
elementId,
trackId,
startMouseX,
startElementTime,
clickOffsetTime,
}: {
elementId: string;
trackId: string;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
}): void {
throw new Error("Not implemented");
}
updateDragTime({ currentTime }: { currentTime: number }): void {
throw new Error("Not implemented");
}
endDrag(): void {
throw new Error("Not implemented");
}
addTrack({ type }: { type: TrackType }): string {
throw new Error("Not implemented");
}
insertTrackAt({ type, index }: { type: TrackType; index: number }): string {
throw new Error("Not implemented");
}
removeTrack({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
}
removeTrackWithRipple({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
}
addElementToTrack({
trackId,
element,
}: {
trackId: string;
element: CreateTimelineElement;
}): void {
throw new Error("Not implemented");
}
moveElementToTrack({
fromTrackId,
toTrackId,
elementId,
}: {
fromTrackId: string;
toTrackId: string;
elementId: string;
}): void {
throw new Error("Not implemented");
}
updateElementTrim({
trackId,
elementId,
trimStart,
trimEnd,
pushHistory = true,
}: {
trackId: string;
elementId: string;
trimStart: number;
trimEnd: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
}
updateElementDuration({
trackId,
elementId,
duration,
pushHistory = true,
}: {
trackId: string;
elementId: string;
duration: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
}
updateElementStartTime({
trackId,
elementId,
startTime,
pushHistory = true,
}: {
trackId: string;
elementId: string;
startTime: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
}
toggleTrackMute({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
}
splitAndKeepLeft({
trackId,
elementId,
splitTime,
}: {
trackId: string;
elementId: string;
splitTime: number;
}): void {
throw new Error("Not implemented");
}
splitAndKeepRight({
trackId,
elementId,
splitTime,
}: {
trackId: string;
elementId: string;
splitTime: number;
}): void {
throw new Error("Not implemented");
}
separateAudio({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): string | null {
throw new Error("Not implemented");
}
replaceElementMedia({
trackId,
elementId,
newFile,
}: {
trackId: string;
elementId: string;
newFile: File;
}): Promise<{ success: boolean; error?: string }> {
throw new Error("Not implemented");
}
updateElementStartTimeWithRipple({
trackId,
elementId,
newStartTime,
}: {
trackId: string;
elementId: string;
newStartTime: number;
}): void {
throw new Error("Not implemented");
}
removeElementFromTrackWithRipple({
trackId,
elementId,
pushHistory = true,
}: {
trackId: string;
elementId: string;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this._tracks });
}
getProjectThumbnail({
projectId,
}: {
projectId: string;
}): Promise<string | null> {
throw new Error("Not implemented");
}
undo(): void {
throw new Error("Not implemented");
}
redo(): void {
throw new Error("Not implemented");
}
pushHistory(): void {
throw new Error("Not implemented");
}
async loadProjectTimeline({
projectId,
sceneId,
}: {
projectId: string;
sceneId?: string;
}): Promise<void> {
throw new Error("Not implemented");
}
async saveProjectTimeline({
projectId,
sceneId,
}: {
projectId: string;
sceneId?: string;
}): Promise<void> {
throw new Error("Not implemented");
}
clearTimeline(): void {
throw new Error("Not implemented");
}
copySelected(): void {
throw new Error("Not implemented");
}
pasteAtTime({ time }: { time: number }): void {
throw new Error("Not implemented");
}
deleteSelected({
trackId,
elementId,
}: {
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
splitSelected({
splitTime,
trackId,
elementId,
}: {
splitTime: number;
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
toggleSelectedHidden({
trackId,
elementId,
}: {
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
toggleSelectedMuted({
trackId,
elementId,
}: {
trackId?: string;
elementId?: string;
}): void {
throw new Error("Not implemented");
}
duplicateElement({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
throw new Error("Not implemented");
}
revealElementInMedia({ elementId }: { elementId: string }): void {
throw new Error("Not implemented");
}
async replaceElementWithFile({
trackId,
elementId,
file,
}: {
trackId: string;
elementId: string;
file: File;
}): Promise<void> {
throw new Error("Not implemented");
}
getContextMenuState({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
hasAudioElements: boolean;
canSplitSelected: boolean;
currentTime: number;
} {
throw new Error("Not implemented");
}
updateTextElement({
trackId,
elementId,
updates,
}: {
trackId: string;
elementId: string;
updates: Partial<
Pick<
TextElement,
| "content"
| "fontSize"
| "fontFamily"
| "color"
| "backgroundColor"
| "textAlign"
| "fontWeight"
| "fontStyle"
| "textDecoration"
| "x"
| "y"
| "rotation"
| "opacity"
>
>;
}): void {
throw new Error("Not implemented");
}
checkElementOverlap({
trackId,
startTime,
duration,
excludeElementId,
}: {
trackId: string;
startTime: number;
duration: number;
excludeElementId?: string;
}): boolean {
throw new Error("Not implemented");
}
findOrCreateTrack({ trackType }: { trackType: TrackType }): string {
throw new Error("Not implemented");
}
addElementAtTime({
item,
currentTime = 0,
}: {
item: MediaFile | TextElement;
currentTime?: number;
}): boolean {
throw new Error("Not implemented");
}
addElementToNewTrack({
item,
}: {
item: MediaFile | TextElement | DragData;
}): boolean {
throw new Error("Not implemented");
}
getTracks(): TimelineTrack[] {
return this.tracks;
}
getTracksWithMain(): TimelineTrack[] {
return this._tracks;
}
getSelectedElements(): { trackId: string; elementId: string }[] {
return this.selectedElements;
}
getDragState(): typeof this.dragState {
return this.dragState;
}
getSnappingEnabled(): boolean {
return this.snappingEnabled;
}
getRippleEditingEnabled(): boolean {
return this.rippleEditingEnabled;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private updateTracks(newTracks: TimelineTrack[]): void {
this._tracks = newTracks;
this.tracks = newTracks; // TODO: Implement proper sorting with main track
this.notify();
}
}