summaryrefslogtreecommitdiff
path: root/apps/web/src/core/managers
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/core/managers')
-rw-r--r--apps/web/src/core/managers/audio-manager.ts1416
-rw-r--r--apps/web/src/core/managers/commands.ts98
-rw-r--r--apps/web/src/core/managers/media-manager.ts3
-rw-r--r--apps/web/src/core/managers/playback-manager.ts24
-rw-r--r--apps/web/src/core/managers/project-manager.ts19
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts6
-rw-r--r--apps/web/src/core/managers/save-manager.ts214
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts726
-rw-r--r--apps/web/src/core/managers/selection-manager.ts146
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts8
10 files changed, 1344 insertions, 1316 deletions
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;
}