From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/core/index.ts | 106 +- apps/web/src/core/managers/audio-manager.ts | 1416 +++++++++++------------ apps/web/src/core/managers/commands.ts | 98 +- apps/web/src/core/managers/media-manager.ts | 3 + apps/web/src/core/managers/playback-manager.ts | 24 +- apps/web/src/core/managers/project-manager.ts | 19 + apps/web/src/core/managers/renderer-manager.ts | 6 +- apps/web/src/core/managers/save-manager.ts | 214 ++-- apps/web/src/core/managers/scenes-manager.ts | 726 ++++++------ apps/web/src/core/managers/selection-manager.ts | 146 +-- apps/web/src/core/managers/timeline-manager.ts | 8 + 11 files changed, 1397 insertions(+), 1369 deletions(-) (limited to 'apps/web/src/core') diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index e7cc5496..ee1873b0 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -1,53 +1,53 @@ -import { PlaybackManager } from "./managers/playback-manager"; -import { TimelineManager } from "./managers/timeline-manager"; -import { ScenesManager } from "./managers/scenes-manager"; -import { ProjectManager } from "./managers/project-manager"; -import { MediaManager } from "./managers/media-manager"; -import { RendererManager } from "./managers/renderer-manager"; -import { CommandManager } from "./managers/commands"; -import { SaveManager } from "./managers/save-manager"; -import { AudioManager } from "./managers/audio-manager"; -import { SelectionManager } from "./managers/selection-manager"; -import { registerDefaultEffects } from "@/lib/effects"; -import { registerDefaultMasks } from "@/lib/masks"; - -export class EditorCore { - private static instance: EditorCore | null = null; - public readonly timeline: TimelineManager; - public readonly command: CommandManager; - public readonly playback: PlaybackManager; - public readonly scenes: ScenesManager; - public readonly project: ProjectManager; - public readonly media: MediaManager; - public readonly renderer: RendererManager; - public readonly save: SaveManager; - public readonly audio: AudioManager; - public readonly selection: SelectionManager; - - private constructor() { - registerDefaultEffects(); - registerDefaultMasks(); - this.command = new CommandManager(); - this.timeline = new TimelineManager(this); - this.playback = new PlaybackManager(this); - this.scenes = new ScenesManager(this); - this.project = new ProjectManager(this); - this.media = new MediaManager(this); - this.renderer = new RendererManager(this); - this.save = new SaveManager(this); - this.audio = new AudioManager(this); - this.selection = new SelectionManager(this); - this.save.start(); - } - - static getInstance(): EditorCore { - if (!EditorCore.instance) { - EditorCore.instance = new EditorCore(); - } - return EditorCore.instance; - } - - static reset(): void { - EditorCore.instance = null; - } -} +import { PlaybackManager } from "./managers/playback-manager"; +import { TimelineManager } from "./managers/timeline-manager"; +import { ScenesManager } from "./managers/scenes-manager"; +import { ProjectManager } from "./managers/project-manager"; +import { MediaManager } from "./managers/media-manager"; +import { RendererManager } from "./managers/renderer-manager"; +import { CommandManager } from "./managers/commands"; +import { SaveManager } from "./managers/save-manager"; +import { AudioManager } from "./managers/audio-manager"; +import { SelectionManager } from "./managers/selection-manager"; +import { registerDefaultEffects } from "@/lib/effects"; +import { registerDefaultMasks } from "@/lib/masks"; + +export class EditorCore { + private static instance: EditorCore | null = null; + public readonly timeline: TimelineManager; + public readonly command: CommandManager; + public readonly playback: PlaybackManager; + public readonly scenes: ScenesManager; + public readonly project: ProjectManager; + public readonly media: MediaManager; + public readonly renderer: RendererManager; + public readonly save: SaveManager; + public readonly audio: AudioManager; + public readonly selection: SelectionManager; + + private constructor() { + registerDefaultEffects(); + registerDefaultMasks(); + this.command = new CommandManager(); + this.timeline = new TimelineManager(this); + this.playback = new PlaybackManager(this); + this.scenes = new ScenesManager(this); + this.project = new ProjectManager(this); + this.media = new MediaManager(this); + this.renderer = new RendererManager(this); + this.save = new SaveManager(this); + this.audio = new AudioManager(this); + this.selection = new SelectionManager(this); + this.save.start(); + } + + static getInstance(): EditorCore { + if (!EditorCore.instance) { + EditorCore.instance = new EditorCore(); + } + return EditorCore.instance; + } + + static reset(): void { + EditorCore.instance = null; + } +} diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 54fda6c2..9e6be22c 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -1,708 +1,708 @@ -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, - 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(); - private inputs = new Map(); - private activeClipIds = new Set(); - private clipIterators = new Map< - string, - AsyncGenerator - >(); - private queuedSources = new Set(); - private preparedClipBuffers = new Map>(); - private decodedBuffers = new Map>(); - private playbackSessionId = 0; - private lastIsPlaying = false; - private lastVolume = 1; - private playbackLatencyCompensationSeconds = 0; - 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(); - this.preparedClipBuffers.clear(); - this.decodedBuffers.clear(); - 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.getIsScrubbing()) { - this.stopPlayback(); - return; - } - - if (this.editor.playback.getIsPlaying()) { - void this.startPlayback({ time: detail.time }); - return; - } - - this.stopPlayback(); - }; - - private handleTimelineChange = (): void => { - this.disposeSinks(); - this.preparedClipBuffers.clear(); - this.decodedBuffers.clear(); - - 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(); - const { input } = createAudioMasteringChain({ - audioContext: this.audioContext, - destination: this.audioContext.destination, - }); - this.masterGain = input; - this.masterGain.gain.value = this.lastVolume; - 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 { - const audioContext = this.ensureAudioContext(); - if (!audioContext) return; - - this.stopPlayback(); - this.playbackSessionId++; - this.playbackLatencyCompensationSeconds = 0; - - 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); - if (this.shouldUsePreparedClipBuffer({ clip })) { - void this.schedulePreparedClip({ - clip, - startTime: currentTime, - sessionId: this.playbackSessionId, - }); - } else { - 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 { - 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 playbackTimeAfterSinkReady = this.getPlaybackTime(); - const iteratorStartTime = Math.max( - startTime, - clipStart, - playbackTimeAfterSinkReady, - ); - if (iteratorStartTime >= clipEnd) { - return; - } - const sourceStartTime = - clip.trimStart + - getSourceTimeAtClipTime({ - clipTime: iteratorStartTime - clip.startTime, - retime: clip.retime, - }); - - const iterator = sink.buffers(sourceStartTime); - this.clipIterators.set(clip.id, iterator); - let consecutiveDroppedBufferCount = 0; - - for await (const { buffer, timestamp } of iterator) { - if (!this.editor.playback.getIsPlaying()) return; - if (sessionId !== this.playbackSessionId) return; - - const timelineTime = - clip.startTime + - getClipTimeAtSourceTime({ - sourceTime: timestamp - clip.trimStart, - retime: clip.retime, - }); - if (timelineTime >= clipEnd) break; - - const node = audioContext.createBufferSource(); - node.buffer = buffer; - 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 + - this.playbackLatencyCompensationSeconds + - (timelineTime - this.playbackStartTime); - - if (startTimestamp >= audioContext.currentTime) { - node.start(startTimestamp); - consecutiveDroppedBufferCount = 0; - } else { - const offset = audioContext.currentTime - startTimestamp; - if (offset < buffer.duration) { - node.start(audioContext.currentTime, offset); - consecutiveDroppedBufferCount = 0; - } else { - consecutiveDroppedBufferCount += 1; - if (consecutiveDroppedBufferCount >= 5) { - const nextCompensationSeconds = Math.max( - this.playbackLatencyCompensationSeconds, - Math.min(0.25, offset + 0.01), - ); - if ( - nextCompensationSeconds > - this.playbackLatencyCompensationSeconds + 0.001 - ) { - this.playbackLatencyCompensationSeconds = nextCompensationSeconds; - } - const resyncStartTime = this.getPlaybackTime(); - this.clipIterators.delete(clip.id); - void this.runClipIterator({ - clip, - startTime: resyncStartTime, - sessionId, - }); - return; - } - continue; - } - } - - this.queuedSources.add(node); - node.addEventListener("ended", () => { - node.disconnect(); - clipGain.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 async schedulePreparedClip({ - clip, - startTime, - sessionId, - }: { - clip: AudioClipSource; - startTime: number; - sessionId: number; - }): Promise { - 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, - }: { - timelineTime: number; - targetAhead: number; - }): Promise { - 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 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 { - 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 { - 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 { - 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, - }: { - clip: AudioClipSource; - }): Promise { - 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; - } - } -} +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, + 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(); + private inputs = new Map(); + private activeClipIds = new Set(); + private clipIterators = new Map< + string, + AsyncGenerator + >(); + private queuedSources = new Set(); + private preparedClipBuffers = new Map>(); + private decodedBuffers = new Map>(); + private playbackSessionId = 0; + private lastIsPlaying = false; + private lastVolume = 1; + private playbackLatencyCompensationSeconds = 0; + 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(); + this.preparedClipBuffers.clear(); + this.decodedBuffers.clear(); + 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.getIsScrubbing()) { + this.stopPlayback(); + return; + } + + if (this.editor.playback.getIsPlaying()) { + void this.startPlayback({ time: detail.time }); + return; + } + + this.stopPlayback(); + }; + + private handleTimelineChange = (): void => { + this.disposeSinks(); + this.preparedClipBuffers.clear(); + this.decodedBuffers.clear(); + + 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(); + const { input } = createAudioMasteringChain({ + audioContext: this.audioContext, + destination: this.audioContext.destination, + }); + this.masterGain = input; + this.masterGain.gain.value = this.lastVolume; + 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 { + const audioContext = this.ensureAudioContext(); + if (!audioContext) return; + + this.stopPlayback(); + this.playbackSessionId++; + this.playbackLatencyCompensationSeconds = 0; + + 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); + if (this.shouldUsePreparedClipBuffer({ clip })) { + void this.schedulePreparedClip({ + clip, + startTime: currentTime, + sessionId: this.playbackSessionId, + }); + } else { + 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 { + 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 playbackTimeAfterSinkReady = this.getPlaybackTime(); + const iteratorStartTime = Math.max( + startTime, + clipStart, + playbackTimeAfterSinkReady, + ); + if (iteratorStartTime >= clipEnd) { + return; + } + const sourceStartTime = + clip.trimStart + + getSourceTimeAtClipTime({ + clipTime: iteratorStartTime - clip.startTime, + retime: clip.retime, + }); + + const iterator = sink.buffers(sourceStartTime); + this.clipIterators.set(clip.id, iterator); + let consecutiveDroppedBufferCount = 0; + + for await (const { buffer, timestamp } of iterator) { + if (!this.editor.playback.getIsPlaying()) return; + if (sessionId !== this.playbackSessionId) return; + + const timelineTime = + clip.startTime + + getClipTimeAtSourceTime({ + sourceTime: timestamp - clip.trimStart, + retime: clip.retime, + }); + if (timelineTime >= clipEnd) break; + + const node = audioContext.createBufferSource(); + node.buffer = buffer; + 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 + + this.playbackLatencyCompensationSeconds + + (timelineTime - this.playbackStartTime); + + if (startTimestamp >= audioContext.currentTime) { + node.start(startTimestamp); + consecutiveDroppedBufferCount = 0; + } else { + const offset = audioContext.currentTime - startTimestamp; + if (offset < buffer.duration) { + node.start(audioContext.currentTime, offset); + consecutiveDroppedBufferCount = 0; + } else { + consecutiveDroppedBufferCount += 1; + if (consecutiveDroppedBufferCount >= 5) { + const nextCompensationSeconds = Math.max( + this.playbackLatencyCompensationSeconds, + Math.min(0.25, offset + 0.01), + ); + if ( + nextCompensationSeconds > + this.playbackLatencyCompensationSeconds + 0.001 + ) { + this.playbackLatencyCompensationSeconds = nextCompensationSeconds; + } + const resyncStartTime = this.getPlaybackTime(); + this.clipIterators.delete(clip.id); + void this.runClipIterator({ + clip, + startTime: resyncStartTime, + sessionId, + }); + return; + } + continue; + } + } + + this.queuedSources.add(node); + node.addEventListener("ended", () => { + node.disconnect(); + clipGain.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 async schedulePreparedClip({ + clip, + startTime, + sessionId, + }: { + clip: AudioClipSource; + startTime: number; + sessionId: number; + }): Promise { + 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, + }: { + timelineTime: number; + targetAhead: number; + }): Promise { + 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 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 { + 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 { + 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 { + 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, + }: { + clip: AudioClipSource; + }): Promise { + 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; + } + } +} diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts index 46ee643a..d4d4c35c 100644 --- a/apps/web/src/core/managers/commands.ts +++ b/apps/web/src/core/managers/commands.ts @@ -1,49 +1,49 @@ -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; - } - - push({ command }: { command: Command }): void { - this.history.push(command); - this.redoStack = []; - } - - 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 = []; - } -} +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; + } + + push({ command }: { command: Command }): void { + this.history.push(command); + this.redoStack = []; + } + + 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 = []; + } +} diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts index cdab13ef..4add3c04 100644 --- a/apps/web/src/core/managers/media-manager.ts +++ b/apps/web/src/core/managers/media-manager.ts @@ -30,6 +30,9 @@ export class MediaManager { try { await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset }); + this.editor.project.ratchetFpsForImportedMedia({ + importedAssets: [newAsset], + }); return newAsset; } catch (error) { console.error("Failed to save media asset:", error); diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 5993a503..6943f187 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -13,19 +13,19 @@ export class PlaybackManager { constructor(private editor: EditorCore) { this.editor.timeline.subscribe(() => { - const duration = this.editor.timeline.getTotalDuration(); - if (this.currentTime > duration && duration > 0) { - this.currentTime = duration; + const maxTime = this.editor.timeline.getLastSeekableTime(); + if (this.currentTime > maxTime && maxTime > 0) { + this.currentTime = maxTime; this.notify(); } }); } play(): void { - const duration = this.editor.timeline.getTotalDuration(); + const maxTime = this.editor.timeline.getLastSeekableTime(); - if (duration > 0) { - if (this.currentTime >= duration) { + if (maxTime > 0) { + if (this.currentTime >= maxTime) { this.seek({ time: 0 }); } } @@ -50,8 +50,8 @@ export class PlaybackManager { } seek({ time }: { time: number }): void { - const duration = this.editor.timeline.getTotalDuration(); - this.currentTime = Math.max(0, Math.min(duration, time)); + const maxTime = this.editor.timeline.getLastSeekableTime(); + this.currentTime = Math.max(0, Math.min(maxTime, time)); this.notify(); window.dispatchEvent( @@ -154,16 +154,16 @@ export class PlaybackManager { this.lastUpdate = now; const newTime = this.currentTime + delta; - const duration = this.editor.timeline.getTotalDuration(); + const maxTime = this.editor.timeline.getLastSeekableTime(); - if (duration > 0 && newTime >= duration) { + if (maxTime > 0 && newTime >= maxTime) { this.pause(); - this.currentTime = duration; + this.currentTime = maxTime; this.notify(); window.dispatchEvent( new CustomEvent("playback-seek", { - detail: { time: duration }, + detail: { time: maxTime }, }), ); } else { diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index f84a25e3..916ab328 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -29,6 +29,8 @@ import { import { loadFonts } from "@/lib/fonts/google-fonts"; import { DEFAULTS } from "@/lib/timeline/defaults"; import { getElementFontFamilies } from "@/lib/timeline/element-utils"; +import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps"; +import type { MediaAsset } from "@/lib/media/types"; export interface MigrationState { isMigrating: boolean; @@ -492,6 +494,23 @@ export class ProjectManager { command.execute(); } + ratchetFpsForImportedMedia({ + importedAssets, + }: { + importedAssets: Array>; + }): number | null { + if (!this.active) return null; + + const nextFps = getRaisedProjectFpsForImportedMedia({ + currentFps: this.active.settings.fps, + importedAssets, + }); + if (nextFps === null) return null; + + new UpdateProjectSettingsCommand({ fps: nextFps }).execute(); + return nextFps; + } + async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise { if (!this.active) return; diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index 75184c53..a2e9371b 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import { SceneExporter } from "@/services/renderer/scene-exporter"; import { buildScene } from "@/services/renderer/scene-builder"; import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import { formatTimeCode, getLastFrameTime } from "@/lib/time"; +import { formatTimeCode } from "@/lib/time"; import { downloadBlob } from "@/utils/browser"; type SnapshotResult = @@ -81,9 +81,7 @@ export class RendererManager { } const { canvasSize, fps } = activeProject.settings; - const currentTime = this.editor.playback.getCurrentTime(); - const lastFrameTime = getLastFrameTime({ duration, fps }); - const renderTime = Math.min(currentTime, lastFrameTime); + const renderTime = this.editor.playback.getCurrentTime(); const renderer = new CanvasRenderer({ width: canvasSize.width, diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts index d5881b6b..b3c894d4 100644 --- a/apps/web/src/core/managers/save-manager.ts +++ b/apps/web/src/core/managers/save-manager.ts @@ -1,107 +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 | 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 { - 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 { - 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; - } -} +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 | 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 { + 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 { + 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; + } +} diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index b42a9b7c..3c53efd6 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,363 +1,363 @@ -import type { EditorCore } from "@/core"; -import type { TimelineTrack, TScene } from "@/lib/timeline"; -import { storageService } from "@/services/storage/service"; -import { - getMainScene, - ensureMainScene, - canDeleteScene, - findCurrentScene, -} from "@/lib/scenes"; -import { - getBookmarkAtTime, - getFrameTime, - isBookmarkAtTime, -} from "@/lib/timeline/bookmarks"; -import { ensureMainTrack } from "@/lib/timeline/track-utils"; -import { - CreateSceneCommand, - DeleteSceneCommand, - MoveBookmarkCommand, - RemoveBookmarkCommand, - RenameSceneCommand, - ToggleBookmarkCommand, - UpdateBookmarkCommand, -} 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const command = new RemoveBookmarkCommand(time); - this.editor.command.execute({ command }); - } - - async updateBookmark({ - time, - updates, - }: { - time: number; - updates: Partial<{ note: string; color: string; duration: number }>; - }): Promise { - const command = new UpdateBookmarkCommand(time, updates); - this.editor.command.execute({ command }); - } - - async moveBookmark({ - fromTime, - toTime, - }: { - fromTime: number; - toTime: number; - }): Promise { - const command = new MoveBookmarkCommand(fromTime, toTime); - this.editor.command.execute({ command }); - } - - getBookmarkAtTime({ time }: { time: number }) { - const activeScene = this.active; - const activeProject = this.editor.project.getActive(); - - if (!activeScene || !activeProject) return null; - - const frameTime = getFrameTime({ - time, - fps: activeProject.settings.fps, - }); - - return getBookmarkAtTime({ - bookmarks: activeScene.bookmarks, - frameTime, - }); - } - - async loadProjectScenes({ projectId }: { projectId: string }): Promise { - 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 }; - } -} +import type { EditorCore } from "@/core"; +import type { TimelineTrack, TScene } from "@/lib/timeline"; +import { storageService } from "@/services/storage/service"; +import { + getMainScene, + ensureMainScene, + canDeleteScene, + findCurrentScene, +} from "@/lib/scenes"; +import { + getBookmarkAtTime, + getFrameTime, + isBookmarkAtTime, +} from "@/lib/timeline/bookmarks"; +import { ensureMainTrack } from "@/lib/timeline/track-utils"; +import { + CreateSceneCommand, + DeleteSceneCommand, + MoveBookmarkCommand, + RemoveBookmarkCommand, + RenameSceneCommand, + ToggleBookmarkCommand, + UpdateBookmarkCommand, +} 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const command = new RemoveBookmarkCommand(time); + this.editor.command.execute({ command }); + } + + async updateBookmark({ + time, + updates, + }: { + time: number; + updates: Partial<{ note: string; color: string; duration: number }>; + }): Promise { + const command = new UpdateBookmarkCommand(time, updates); + this.editor.command.execute({ command }); + } + + async moveBookmark({ + fromTime, + toTime, + }: { + fromTime: number; + toTime: number; + }): Promise { + const command = new MoveBookmarkCommand(fromTime, toTime); + this.editor.command.execute({ command }); + } + + getBookmarkAtTime({ time }: { time: number }) { + const activeScene = this.active; + const activeProject = this.editor.project.getActive(); + + if (!activeScene || !activeProject) return null; + + const frameTime = getFrameTime({ + time, + fps: activeProject.settings.fps, + }); + + return getBookmarkAtTime({ + bookmarks: activeScene.bookmarks, + frameTime, + }); + } + + async loadProjectScenes({ projectId }: { projectId: string }): Promise { + 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 }; + } +} diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts index 2062cfb0..9bafc6d7 100644 --- a/apps/web/src/core/managers/selection-manager.ts +++ b/apps/web/src/core/managers/selection-manager.ts @@ -1,73 +1,73 @@ -import type { EditorCore } from "@/core"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; -import type { ElementRef } from "@/lib/timeline/types"; - -export class SelectionManager { - private selectedElements: ElementRef[] = []; - private selectedKeyframes: SelectedKeyframeRef[] = []; - private keyframeSelectionAnchor: SelectedKeyframeRef | null = null; - private listeners = new Set<() => void>(); - - constructor(editor: EditorCore) { - void editor; - } - - getSelectedElements(): ElementRef[] { - return this.selectedElements; - } - - getSelectedKeyframes(): SelectedKeyframeRef[] { - return this.selectedKeyframes; - } - - getKeyframeSelectionAnchor(): SelectedKeyframeRef | null { - return this.keyframeSelectionAnchor; - } - - setSelectedElements({ elements }: { elements: ElementRef[] }): void { - this.selectedElements = elements; - this.selectedKeyframes = []; - this.keyframeSelectionAnchor = null; - this.notify(); - } - - setSelectedKeyframes({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef | null; - }): void { - this.selectedKeyframes = keyframes; - if (anchorKeyframe !== undefined) { - this.keyframeSelectionAnchor = anchorKeyframe; - } else if (keyframes.length === 0) { - this.keyframeSelectionAnchor = null; - } - this.notify(); - } - - clearSelection(): void { - this.selectedElements = []; - this.selectedKeyframes = []; - this.keyframeSelectionAnchor = null; - this.notify(); - } - - clearKeyframeSelection(): void { - this.selectedKeyframes = []; - this.keyframeSelectionAnchor = null; - this.notify(); - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - this.listeners.forEach((fn) => { - fn(); - }); - } -} +import type { EditorCore } from "@/core"; +import type { SelectedKeyframeRef } from "@/lib/animation/types"; +import type { ElementRef } from "@/lib/timeline/types"; + +export class SelectionManager { + private selectedElements: ElementRef[] = []; + private selectedKeyframes: SelectedKeyframeRef[] = []; + private keyframeSelectionAnchor: SelectedKeyframeRef | null = null; + private listeners = new Set<() => void>(); + + constructor(editor: EditorCore) { + void editor; + } + + getSelectedElements(): ElementRef[] { + return this.selectedElements; + } + + getSelectedKeyframes(): SelectedKeyframeRef[] { + return this.selectedKeyframes; + } + + getKeyframeSelectionAnchor(): SelectedKeyframeRef | null { + return this.keyframeSelectionAnchor; + } + + setSelectedElements({ elements }: { elements: ElementRef[] }): void { + this.selectedElements = elements; + this.selectedKeyframes = []; + this.keyframeSelectionAnchor = null; + this.notify(); + } + + setSelectedKeyframes({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef | null; + }): void { + this.selectedKeyframes = keyframes; + if (anchorKeyframe !== undefined) { + this.keyframeSelectionAnchor = anchorKeyframe; + } else if (keyframes.length === 0) { + this.keyframeSelectionAnchor = null; + } + this.notify(); + } + + clearSelection(): void { + this.selectedElements = []; + this.selectedKeyframes = []; + this.keyframeSelectionAnchor = null; + this.notify(); + } + + clearKeyframeSelection(): void { + this.selectedKeyframes = []; + this.keyframeSelectionAnchor = null; + this.notify(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + 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 455222d6..3dd95ee2 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -13,6 +13,7 @@ import type { AnimationValue, } from "@/lib/animation/types"; import { calculateTotalDuration } from "@/lib/timeline"; +import { getLastFrameTime } from "@/lib/time"; import { AddTrackCommand, RemoveTrackCommand, @@ -224,6 +225,13 @@ export class TimelineManager { return calculateTotalDuration({ tracks: this.getTracks() }); } + getLastSeekableTime(): number { + const duration = this.getTotalDuration(); + const fps = this.editor.project.getActive()?.settings.fps; + if (!fps || duration <= 0) return duration; + return getLastFrameTime({ duration, fps }); + } + getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { return this.getTracks().find((track) => track.id === trackId) ?? null; } -- cgit v1.3.1