summaryrefslogtreecommitdiff
path: root/apps/web/src/core/managers
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/core/managers')
-rw-r--r--apps/web/src/core/managers/audio-manager.ts352
-rw-r--r--apps/web/src/core/managers/media-manager.ts73
-rw-r--r--apps/web/src/core/managers/playback-manager.ts15
-rw-r--r--apps/web/src/core/managers/project-manager.ts49
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts60
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts6
-rw-r--r--apps/web/src/core/managers/selection-manager.ts9
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts155
8 files changed, 598 insertions, 121 deletions
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 20b124b6..54fda6c2 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -1,7 +1,21 @@
import type { EditorCore } from "@/core";
+import {
+ clampRetimeRate,
+ shouldMaintainPitch,
+} from "@/constants/retime-constants";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
import {
+ buildAudioGainAutomation,
+ hasAnimatedVolume,
+} from "@/lib/timeline/audio-state";
+import { createAudioMasteringChain } from "@/lib/media/audio-mastering";
+import {
+ getClipTimeAtSourceTime,
+ getSourceTimeAtClipTime,
+ renderRetimedBuffer,
+} from "@/lib/retime";
+import {
ALL_FORMATS,
AudioBufferSink,
BlobSource,
@@ -26,6 +40,8 @@ export class AudioManager {
AsyncGenerator<WrappedAudioBuffer, void, unknown>
>();
private queuedSources = new Set<AudioBufferSourceNode>();
+ private preparedClipBuffers = new Map<string, Promise<AudioBuffer | null>>();
+ private decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
@@ -55,6 +71,8 @@ export class AudioManager {
window.removeEventListener("playback-seek", this.handleSeek);
}
this.disposeSinks();
+ this.preparedClipBuffers.clear();
+ this.decodedBuffers.clear();
if (this.audioContext) {
void this.audioContext.close();
this.audioContext = null;
@@ -102,6 +120,8 @@ export class AudioManager {
private handleTimelineChange = (): void => {
this.disposeSinks();
+ this.preparedClipBuffers.clear();
+ this.decodedBuffers.clear();
if (!this.editor.playback.getIsPlaying()) return;
@@ -113,9 +133,12 @@ export class AudioManager {
if (typeof window === "undefined") return null;
this.audioContext = createAudioContext();
- this.masterGain = this.audioContext.createGain();
+ const { input } = createAudioMasteringChain({
+ audioContext: this.audioContext,
+ destination: this.audioContext.destination,
+ });
+ this.masterGain = input;
this.masterGain.gain.value = this.lastVolume;
- this.masterGain.connect(this.audioContext.destination);
return this.audioContext;
}
@@ -179,11 +202,19 @@ export class AudioManager {
if (clip.startTime > windowEnd) continue;
this.activeClipIds.add(clip.id);
- void this.runClipIterator({
- clip,
- startTime: currentTime,
- sessionId: this.playbackSessionId,
- });
+ if (this.shouldUsePreparedClipBuffer({ clip })) {
+ void this.schedulePreparedClip({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
+ } else {
+ void this.runClipIterator({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
+ }
}
}
@@ -236,7 +267,11 @@ export class AudioManager {
return;
}
const sourceStartTime =
- clip.trimStart + (iteratorStartTime - clip.startTime);
+ clip.trimStart +
+ getSourceTimeAtClipTime({
+ clipTime: iteratorStartTime - clip.startTime,
+ retime: clip.retime,
+ });
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
@@ -246,12 +281,23 @@ export class AudioManager {
if (!this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
- const timelineTime = clip.startTime + (timestamp - clip.trimStart);
+ const timelineTime =
+ clip.startTime +
+ getClipTimeAtSourceTime({
+ sourceTime: timestamp - clip.trimStart,
+ retime: clip.retime,
+ });
if (timelineTime >= clipEnd) break;
const node = audioContext.createBufferSource();
node.buffer = buffer;
- node.connect(this.masterGain ?? audioContext.destination);
+ if (clip.retime) {
+ node.playbackRate.value = clampRetimeRate({ rate: clip.retime.rate });
+ }
+ const clipGain = audioContext.createGain();
+ clipGain.gain.value = clip.volume;
+ node.connect(clipGain);
+ clipGain.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime +
@@ -277,8 +323,7 @@ export class AudioManager {
nextCompensationSeconds >
this.playbackLatencyCompensationSeconds + 0.001
) {
- this.playbackLatencyCompensationSeconds =
- nextCompensationSeconds;
+ this.playbackLatencyCompensationSeconds = nextCompensationSeconds;
}
const resyncStartTime = this.getPlaybackTime();
this.clipIterators.delete(clip.id);
@@ -296,6 +341,7 @@ export class AudioManager {
this.queuedSources.add(node);
node.addEventListener("ended", () => {
node.disconnect();
+ clipGain.disconnect();
this.queuedSources.delete(node);
});
@@ -311,6 +357,73 @@ export class AudioManager {
// the set is cleared on stopPlayback anyway
}
+ private async schedulePreparedClip({
+ clip,
+ startTime,
+ sessionId,
+ }: {
+ clip: AudioClipSource;
+ startTime: number;
+ sessionId: number;
+ }): Promise<void> {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) return;
+
+ const buffer = await this.getPreparedClipBuffer({ clip });
+ if (!buffer || !this.editor.playback.getIsPlaying()) return;
+ if (sessionId !== this.playbackSessionId) return;
+
+ const clipStart = clip.startTime;
+ const clipEnd = clip.startTime + clip.duration;
+ const playbackTimeAfterReady = this.getPlaybackTime();
+ const effectiveStartTime = Math.max(
+ startTime,
+ clipStart,
+ playbackTimeAfterReady,
+ );
+ if (effectiveStartTime >= clipEnd) {
+ return;
+ }
+
+ const node = audioContext.createBufferSource();
+ node.buffer = buffer;
+ const clipGain = audioContext.createGain();
+ node.connect(clipGain);
+ clipGain.connect(this.masterGain ?? audioContext.destination);
+
+ const startTimestamp =
+ this.playbackStartContextTime +
+ this.playbackLatencyCompensationSeconds +
+ (effectiveStartTime - this.playbackStartTime);
+ const clipOffset = effectiveStartTime - clipStart;
+ let actualStartTimestamp = startTimestamp;
+ let actualClipOffset = clipOffset;
+
+ if (startTimestamp >= audioContext.currentTime) {
+ node.start(startTimestamp, clipOffset);
+ } else {
+ const lateOffset = audioContext.currentTime - startTimestamp;
+ actualStartTimestamp = audioContext.currentTime;
+ actualClipOffset = clipOffset + lateOffset;
+ node.start(actualStartTimestamp, actualClipOffset);
+ }
+
+ this.scheduleClipGainAutomation({
+ audioContext,
+ clip,
+ clipGain,
+ startTimestamp: actualStartTimestamp,
+ startLocalTime: actualClipOffset,
+ });
+
+ this.queuedSources.add(node);
+ node.addEventListener("ended", () => {
+ node.disconnect();
+ clipGain.disconnect();
+ this.queuedSources.delete(node);
+ });
+ }
+
private waitUntilCaughtUp({
timelineTime,
targetAhead,
@@ -349,6 +462,221 @@ export class AudioManager {
this.sinks.clear();
}
+ private shouldUsePreparedClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): boolean {
+ return (
+ this.hasCurveRetime({ clip }) ||
+ hasAnimatedVolume({ element: clip.timelineElement }) ||
+ shouldMaintainPitch({
+ rate: clip.retime?.rate ?? 1,
+ maintainPitch: clip.retime?.maintainPitch,
+ })
+ );
+ }
+
+ private hasCurveRetime({ clip }: { clip: AudioClipSource }): boolean {
+ const mode = (clip.retime as { mode?: unknown } | undefined)?.mode;
+ return mode === "curve";
+ }
+
+ private scheduleClipGainAutomation({
+ audioContext,
+ clip,
+ clipGain,
+ startTimestamp,
+ startLocalTime,
+ }: {
+ audioContext: AudioContext;
+ clip: AudioClipSource;
+ clipGain: GainNode;
+ startTimestamp: number;
+ startLocalTime: number;
+ }): void {
+ clipGain.gain.cancelScheduledValues(startTimestamp);
+ clipGain.gain.setValueAtTime(clip.volume, startTimestamp);
+
+ if (!hasAnimatedVolume({ element: clip.timelineElement })) {
+ return;
+ }
+
+ const points = buildAudioGainAutomation({
+ element: clip.timelineElement,
+ fromLocalTime: startLocalTime,
+ toLocalTime: clip.duration,
+ });
+
+ if (points.length === 0) {
+ return;
+ }
+
+ clipGain.gain.setValueAtTime(points[0].gain, startTimestamp);
+ for (let index = 1; index < points.length; index++) {
+ const point = points[index];
+ const pointTimestamp =
+ startTimestamp + (point.localTime - startLocalTime);
+ if (pointTimestamp < audioContext.currentTime) {
+ continue;
+ }
+
+ clipGain.gain.linearRampToValueAtTime(point.gain, pointTimestamp);
+ }
+ }
+
+ private buildPreparedClipCacheKey({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): string {
+ return JSON.stringify({
+ id: clip.id,
+ sourceKey: clip.sourceKey,
+ startTime: clip.startTime,
+ duration: clip.duration,
+ trimStart: clip.trimStart,
+ trimEnd: clip.trimEnd,
+ retime: clip.retime ?? null,
+ });
+ }
+
+ private async getPreparedClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBuffer | null> {
+ const cacheKey = this.buildPreparedClipCacheKey({ clip });
+ const existing = this.preparedClipBuffers.get(cacheKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = (async () => {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) {
+ return null;
+ }
+
+ const decodedBuffer = await this.getDecodedBuffer({ clip });
+ if (!decodedBuffer) {
+ return null;
+ }
+
+ return await renderRetimedBuffer({
+ audioContext,
+ sourceBuffer: decodedBuffer,
+ trimStart: clip.trimStart,
+ clipDuration: clip.duration,
+ retime: clip.retime,
+ maintainPitch: clip.retime?.maintainPitch === true,
+ });
+ })();
+
+ this.preparedClipBuffers.set(cacheKey, promise);
+ return promise;
+ }
+
+ private async getDecodedBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBuffer | null> {
+ const existing = this.decodedBuffers.get(clip.sourceKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = this.decodeClipBuffer({ clip });
+ this.decodedBuffers.set(clip.sourceKey, promise);
+ return promise;
+ }
+
+ private async decodeClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBuffer | null> {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) {
+ return null;
+ }
+
+ const input = new Input({
+ source: new BlobSource(clip.file),
+ formats: ALL_FORMATS,
+ });
+
+ try {
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) {
+ return null;
+ }
+
+ const sink = new AudioBufferSink(audioTrack);
+ const chunks: AudioBuffer[] = [];
+ let totalSamples = 0;
+
+ for await (const { buffer } of sink.buffers(0)) {
+ chunks.push(buffer);
+ totalSamples += buffer.length;
+ }
+
+ if (chunks.length === 0) {
+ return null;
+ }
+
+ const targetSampleRate = audioContext.sampleRate;
+ const nativeSampleRate = chunks[0].sampleRate;
+ const numChannels = Math.min(2, chunks[0].numberOfChannels);
+ const nativeChannels = Array.from(
+ { length: numChannels },
+ () => new Float32Array(totalSamples),
+ );
+
+ let offset = 0;
+ for (const chunk of chunks) {
+ for (let channel = 0; channel < numChannels; channel++) {
+ nativeChannels[channel].set(
+ chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)),
+ offset,
+ );
+ }
+ offset += chunk.length;
+ }
+
+ const outputSamples = Math.ceil(
+ totalSamples * (targetSampleRate / nativeSampleRate),
+ );
+ const offlineContext = new OfflineAudioContext(
+ numChannels,
+ outputSamples,
+ targetSampleRate,
+ );
+ const nativeBuffer = audioContext.createBuffer(
+ numChannels,
+ totalSamples,
+ nativeSampleRate,
+ );
+
+ for (let channel = 0; channel < numChannels; channel++) {
+ nativeBuffer.copyToChannel(nativeChannels[channel], channel);
+ }
+
+ const sourceNode = offlineContext.createBufferSource();
+ sourceNode.buffer = nativeBuffer;
+ sourceNode.connect(offlineContext.destination);
+ sourceNode.start(0);
+
+ return await offlineContext.startRendering();
+ } catch (error) {
+ console.warn("Failed to decode clip audio:", error);
+ return null;
+ } finally {
+ input.dispose();
+ }
+ }
+
private async getAudioSink({
clip,
}: {
diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts
index fd8726d9..cdab13ef 100644
--- a/apps/web/src/core/managers/media-manager.ts
+++ b/apps/web/src/core/managers/media-manager.ts
@@ -1,9 +1,10 @@
import type { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import { toast } from "sonner";
+import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { generateUUID } from "@/utils/id";
import { videoCache } from "@/services/video-cache/service";
-import { hasMediaId } from "@/lib/timeline/element-utils";
+import { BatchCommand, RemoveMediaAssetCommand } from "@/lib/commands";
export class MediaManager {
private assets: MediaAsset[] = [];
@@ -18,7 +19,7 @@ export class MediaManager {
}: {
projectId: string;
asset: Omit<MediaAsset, "id">;
- }): Promise<void> {
+ }): Promise<MediaAsset | null> {
const newAsset: MediaAsset = {
...asset,
id: generateUUID(),
@@ -29,54 +30,46 @@ export class MediaManager {
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
+ return newAsset;
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
this.notify();
+
+ if (storageService.isQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+
+ return null;
}
}
- async removeMediaAsset({
+ removeMediaAsset({ projectId, id }: { projectId: string; id: string }): void {
+ this.removeMediaAssets({ projectId, ids: [id] });
+ }
+
+ removeMediaAssets({
projectId,
- id,
+ ids,
}: {
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);
- }
+ ids: string[];
+ }): void {
+ const uniqueIds = [...new Set(ids)];
+ if (uniqueIds.length === 0) {
+ return;
}
- this.assets = this.assets.filter((asset) => asset.id !== id);
- this.notify();
+ const command =
+ uniqueIds.length === 1
+ ? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
+ : new BatchCommand(
+ uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
+ );
- 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);
- }
+ this.editor.command.execute({ command });
}
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
@@ -157,6 +150,8 @@ export class MediaManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index dacd76c0..5993a503 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -11,7 +11,15 @@ export class PlaybackManager {
private playbackTimer: number | null = null;
private lastUpdate = 0;
- constructor(private editor: EditorCore) {}
+ constructor(private editor: EditorCore) {
+ this.editor.timeline.subscribe(() => {
+ const duration = this.editor.timeline.getTotalDuration();
+ if (this.currentTime > duration && duration > 0) {
+ this.currentTime = duration;
+ this.notify();
+ }
+ });
+ }
play(): void {
const duration = this.editor.timeline.getTotalDuration();
@@ -117,7 +125,9 @@ export class PlaybackManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
private startTimer(): void {
@@ -158,7 +168,6 @@ export class PlaybackManager {
);
} else {
this.currentTime = newTime;
- this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 05889a94..f84a25e3 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -6,8 +6,8 @@ import type {
TProjectSortOption,
TProjectSettings,
TTimelineViewState,
-} from "@/types/project";
-import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
+} from "@/lib/project/types";
+import type { ExportOptions, ExportResult, ExportState } from "@/lib/export";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@@ -26,9 +26,9 @@ import {
runStorageMigrations,
type MigrationProgress,
} from "@/services/storage/migrations";
-import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
import { loadFonts } from "@/lib/fonts/google-fonts";
-import { collectFontFamilies } from "@/lib/timeline/element-utils";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { getElementFontFamilies } from "@/lib/timeline/element-utils";
export interface MigrationState {
isMigrating: boolean;
@@ -94,6 +94,8 @@ export class ProjectManager {
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
+ canvasSizeMode: "preset",
+ lastCustomCanvasSize: null,
originalCanvasSize: null,
background: {
type: "color",
@@ -155,7 +157,9 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
- await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) });
+ await loadFonts({
+ families: getElementFontFamilies({ tracks: allTracks }),
+ });
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
@@ -239,14 +243,21 @@ export class ProjectManager {
this.notify();
}
- await this.ensureStorageMigrations();
try {
- const metadata = await storageService.loadAllProjectsMetadata();
- this.savedProjects = metadata;
- 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();
+ }
} catch (error) {
- console.error("Failed to load projects:", error);
- } finally {
+ console.error("Failed to run migrations:", error);
this.isLoading = false;
this.isInitialized = true;
this.notify();
@@ -495,10 +506,12 @@ export class ProjectManager {
}
async prepareExit(): Promise<void> {
+ console.log("prepareExit", this.active);
if (!this.active) return;
try {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
+ console.log("didUpdateThumbnail", didUpdateThumbnail);
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
@@ -571,7 +584,7 @@ export class ProjectManager {
}
getTimelineViewState(): TTimelineViewState {
- return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
+ return this.active?.timelineViewState ?? DEFAULTS.timeline.viewState;
}
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
@@ -581,6 +594,7 @@ export class ProjectManager {
timelineViewState: viewState ?? undefined,
};
this.editor.save.markDirty();
+ this.notify();
}
getSavedProjects(): TProjectMetadata[] {
@@ -615,15 +629,12 @@ export class ProjectManager {
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,
+ duration: duration || 1,
canvasSize,
background,
});
@@ -656,7 +667,7 @@ export class ProjectManager {
);
if (index !== -1) {
- this.savedProjects[index] = project.metadata;
+ this.savedProjects = this.savedProjects.with(index, project.metadata);
} else {
this.savedProjects = [project.metadata, ...this.savedProjects];
}
@@ -665,6 +676,8 @@ export class ProjectManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index 601a007e..75184c53 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -1,6 +1,6 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
-import type { ExportOptions, ExportResult } from "@/types/export";
+import type { ExportOptions, ExportResult } from "@/lib/export";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
@@ -8,6 +8,10 @@ import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { downloadBlob } from "@/utils/browser";
+type SnapshotResult =
+ | { success: true; blob: Blob; filename: string }
+ | { success: false; error: string };
+
export class RendererManager {
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
@@ -24,6 +28,45 @@ export class RendererManager {
}
async saveSnapshot(): Promise<{ success: boolean; error?: string }> {
+ const snapshot = await this.createSnapshot();
+ if (!snapshot.success) {
+ return snapshot;
+ }
+
+ downloadBlob({ blob: snapshot.blob, filename: snapshot.filename });
+ return { success: true };
+ }
+
+ async copySnapshot(): Promise<{ success: boolean; error?: string }> {
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
+ return {
+ success: false,
+ error: "Clipboard image copy is not supported in this browser",
+ };
+ }
+
+ const snapshot = await this.createSnapshot();
+ if (!snapshot.success) {
+ return snapshot;
+ }
+
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ [snapshot.blob.type || "image/png"]: snapshot.blob,
+ }),
+ ]);
+ return { success: true };
+ } catch (error) {
+ console.error("Copy snapshot failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ }
+
+ private async createSnapshot(): Promise<SnapshotResult> {
try {
const renderTree = this.getRenderTree();
const activeProject = this.editor.project.getActive();
@@ -70,15 +113,14 @@ export class RendererManager {
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
- const safeName = activeProject.metadata.name
- .replace(/[<>:"/\\|?*]/g, "-")
- .trim() || "snapshot";
+ const safeName =
+ activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
+ "snapshot";
const filename = `${safeName}-${timecode}.png`;
- downloadBlob({ blob, filename });
- return { success: true };
+ return { success: true, blob, filename };
} catch (error) {
- console.error("Save snapshot failed:", error);
+ console.error("Snapshot capture failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
@@ -193,6 +235,8 @@ export class RendererManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts
index c844f448..b42a9b7c 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
-import type { TimelineTrack, TScene } from "@/types/timeline";
+import type { TimelineTrack, TScene } from "@/lib/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -302,7 +302,9 @@ export class ScenesManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts
index e4295c11..2062cfb0 100644
--- a/apps/web/src/core/managers/selection-manager.ts
+++ b/apps/web/src/core/managers/selection-manager.ts
@@ -1,7 +1,6 @@
import type { EditorCore } from "@/core";
-import type { SelectedKeyframeRef } from "@/types/animation";
-
-type ElementRef = { trackId: string; elementId: string };
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { ElementRef } from "@/lib/timeline/types";
export class SelectionManager {
private selectedElements: ElementRef[] = [];
@@ -67,6 +66,8 @@ export class SelectionManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 173f0744..455222d6 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,16 +1,17 @@
import type { EditorCore } from "@/core";
-import type { EffectParamValues } from "@/types/effects";
+import type { ParamValues } from "@/lib/params";
import type {
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
-} from "@/types/timeline";
+ RetimeConfig,
+} from "@/lib/timeline";
import type {
+ AnimationPath,
AnimationInterpolation,
- AnimationPropertyPath,
AnimationValue,
-} from "@/types/animation";
+} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@@ -30,6 +31,7 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
+ UpdateElementRetimeCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
@@ -38,15 +40,18 @@ import {
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
+ RemoveMaskCommand,
+ ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
} from "@/lib/commands/timeline";
-import { BatchCommand, PreviewTracker } from "@/lib/commands";
+import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
- private previewTracker = new PreviewTracker<TimelineTrack[]>();
+ private previewOverlay = new Map<string, Partial<TimelineElement>>();
+ private previewTracks: TimelineTrack[] | null = null;
constructor(private editor: EditorCore) {}
@@ -121,6 +126,29 @@ export class TimelineManager {
}
}
+ updateElementRetime({
+ trackId,
+ elementId,
+ retime,
+ pushHistory = true,
+ }: {
+ trackId: string;
+ elementId: string;
+ retime?: RetimeConfig;
+ pushHistory?: boolean;
+ }): void {
+ const command = new UpdateElementRetimeCommand({
+ trackId,
+ elementId,
+ retime,
+ });
+ if (pushHistory) {
+ this.editor.command.execute({ command });
+ } else {
+ command.execute();
+ }
+ }
+
updateElementStartTime({
elements,
startTime,
@@ -308,6 +336,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ removeMask({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }): void {
+ const command = new RemoveMaskCommand({
+ trackId,
+ elementId,
+ maskId,
+ });
+ this.editor.command.execute({ command });
+ }
+
updateClipEffectParams({
trackId,
elementId,
@@ -318,7 +363,7 @@ export class TimelineManager {
trackId: string;
elementId: string;
effectId: string;
- params: Partial<EffectParamValues>;
+ params: Partial<ParamValues>;
pushHistory?: boolean;
}): void {
const command = new UpdateClipEffectParamsCommand({
@@ -351,6 +396,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }): void {
+ const command = new ToggleMaskInvertedCommand({
+ trackId,
+ elementId,
+ maskId,
+ });
+ this.editor.command.execute({ command });
+ }
+
reorderClipEffects({
trackId,
elementId,
@@ -377,7 +439,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -419,7 +481,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}>;
}): void {
@@ -450,7 +512,7 @@ export class TimelineManager {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): void {
@@ -520,7 +582,7 @@ export class TimelineManager {
}
isPreviewActive(): boolean {
- return this.previewTracker.isActive();
+ return this.previewOverlay.size > 0;
}
previewElements({
@@ -532,37 +594,51 @@ export class TimelineManager {
updates: Partial<TimelineElement>;
}>;
}): void {
- const tracks = this.getTracks();
- this.previewTracker.begin({ state: tracks });
-
- let updatedTracks = tracks;
- for (const { trackId, elementId, updates: elementUpdates } of updates) {
- updatedTracks = updatedTracks.map((track) => {
- if (track.id !== trackId) return track;
- const newElements = track.elements.map((element) =>
- element.id === elementId
- ? { ...element, ...elementUpdates }
- : element,
- );
- return { ...track, elements: newElements } as TimelineTrack;
- });
+ for (const { elementId, updates: elementUpdates } of updates) {
+ const existingOverlay = this.previewOverlay.get(elementId);
+ const mergedOverlay = {
+ ...existingOverlay,
+ ...elementUpdates,
+ } as Partial<TimelineElement>;
+ this.previewOverlay.set(elementId, mergedOverlay);
}
- this.updateTracks(updatedTracks);
+ const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ this.previewTracks = this.applyPreviewOverlay(committedTracks);
+ this.notify();
}
commitPreview(): void {
- const snapshot = this.previewTracker.end();
- if (snapshot === null) return;
- const currentTracks = this.getTracks();
- const command = new TracksSnapshotCommand(snapshot, currentTracks);
+ if (this.previewOverlay.size === 0) return;
+ const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ const afterTracks =
+ this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
+ const command = new TracksSnapshotCommand(committedTracks, afterTracks);
this.editor.command.push({ command });
+ this.previewOverlay.clear();
+ this.previewTracks = null;
+ this.updateTracks(afterTracks);
}
discardPreview(): void {
- const snapshot = this.previewTracker.end();
- if (snapshot !== null) {
- this.updateTracks(snapshot);
- }
+ if (this.previewOverlay.size === 0) return;
+ this.previewOverlay.clear();
+ this.previewTracks = null;
+ this.notify();
+ }
+
+ private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
+ if (this.previewOverlay.size === 0) return tracks;
+ return tracks.map((track) => {
+ const hasOverlay = track.elements.some((el) =>
+ this.previewOverlay.has(el.id),
+ );
+ if (!hasOverlay) return track;
+ const newElements = track.elements.map((el) => {
+ const overlay = this.previewOverlay.get(el.id);
+ return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
+ });
+ return { ...track, elements: newElements } as TimelineTrack;
+ });
}
duplicateElements({
@@ -597,16 +673,25 @@ export class TimelineManager {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
+ getRenderTracks(): TimelineTrack[] {
+ if (this.previewTracks !== null) return this.previewTracks;
+ return this.getTracks();
+ }
+
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
updateTracks(newTracks: TimelineTrack[]): void {
+ this.previewOverlay.clear();
+ this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}