lots of stuff

This commit is contained in:
Maze Winther
2026-01-23 15:40:01 +01:00
parent b1701e1b0e
commit c6deceaa89
292 changed files with 28825 additions and 26457 deletions
+29 -29
View File
@@ -8,37 +8,37 @@ import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
export class EditorCore {
private static instance: EditorCore | null = null;
private static instance: EditorCore | null = null;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly timeline: TimelineManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
public readonly renderer: RendererManager;
public readonly save: SaveManager;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly timeline: TimelineManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
public readonly renderer: RendererManager;
public readonly save: SaveManager;
private constructor() {
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
this.scenes = new ScenesManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
this.renderer = new RendererManager(this);
this.save = new SaveManager(this);
this.save.start();
}
private constructor() {
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
this.scenes = new ScenesManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
this.renderer = new RendererManager(this);
this.save = new SaveManager(this);
this.save.start();
}
static getInstance(): EditorCore {
if (!EditorCore.instance) {
EditorCore.instance = new EditorCore();
}
return EditorCore.instance;
}
static getInstance(): EditorCore {
if (!EditorCore.instance) {
EditorCore.instance = new EditorCore();
}
return EditorCore.instance;
}
static reset(): void {
EditorCore.instance = null;
}
static reset(): void {
EditorCore.instance = null;
}
}
+35 -35
View File
@@ -1,44 +1,44 @@
import { Command } from "@/lib/commands";
import type { Command } from "@/lib/commands";
export class CommandManager {
private history: Command[] = [];
private redoStack: Command[] = [];
private history: Command[] = [];
private redoStack: Command[] = [];
execute({ command }: { command: Command }): Command {
command.execute();
this.history.push(command);
this.redoStack = [];
return 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);
}
}
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);
}
}
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;
}
canUndo(): boolean {
return this.history.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.history = [];
this.redoStack = [];
}
clear(): void {
this.history = [];
this.redoStack = [];
}
}
+128 -128
View File
@@ -6,157 +6,157 @@ import { videoCache } from "@/services/media/video-cache";
import { hasMediaId } from "@/lib/timeline/element-utils";
export class MediaManager {
private assets: MediaAsset[] = [];
private isLoading = false;
private listeners = new Set<() => void>();
private assets: MediaAsset[] = [];
private isLoading = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) {}
async addMediaAsset({
projectId,
asset,
}: {
projectId: string;
asset: Omit<MediaAsset, "id">;
}): Promise<void> {
const newAsset: MediaAsset = {
...asset,
id: generateUUID(),
};
async addMediaAsset({
projectId,
asset,
}: {
projectId: string;
asset: Omit<MediaAsset, "id">;
}): Promise<void> {
const newAsset: MediaAsset = {
...asset,
id: generateUUID(),
};
this.assets = [...this.assets, newAsset];
this.notify();
this.assets = [...this.assets, newAsset];
this.notify();
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
this.notify();
}
}
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
this.notify();
}
}
async removeMediaAsset({
projectId,
id,
}: {
projectId: string;
id: string;
}): Promise<void> {
const asset = this.assets.find((asset) => asset.id === id);
async removeMediaAsset({
projectId,
id,
}: {
projectId: string;
id: string;
}): Promise<void> {
const asset = this.assets.find((asset) => asset.id === id);
videoCache.clearVideo({ mediaId: id });
videoCache.clearVideo({ mediaId: id });
if (asset?.url) {
URL.revokeObjectURL(asset.url);
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
}
if (asset?.url) {
URL.revokeObjectURL(asset.url);
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
}
this.assets = this.assets.filter((asset) => asset.id !== id);
this.notify();
this.assets = this.assets.filter((asset) => asset.id !== id);
this.notify();
const tracks = this.editor.timeline.getTracks();
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
const tracks = this.editor.timeline.getTracks();
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of tracks) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === id) {
elementsToRemove.push({ trackId: track.id, elementId: element.id });
}
}
}
for (const track of tracks) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === id) {
elementsToRemove.push({ trackId: track.id, elementId: element.id });
}
}
}
if (elementsToRemove.length > 0) {
this.editor.timeline.deleteElements({ elements: elementsToRemove });
}
if (elementsToRemove.length > 0) {
this.editor.timeline.deleteElements({ elements: elementsToRemove });
}
try {
await storageService.deleteMediaAsset({ projectId, id });
} catch (error) {
console.error("Failed to delete media asset:", error);
}
}
try {
await storageService.deleteMediaAsset({ projectId, id });
} catch (error) {
console.error("Failed to delete media asset:", error);
}
}
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
this.isLoading = true;
this.notify();
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
this.isLoading = true;
this.notify();
try {
const mediaAssets = await storageService.loadAllMediaAssets({
projectId,
});
this.assets = mediaAssets;
this.notify();
} catch (error) {
console.error("Failed to load media assets:", error);
} finally {
this.isLoading = false;
this.notify();
}
}
try {
const mediaAssets = await storageService.loadAllMediaAssets({
projectId,
});
this.assets = mediaAssets;
this.notify();
} catch (error) {
console.error("Failed to load media assets:", error);
} finally {
this.isLoading = false;
this.notify();
}
}
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
this.assets.forEach((asset) => {
if (asset.url) {
URL.revokeObjectURL(asset.url);
}
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
});
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
this.assets.forEach((asset) => {
if (asset.url) {
URL.revokeObjectURL(asset.url);
}
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
});
const mediaIds = this.assets.map((asset) => asset.id);
this.assets = [];
this.notify();
const mediaIds = this.assets.map((asset) => asset.id);
this.assets = [];
this.notify();
try {
await Promise.all(
mediaIds.map((id) =>
storageService.deleteMediaAsset({ projectId, id }),
),
);
} catch (error) {
console.error("Failed to clear media assets from storage:", error);
}
}
try {
await Promise.all(
mediaIds.map((id) =>
storageService.deleteMediaAsset({ projectId, id }),
),
);
} catch (error) {
console.error("Failed to clear media assets from storage:", error);
}
}
clearAllAssets(): void {
videoCache.clearAll();
clearAllAssets(): void {
videoCache.clearAll();
this.assets.forEach((asset) => {
if (asset.url) {
URL.revokeObjectURL(asset.url);
}
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
});
this.assets.forEach((asset) => {
if (asset.url) {
URL.revokeObjectURL(asset.url);
}
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
});
this.assets = [];
this.notify();
}
this.assets = [];
this.notify();
}
getAssets(): MediaAsset[] {
return this.assets;
}
getAssets(): MediaAsset[] {
return this.assets;
}
setAssets({ assets }: { assets: MediaAsset[] }): void {
this.assets = assets;
this.notify();
}
setAssets({ assets }: { assets: MediaAsset[] }): void {
this.assets = assets;
this.notify();
}
isLoadingMedia(): boolean {
return this.isLoading;
}
isLoadingMedia(): boolean {
return this.isLoading;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
+151 -151
View File
@@ -1,188 +1,188 @@
import type { EditorCore } from "@/core";
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;
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) {}
constructor(private editor: EditorCore) {}
play(): void {
const duration = this.editor.timeline.getTotalDuration();
play(): void {
const duration = this.editor.timeline.getTotalDuration();
if (duration > 0) {
const activeProject = this.editor.project.getActive();
const fps = activeProject.settings.fps;
const frameOffset = 1 / fps;
const endThreshold = Math.max(0, duration - frameOffset);
if (duration > 0) {
const activeProject = this.editor.project.getActive();
const fps = activeProject.settings.fps;
const frameOffset = 1 / fps;
const endThreshold = Math.max(0, duration - frameOffset);
if (this.currentTime >= endThreshold) {
this.seek({ time: 0 });
}
}
if (this.currentTime >= endThreshold) {
this.seek({ time: 0 });
}
}
this.isPlaying = true;
this.startTimer();
this.notify();
}
this.isPlaying = true;
this.startTimer();
this.notify();
}
pause(): void {
this.isPlaying = false;
this.stopTimer();
this.notify();
}
pause(): void {
this.isPlaying = false;
this.stopTimer();
this.notify();
}
toggle(): void {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
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();
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 },
}),
);
}
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();
}
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();
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 },
}),
);
}
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();
}
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();
}
unmute(): void {
this.muted = false;
this.volume = this.previousVolume;
this.notify();
}
toggleMute(): void {
if (this.muted) {
this.unmute();
} else {
this.mute();
}
}
toggleMute(): void {
if (this.muted) {
this.unmute();
} else {
this.mute();
}
}
getIsPlaying(): boolean {
return this.isPlaying;
}
getIsPlaying(): boolean {
return this.isPlaying;
}
getCurrentTime(): number {
return this.currentTime;
}
getCurrentTime(): number {
return this.currentTime;
}
getVolume(): number {
return this.volume;
}
getVolume(): number {
return this.volume;
}
isMuted(): boolean {
return this.muted;
}
isMuted(): boolean {
return this.muted;
}
getSpeed(): number {
return this.speed;
}
getSpeed(): number {
return this.speed;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private startTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
}
private startTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
}
this.lastUpdate = performance.now();
this.updateTime();
}
this.lastUpdate = performance.now();
this.updateTime();
}
private stopTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
this.playbackTimer = null;
}
}
private stopTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
this.playbackTimer = null;
}
}
private updateTime = (): void => {
if (!this.isPlaying) return;
private updateTime = (): void => {
if (!this.isPlaying) return;
const now = performance.now();
const delta = (now - this.lastUpdate) / 1000;
this.lastUpdate = now;
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();
const newTime = this.currentTime + delta * this.speed;
const duration = this.editor.timeline.getTotalDuration();
if (duration > 0 && newTime >= duration) {
const activeProject = this.editor.project.getActive();
const fps = activeProject.settings.fps;
const frameOffset = 1 / fps;
const stopTime = Math.max(0, duration - frameOffset);
if (duration > 0 && newTime >= duration) {
const activeProject = this.editor.project.getActive();
const fps = activeProject.settings.fps;
const frameOffset = 1 / fps;
const stopTime = Math.max(0, duration - frameOffset);
this.pause();
this.currentTime = stopTime;
this.notify();
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-seek", {
detail: { time: stopTime },
}),
);
} else {
this.currentTime = newTime;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
detail: { time: newTime },
}),
);
}
window.dispatchEvent(
new CustomEvent("playback-update", {
detail: { time: newTime },
}),
);
}
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
}
File diff suppressed because it is too large Load Diff
+100 -100
View File
@@ -6,124 +6,124 @@ import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
export class RendererManager {
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) {}
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree;
this.notify();
}
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree;
this.notify();
}
getRenderTree(): RootNode | null {
return this.renderTree;
}
getRenderTree(): RootNode | null {
return this.renderTree;
}
async exportProject({
options,
}: {
options: ExportOptions;
}): Promise<ExportResult> {
const { format, quality, fps, includeAudio, onProgress, onCancel } =
options;
async exportProject({
options,
}: {
options: ExportOptions;
}): Promise<ExportResult> {
const { format, quality, fps, includeAudio, onProgress, onCancel } =
options;
try {
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const activeProject = this.editor.project.getActive();
try {
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const activeProject = this.editor.project.getActive();
if (!activeProject) {
return { success: false, error: "No active project" };
}
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 duration = this.editor.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Project is empty" };
}
const exportFps = fps || activeProject.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
const exportFps = fps || activeProject.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.({ progress: 0.05 });
audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
});
}
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.({ progress: 0.05 });
audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
});
}
const scene = buildScene({
tracks,
mediaAssets,
duration,
canvasSize,
background: activeProject.settings.background,
});
const scene = buildScene({
tracks,
mediaAssets,
duration,
canvasSize,
background: activeProject.settings.background,
});
const exporter = new SceneExporter({
width: canvasSize.width,
height: canvasSize.height,
fps: exportFps,
format,
quality,
includeAudio: !!includeAudio,
audioBuffer: audioBuffer || undefined,
});
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?.({ progress: adjustedProgress });
});
exporter.on("progress", (progress) => {
const adjustedProgress = includeAudio
? 0.05 + progress * 0.95
: progress;
onProgress?.({ progress: adjustedProgress });
});
let cancelled = false;
const checkCancel = () => {
if (onCancel?.()) {
cancelled = true;
exporter.cancel();
}
};
let cancelled = false;
const checkCancel = () => {
if (onCancel?.()) {
cancelled = true;
exporter.cancel();
}
};
const cancelInterval = setInterval(checkCancel, 100);
const cancelInterval = setInterval(checkCancel, 100);
try {
const buffer = await exporter.export(scene);
clearInterval(cancelInterval);
try {
const buffer = await exporter.export(scene);
clearInterval(cancelInterval);
if (cancelled) {
return { success: false, cancelled: true };
}
if (cancelled) {
return { success: false, cancelled: true };
}
if (!buffer) {
return { success: false, error: "Export failed to produce buffer" };
}
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",
};
}
}
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",
};
}
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
+82 -82
View File
@@ -1,103 +1,103 @@
import type { EditorCore } from "@/core";
type SaveManagerOptions = {
debounceMs?: number;
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> = [];
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;
}
constructor(
private editor: EditorCore,
{ debounceMs = 800 }: SaveManagerOptions = {},
) {
this.debounceMs = debounceMs;
}
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
this.unsubscribeHandlers = [
this.editor.scenes.subscribe(() => {
this.markDirty();
}),
this.editor.timeline.subscribe(() => {
this.markDirty();
}),
];
}
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();
}
stop(): void {
for (const unsubscribe of this.unsubscribeHandlers) {
unsubscribe();
}
this.unsubscribeHandlers = [];
this.clearTimer();
}
pause(): void {
this.isPaused = true;
}
pause(): void {
this.isPaused = true;
}
resume(): void {
this.isPaused = false;
if (this.hasPendingSave) {
this.queueSave();
}
}
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();
}
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();
}
async flush(): Promise<void> {
this.hasPendingSave = true;
await this.saveNow();
}
private queueSave(): void {
if (this.isSaving) return;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(() => {
void this.saveNow();
}, this.debounceMs);
}
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;
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;
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();
this.isSaving = true;
this.hasPendingSave = false;
this.clearTimer();
try {
await this.editor.project.saveCurrentProject();
} finally {
this.isSaving = false;
if (this.hasPendingSave) {
this.queueSave();
}
}
}
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;
}
private clearTimer(): void {
if (!this.saveTimer) return;
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
}
+258 -258
View File
@@ -2,314 +2,314 @@ import type { EditorCore } from "@/core";
import type { TimelineTrack, TScene } from "@/types/timeline";
import { storageService } from "@/services/storage/storage-service";
import {
getMainScene,
ensureMainScene,
canDeleteScene,
findCurrentScene,
getMainScene,
ensureMainScene,
canDeleteScene,
findCurrentScene,
} from "@/lib/scenes";
import { getFrameTime, isBookmarkAtTime } from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import {
CreateSceneCommand,
DeleteSceneCommand,
RemoveBookmarkCommand,
RenameSceneCommand,
ToggleBookmarkCommand,
CreateSceneCommand,
DeleteSceneCommand,
RemoveBookmarkCommand,
RenameSceneCommand,
ToggleBookmarkCommand,
} from "@/lib/commands/scene";
export class ScenesManager {
private active: TScene | null = null;
private list: TScene[] = [];
private listeners = new Set<() => void>();
private active: TScene | null = null;
private list: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
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");
}
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();
}
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);
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
const sceneToDelete = this.list.find((s) => s.id === sceneId);
if (!sceneToDelete) {
throw new Error("Scene not found");
}
if (!sceneToDelete) {
throw new Error("Scene not found");
}
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
if (!canDelete) {
throw new Error(reason);
}
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
if (!canDelete) {
throw new Error(reason);
}
if (!this.editor.project.getActive()) {
throw new Error("No active project");
}
if (!this.editor.project.getActive()) {
throw new Error("No active project");
}
const command = new DeleteSceneCommand(sceneId);
this.editor.command.execute({ command });
}
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");
}
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 });
}
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);
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
const targetScene = this.list.find((s) => s.id === sceneId);
if (!targetScene) {
throw new Error("Scene not found");
}
if (!targetScene) {
throw new Error("Scene not found");
}
const activeProject = this.editor.project.getActive();
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
if (activeProject) {
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
}
this.editor.project.setActiveProject({ project: updatedProject });
}
this.active = targetScene;
this.notify();
}
this.active = targetScene;
this.notify();
}
async toggleBookmark({ time }: { time: number }): Promise<void> {
const command = new ToggleBookmarkCommand(time);
this.editor.command.execute({ command });
}
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();
isBookmarked({ time }: { time: number }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
if (!activeScene || !this.active || !activeProject) return false;
if (!activeScene || !this.active || !activeProject) return false;
const frameTime = getFrameTime({
time,
fps: activeProject.settings.fps,
});
const frameTime = getFrameTime({
time,
fps: activeProject.settings.fps,
});
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
async removeBookmark({ time }: { time: number }): Promise<void> {
const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command });
}
async removeBookmark({ time }: { time: number }): Promise<void> {
const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command });
}
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,
});
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();
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();
}
}
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;
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 });
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
this.list = scenesWithMainTracks;
this.active = currentScene || fallbackScene;
this.notify();
this.list = scenesWithMainTracks;
this.active = currentScene || fallbackScene;
this.notify();
const hasAddedMainScene = ensuredScenes.length > scenes.length;
if (hasAddedMainScene || hasAddedMainTrack) {
const activeProject = this.editor.project.getActive();
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(),
},
};
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 });
}
}
}
this.editor.project.setActiveProject({ project: updatedProject });
this.editor.save.markDirty({ force: true });
}
}
}
clearScenes(): void {
this.list = [];
this.active = null;
this.notify();
}
clearScenes(): void {
this.list = [];
this.active = null;
this.notify();
}
getActiveScene(): TScene {
if (!this.active) {
throw new Error("No active scene.");
}
return this.active;
}
getActiveScene(): TScene {
if (!this.active) {
throw new Error("No active scene.");
}
return this.active;
}
getScenes(): TScene[] {
return this.list;
}
getScenes(): TScene[] {
return this.list;
}
setScenes({
scenes,
activeSceneId,
}: {
scenes: TScene[];
activeSceneId?: string;
}): void {
this.list = scenes;
this.active = activeSceneId
? (scenes.find((s) => s.id === activeSceneId) ?? null)
: this.active;
this.notify();
setScenes({
scenes,
activeSceneId,
}: {
scenes: TScene[];
activeSceneId?: string;
}): void {
this.list = scenes;
this.active = activeSceneId
? (scenes.find((s) => s.id === activeSceneId) ?? null)
: this.active;
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 });
}
}
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);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
if (!this.active) return;
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
if (!this.active) return;
const updatedScene: TScene = {
...this.active,
tracks,
updatedAt: new Date(),
};
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();
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 });
}
}
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[] = [];
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);
}
}
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 };
}
return { scenes: ensuredScenes, hasAddedMainTrack };
}
}
+238 -238
View File
@@ -1,274 +1,274 @@
import type { EditorCore } from "@/core";
import type {
TrackType,
CreateTimelineElement,
TimelineTrack,
TextElement,
TimelineElement,
ClipboardItem,
TrackType,
CreateTimelineElement,
TimelineTrack,
TextElement,
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
InsertElementCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
ToggleElementsVisibilityCommand,
ToggleElementsMutedCommand,
UpdateTextElementCommand,
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
InsertElementCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
ToggleElementsVisibilityCommand,
ToggleElementsMutedCommand,
UpdateTextElementCommand,
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
} from "@/lib/commands/timeline";
import { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) {}
addTrack({ type, index }: { type: TrackType; index?: number }): string {
const command = new AddTrackCommand(type, index);
this.editor.command.execute({ command });
return command.getTrackId();
}
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 {
const command = new RemoveTrackCommand(trackId);
this.editor.command.execute({ command });
}
removeTrack({ trackId }: { trackId: string }): void {
const command = new RemoveTrackCommand(trackId);
this.editor.command.execute({ command });
}
insertElement({ element, placement }: InsertElementParams): void {
const command = new InsertElementCommand({ element, placement });
this.editor.command.execute({ command });
}
insertElement({ element, placement }: InsertElementParams): void {
const command = new InsertElementCommand({ element, placement });
this.editor.command.execute({ command });
}
updateElementTrim({
elementId,
trimStart,
trimEnd,
pushHistory = true,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementTrim({
elementId,
trimStart,
trimEnd,
pushHistory = true,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementDuration({
trackId,
elementId,
duration,
pushHistory = true,
}: {
trackId: string;
elementId: string;
duration: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementDurationCommand(
trackId,
elementId,
duration,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementDuration({
trackId,
elementId,
duration,
pushHistory = true,
}: {
trackId: string;
elementId: string;
duration: number;
pushHistory?: boolean;
}): void {
const command = new UpdateElementDurationCommand(
trackId,
elementId,
duration,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementStartTime({
elements,
startTime,
}: {
elements: { trackId: string; elementId: string }[];
startTime: number;
}): void {
const command = new UpdateElementStartTimeCommand(elements, startTime);
this.editor.command.execute({ command });
}
updateElementStartTime({
elements,
startTime,
}: {
elements: { trackId: string; elementId: string }[];
startTime: number;
}): void {
const command = new UpdateElementStartTimeCommand(elements, startTime);
this.editor.command.execute({ command });
}
moveElement({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}): void {
const command = new MoveElementCommand(
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
);
this.editor.command.execute({ command });
}
moveElement({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}): void {
const command = new MoveElementCommand(
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
);
this.editor.command.execute({ command });
}
toggleTrackMute({ trackId }: { trackId: string }): void {
const command = new ToggleTrackMuteCommand(trackId);
this.editor.command.execute({ command });
}
toggleTrackMute({ trackId }: { trackId: string }): void {
const command = new ToggleTrackMuteCommand(trackId);
this.editor.command.execute({ command });
}
toggleTrackVisibility({ trackId }: { trackId: string }): void {
const command = new ToggleTrackVisibilityCommand(trackId);
this.editor.command.execute({ command });
}
toggleTrackVisibility({ trackId }: { trackId: string }): void {
const command = new ToggleTrackVisibilityCommand(trackId);
this.editor.command.execute({ command });
}
splitElements({
elements,
splitTime,
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): string[] {
const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command });
return command.splitElementIds;
}
splitElements({
elements,
splitTime,
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): string[] {
const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command });
return command.splitElementIds;
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this.getTracks() });
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this.getTracks() });
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
return this.getTracks().find((track) => track.id === trackId) ?? null;
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
return this.getTracks().find((track) => track.id === trackId) ?? null;
}
getElementsWithTracks({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): Array<{ track: TimelineTrack; element: TimelineElement }> {
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
[];
getElementsWithTracks({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): Array<{ track: TimelineTrack; element: TimelineElement }> {
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
[];
for (const { trackId, elementId } of elements) {
const track = this.getTrackById({ trackId });
const element = track?.elements.find(
(trackElement) => trackElement.id === elementId,
);
for (const { trackId, elementId } of elements) {
const track = this.getTrackById({ trackId });
const element = track?.elements.find(
(trackElement) => trackElement.id === elementId,
);
if (track && element) {
result.push({ track, element });
}
}
if (track && element) {
result.push({ track, element });
}
}
return result;
}
return result;
}
pasteAtTime({
time,
clipboardItems,
}: {
time: number;
clipboardItems: ClipboardItem[];
}): void {
const command = new PasteCommand(time, clipboardItems);
this.editor.command.execute({ command });
}
pasteAtTime({
time,
clipboardItems,
}: {
time: number;
clipboardItems: ClipboardItem[];
}): void {
const command = new PasteCommand(time, clipboardItems);
this.editor.command.execute({ command });
}
deleteElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DeleteElementsCommand(elements);
this.editor.command.execute({ command });
}
deleteElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DeleteElementsCommand(elements);
this.editor.command.execute({ command });
}
updateTextElement({
trackId,
elementId,
updates,
}: {
trackId: string;
elementId: string;
updates: Partial<
Pick<
TextElement,
| "content"
| "fontSize"
| "fontFamily"
| "color"
| "backgroundColor"
| "textAlign"
| "fontWeight"
| "fontStyle"
| "textDecoration"
| "transform"
| "opacity"
>
>;
}): void {
const command = new UpdateTextElementCommand(trackId, elementId, updates);
this.editor.command.execute({ command });
}
updateTextElement({
trackId,
elementId,
updates,
}: {
trackId: string;
elementId: string;
updates: Partial<
Pick<
TextElement,
| "content"
| "fontSize"
| "fontFamily"
| "color"
| "backgroundColor"
| "textAlign"
| "fontWeight"
| "fontStyle"
| "textDecoration"
| "transform"
| "opacity"
>
>;
}): void {
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 });
}
duplicateElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DuplicateElementsCommand({ elements });
this.editor.command.execute({ command });
}
toggleElementsVisibility({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsVisibilityCommand(elements);
this.editor.command.execute({ command });
}
toggleElementsVisibility({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsVisibilityCommand(elements);
this.editor.command.execute({ command });
}
toggleElementsMuted({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsMutedCommand(elements);
this.editor.command.execute({ command });
}
toggleElementsMuted({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new ToggleElementsMutedCommand(elements);
this.editor.command.execute({ command });
}
getTracks(): TimelineTrack[] {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
getTracks(): TimelineTrack[] {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
updateTracks(newTracks: TimelineTrack[]): void {
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}
updateTracks(newTracks: TimelineTrack[]): void {
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}
}