This commit is contained in:
Maze Winther
2026-01-05 01:35:10 +01:00
parent d5ca991501
commit c19f085e48
95 changed files with 4133 additions and 4196 deletions
+3
View File
@@ -4,6 +4,7 @@ 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 { CommandManager } from "./managers/commands";
import { buildScene } from "@/services/renderer/scene-builder";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import type { ExportOptions } from "@/types/export";
@@ -12,6 +13,7 @@ import { DEFAULT_FPS } from "@/constants/editor-constants";
export class EditorCore {
private static instance: EditorCore | null = null;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly timeline: TimelineManager;
public readonly scene: SceneManager;
@@ -20,6 +22,7 @@ export class EditorCore {
public readonly renderer: RendererManager;
private constructor() {
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
this.scene = new SceneManager(this);
+44
View File
@@ -0,0 +1,44 @@
import { 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;
}
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 = [];
}
}
+4 -30
View File
@@ -1,13 +1,12 @@
import type { EditorCore } from "@/core";
import type { MediaFile } from "@/types/media";
import type { MediaFile } from "@/types/assets";
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;
public mediaFiles: MediaFile[] = [];
public isLoading = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
@@ -88,32 +87,7 @@ export class MediaManager {
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.mediaFiles = mediaItems;
this.notify();
} catch (error) {
console.error("Failed to load media items:", error);
@@ -2,12 +2,12 @@ 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;
public isPlaying = false;
public currentTime = 0;
public volume = 1;
public muted = false;
public previousVolume = 1;
public speed = 1.0;
private listeners = new Set<() => void>();
private playbackTimer: number | null = null;
private lastUpdate = 0;
+74 -5
View File
@@ -4,14 +4,19 @@ 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 {
DEFAULT_FPS,
DEFAULT_CANVAS_SIZE,
DEFAULT_BLUR_INTENSITY,
} from "@/constants/editor-constants";
import { buildDefaultScene } from "@/lib/scene-utils";
import { generateThumbnail } from "@/lib/media-processing-utils";
export class ProjectManager {
private activeProject: TProject | null = null;
private savedProjects: TProject[] = [];
private isLoading = true;
private isInitialized = false;
public activeProject: TProject | null = null;
public savedProjects: TProject[] = [];
public isLoading = true;
public isInitialized = false;
private invalidProjectIds = new Set<string>();
private listeners = new Set<() => void>();
@@ -257,6 +262,70 @@ export class ProjectManager {
}
}
async updateProjectThumbnail({
thumbnail,
}: {
thumbnail: string;
}): Promise<void> {
if (!this.activeProject) return;
const updatedProject = {
...this.activeProject,
thumbnail,
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 thumbnail:", error);
}
}
async prepareExit(): Promise<void> {
if (!this.activeProject) return;
try {
const tracks = this.editor.timeline.getTracks();
const mediaFiles = this.editor.media.getMediaFiles();
const firstElement = tracks
.flatMap((track) => track.elements)
.sort((a, b) => a.startTime - b.startTime)[0];
if (
firstElement &&
(firstElement.type === "video" || firstElement.type === "image")
) {
const mediaFile = mediaFiles.find(
(item) => item.id === firstElement.mediaId,
);
if (mediaFile) {
let thumbnailDataUrl: string | undefined;
if (mediaFile.type === "video" && mediaFile.file) {
thumbnailDataUrl = await generateThumbnail({
videoFile: mediaFile.file,
timeInSeconds: 1,
});
} else if (mediaFile.type === "image" && mediaFile.url) {
thumbnailDataUrl = mediaFile.thumbnailUrl || mediaFile.url;
}
if (thumbnailDataUrl && !thumbnailDataUrl.startsWith("blob:")) {
await this.updateProjectThumbnail({ thumbnail: thumbnailDataUrl });
}
}
}
} catch (error) {
console.error("Failed to generate project thumbnail on exit:", error);
}
}
async updateProjectBackground({
backgroundColor,
}: {
+15 -12
View File
@@ -2,7 +2,7 @@ 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 type { MediaFile } from "@/types/assets";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
@@ -23,7 +23,7 @@ interface AudioElement {
}
export class RendererManager {
private renderTree: RootNode | null = null;
public renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
@@ -166,14 +166,17 @@ export class RendererManager {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type !== "media") continue;
if (element.type !== "audio") {
continue;
}
const mediaElement = element;
const mediaItem = mediaMap.get(mediaElement.mediaId);
if (!mediaItem || mediaItem.type !== "audio") continue;
const mediaItem = mediaMap.get(element.mediaId);
if (!mediaItem || mediaItem.type !== "audio") {
continue;
}
const visibleDuration =
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
element.duration - element.trimStart - element.trimEnd;
if (visibleDuration <= 0) continue;
try {
@@ -184,11 +187,11 @@ export class RendererManager {
audioElements.push({
buffer: audioBuffer,
startTime: mediaElement.startTime,
duration: mediaElement.duration,
trimStart: mediaElement.trimStart,
trimEnd: mediaElement.trimEnd,
muted: mediaElement.muted || track.muted || false,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || track.muted || false,
});
} catch (error) {
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
+2 -2
View File
@@ -21,8 +21,8 @@ import {
} from "@/lib/timeline/bookmark-utils";
export class SceneManager {
private currentScene: TScene | null = null;
private scenes: TScene[] = [];
public currentScene: TScene | null = null;
public scenes: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
+169 -313
View File
@@ -4,31 +4,30 @@ import type {
CreateTimelineElement,
TimelineTrack,
TextElement,
DragData,
TimelineElement,
} from "@/types/timeline";
import type { MediaFile } from "@/types/media";
import { calculateTotalDuration } from "@/lib/timeline/calculation-utils";
import { calculateTotalDuration } from "@/lib/timeline";
import { storageService } from "@/lib/storage/storage-service";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
AddElementToTrackCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
ToggleElementsHiddenCommand,
ToggleElementsMutedCommand,
UpdateTextElementCommand,
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
} from "@/lib/commands/timeline";
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,
};
public sortedTracks: TimelineTrack[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {
@@ -36,110 +35,18 @@ export class TimelineManager {
}
private initializeTracks(): void {
this._tracks = [];
this.tracks = [];
this.sortedTracks = [];
}
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");
addTrack({ type, index }: { type: TrackType; index?: number }): string {
const command = new AddTrackCommand(type, index);
this.editor.command.execute({ command });
return command.getTrackId();
}
removeTrack({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
}
removeTrackWithRipple({ trackId }: { trackId: string }): void {
throw new Error("Not implemented");
const command = new RemoveTrackCommand(trackId);
this.editor.command.execute({ command });
}
addElementToTrack({
@@ -149,7 +56,8 @@ export class TimelineManager {
trackId: string;
element: CreateTimelineElement;
}): void {
throw new Error("Not implemented");
const command = new AddElementToTrackCommand(trackId, element);
this.editor.command.execute({ command });
}
updateElementTrim({
@@ -165,7 +73,17 @@ export class TimelineManager {
trimEnd: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
const command = new UpdateElementTrimCommand(
trackId,
elementId,
trimStart,
trimEnd,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementDuration({
@@ -179,97 +97,99 @@ export class TimelineManager {
duration: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
const command = new UpdateElementDurationCommand(
trackId,
elementId,
duration,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementStartTime({
trackId,
elementId,
elements,
startTime,
pushHistory = true,
}: {
trackId: string;
elementId: string;
elements: { trackId: string; elementId: string }[];
startTime: number;
pushHistory?: boolean;
}): void {
throw new Error("Not implemented");
const command = new UpdateElementStartTimeCommand(elements, startTime);
this.editor.command.execute({ command });
}
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");
}
updateElementStartTimeWithRipple({
trackId,
moveElement({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
}: {
trackId: string;
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
}): void {
throw new Error("Not implemented");
const command = new MoveElementCommand(
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
);
this.editor.command.execute({ command });
}
removeElementFromTrackWithRipple({
trackId,
elementId,
pushHistory = true,
toggleTrackMute({ trackId }: { trackId: string }): void {
const command = new ToggleTrackMuteCommand(trackId);
this.editor.command.execute({ command });
}
splitElements({
elements,
splitTime,
retainSide = "both",
}: {
trackId: string;
elementId: string;
pushHistory?: boolean;
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): void {
throw new Error("Not implemented");
const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command });
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this._tracks });
return calculateTotalDuration({ tracks: this.sortedTracks });
}
getProjectThumbnail({
projectId,
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
return this.sortedTracks.find((track) => track.id === trackId) ?? null;
}
getElementsWithTracks({
elements,
}: {
projectId: string;
}): Promise<string | null> {
throw new Error("Not implemented");
}
elements:
| { trackId: string; elementId: string }[]
| { trackId: string; elementId: string };
}):
| Array<{ track: TimelineTrack; element: TimelineElement }>
| { track: TimelineTrack; element: TimelineElement }
| null {
const normalized = Array.isArray(elements) ? elements : [elements];
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
[];
undo(): void {
throw new Error("Not implemented");
}
for (const { trackId, elementId } of normalized) {
const track = this.getTrackById({ trackId });
const element = track?.elements.find((el) => el.id === elementId);
redo(): void {
throw new Error("Not implemented");
}
if (track && element) {
result.push({ track, element });
}
}
pushHistory(): void {
throw new Error("Not implemented");
return Array.isArray(elements) ? result : (result[0] ?? null);
}
async loadProjectTimeline({
@@ -279,7 +199,19 @@ export class TimelineManager {
projectId: string;
sceneId?: string;
}): Promise<void> {
throw new Error("Not implemented");
try {
const tracks = await storageService.loadTimeline({
projectId,
sceneId,
});
if (tracks) {
this.updateTracks(tracks);
}
} catch (error) {
console.error("Failed to load timeline:", error);
throw error;
}
}
async saveProjectTimeline({
@@ -289,91 +221,34 @@ export class TimelineManager {
projectId: string;
sceneId?: string;
}): Promise<void> {
throw new Error("Not implemented");
try {
await storageService.saveTimeline({
projectId,
tracks: this.sortedTracks,
sceneId,
});
} catch (error) {
console.error("Failed to save timeline:", error);
throw error;
}
}
clearTimeline(): void {
throw new Error("Not implemented");
}
copySelected(): void {
throw new Error("Not implemented");
this.updateTracks([]);
}
pasteAtTime({ time }: { time: number }): void {
throw new Error("Not implemented");
const command = new PasteCommand(time);
this.editor.command.execute({ command });
}
deleteSelected({
trackId,
elementId,
deleteElements({
elements,
}: {
trackId?: string;
elementId?: string;
elements: { 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");
}
getContextMenuState({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): {
isMultipleSelected: boolean;
isCurrentElementSelected: boolean;
hasAudioElements: boolean;
canSplitSelected: boolean;
currentTime: number;
} {
throw new Error("Not implemented");
const command = new DeleteElementsCommand(elements);
this.editor.command.execute({ command });
}
updateTextElement({
@@ -395,14 +270,38 @@ export class TimelineManager {
| "fontWeight"
| "fontStyle"
| "textDecoration"
| "x"
| "y"
| "rotation"
| "opacity"
>
>;
}): void {
throw new Error("Not implemented");
const command = new UpdateTextElementCommand(trackId, elementId, updates);
this.editor.command.execute({ command });
}
duplicateElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DuplicateElementsCommand({ elements });
this.editor.command.execute({ command });
}
toggleElementsHidden({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsHiddenCommand(elements);
this.editor.command.execute({ command });
}
toggleElementsMuted({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsMutedCommand(elements);
this.editor.command.execute({ command });
}
checkElementOverlap({
@@ -419,50 +318,8 @@ export class TimelineManager {
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;
return this.sortedTracks;
}
subscribe(listener: () => void): () => void {
@@ -474,9 +331,8 @@ export class TimelineManager {
this.listeners.forEach((fn) => fn());
}
private updateTracks(newTracks: TimelineTrack[]): void {
this._tracks = newTracks;
this.tracks = newTracks; // TODO: Implement proper sorting with main track
updateTracks(newTracks: TimelineTrack[]): void {
this.sortedTracks = newTracks;
this.notify();
}
}