diff options
Diffstat (limited to 'apps/web/src/core/managers')
| -rw-r--r-- | apps/web/src/core/managers/audio-manager.ts | 42 | ||||
| -rw-r--r-- | apps/web/src/core/managers/project-manager.ts | 43 | ||||
| -rw-r--r-- | apps/web/src/core/managers/renderer-manager.ts | 7 | ||||
| -rw-r--r-- | apps/web/src/core/managers/selection-manager.ts | 37 | ||||
| -rw-r--r-- | apps/web/src/core/managers/timeline-manager.ts | 316 |
5 files changed, 427 insertions, 18 deletions
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 457e49d6..20b124b6 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -29,6 +29,7 @@ export class AudioManager { private playbackSessionId = 0; private lastIsPlaying = false; private lastVolume = 1; + private playbackLatencyCompensationSeconds = 0; private unsubscribers: Array<() => void> = []; constructor(private editor: EditorCore) { @@ -136,6 +137,7 @@ export class AudioManager { this.stopPlayback(); this.playbackSessionId++; + this.playbackLatencyCompensationSeconds = 0; const tracks = this.editor.timeline.getTracks(); const mediaAssets = this.editor.media.getAssets(); @@ -224,13 +226,21 @@ export class AudioManager { const clipStart = clip.startTime; const clipEnd = clip.startTime + clip.duration; - - const iteratorStartTime = Math.max(startTime, clipStart); + const playbackTimeAfterSinkReady = this.getPlaybackTime(); + const iteratorStartTime = Math.max( + startTime, + clipStart, + playbackTimeAfterSinkReady, + ); + if (iteratorStartTime >= clipEnd) { + return; + } const sourceStartTime = clip.trimStart + (iteratorStartTime - clip.startTime); 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; @@ -244,15 +254,41 @@ export class AudioManager { node.connect(this.masterGain ?? audioContext.destination); const startTimestamp = - this.playbackStartContextTime + (timelineTime - this.playbackStartTime); + 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; } } diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 2608b8c0..05889a94 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -7,7 +7,7 @@ import type { TProjectSettings, TTimelineViewState, } from "@/types/project"; -import type { ExportOptions, ExportResult } from "@/types/export"; +import type { ExportOptions, ExportResult, ExportState } from "@/types/export"; import { storageService } from "@/services/storage/service"; import { toast } from "sonner"; import { generateUUID } from "@/utils/id"; @@ -51,6 +51,12 @@ export class ProjectManager { toVersion: null, projectName: null, }; + private exportState: ExportState = { + isExporting: false, + progress: 0, + result: null, + }; + private exportCancelRequested = false; constructor(private editor: EditorCore) {} @@ -191,7 +197,40 @@ export class ProjectManager { } async export({ options }: { options: ExportOptions }): Promise<ExportResult> { - return this.editor.renderer.exportProject({ options }); + this.exportCancelRequested = false; + this.exportState = { isExporting: true, progress: 0, result: null }; + this.notify(); + + const result = await this.editor.renderer.exportProject({ + options, + onProgress: ({ progress }) => { + this.exportState = { ...this.exportState, progress }; + this.notify(); + }, + onCancel: () => this.exportCancelRequested, + }); + + this.exportState = { + isExporting: false, + progress: this.exportState.progress, + result, + }; + this.notify(); + + return result; + } + + cancelExport(): void { + this.exportCancelRequested = true; + } + + clearExportState(): void { + this.exportState = { isExporting: false, progress: 0, result: null }; + this.notify(); + } + + getExportState(): ExportState { + return this.exportState; } async loadAllProjects(): Promise<void> { diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index 03463b2c..601a007e 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -88,11 +88,14 @@ export class RendererManager { async exportProject({ options, + onProgress, + onCancel, }: { options: ExportOptions; + onProgress?: ({ progress }: { progress: number }) => void; + onCancel?: () => boolean; }): Promise<ExportResult> { - const { format, quality, fps, includeAudio, onProgress, onCancel } = - options; + const { format, quality, fps, includeAudio } = options; try { const tracks = this.editor.timeline.getTracks(); diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts index fa6a02bf..e4295c11 100644 --- a/apps/web/src/core/managers/selection-manager.ts +++ b/apps/web/src/core/managers/selection-manager.ts @@ -1,9 +1,12 @@ import type { EditorCore } from "@/core"; +import type { SelectedKeyframeRef } from "@/types/animation"; type ElementRef = { trackId: string; elementId: string }; export class SelectionManager { private selectedElements: ElementRef[] = []; + private selectedKeyframes: SelectedKeyframeRef[] = []; + private keyframeSelectionAnchor: SelectedKeyframeRef | null = null; private listeners = new Set<() => void>(); constructor(editor: EditorCore) { @@ -14,13 +17,47 @@ export class SelectionManager { 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(); } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index c8ac5a79..173f0744 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -1,10 +1,16 @@ import type { EditorCore } from "@/core"; +import type { EffectParamValues } from "@/types/effects"; import type { TrackType, TimelineTrack, TimelineElement, ClipboardItem, } from "@/types/timeline"; +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, +} from "@/types/animation"; import { calculateTotalDuration } from "@/lib/timeline"; import { AddTrackCommand, @@ -24,6 +30,16 @@ import { UpdateElementStartTimeCommand, MoveElementCommand, TracksSnapshotCommand, + UpsertKeyframeCommand, + RemoveKeyframeCommand, + RetimeKeyframeCommand, + AddClipEffectCommand, + RemoveClipEffectCommand, + UpdateClipEffectParamsCommand, + ToggleClipEffectCommand, + ReorderClipEffectsCommand, + UpsertEffectParamKeyframeCommand, + RemoveEffectParamKeyframeCommand, } from "@/lib/commands/timeline"; import { BatchCommand, PreviewTracker } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; @@ -54,14 +70,27 @@ export class TimelineManager { elementId, trimStart, trimEnd, + startTime, + duration, pushHistory = true, + rippleEnabled = false, }: { elementId: string; trimStart: number; trimEnd: number; + startTime?: number; + duration?: number; pushHistory?: boolean; + rippleEnabled?: boolean; }): void { - const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd); + const command = new UpdateElementTrimCommand({ + elementId, + trimStart, + trimEnd, + startTime, + duration, + rippleEnabled, + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -80,11 +109,11 @@ export class TimelineManager { duration: number; pushHistory?: boolean; }): void { - const command = new UpdateElementDurationCommand( + const command = new UpdateElementDurationCommand({ trackId, elementId, duration, - ); + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -99,7 +128,10 @@ export class TimelineManager { elements: { trackId: string; elementId: string }[]; startTime: number; }): void { - const command = new UpdateElementStartTimeCommand(elements, startTime); + const command = new UpdateElementStartTimeCommand({ + elements, + startTime, + }); this.editor.command.execute({ command }); } @@ -109,20 +141,23 @@ export class TimelineManager { elementId, newStartTime, createTrack, + rippleEnabled = false, }: { sourceTrackId: string; targetTrackId: string; elementId: string; newStartTime: number; createTrack?: { type: TrackType; index: number }; + rippleEnabled?: boolean; }): void { - const command = new MoveElementCommand( + const command = new MoveElementCommand({ sourceTrackId, targetTrackId, elementId, newStartTime, createTrack, - ); + rippleEnabled, + }); this.editor.command.execute({ command }); } @@ -140,12 +175,19 @@ export class TimelineManager { elements, splitTime, retainSide = "both", + rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; + rippleEnabled?: boolean; }): { trackId: string; elementId: string }[] { - const command = new SplitElementsCommand(elements, splitTime, retainSide); + const command = new SplitElementsCommand({ + elements, + splitTime, + retainSide, + rippleEnabled, + }); this.editor.command.execute({ command }); return command.getRightSideElements(); } @@ -194,10 +236,12 @@ export class TimelineManager { deleteElements({ elements, + rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; }): void { - const command = new DeleteElementsCommand(elements); + const command = new DeleteElementsCommand({ elements, rippleEnabled }); this.editor.command.execute({ command }); } @@ -208,13 +252,17 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial<Record<string, unknown>>; + updates: Partial<TimelineElement>; }>; pushHistory?: boolean; }): void { const commands = updates.map( ({ trackId, elementId, updates: elementUpdates }) => - new UpdateElementCommand(trackId, elementId, elementUpdates), + new UpdateElementCommand({ + trackId, + elementId, + updates: elementUpdates, + }), ); const command = commands.length === 1 ? commands[0] : new BatchCommand(commands); @@ -225,6 +273,252 @@ export class TimelineManager { } } + addClipEffect({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }): string { + const command = new AddClipEffectCommand({ + trackId, + elementId, + effectType, + }); + this.editor.command.execute({ command }); + return command.getEffectId() ?? ""; + } + + removeClipEffect({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }): void { + const command = new RemoveClipEffectCommand({ + trackId, + elementId, + effectId, + }); + this.editor.command.execute({ command }); + } + + updateClipEffectParams({ + trackId, + elementId, + effectId, + params, + pushHistory = true, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial<EffectParamValues>; + pushHistory?: boolean; + }): void { + const command = new UpdateClipEffectParamsCommand({ + trackId, + elementId, + effectId, + params, + }); + if (pushHistory) { + this.editor.command.execute({ command }); + } else { + command.execute(); + } + } + + toggleClipEffect({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }): void { + const command = new ToggleClipEffectCommand({ + trackId, + elementId, + effectId, + }); + this.editor.command.execute({ command }); + } + + reorderClipEffects({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }): void { + const command = new ReorderClipEffectsCommand({ + trackId, + elementId, + fromIndex, + toIndex, + }); + this.editor.command.execute({ command }); + } + + upsertKeyframes({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }) => + new UpsertKeyframeCommand({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + + removeKeyframes({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ trackId, elementId, propertyPath, keyframeId }) => + new RemoveKeyframeCommand({ + trackId, + elementId, + propertyPath, + keyframeId, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + + retimeKeyframe({ + trackId, + elementId, + propertyPath, + keyframeId, + time, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + time: number; + }): void { + const command = new RetimeKeyframeCommand({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime: time, + }); + this.editor.command.execute({ command }); + } + + upsertEffectParamKeyframe({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number; + interpolation?: "linear" | "hold"; + keyframeId?: string; + }): void { + const command = new UpsertEffectParamKeyframeCommand({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }); + this.editor.command.execute({ command }); + } + + removeEffectParamKeyframe({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }): void { + const command = new RemoveEffectParamKeyframeCommand({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }); + this.editor.command.execute({ command }); + } + isPreviewActive(): boolean { return this.previewTracker.isActive(); } @@ -235,7 +529,7 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial<Record<string, unknown>>; + updates: Partial<TimelineElement>; }>; }): void { const tracks = this.getTracks(); |
