diff options
Diffstat (limited to 'apps/web/src/core/managers')
| -rw-r--r-- | apps/web/src/core/managers/audio-manager.ts | 352 | ||||
| -rw-r--r-- | apps/web/src/core/managers/media-manager.ts | 73 | ||||
| -rw-r--r-- | apps/web/src/core/managers/playback-manager.ts | 15 | ||||
| -rw-r--r-- | apps/web/src/core/managers/project-manager.ts | 49 | ||||
| -rw-r--r-- | apps/web/src/core/managers/renderer-manager.ts | 60 | ||||
| -rw-r--r-- | apps/web/src/core/managers/scenes-manager.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/core/managers/selection-manager.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/core/managers/timeline-manager.ts | 155 |
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(); } |
