codebase overhaul (#697)

This commit is contained in:
Maze
2026-01-31 00:20:04 +01:00
committed by GitHub
parent 0173db9944
commit 7bf0984698
469 changed files with 36184 additions and 32931 deletions
+335
View File
@@ -0,0 +1,335 @@
import type { EditorCore } from "@/core";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
import {
ALL_FORMATS,
AudioBufferSink,
BlobSource,
Input,
type WrappedAudioBuffer,
} from "mediabunny";
export class AudioManager {
private audioContext: AudioContext | null = null;
private masterGain: GainNode | null = null;
private playbackStartTime = 0;
private playbackStartContextTime = 0;
private scheduleTimer: number | null = null;
private lookaheadSeconds = 2;
private scheduleIntervalMs = 500;
private clips: AudioClipSource[] = [];
private sinks = new Map<string, AudioBufferSink>();
private inputs = new Map<string, Input>();
private activeClipIds = new Set<string>();
private clipIterators = new Map<
string,
AsyncGenerator<WrappedAudioBuffer, void, unknown>
>();
private queuedSources = new Set<AudioBufferSourceNode>();
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
private unsubscribers: Array<() => void> = [];
constructor(private editor: EditorCore) {
this.lastVolume = this.editor.playback.getVolume();
this.unsubscribers.push(
this.editor.playback.subscribe(this.handlePlaybackChange),
this.editor.timeline.subscribe(this.handleTimelineChange),
this.editor.media.subscribe(this.handleTimelineChange),
);
if (typeof window !== "undefined") {
window.addEventListener("playback-seek", this.handleSeek);
}
}
dispose(): void {
this.stopPlayback();
for (const unsub of this.unsubscribers) {
unsub();
}
this.unsubscribers = [];
if (typeof window !== "undefined") {
window.removeEventListener("playback-seek", this.handleSeek);
}
this.disposeSinks();
if (this.audioContext) {
void this.audioContext.close();
this.audioContext = null;
this.masterGain = null;
}
}
private handlePlaybackChange = (): void => {
const isPlaying = this.editor.playback.getIsPlaying();
const volume = this.editor.playback.getVolume();
if (volume !== this.lastVolume) {
this.lastVolume = volume;
this.updateGain();
}
if (isPlaying !== this.lastIsPlaying) {
this.lastIsPlaying = isPlaying;
if (isPlaying) {
void this.startPlayback({
time: this.editor.playback.getCurrentTime(),
});
} else {
this.stopPlayback();
}
}
};
private handleSeek = (event: Event): void => {
const detail = (event as CustomEvent<{ time: number }>).detail;
if (!detail) return;
if (this.editor.playback.getIsPlaying()) {
void this.startPlayback({ time: detail.time });
return;
}
this.stopPlayback();
};
private handleTimelineChange = (): void => {
this.disposeSinks();
if (!this.editor.playback.getIsPlaying()) return;
void this.startPlayback({ time: this.editor.playback.getCurrentTime() });
};
private ensureAudioContext(): AudioContext | null {
if (this.audioContext) return this.audioContext;
if (typeof window === "undefined") return null;
this.audioContext = createAudioContext();
this.masterGain = this.audioContext.createGain();
this.masterGain.gain.value = this.lastVolume;
this.masterGain.connect(this.audioContext.destination);
return this.audioContext;
}
private updateGain(): void {
if (!this.masterGain) return;
this.masterGain.gain.value = this.lastVolume;
}
private getPlaybackTime(): number {
if (!this.audioContext) return this.playbackStartTime;
const elapsed = this.audioContext.currentTime - this.playbackStartContextTime;
return this.playbackStartTime + elapsed;
}
private async startPlayback({ time }: { time: number }): Promise<void> {
const audioContext = this.ensureAudioContext();
if (!audioContext) return;
this.stopPlayback();
this.playbackSessionId++;
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
if (duration <= 0) return;
if (audioContext.state === "suspended") {
await audioContext.resume();
}
this.clips = await collectAudioClips({ tracks, mediaAssets });
if (!this.editor.playback.getIsPlaying()) return;
this.playbackStartTime = time;
this.playbackStartContextTime = audioContext.currentTime;
this.scheduleUpcomingClips();
if (typeof window !== "undefined") {
this.scheduleTimer = window.setInterval(() => {
this.scheduleUpcomingClips();
}, this.scheduleIntervalMs);
}
}
private scheduleUpcomingClips(): void {
if (!this.editor.playback.getIsPlaying()) return;
const currentTime = this.getPlaybackTime();
const windowEnd = currentTime + this.lookaheadSeconds;
for (const clip of this.clips) {
if (clip.muted) continue;
if (this.activeClipIds.has(clip.id)) continue;
const clipEnd = clip.startTime + clip.duration;
if (clipEnd <= currentTime) continue;
if (clip.startTime > windowEnd) continue;
this.activeClipIds.add(clip.id);
void this.runClipIterator({ clip, startTime: currentTime, sessionId: this.playbackSessionId });
}
}
private stopPlayback(): void {
if (this.scheduleTimer && typeof window !== "undefined") {
window.clearInterval(this.scheduleTimer);
}
this.scheduleTimer = null;
for (const iterator of this.clipIterators.values()) {
void iterator.return();
}
this.clipIterators.clear();
this.activeClipIds.clear();
for (const source of this.queuedSources) {
try {
source.stop();
} catch {}
source.disconnect();
}
this.queuedSources.clear();
}
private async runClipIterator({
clip,
startTime,
sessionId,
}: {
clip: AudioClipSource;
startTime: number;
sessionId: number;
}): Promise<void> {
const audioContext = this.ensureAudioContext();
if (!audioContext) return;
const sink = await this.getAudioSink({ clip });
if (!sink || !this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
const clipStart = clip.startTime;
const clipEnd = clip.startTime + clip.duration;
const iteratorStartTime = Math.max(startTime, clipStart);
const sourceStartTime =
clip.trimStart + (iteratorStartTime - clip.startTime);
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
for await (const { buffer, timestamp } of iterator) {
if (!this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
const timelineTime = clip.startTime + (timestamp - clip.trimStart);
if (timelineTime >= clipEnd) break;
const node = audioContext.createBufferSource();
node.buffer = buffer;
node.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime +
(timelineTime - this.playbackStartTime);
if (startTimestamp >= audioContext.currentTime) {
node.start(startTimestamp);
} else {
const offset = audioContext.currentTime - startTimestamp;
if (offset < buffer.duration) {
node.start(audioContext.currentTime, offset);
} else {
continue;
}
}
this.queuedSources.add(node);
node.addEventListener("ended", () => {
node.disconnect();
this.queuedSources.delete(node);
});
const aheadTime = timelineTime - this.getPlaybackTime();
if (aheadTime >= 1) {
await this.waitUntilCaughtUp({ timelineTime, targetAhead: 1 });
if (sessionId !== this.playbackSessionId) return;
}
}
this.clipIterators.delete(clip.id);
// don't remove from activeClipIds - prevents scheduler from restarting this clip
// the set is cleared on stopPlayback anyway
}
private waitUntilCaughtUp({
timelineTime,
targetAhead,
}: {
timelineTime: number;
targetAhead: number;
}): Promise<void> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.editor.playback.getIsPlaying()) {
clearInterval(checkInterval);
resolve();
return;
}
const playbackTime = this.getPlaybackTime();
if (timelineTime - playbackTime < targetAhead) {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
}
private disposeSinks(): void {
for (const iterator of this.clipIterators.values()) {
void iterator.return();
}
this.clipIterators.clear();
this.activeClipIds.clear();
for (const input of this.inputs.values()) {
input.dispose();
}
this.inputs.clear();
this.sinks.clear();
}
private async getAudioSink({
clip,
}: {
clip: AudioClipSource;
}): Promise<AudioBufferSink | null> {
const existingSink = this.sinks.get(clip.sourceKey);
if (existingSink) return existingSink;
try {
const input = new Input({
source: new BlobSource(clip.file),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) {
input.dispose();
return null;
}
const sink = new AudioBufferSink(audioTrack);
this.inputs.set(clip.sourceKey, input);
this.sinks.set(clip.sourceKey, sink);
return sink;
} catch (error) {
console.warn("Failed to initialize audio sink:", error);
return null;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Command } from "@/lib/commands";
export class CommandManager {
private history: Command[] = [];
private redoStack: Command[] = [];
execute({ command }: { command: Command }): Command {
command.execute();
this.history.push(command);
this.redoStack = [];
return command;
}
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 = [];
}
}
+162
View File
@@ -0,0 +1,162 @@
import type { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { storageService } from "@/services/storage/storage-service";
import { generateUUID } from "@/utils/id";
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>();
constructor(private editor: EditorCore) {}
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();
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);
videoCache.clearVideo({ mediaId: id });
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();
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 });
}
}
}
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);
}
}
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();
}
}
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();
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();
this.assets.forEach((asset) => {
if (asset.url) {
URL.revokeObjectURL(asset.url);
}
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
});
this.assets = [];
this.notify();
}
getAssets(): MediaAsset[] {
return this.assets;
}
setAssets({ assets }: { assets: MediaAsset[] }): void {
this.assets = assets;
this.notify();
}
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,162 @@
import type { EditorCore } from "@/core";
export class PlaybackManager {
private isPlaying = false;
private currentTime = 0;
private volume = 1;
private muted = false;
private previousVolume = 1;
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) {
if (this.currentTime >= duration) {
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();
}
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;
}
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;
const duration = this.editor.timeline.getTotalDuration();
if (duration > 0 && newTime >= duration) {
this.pause();
this.currentTime = duration;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: duration },
}),
);
} else {
this.currentTime = newTime;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
detail: { time: newTime },
}),
);
}
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
}
@@ -0,0 +1,648 @@
import type { EditorCore } from "@/core";
import type {
TProject,
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
TProjectSettings,
TTimelineViewState,
} from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
import {
DEFAULT_FPS,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { buildScene } from "@/services/renderer/scene-builder";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import {
CURRENT_STORAGE_VERSION,
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
export interface MigrationState {
isMigrating: boolean;
fromVersion: number | null;
toVersion: number | null;
projectName: string | null;
}
export class ProjectManager {
private active: TProject | null = null;
private savedProjects: TProjectMetadata[] = [];
private isLoading = true;
private isInitialized = false;
private invalidProjectIds = new Set<string>();
private storageMigrationPromise: Promise<void> | null = null;
private listeners = new Set<() => void>();
private migrationState: MigrationState = {
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
};
constructor(private editor: EditorCore) {}
private async ensureStorageMigrations(): Promise<void> {
if (this.storageMigrationPromise) {
await this.storageMigrationPromise;
return;
}
this.storageMigrationPromise = (async () => {
let hasShownState = false;
await runStorageMigrations({
migrations,
callbacks: {
onMigrationStart: ({ fromVersion, toVersion }) => {
hasShownState = true;
this.setMigrationState({
isMigrating: true,
fromVersion,
toVersion,
projectName: null,
});
},
},
});
if (hasShownState) {
this.setMigrationState({
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
});
}
})();
await this.storageMigrationPromise;
}
async createNewProject({ name }: { name: string }): Promise<string> {
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
const newProject: TProject = {
metadata: {
id: generateUUID(),
name,
duration: getProjectDurationFromScenes({ scenes: [mainScene] }),
createdAt: new Date(),
updatedAt: new Date(),
},
scenes: [mainScene],
currentSceneId: mainScene.id,
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
originalCanvasSize: null,
background: {
type: "color",
color: DEFAULT_COLOR,
},
},
version: CURRENT_STORAGE_VERSION,
};
this.active = newProject;
this.notify();
this.editor.media.clearAllAssets();
this.editor.scenes.initializeScenes({
scenes: newProject.scenes,
currentSceneId: newProject.currentSceneId,
});
try {
await storageService.saveProject({ project: newProject });
this.updateMetadata(newProject);
return newProject.metadata.id;
} catch (error) {
toast.error("Failed to save new project");
throw error;
}
}
async loadProject({ id }: { id: string }): Promise<void> {
if (!this.isInitialized) {
this.isLoading = true;
this.notify();
}
this.editor.save.pause();
await this.ensureStorageMigrations();
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
try {
const result = await storageService.loadProject({ id });
if (!result) {
throw new Error(`Project with id ${id} not found`);
}
const project = result.project;
this.active = project;
this.notify();
if (project.scenes && project.scenes.length > 0) {
this.editor.scenes.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
}
await this.editor.media.loadProjectMedia({ projectId: id });
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
await this.saveCurrentProject();
}
}
} catch (error) {
console.error("Failed to load project:", error);
throw error;
} finally {
this.isLoading = false;
this.notify();
this.editor.save.resume();
}
}
async saveCurrentProject(): Promise<void> {
if (!this.active) return;
try {
const scenes = this.editor.scenes.getScenes();
const updatedProject = {
...this.active,
scenes,
metadata: {
...this.active.metadata,
duration: getProjectDurationFromScenes({ scenes }),
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
this.active = updatedProject;
this.updateMetadata(updatedProject);
} catch (error) {
console.error("Failed to save project:", error);
}
}
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
return this.editor.renderer.exportProject({ options });
}
async loadAllProjects(): Promise<void> {
if (!this.isInitialized) {
this.isLoading = true;
this.notify();
}
await this.ensureStorageMigrations();
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
this.notify();
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
this.isLoading = false;
this.isInitialized = true;
this.notify();
}
}
async deleteProjects({ ids }: { ids: string[] }): Promise<void> {
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0) return;
try {
await Promise.all(
uniqueIds.map((id) =>
Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProject({ id }),
]),
),
);
const idSet = new Set(uniqueIds);
this.savedProjects = this.savedProjects.filter(
(project) => !idSet.has(project.id),
);
const shouldClearActive =
this.active && idSet.has(this.active.metadata.id);
if (shouldClearActive) {
this.active = null;
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
}
this.notify();
} catch (error) {
console.error("Failed to delete projects:", error);
}
}
closeProject(): void {
this.active = null;
this.notify();
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
}
async renameProject({
id,
name,
}: {
id: string;
name: string;
}): Promise<void> {
try {
const result = await storageService.loadProject({ id });
if (!result) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject: TProject = {
...result.project,
metadata: {
...result.project.metadata,
name,
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
if (this.active?.metadata.id === id) {
this.active = updatedProject;
this.notify();
}
this.updateMetadata(updatedProject);
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
}
async duplicateProjects({ ids }: { ids: string[] }): Promise<string[]> {
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0) return [];
try {
const getDuplicateBaseName = ({ name }: { name: string }) => {
const match = name.match(/^\((\d+)\)\s+(.+)$/);
const number = match ? Number.parseInt(match[1], 10) : null;
const baseName = match ? match[2] : name;
return { baseName, number };
};
const loadResults = await Promise.all(
uniqueIds.map(async (projectId) => {
const result = await storageService.loadProject({ id: projectId });
return { projectId, project: result?.project ?? null };
}),
);
const missingProjectIds = loadResults
.filter((result) => !result.project)
.map((result) => result.projectId);
if (missingProjectIds.length > 0) {
toast.error(
missingProjectIds.length === 1
? "Project not found"
: "Projects not found",
{
description:
missingProjectIds.length === 1
? "Please try again"
: "Some projects could not be found",
},
);
throw new Error(`Projects not found: ${missingProjectIds.join(", ")}`);
}
const projectsToDuplicate = loadResults.flatMap((result) =>
result.project ? [result.project] : [],
);
const maxNumberByBaseName = new Map<string, number>();
for (const project of this.savedProjects) {
const { baseName, number } = getDuplicateBaseName({
name: project.name,
});
if (number === null) continue;
const currentMax = maxNumberByBaseName.get(baseName);
if (currentMax === undefined || number > currentMax) {
maxNumberByBaseName.set(baseName, number);
}
}
const nextNumberByBaseName = new Map<string, number>();
for (const [baseName, maxNumber] of maxNumberByBaseName) {
nextNumberByBaseName.set(baseName, maxNumber + 1);
}
const duplicationPlans = projectsToDuplicate.map((project) => {
const { baseName } = getDuplicateBaseName({
name: project.metadata.name,
});
const nextNumber = nextNumberByBaseName.get(baseName) ?? 1;
nextNumberByBaseName.set(baseName, nextNumber + 1);
const newProjectId = generateUUID();
const newProject: TProject = {
...project,
metadata: {
...project.metadata,
id: newProjectId,
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
},
};
return {
newProjectId,
newProject,
sourceProjectId: project.metadata.id,
};
});
await Promise.all(
duplicationPlans.map(({ newProject }) =>
storageService.saveProject({ project: newProject }),
),
);
await Promise.all(
duplicationPlans.map(async ({ sourceProjectId, newProjectId }) => {
const sourceMediaAssets = await storageService.loadAllMediaAssets({
projectId: sourceProjectId,
});
await Promise.all(
sourceMediaAssets.map((mediaAsset) =>
storageService.saveMediaAsset({
projectId: newProjectId,
mediaAsset,
}),
),
);
}),
);
for (const { newProject } of duplicationPlans) {
this.updateMetadata(newProject);
}
return duplicationPlans.map((plan) => plan.newProjectId);
} catch (error) {
console.error("Failed to duplicate projects:", error);
toast.error("Failed to duplicate projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
}
async updateSettings({
settings,
pushHistory = true,
}: {
settings: Partial<TProjectSettings>;
pushHistory?: boolean;
}): Promise<void> {
if (!this.active) return;
const command = new UpdateProjectSettingsCommand(settings);
if (pushHistory) {
this.editor.command.execute({ command });
return;
}
command.execute();
}
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
if (!this.active) return;
const updatedProject: TProject = {
...this.active,
metadata: { ...this.active.metadata, thumbnail, updatedAt: new Date() },
};
this.active = updatedProject;
this.notify();
this.updateMetadata(updatedProject);
this.editor.save.markDirty();
}
async prepareExit(): Promise<void> {
if (!this.active) return;
try {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
} catch (error) {
console.error("Failed to generate project thumbnail on exit:", error);
}
}
getFilteredAndSortedProjects({
searchQuery,
sortOption,
}: {
searchQuery: string;
sortOption: TProjectSortOption;
}): TProjectMetadata[] {
const filteredProjects = this.savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const [key, order] = sortOption.split("-") as [
TProjectSortKey,
"asc" | "desc",
];
const sortedProjects = [...filteredProjects].sort((a, b) => {
const aValue = a[key];
const bValue = b[key];
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
}
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
});
return sortedProjects;
}
isInvalidProjectId({ id }: { id: string }): boolean {
return this.invalidProjectIds.has(id);
}
markProjectIdAsInvalid({ id }: { id: string }): void {
this.invalidProjectIds.add(id);
this.notify();
}
clearInvalidProjectIds(): void {
this.invalidProjectIds.clear();
this.notify();
}
getActive(): TProject {
if (!this.active) {
throw new Error("No active project");
}
return this.active;
}
/**
* for agents:
* in most cases, the project is guaranteed to be active, in which getActive() should be used instead.
* for very rare cases, this function may be used.
*/
getActiveOrNull(): TProject | null {
return this.active;
}
getTimelineViewState(): TTimelineViewState {
return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
}
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
if (!this.active) return;
this.active = {
...this.active,
timelineViewState: viewState ?? undefined,
};
this.editor.save.markDirty();
}
getSavedProjects(): TProjectMetadata[] {
return this.savedProjects;
}
getIsLoading(): boolean {
return this.isLoading;
}
getIsInitialized(): boolean {
return this.isInitialized;
}
getMigrationState(): MigrationState {
return this.migrationState;
}
private setMigrationState(state: Partial<MigrationState>): void {
this.migrationState = { ...this.migrationState, ...state };
this.notify();
}
setActiveProject({ project }: { project: TProject }): void {
this.active = project;
this.notify();
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private async updateThumbnailFromTimeline(): Promise<boolean> {
if (!this.active) return false;
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
if (duration === 0) return false;
const { canvasSize, background } = this.active.settings;
const scene = buildScene({
tracks,
mediaAssets,
duration,
canvasSize,
background,
});
const renderer = new CanvasRenderer({
width: canvasSize.width,
height: canvasSize.height,
fps: this.active.settings.fps,
});
const tempCanvas = document.createElement("canvas");
tempCanvas.width = canvasSize.width;
tempCanvas.height = canvasSize.height;
await renderer.renderToCanvas({
node: scene,
time: 0,
targetCanvas: tempCanvas,
});
const thumbnailDataUrl = tempCanvas.toDataURL("image/png");
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
return true;
}
private updateMetadata(project: TProject): void {
const index = this.savedProjects.findIndex(
(p) => p.id === project.metadata.id,
);
if (index !== -1) {
this.savedProjects[index] = project.metadata;
} else {
this.savedProjects = [project.metadata, ...this.savedProjects];
}
this.notify();
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
@@ -0,0 +1,129 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/types/export";
import { SceneExporter } from "@/services/renderer/scene-exporter";
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>();
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 mediaAssets = this.editor.media.getAssets();
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.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
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 exporter = new SceneExporter({
width: canvasSize.width,
height: canvasSize.height,
fps: exportFps,
format,
quality,
shouldIncludeAudio: !!includeAudio,
audioBuffer: audioBuffer || undefined,
});
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();
}
};
const cancelInterval = setInterval(checkCancel, 100);
try {
const buffer = await exporter.export({ rootNode: 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",
};
}
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { EditorCore } from "@/core";
type SaveManagerOptions = {
debounceMs?: number;
};
export class SaveManager {
private debounceMs: number;
private isPaused = false;
private isSaving = false;
private hasPendingSave = false;
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeHandlers: Array<() => void> = [];
constructor(
private editor: EditorCore,
{ debounceMs = 800 }: SaveManagerOptions = {},
) {
this.debounceMs = debounceMs;
}
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
this.unsubscribeHandlers = [
this.editor.scenes.subscribe(() => {
this.markDirty();
}),
this.editor.timeline.subscribe(() => {
this.markDirty();
}),
];
}
stop(): void {
for (const unsubscribe of this.unsubscribeHandlers) {
unsubscribe();
}
this.unsubscribeHandlers = [];
this.clearTimer();
}
pause(): void {
this.isPaused = true;
}
resume(): void {
this.isPaused = false;
if (this.hasPendingSave) {
this.queueSave();
}
}
markDirty({ force = false }: { force?: boolean } = {}): void {
if (this.isPaused && !force) return;
this.hasPendingSave = true;
this.queueSave();
}
async flush(): Promise<void> {
this.hasPendingSave = true;
await this.saveNow();
}
getIsDirty(): boolean {
return this.hasPendingSave || this.isSaving;
}
private queueSave(): void {
if (this.isSaving) return;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(() => {
void this.saveNow();
}, this.debounceMs);
}
private async saveNow(): Promise<void> {
if (this.isSaving) return;
if (!this.hasPendingSave) return;
const activeProject = this.editor.project.getActive();
if (!activeProject) return;
if (this.editor.project.getIsLoading()) return;
if (this.editor.project.getMigrationState().isMigrating) return;
this.isSaving = true;
this.hasPendingSave = false;
this.clearTimer();
try {
await this.editor.project.saveCurrentProject();
} finally {
this.isSaving = false;
if (this.hasPendingSave) {
this.queueSave();
}
}
}
private clearTimer(): void {
if (!this.saveTimer) return;
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
}
@@ -0,0 +1,316 @@
import type { EditorCore } from "@/core";
import type { TimelineTrack, TScene } from "@/types/timeline";
import { storageService } from "@/services/storage/storage-service";
import {
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,
} from "@/lib/commands/scene";
export class ScenesManager {
private active: TScene | null = null;
private list: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
async createScene({
name,
isMain = false,
}: {
name: string;
isMain: boolean;
}): Promise<string> {
if (!this.editor.project.getActive()) {
throw new Error("No active project");
}
const command = new CreateSceneCommand(name, isMain);
this.editor.command.execute({ command });
return command.getSceneId();
}
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
const sceneToDelete = this.list.find((s) => s.id === sceneId);
if (!sceneToDelete) {
throw new Error("Scene not found");
}
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
if (!canDelete) {
throw new Error(reason);
}
if (!this.editor.project.getActive()) {
throw new Error("No active project");
}
const command = new DeleteSceneCommand(sceneId);
this.editor.command.execute({ command });
}
async renameScene({
sceneId,
name,
}: {
sceneId: string;
name: string;
}): Promise<void> {
if (!this.editor.project.getActive()) {
throw new Error("No active project");
}
const command = new RenameSceneCommand(sceneId, name);
this.editor.command.execute({ command });
}
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
const targetScene = this.list.find((s) => s.id === sceneId);
if (!targetScene) {
throw new Error("Scene not found");
}
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
}
this.active = targetScene;
this.notify();
}
async toggleBookmark({ time }: { time: number }): Promise<void> {
const command = new ToggleBookmarkCommand(time);
this.editor.command.execute({ command });
}
isBookmarked({ time }: { time: number }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
if (!activeScene || !this.active || !activeProject) return false;
const frameTime = getFrameTime({
time,
fps: activeProject.settings.fps,
});
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
async removeBookmark({ time }: { time: number }): Promise<void> {
const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command });
}
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
try {
const result = await storageService.loadProject({ id: projectId });
if (result?.project.scenes) {
const { scenes: ensuredScenes, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({
scenes: result.project.scenes ?? [],
});
const currentScene = findCurrentScene({
scenes: ensuredScenes,
currentSceneId: result.project.currentSceneId,
});
this.list = ensuredScenes;
this.active = currentScene;
this.notify();
if (hasAddedMainTrack) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
this.editor.save.markDirty({ force: true });
}
}
}
} catch (error) {
console.error("Failed to load project scenes:", error);
this.list = [];
this.active = null;
this.notify();
}
}
initializeScenes({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}): void {
const ensuredScenes = ensureMainScene({ scenes });
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
const currentScene = currentSceneId
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
this.list = scenesWithMainTracks;
this.active = currentScene || fallbackScene;
this.notify();
const hasAddedMainScene = ensuredScenes.length > scenes.length;
if (hasAddedMainScene || hasAddedMainTrack) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: scenesWithMainTracks,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
this.editor.save.markDirty({ force: true });
}
}
}
clearScenes(): void {
this.list = [];
this.active = null;
this.notify();
}
getActiveScene(): TScene {
if (!this.active) {
throw new Error("No active scene.");
}
return this.active;
}
getScenes(): TScene[] {
return this.list;
}
setScenes({
scenes,
activeSceneId,
}: {
scenes: TScene[];
activeSceneId?: string;
}): void {
this.list = scenes;
const nextActiveSceneId = activeSceneId ?? this.active?.id ?? null;
this.active = nextActiveSceneId
? (scenes.find((scene) => scene.id === nextActiveSceneId) ?? null)
: null;
this.notify();
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
}
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
if (!this.active) return;
const updatedScene: TScene = {
...this.active,
tracks,
updatedAt: new Date(),
};
this.list = this.list.map((s) =>
s.id === this.active?.id ? updatedScene : s,
);
this.active = updatedScene;
this.notify();
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: this.list,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
}
}
private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
scenes: TScene[];
hasAddedMainTrack: boolean;
} {
let hasAddedMainTrack = false;
const ensuredScenes: TScene[] = [];
for (const scene of scenes) {
const existingTracks = scene.tracks ?? [];
const updatedTracks = ensureMainTrack({ tracks: existingTracks });
if (updatedTracks !== existingTracks) {
hasAddedMainTrack = true;
ensuredScenes.push({
...scene,
tracks: updatedTracks,
updatedAt: new Date(),
});
} else {
ensuredScenes.push(scene);
}
}
return { scenes: ensuredScenes, hasAddedMainTrack };
}
}
@@ -0,0 +1,35 @@
import type { EditorCore } from "@/core";
type ElementRef = { trackId: string; elementId: string };
export class SelectionManager {
private selectedElements: ElementRef[] = [];
private listeners = new Set<() => void>();
constructor(editor: EditorCore) {
void editor;
}
getSelectedElements(): ElementRef[] {
return this.selectedElements;
}
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
this.selectedElements = elements;
this.notify();
}
clearSelection(): void {
this.selectedElements = [];
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,275 @@
import type { EditorCore } from "@/core";
import type {
TrackType,
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,
} from "@/lib/commands/timeline";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
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();
}
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 });
}
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();
}
}
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 });
}
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 });
}
splitElements({
elements,
splitTime,
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand(elements, splitTime, retainSide);
this.editor.command.execute({ command });
return command.getRightSideElements();
}
getTotalDuration(): number {
return calculateTotalDuration({ tracks: this.getTracks() });
}
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 }> =
[];
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 });
}
}
return result;
}
pasteAtTime({
time,
clipboardItems,
}: {
time: number;
clipboardItems: ClipboardItem[];
}): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems);
this.editor.command.execute({ command });
return command.getPastedElements();
}
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 });
}
duplicateElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): { trackId: string; elementId: string }[] {
const command = new DuplicateElementsCommand({ elements });
this.editor.command.execute({ command });
return command.getDuplicatedElements();
}
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 });
}
getTracks(): TimelineTrack[] {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
updateTracks(newTracks: TimelineTrack[]): void {
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}
}