mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
codebase overhaul (#697)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user