diff options
Diffstat (limited to 'apps/web/src/core')
| -rw-r--r-- | apps/web/src/core/index.ts | 106 | ||||
| -rw-r--r-- | apps/web/src/core/managers/audio-manager.ts | 1416 | ||||
| -rw-r--r-- | apps/web/src/core/managers/commands.ts | 98 | ||||
| -rw-r--r-- | apps/web/src/core/managers/media-manager.ts | 3 | ||||
| -rw-r--r-- | apps/web/src/core/managers/playback-manager.ts | 24 | ||||
| -rw-r--r-- | apps/web/src/core/managers/project-manager.ts | 19 | ||||
| -rw-r--r-- | apps/web/src/core/managers/renderer-manager.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/core/managers/save-manager.ts | 214 | ||||
| -rw-r--r-- | apps/web/src/core/managers/scenes-manager.ts | 726 | ||||
| -rw-r--r-- | apps/web/src/core/managers/selection-manager.ts | 146 | ||||
| -rw-r--r-- | apps/web/src/core/managers/timeline-manager.ts | 8 |
11 files changed, 1397 insertions, 1369 deletions
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<string, AudioBufferSink>(); - private inputs = new Map<string, Input>(); - private activeClipIds = new Set<string>(); - private clipIterators = new Map< - string, - AsyncGenerator<WrappedAudioBuffer, void, unknown> - >(); - private queuedSources = new Set<AudioBufferSourceNode>(); - private preparedClipBuffers = new Map<string, Promise<AudioBuffer | null>>(); - private decodedBuffers = new Map<string, Promise<AudioBuffer | null>>(); - 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<void> { - 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<void> { - const audioContext = this.ensureAudioContext(); - if (!audioContext) return; - - const sink = await this.getAudioSink({ clip }); - if (!sink || !this.editor.playback.getIsPlaying()) return; - if (sessionId !== this.playbackSessionId) return; - - const clipStart = clip.startTime; - const clipEnd = clip.startTime + clip.duration; - const 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<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, - }: { - timelineTime: number; - targetAhead: number; - }): Promise<void> { - return new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (!this.editor.playback.getIsPlaying()) { - clearInterval(checkInterval); - resolve(); - return; - } - - const playbackTime = this.getPlaybackTime(); - if (timelineTime - playbackTime < targetAhead) { - clearInterval(checkInterval); - resolve(); - } - }, 100); - }); - } - - private disposeSinks(): void { - for (const iterator of this.clipIterators.values()) { - void iterator.return(); - } - this.clipIterators.clear(); - this.activeClipIds.clear(); - - for (const input of this.inputs.values()) { - input.dispose(); - } - this.inputs.clear(); - this.sinks.clear(); - } - - private 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, - }: { - clip: AudioClipSource; - }): Promise<AudioBufferSink | null> { - const existingSink = this.sinks.get(clip.sourceKey); - if (existingSink) return existingSink; - - try { - const input = new Input({ - source: new BlobSource(clip.file), - formats: ALL_FORMATS, - }); - const audioTrack = await input.getPrimaryAudioTrack(); - if (!audioTrack) { - input.dispose(); - return null; - } - - const sink = new AudioBufferSink(audioTrack); - this.inputs.set(clip.sourceKey, input); - this.sinks.set(clip.sourceKey, sink); - return sink; - } catch (error) { - console.warn("Failed to initialize audio sink:", error); - return null; - } - } -} +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<string, AudioBufferSink>();
+ private inputs = new Map<string, Input>();
+ private activeClipIds = new Set<string>();
+ private clipIterators = new Map<
+ string,
+ AsyncGenerator<WrappedAudioBuffer, void, unknown>
+ >();
+ private queuedSources = new Set<AudioBufferSourceNode>();
+ private preparedClipBuffers = new Map<string, Promise<AudioBuffer | null>>();
+ private decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
+ 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<void> {
+ 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<void> {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) return;
+
+ const sink = await this.getAudioSink({ clip });
+ if (!sink || !this.editor.playback.getIsPlaying()) return;
+ if (sessionId !== this.playbackSessionId) return;
+
+ const clipStart = clip.startTime;
+ const clipEnd = clip.startTime + clip.duration;
+ const 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<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,
+ }: {
+ timelineTime: number;
+ targetAhead: number;
+ }): Promise<void> {
+ return new Promise((resolve) => {
+ const checkInterval = setInterval(() => {
+ if (!this.editor.playback.getIsPlaying()) {
+ clearInterval(checkInterval);
+ resolve();
+ return;
+ }
+
+ const playbackTime = this.getPlaybackTime();
+ if (timelineTime - playbackTime < targetAhead) {
+ clearInterval(checkInterval);
+ resolve();
+ }
+ }, 100);
+ });
+ }
+
+ private disposeSinks(): void {
+ for (const iterator of this.clipIterators.values()) {
+ void iterator.return();
+ }
+ this.clipIterators.clear();
+ this.activeClipIds.clear();
+
+ for (const input of this.inputs.values()) {
+ input.dispose();
+ }
+ this.inputs.clear();
+ this.sinks.clear();
+ }
+
+ private 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,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBufferSink | null> {
+ const existingSink = this.sinks.get(clip.sourceKey);
+ if (existingSink) return existingSink;
+
+ try {
+ const input = new Input({
+ source: new BlobSource(clip.file),
+ formats: ALL_FORMATS,
+ });
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) {
+ input.dispose();
+ return null;
+ }
+
+ const sink = new AudioBufferSink(audioTrack);
+ this.inputs.set(clip.sourceKey, input);
+ this.sinks.set(clip.sourceKey, sink);
+ return sink;
+ } catch (error) {
+ console.warn("Failed to initialize audio sink:", error);
+ return null;
+ }
+ }
+}
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<Pick<MediaAsset, "type" | "fps">>; + }): 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<void> { 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<typeof setTimeout> | null = null; - private unsubscribeHandlers: Array<() => void> = []; - - constructor( - private editor: EditorCore, - { debounceMs = 800 }: SaveManagerOptions = {}, - ) { - this.debounceMs = debounceMs; - } - - start(): void { - if (this.unsubscribeHandlers.length > 0) return; - - this.unsubscribeHandlers = [ - this.editor.scenes.subscribe(() => { - this.markDirty(); - }), - this.editor.timeline.subscribe(() => { - this.markDirty(); - }), - ]; - } - - stop(): void { - for (const unsubscribe of this.unsubscribeHandlers) { - unsubscribe(); - } - this.unsubscribeHandlers = []; - this.clearTimer(); - } - - pause(): void { - this.isPaused = true; - } - - resume(): void { - this.isPaused = false; - if (this.hasPendingSave) { - this.queueSave(); - } - } - - markDirty({ force = false }: { force?: boolean } = {}): void { - if (this.isPaused && !force) return; - this.hasPendingSave = true; - this.queueSave(); - } - - async flush(): Promise<void> { - this.hasPendingSave = true; - await this.saveNow(); - } - - getIsDirty(): boolean { - return this.hasPendingSave || this.isSaving; - } - - private queueSave(): void { - if (this.isSaving) return; - if (this.saveTimer) { - clearTimeout(this.saveTimer); - } - this.saveTimer = setTimeout(() => { - void this.saveNow(); - }, this.debounceMs); - } - - private async saveNow(): Promise<void> { - if (this.isSaving) return; - if (!this.hasPendingSave) return; - - const activeProject = this.editor.project.getActive(); - if (!activeProject) return; - if (this.editor.project.getIsLoading()) return; - if (this.editor.project.getMigrationState().isMigrating) return; - - this.isSaving = true; - this.hasPendingSave = false; - this.clearTimer(); - - try { - await this.editor.project.saveCurrentProject(); - } finally { - this.isSaving = false; - if (this.hasPendingSave) { - this.queueSave(); - } - } - } - - private clearTimer(): void { - if (!this.saveTimer) return; - clearTimeout(this.saveTimer); - this.saveTimer = null; - } -} +import type { EditorCore } from "@/core";
+
+type SaveManagerOptions = {
+ debounceMs?: number;
+};
+
+export class SaveManager {
+ private debounceMs: number;
+ private isPaused = false;
+ private isSaving = false;
+ private hasPendingSave = false;
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
+ private unsubscribeHandlers: Array<() => void> = [];
+
+ constructor(
+ private editor: EditorCore,
+ { debounceMs = 800 }: SaveManagerOptions = {},
+ ) {
+ this.debounceMs = debounceMs;
+ }
+
+ start(): void {
+ if (this.unsubscribeHandlers.length > 0) return;
+
+ this.unsubscribeHandlers = [
+ this.editor.scenes.subscribe(() => {
+ this.markDirty();
+ }),
+ this.editor.timeline.subscribe(() => {
+ this.markDirty();
+ }),
+ ];
+ }
+
+ stop(): void {
+ for (const unsubscribe of this.unsubscribeHandlers) {
+ unsubscribe();
+ }
+ this.unsubscribeHandlers = [];
+ this.clearTimer();
+ }
+
+ pause(): void {
+ this.isPaused = true;
+ }
+
+ resume(): void {
+ this.isPaused = false;
+ if (this.hasPendingSave) {
+ this.queueSave();
+ }
+ }
+
+ markDirty({ force = false }: { force?: boolean } = {}): void {
+ if (this.isPaused && !force) return;
+ this.hasPendingSave = true;
+ this.queueSave();
+ }
+
+ async flush(): Promise<void> {
+ this.hasPendingSave = true;
+ await this.saveNow();
+ }
+
+ getIsDirty(): boolean {
+ return this.hasPendingSave || this.isSaving;
+ }
+
+ private queueSave(): void {
+ if (this.isSaving) return;
+ if (this.saveTimer) {
+ clearTimeout(this.saveTimer);
+ }
+ this.saveTimer = setTimeout(() => {
+ void this.saveNow();
+ }, this.debounceMs);
+ }
+
+ private async saveNow(): Promise<void> {
+ if (this.isSaving) return;
+ if (!this.hasPendingSave) return;
+
+ const activeProject = this.editor.project.getActive();
+ if (!activeProject) return;
+ if (this.editor.project.getIsLoading()) return;
+ if (this.editor.project.getMigrationState().isMigrating) return;
+
+ this.isSaving = true;
+ this.hasPendingSave = false;
+ this.clearTimer();
+
+ try {
+ await this.editor.project.saveCurrentProject();
+ } finally {
+ this.isSaving = false;
+ if (this.hasPendingSave) {
+ this.queueSave();
+ }
+ }
+ }
+
+ private clearTimer(): void {
+ if (!this.saveTimer) return;
+ clearTimeout(this.saveTimer);
+ this.saveTimer = null;
+ }
+}
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<string> { - if (!this.editor.project.getActive()) { - throw new Error("No active project"); - } - - const command = new CreateSceneCommand(name, isMain); - this.editor.command.execute({ command }); - return command.getSceneId(); - } - - async deleteScene({ sceneId }: { sceneId: string }): Promise<void> { - const sceneToDelete = this.list.find((s) => s.id === sceneId); - - if (!sceneToDelete) { - throw new Error("Scene not found"); - } - - const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete }); - if (!canDelete) { - throw new Error(reason); - } - - if (!this.editor.project.getActive()) { - throw new Error("No active project"); - } - - const command = new DeleteSceneCommand(sceneId); - this.editor.command.execute({ command }); - } - - async renameScene({ - sceneId, - name, - }: { - sceneId: string; - name: string; - }): Promise<void> { - if (!this.editor.project.getActive()) { - throw new Error("No active project"); - } - - const command = new RenameSceneCommand(sceneId, name); - this.editor.command.execute({ command }); - } - - async switchToScene({ sceneId }: { sceneId: string }): Promise<void> { - const targetScene = this.list.find((s) => s.id === sceneId); - - if (!targetScene) { - throw new Error("Scene not found"); - } - - const activeProject = this.editor.project.getActive(); - - if (activeProject) { - const updatedProject = { - ...activeProject, - currentSceneId: sceneId, - metadata: { - ...activeProject.metadata, - updatedAt: new Date(), - }, - }; - - this.editor.project.setActiveProject({ project: updatedProject }); - } - - this.active = targetScene; - this.notify(); - } - - async toggleBookmark({ time }: { time: number }): Promise<void> { - const command = new ToggleBookmarkCommand(time); - this.editor.command.execute({ command }); - } - - isBookmarked({ time }: { time: number }): boolean { - const activeScene = this.getActiveScene(); - const activeProject = this.editor.project.getActive(); - - if (!activeScene || !this.active || !activeProject) return false; - - const frameTime = getFrameTime({ - time, - fps: activeProject.settings.fps, - }); - - return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); - } - - async removeBookmark({ time }: { time: number }): Promise<void> { - const command = new RemoveBookmarkCommand(time); - this.editor.command.execute({ command }); - } - - async updateBookmark({ - time, - updates, - }: { - time: number; - updates: Partial<{ note: string; color: string; duration: number }>; - }): Promise<void> { - const command = new UpdateBookmarkCommand(time, updates); - this.editor.command.execute({ command }); - } - - async moveBookmark({ - fromTime, - toTime, - }: { - fromTime: number; - toTime: number; - }): Promise<void> { - 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<void> { - try { - const result = await storageService.loadProject({ id: projectId }); - if (result?.project.scenes) { - const { scenes: ensuredScenes, hasAddedMainTrack } = - this.ensureScenesHaveMainTrack({ - scenes: result.project.scenes ?? [], - }); - const currentScene = findCurrentScene({ - scenes: ensuredScenes, - currentSceneId: result.project.currentSceneId, - }); - - this.list = ensuredScenes; - this.active = currentScene; - this.notify(); - - if (hasAddedMainTrack) { - const activeProject = this.editor.project.getActive(); - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes: ensuredScenes, - metadata: { - ...activeProject.metadata, - updatedAt: new Date(), - }, - }; - this.editor.project.setActiveProject({ project: updatedProject }); - this.editor.save.markDirty({ force: true }); - } - } - } - } catch (error) { - console.error("Failed to load project scenes:", error); - this.list = []; - this.active = null; - this.notify(); - } - } - - initializeScenes({ - scenes, - currentSceneId, - }: { - scenes: TScene[]; - currentSceneId?: string; - }): void { - const ensuredScenes = ensureMainScene({ scenes }); - const { scenes: scenesWithMainTracks, hasAddedMainTrack } = - this.ensureScenesHaveMainTrack({ scenes: ensuredScenes }); - const currentScene = currentSceneId - ? scenesWithMainTracks.find((s) => s.id === currentSceneId) - : null; - - const fallbackScene = getMainScene({ scenes: scenesWithMainTracks }); - - this.list = scenesWithMainTracks; - this.active = currentScene || fallbackScene; - this.notify(); - - const hasAddedMainScene = ensuredScenes.length > scenes.length; - if (hasAddedMainScene || hasAddedMainTrack) { - const activeProject = this.editor.project.getActive(); - - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes: scenesWithMainTracks, - metadata: { - ...activeProject.metadata, - updatedAt: new Date(), - }, - }; - - this.editor.project.setActiveProject({ project: updatedProject }); - this.editor.save.markDirty({ force: true }); - } - } - } - - clearScenes(): void { - this.list = []; - this.active = null; - this.notify(); - } - - getActiveScene(): TScene { - if (!this.active) { - throw new Error("No active scene."); - } - return this.active; - } - - getScenes(): TScene[] { - return this.list; - } - - setScenes({ - scenes, - activeSceneId, - }: { - scenes: TScene[]; - activeSceneId?: string; - }): void { - this.list = scenes; - const nextActiveSceneId = activeSceneId ?? this.active?.id ?? null; - this.active = nextActiveSceneId - ? (scenes.find((scene) => scene.id === nextActiveSceneId) ?? null) - : null; - this.notify(); - - const activeProject = this.editor.project.getActive(); - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes, - metadata: { - ...activeProject.metadata, - updatedAt: new Date(), - }, - }; - this.editor.project.setActiveProject({ project: updatedProject }); - } - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - this.listeners.forEach((fn) => { - fn(); - }); - } - - updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void { - if (!this.active) return; - - const updatedScene: TScene = { - ...this.active, - tracks, - updatedAt: new Date(), - }; - - this.list = this.list.map((s) => - s.id === this.active?.id ? updatedScene : s, - ); - this.active = updatedScene; - this.notify(); - - const activeProject = this.editor.project.getActive(); - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes: this.list, - metadata: { - ...activeProject.metadata, - updatedAt: new Date(), - }, - }; - this.editor.project.setActiveProject({ project: updatedProject }); - } - } - - private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): { - scenes: TScene[]; - hasAddedMainTrack: boolean; - } { - let hasAddedMainTrack = false; - const ensuredScenes: TScene[] = []; - - for (const scene of scenes) { - const existingTracks = scene.tracks ?? []; - const updatedTracks = ensureMainTrack({ tracks: existingTracks }); - if (updatedTracks !== existingTracks) { - hasAddedMainTrack = true; - ensuredScenes.push({ - ...scene, - tracks: updatedTracks, - updatedAt: new Date(), - }); - } else { - ensuredScenes.push(scene); - } - } - - return { scenes: ensuredScenes, hasAddedMainTrack }; - } -} +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<string> {
+ if (!this.editor.project.getActive()) {
+ throw new Error("No active project");
+ }
+
+ const command = new CreateSceneCommand(name, isMain);
+ this.editor.command.execute({ command });
+ return command.getSceneId();
+ }
+
+ async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
+ const sceneToDelete = this.list.find((s) => s.id === sceneId);
+
+ if (!sceneToDelete) {
+ throw new Error("Scene not found");
+ }
+
+ const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
+ if (!canDelete) {
+ throw new Error(reason);
+ }
+
+ if (!this.editor.project.getActive()) {
+ throw new Error("No active project");
+ }
+
+ const command = new DeleteSceneCommand(sceneId);
+ this.editor.command.execute({ command });
+ }
+
+ async renameScene({
+ sceneId,
+ name,
+ }: {
+ sceneId: string;
+ name: string;
+ }): Promise<void> {
+ if (!this.editor.project.getActive()) {
+ throw new Error("No active project");
+ }
+
+ const command = new RenameSceneCommand(sceneId, name);
+ this.editor.command.execute({ command });
+ }
+
+ async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
+ const targetScene = this.list.find((s) => s.id === sceneId);
+
+ if (!targetScene) {
+ throw new Error("Scene not found");
+ }
+
+ const activeProject = this.editor.project.getActive();
+
+ if (activeProject) {
+ const updatedProject = {
+ ...activeProject,
+ currentSceneId: sceneId,
+ metadata: {
+ ...activeProject.metadata,
+ updatedAt: new Date(),
+ },
+ };
+
+ this.editor.project.setActiveProject({ project: updatedProject });
+ }
+
+ this.active = targetScene;
+ this.notify();
+ }
+
+ async toggleBookmark({ time }: { time: number }): Promise<void> {
+ const command = new ToggleBookmarkCommand(time);
+ this.editor.command.execute({ command });
+ }
+
+ isBookmarked({ time }: { time: number }): boolean {
+ const activeScene = this.getActiveScene();
+ const activeProject = this.editor.project.getActive();
+
+ if (!activeScene || !this.active || !activeProject) return false;
+
+ const frameTime = getFrameTime({
+ time,
+ fps: activeProject.settings.fps,
+ });
+
+ return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
+ }
+
+ async removeBookmark({ time }: { time: number }): Promise<void> {
+ const command = new RemoveBookmarkCommand(time);
+ this.editor.command.execute({ command });
+ }
+
+ async updateBookmark({
+ time,
+ updates,
+ }: {
+ time: number;
+ updates: Partial<{ note: string; color: string; duration: number }>;
+ }): Promise<void> {
+ const command = new UpdateBookmarkCommand(time, updates);
+ this.editor.command.execute({ command });
+ }
+
+ async moveBookmark({
+ fromTime,
+ toTime,
+ }: {
+ fromTime: number;
+ toTime: number;
+ }): Promise<void> {
+ 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<void> {
+ try {
+ const result = await storageService.loadProject({ id: projectId });
+ if (result?.project.scenes) {
+ const { scenes: ensuredScenes, hasAddedMainTrack } =
+ this.ensureScenesHaveMainTrack({
+ scenes: result.project.scenes ?? [],
+ });
+ const currentScene = findCurrentScene({
+ scenes: ensuredScenes,
+ currentSceneId: result.project.currentSceneId,
+ });
+
+ this.list = ensuredScenes;
+ this.active = currentScene;
+ this.notify();
+
+ if (hasAddedMainTrack) {
+ const activeProject = this.editor.project.getActive();
+ if (activeProject) {
+ const updatedProject = {
+ ...activeProject,
+ scenes: ensuredScenes,
+ metadata: {
+ ...activeProject.metadata,
+ updatedAt: new Date(),
+ },
+ };
+ this.editor.project.setActiveProject({ project: updatedProject });
+ this.editor.save.markDirty({ force: true });
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load project scenes:", error);
+ this.list = [];
+ this.active = null;
+ this.notify();
+ }
+ }
+
+ initializeScenes({
+ scenes,
+ currentSceneId,
+ }: {
+ scenes: TScene[];
+ currentSceneId?: string;
+ }): void {
+ const ensuredScenes = ensureMainScene({ scenes });
+ const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
+ this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
+ const currentScene = currentSceneId
+ ? scenesWithMainTracks.find((s) => s.id === currentSceneId)
+ : null;
+
+ const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
+
+ this.list = scenesWithMainTracks;
+ this.active = currentScene || fallbackScene;
+ this.notify();
+
+ const hasAddedMainScene = ensuredScenes.length > scenes.length;
+ if (hasAddedMainScene || hasAddedMainTrack) {
+ const activeProject = this.editor.project.getActive();
+
+ if (activeProject) {
+ const updatedProject = {
+ ...activeProject,
+ scenes: scenesWithMainTracks,
+ metadata: {
+ ...activeProject.metadata,
+ updatedAt: new Date(),
+ },
+ };
+
+ this.editor.project.setActiveProject({ project: updatedProject });
+ this.editor.save.markDirty({ force: true });
+ }
+ }
+ }
+
+ clearScenes(): void {
+ this.list = [];
+ this.active = null;
+ this.notify();
+ }
+
+ getActiveScene(): TScene {
+ if (!this.active) {
+ throw new Error("No active scene.");
+ }
+ return this.active;
+ }
+
+ getScenes(): TScene[] {
+ return this.list;
+ }
+
+ setScenes({
+ scenes,
+ activeSceneId,
+ }: {
+ scenes: TScene[];
+ activeSceneId?: string;
+ }): void {
+ this.list = scenes;
+ const nextActiveSceneId = activeSceneId ?? this.active?.id ?? null;
+ this.active = nextActiveSceneId
+ ? (scenes.find((scene) => scene.id === nextActiveSceneId) ?? null)
+ : null;
+ this.notify();
+
+ const activeProject = this.editor.project.getActive();
+ if (activeProject) {
+ const updatedProject = {
+ ...activeProject,
+ scenes,
+ metadata: {
+ ...activeProject.metadata,
+ updatedAt: new Date(),
+ },
+ };
+ this.editor.project.setActiveProject({ project: updatedProject });
+ }
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify(): void {
+ this.listeners.forEach((fn) => {
+ fn();
+ });
+ }
+
+ updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
+ if (!this.active) return;
+
+ const updatedScene: TScene = {
+ ...this.active,
+ tracks,
+ updatedAt: new Date(),
+ };
+
+ this.list = this.list.map((s) =>
+ s.id === this.active?.id ? updatedScene : s,
+ );
+ this.active = updatedScene;
+ this.notify();
+
+ const activeProject = this.editor.project.getActive();
+ if (activeProject) {
+ const updatedProject = {
+ ...activeProject,
+ scenes: this.list,
+ metadata: {
+ ...activeProject.metadata,
+ updatedAt: new Date(),
+ },
+ };
+ this.editor.project.setActiveProject({ project: updatedProject });
+ }
+ }
+
+ private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
+ scenes: TScene[];
+ hasAddedMainTrack: boolean;
+ } {
+ let hasAddedMainTrack = false;
+ const ensuredScenes: TScene[] = [];
+
+ for (const scene of scenes) {
+ const existingTracks = scene.tracks ?? [];
+ const updatedTracks = ensureMainTrack({ tracks: existingTracks });
+ if (updatedTracks !== existingTracks) {
+ hasAddedMainTrack = true;
+ ensuredScenes.push({
+ ...scene,
+ tracks: updatedTracks,
+ updatedAt: new Date(),
+ });
+ } else {
+ ensuredScenes.push(scene);
+ }
+ }
+
+ return { scenes: ensuredScenes, hasAddedMainTrack };
+ }
+}
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; } |
