feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
+340 -12
View File
@@ -1,6 +1,20 @@
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,
@@ -26,6 +40,8 @@ export class AudioManager {
AsyncGenerator<WrappedAudioBuffer, void, unknown>
>();
private queuedSources = new Set<AudioBufferSourceNode>();
private preparedClipBuffers = new Map<string, Promise<AudioBuffer | null>>();
private decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
@@ -55,6 +71,8 @@ export class AudioManager {
window.removeEventListener("playback-seek", this.handleSeek);
}
this.disposeSinks();
this.preparedClipBuffers.clear();
this.decodedBuffers.clear();
if (this.audioContext) {
void this.audioContext.close();
this.audioContext = null;
@@ -102,6 +120,8 @@ export class AudioManager {
private handleTimelineChange = (): void => {
this.disposeSinks();
this.preparedClipBuffers.clear();
this.decodedBuffers.clear();
if (!this.editor.playback.getIsPlaying()) return;
@@ -113,9 +133,12 @@ export class AudioManager {
if (typeof window === "undefined") return null;
this.audioContext = createAudioContext();
this.masterGain = this.audioContext.createGain();
const { input } = createAudioMasteringChain({
audioContext: this.audioContext,
destination: this.audioContext.destination,
});
this.masterGain = input;
this.masterGain.gain.value = this.lastVolume;
this.masterGain.connect(this.audioContext.destination);
return this.audioContext;
}
@@ -179,11 +202,19 @@ export class AudioManager {
if (clip.startTime > windowEnd) continue;
this.activeClipIds.add(clip.id);
void this.runClipIterator({
clip,
startTime: currentTime,
sessionId: this.playbackSessionId,
});
if (this.shouldUsePreparedClipBuffer({ clip })) {
void this.schedulePreparedClip({
clip,
startTime: currentTime,
sessionId: this.playbackSessionId,
});
} else {
void this.runClipIterator({
clip,
startTime: currentTime,
sessionId: this.playbackSessionId,
});
}
}
}
@@ -236,7 +267,11 @@ export class AudioManager {
return;
}
const sourceStartTime =
clip.trimStart + (iteratorStartTime - clip.startTime);
clip.trimStart +
getSourceTimeAtClipTime({
clipTime: iteratorStartTime - clip.startTime,
retime: clip.retime,
});
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
@@ -246,12 +281,23 @@ export class AudioManager {
if (!this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
const timelineTime = clip.startTime + (timestamp - clip.trimStart);
const timelineTime =
clip.startTime +
getClipTimeAtSourceTime({
sourceTime: timestamp - clip.trimStart,
retime: clip.retime,
});
if (timelineTime >= clipEnd) break;
const node = audioContext.createBufferSource();
node.buffer = buffer;
node.connect(this.masterGain ?? audioContext.destination);
if (clip.retime) {
node.playbackRate.value = clampRetimeRate({ rate: clip.retime.rate });
}
const clipGain = audioContext.createGain();
clipGain.gain.value = clip.volume;
node.connect(clipGain);
clipGain.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime +
@@ -277,8 +323,7 @@ export class AudioManager {
nextCompensationSeconds >
this.playbackLatencyCompensationSeconds + 0.001
) {
this.playbackLatencyCompensationSeconds =
nextCompensationSeconds;
this.playbackLatencyCompensationSeconds = nextCompensationSeconds;
}
const resyncStartTime = this.getPlaybackTime();
this.clipIterators.delete(clip.id);
@@ -296,6 +341,7 @@ export class AudioManager {
this.queuedSources.add(node);
node.addEventListener("ended", () => {
node.disconnect();
clipGain.disconnect();
this.queuedSources.delete(node);
});
@@ -311,6 +357,73 @@ export class AudioManager {
// the set is cleared on stopPlayback anyway
}
private async schedulePreparedClip({
clip,
startTime,
sessionId,
}: {
clip: AudioClipSource;
startTime: number;
sessionId: number;
}): Promise<void> {
const audioContext = this.ensureAudioContext();
if (!audioContext) return;
const buffer = await this.getPreparedClipBuffer({ clip });
if (!buffer || !this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
const clipStart = clip.startTime;
const clipEnd = clip.startTime + clip.duration;
const playbackTimeAfterReady = this.getPlaybackTime();
const effectiveStartTime = Math.max(
startTime,
clipStart,
playbackTimeAfterReady,
);
if (effectiveStartTime >= clipEnd) {
return;
}
const node = audioContext.createBufferSource();
node.buffer = buffer;
const clipGain = audioContext.createGain();
node.connect(clipGain);
clipGain.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime +
this.playbackLatencyCompensationSeconds +
(effectiveStartTime - this.playbackStartTime);
const clipOffset = effectiveStartTime - clipStart;
let actualStartTimestamp = startTimestamp;
let actualClipOffset = clipOffset;
if (startTimestamp >= audioContext.currentTime) {
node.start(startTimestamp, clipOffset);
} else {
const lateOffset = audioContext.currentTime - startTimestamp;
actualStartTimestamp = audioContext.currentTime;
actualClipOffset = clipOffset + lateOffset;
node.start(actualStartTimestamp, actualClipOffset);
}
this.scheduleClipGainAutomation({
audioContext,
clip,
clipGain,
startTimestamp: actualStartTimestamp,
startLocalTime: actualClipOffset,
});
this.queuedSources.add(node);
node.addEventListener("ended", () => {
node.disconnect();
clipGain.disconnect();
this.queuedSources.delete(node);
});
}
private waitUntilCaughtUp({
timelineTime,
targetAhead,
@@ -349,6 +462,221 @@ export class AudioManager {
this.sinks.clear();
}
private shouldUsePreparedClipBuffer({
clip,
}: {
clip: AudioClipSource;
}): boolean {
return (
this.hasCurveRetime({ clip }) ||
hasAnimatedVolume({ element: clip.timelineElement }) ||
shouldMaintainPitch({
rate: clip.retime?.rate ?? 1,
maintainPitch: clip.retime?.maintainPitch,
})
);
}
private hasCurveRetime({ clip }: { clip: AudioClipSource }): boolean {
const mode = (clip.retime as { mode?: unknown } | undefined)?.mode;
return mode === "curve";
}
private scheduleClipGainAutomation({
audioContext,
clip,
clipGain,
startTimestamp,
startLocalTime,
}: {
audioContext: AudioContext;
clip: AudioClipSource;
clipGain: GainNode;
startTimestamp: number;
startLocalTime: number;
}): void {
clipGain.gain.cancelScheduledValues(startTimestamp);
clipGain.gain.setValueAtTime(clip.volume, startTimestamp);
if (!hasAnimatedVolume({ element: clip.timelineElement })) {
return;
}
const points = buildAudioGainAutomation({
element: clip.timelineElement,
fromLocalTime: startLocalTime,
toLocalTime: clip.duration,
});
if (points.length === 0) {
return;
}
clipGain.gain.setValueAtTime(points[0].gain, startTimestamp);
for (let index = 1; index < points.length; index++) {
const point = points[index];
const pointTimestamp =
startTimestamp + (point.localTime - startLocalTime);
if (pointTimestamp < audioContext.currentTime) {
continue;
}
clipGain.gain.linearRampToValueAtTime(point.gain, pointTimestamp);
}
}
private buildPreparedClipCacheKey({
clip,
}: {
clip: AudioClipSource;
}): string {
return JSON.stringify({
id: clip.id,
sourceKey: clip.sourceKey,
startTime: clip.startTime,
duration: clip.duration,
trimStart: clip.trimStart,
trimEnd: clip.trimEnd,
retime: clip.retime ?? null,
});
}
private async getPreparedClipBuffer({
clip,
}: {
clip: AudioClipSource;
}): Promise<AudioBuffer | null> {
const cacheKey = this.buildPreparedClipCacheKey({ clip });
const existing = this.preparedClipBuffers.get(cacheKey);
if (existing) {
return existing;
}
const promise = (async () => {
const audioContext = this.ensureAudioContext();
if (!audioContext) {
return null;
}
const decodedBuffer = await this.getDecodedBuffer({ clip });
if (!decodedBuffer) {
return null;
}
return await renderRetimedBuffer({
audioContext,
sourceBuffer: decodedBuffer,
trimStart: clip.trimStart,
clipDuration: clip.duration,
retime: clip.retime,
maintainPitch: clip.retime?.maintainPitch === true,
});
})();
this.preparedClipBuffers.set(cacheKey, promise);
return promise;
}
private async getDecodedBuffer({
clip,
}: {
clip: AudioClipSource;
}): Promise<AudioBuffer | null> {
const existing = this.decodedBuffers.get(clip.sourceKey);
if (existing) {
return existing;
}
const promise = this.decodeClipBuffer({ clip });
this.decodedBuffers.set(clip.sourceKey, promise);
return promise;
}
private async decodeClipBuffer({
clip,
}: {
clip: AudioClipSource;
}): Promise<AudioBuffer | null> {
const audioContext = this.ensureAudioContext();
if (!audioContext) {
return null;
}
const input = new Input({
source: new BlobSource(clip.file),
formats: ALL_FORMATS,
});
try {
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) {
return null;
}
const sink = new AudioBufferSink(audioTrack);
const chunks: AudioBuffer[] = [];
let totalSamples = 0;
for await (const { buffer } of sink.buffers(0)) {
chunks.push(buffer);
totalSamples += buffer.length;
}
if (chunks.length === 0) {
return null;
}
const targetSampleRate = audioContext.sampleRate;
const nativeSampleRate = chunks[0].sampleRate;
const numChannels = Math.min(2, chunks[0].numberOfChannels);
const nativeChannels = Array.from(
{ length: numChannels },
() => new Float32Array(totalSamples),
);
let offset = 0;
for (const chunk of chunks) {
for (let channel = 0; channel < numChannels; channel++) {
nativeChannels[channel].set(
chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)),
offset,
);
}
offset += chunk.length;
}
const outputSamples = Math.ceil(
totalSamples * (targetSampleRate / nativeSampleRate),
);
const offlineContext = new OfflineAudioContext(
numChannels,
outputSamples,
targetSampleRate,
);
const nativeBuffer = audioContext.createBuffer(
numChannels,
totalSamples,
nativeSampleRate,
);
for (let channel = 0; channel < numChannels; channel++) {
nativeBuffer.copyToChannel(nativeChannels[channel], channel);
}
const sourceNode = offlineContext.createBufferSource();
sourceNode.buffer = nativeBuffer;
sourceNode.connect(offlineContext.destination);
sourceNode.start(0);
return await offlineContext.startRendering();
} catch (error) {
console.warn("Failed to decode clip audio:", error);
return null;
} finally {
input.dispose();
}
}
private async getAudioSink({
clip,
}: {
+34 -39
View File
@@ -1,9 +1,10 @@
import type { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { generateUUID } from "@/utils/id";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import { BatchCommand, RemoveMediaAssetCommand } from "@/lib/commands";
export class MediaManager {
private assets: MediaAsset[] = [];
@@ -18,7 +19,7 @@ export class MediaManager {
}: {
projectId: string;
asset: Omit<MediaAsset, "id">;
}): Promise<void> {
}): Promise<MediaAsset | null> {
const newAsset: MediaAsset = {
...asset,
id: generateUUID(),
@@ -29,54 +30,46 @@ export class MediaManager {
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
return newAsset;
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
this.notify();
if (storageService.isQuotaExceededError({ error })) {
toast.error("Not enough browser storage", {
description: error instanceof Error ? error.message : undefined,
});
}
return null;
}
}
async removeMediaAsset({
removeMediaAsset({ projectId, id }: { projectId: string; id: string }): void {
this.removeMediaAssets({ projectId, ids: [id] });
}
removeMediaAssets({
projectId,
id,
ids,
}: {
projectId: string;
id: string;
}): Promise<void> {
const asset = this.assets.find((asset) => asset.id === id);
videoCache.clearVideo({ mediaId: id });
if (asset?.url) {
URL.revokeObjectURL(asset.url);
if (asset.thumbnailUrl) {
URL.revokeObjectURL(asset.thumbnailUrl);
}
ids: string[];
}): void {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) {
return;
}
this.assets = this.assets.filter((asset) => asset.id !== id);
this.notify();
const command =
uniqueIds.length === 1
? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
: new BatchCommand(
uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
);
const tracks = this.editor.timeline.getTracks();
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of tracks) {
for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === id) {
elementsToRemove.push({ trackId: track.id, elementId: element.id });
}
}
}
if (elementsToRemove.length > 0) {
this.editor.timeline.deleteElements({ elements: elementsToRemove });
}
try {
await storageService.deleteMediaAsset({ projectId, id });
} catch (error) {
console.error("Failed to delete media asset:", error);
}
this.editor.command.execute({ command });
}
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
@@ -157,6 +150,8 @@ export class MediaManager {
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
}
+12 -3
View File
@@ -11,7 +11,15 @@ export class PlaybackManager {
private playbackTimer: number | null = null;
private lastUpdate = 0;
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) {
this.editor.timeline.subscribe(() => {
const duration = this.editor.timeline.getTotalDuration();
if (this.currentTime > duration && duration > 0) {
this.currentTime = duration;
this.notify();
}
});
}
play(): void {
const duration = this.editor.timeline.getTotalDuration();
@@ -117,7 +125,9 @@ export class PlaybackManager {
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
private startTimer(): void {
@@ -158,7 +168,6 @@ export class PlaybackManager {
);
} else {
this.currentTime = newTime;
this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
+31 -18
View File
@@ -6,8 +6,8 @@ import type {
TProjectSortOption,
TProjectSettings,
TTimelineViewState,
} from "@/types/project";
import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
} from "@/lib/project/types";
import type { ExportOptions, ExportResult, ExportState } from "@/lib/export";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@@ -26,9 +26,9 @@ import {
runStorageMigrations,
type MigrationProgress,
} from "@/services/storage/migrations";
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
import { loadFonts } from "@/lib/fonts/google-fonts";
import { collectFontFamilies } from "@/lib/timeline/element-utils";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
export interface MigrationState {
isMigrating: boolean;
@@ -94,6 +94,8 @@ export class ProjectManager {
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
canvasSizeMode: "preset",
lastCustomCanvasSize: null,
originalCanvasSize: null,
background: {
type: "color",
@@ -155,7 +157,9 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) });
await loadFonts({
families: getElementFontFamilies({ tracks: allTracks }),
});
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
@@ -239,14 +243,21 @@ export class ProjectManager {
this.notify();
}
await this.ensureStorageMigrations();
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
this.notify();
await this.ensureStorageMigrations();
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
this.notify();
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
this.isLoading = false;
this.isInitialized = true;
this.notify();
}
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
console.error("Failed to run migrations:", error);
this.isLoading = false;
this.isInitialized = true;
this.notify();
@@ -495,10 +506,12 @@ export class ProjectManager {
}
async prepareExit(): Promise<void> {
console.log("prepareExit", this.active);
if (!this.active) return;
try {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
console.log("didUpdateThumbnail", didUpdateThumbnail);
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
@@ -571,7 +584,7 @@ export class ProjectManager {
}
getTimelineViewState(): TTimelineViewState {
return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
return this.active?.timelineViewState ?? DEFAULTS.timeline.viewState;
}
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
@@ -581,6 +594,7 @@ export class ProjectManager {
timelineViewState: viewState ?? undefined,
};
this.editor.save.markDirty();
this.notify();
}
getSavedProjects(): TProjectMetadata[] {
@@ -615,15 +629,12 @@ export class ProjectManager {
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
if (duration === 0) return false;
const { canvasSize, background } = this.active.settings;
const scene = buildScene({
tracks,
mediaAssets,
duration,
duration: duration || 1,
canvasSize,
background,
});
@@ -656,7 +667,7 @@ export class ProjectManager {
);
if (index !== -1) {
this.savedProjects[index] = project.metadata;
this.savedProjects = this.savedProjects.with(index, project.metadata);
} else {
this.savedProjects = [project.metadata, ...this.savedProjects];
}
@@ -665,6 +676,8 @@ export class ProjectManager {
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
}
+52 -8
View File
@@ -1,6 +1,6 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/types/export";
import type { ExportOptions, ExportResult } from "@/lib/export";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
@@ -8,6 +8,10 @@ import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
| { success: true; blob: Blob; filename: string }
| { success: false; error: string };
export class RendererManager {
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
@@ -24,6 +28,45 @@ export class RendererManager {
}
async saveSnapshot(): Promise<{ success: boolean; error?: string }> {
const snapshot = await this.createSnapshot();
if (!snapshot.success) {
return snapshot;
}
downloadBlob({ blob: snapshot.blob, filename: snapshot.filename });
return { success: true };
}
async copySnapshot(): Promise<{ success: boolean; error?: string }> {
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
return {
success: false,
error: "Clipboard image copy is not supported in this browser",
};
}
const snapshot = await this.createSnapshot();
if (!snapshot.success) {
return snapshot;
}
try {
await navigator.clipboard.write([
new ClipboardItem({
[snapshot.blob.type || "image/png"]: snapshot.blob,
}),
]);
return { success: true };
} catch (error) {
console.error("Copy snapshot failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
private async createSnapshot(): Promise<SnapshotResult> {
try {
const renderTree = this.getRenderTree();
const activeProject = this.editor.project.getActive();
@@ -70,15 +113,14 @@ export class RendererManager {
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
const safeName = activeProject.metadata.name
.replace(/[<>:"/\\|?*]/g, "-")
.trim() || "snapshot";
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
const filename = `${safeName}-${timecode}.png`;
downloadBlob({ blob, filename });
return { success: true };
return { success: true, blob, filename };
} catch (error) {
console.error("Save snapshot failed:", error);
console.error("Snapshot capture failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
@@ -193,6 +235,8 @@ export class RendererManager {
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
}
+4 -2
View File
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
import type { TimelineTrack, TScene } from "@/types/timeline";
import type { TimelineTrack, TScene } from "@/lib/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -302,7 +302,9 @@ export class ScenesManager {
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
@@ -1,7 +1,6 @@
import type { EditorCore } from "@/core";
import type { SelectedKeyframeRef } from "@/types/animation";
type ElementRef = { trackId: string; elementId: string };
import type { SelectedKeyframeRef } from "@/lib/animation/types";
import type { ElementRef } from "@/lib/timeline/types";
export class SelectionManager {
private selectedElements: ElementRef[] = [];
@@ -67,6 +66,8 @@ export class SelectionManager {
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
}
+120 -35
View File
@@ -1,16 +1,17 @@
import type { EditorCore } from "@/core";
import type { EffectParamValues } from "@/types/effects";
import type { ParamValues } from "@/lib/params";
import type {
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
RetimeConfig,
} from "@/lib/timeline";
import type {
AnimationPath,
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/types/animation";
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@@ -30,6 +31,7 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
UpdateElementRetimeCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
@@ -38,15 +40,18 @@ import {
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
RemoveMaskCommand,
ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
} from "@/lib/commands/timeline";
import { BatchCommand, PreviewTracker } from "@/lib/commands";
import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
private previewTracker = new PreviewTracker<TimelineTrack[]>();
private previewOverlay = new Map<string, Partial<TimelineElement>>();
private previewTracks: TimelineTrack[] | null = null;
constructor(private editor: EditorCore) {}
@@ -121,6 +126,29 @@ export class TimelineManager {
}
}
updateElementRetime({
trackId,
elementId,
retime,
pushHistory = true,
}: {
trackId: string;
elementId: string;
retime?: RetimeConfig;
pushHistory?: boolean;
}): void {
const command = new UpdateElementRetimeCommand({
trackId,
elementId,
retime,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementStartTime({
elements,
startTime,
@@ -308,6 +336,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
removeMask({
trackId,
elementId,
maskId,
}: {
trackId: string;
elementId: string;
maskId: string;
}): void {
const command = new RemoveMaskCommand({
trackId,
elementId,
maskId,
});
this.editor.command.execute({ command });
}
updateClipEffectParams({
trackId,
elementId,
@@ -318,7 +363,7 @@ export class TimelineManager {
trackId: string;
elementId: string;
effectId: string;
params: Partial<EffectParamValues>;
params: Partial<ParamValues>;
pushHistory?: boolean;
}): void {
const command = new UpdateClipEffectParamsCommand({
@@ -351,6 +396,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
toggleMaskInverted({
trackId,
elementId,
maskId,
}: {
trackId: string;
elementId: string;
maskId: string;
}): void {
const command = new ToggleMaskInvertedCommand({
trackId,
elementId,
maskId,
});
this.editor.command.execute({ command });
}
reorderClipEffects({
trackId,
elementId,
@@ -377,7 +439,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -419,7 +481,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
}>;
}): void {
@@ -450,7 +512,7 @@ export class TimelineManager {
}: {
trackId: string;
elementId: string;
propertyPath: AnimationPropertyPath;
propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): void {
@@ -520,7 +582,7 @@ export class TimelineManager {
}
isPreviewActive(): boolean {
return this.previewTracker.isActive();
return this.previewOverlay.size > 0;
}
previewElements({
@@ -532,37 +594,51 @@ export class TimelineManager {
updates: Partial<TimelineElement>;
}>;
}): void {
const tracks = this.getTracks();
this.previewTracker.begin({ state: tracks });
let updatedTracks = tracks;
for (const { trackId, elementId, updates: elementUpdates } of updates) {
updatedTracks = updatedTracks.map((track) => {
if (track.id !== trackId) return track;
const newElements = track.elements.map((element) =>
element.id === elementId
? { ...element, ...elementUpdates }
: element,
);
return { ...track, elements: newElements } as TimelineTrack;
});
for (const { elementId, updates: elementUpdates } of updates) {
const existingOverlay = this.previewOverlay.get(elementId);
const mergedOverlay = {
...existingOverlay,
...elementUpdates,
} as Partial<TimelineElement>;
this.previewOverlay.set(elementId, mergedOverlay);
}
this.updateTracks(updatedTracks);
const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
this.previewTracks = this.applyPreviewOverlay(committedTracks);
this.notify();
}
commitPreview(): void {
const snapshot = this.previewTracker.end();
if (snapshot === null) return;
const currentTracks = this.getTracks();
const command = new TracksSnapshotCommand(snapshot, currentTracks);
if (this.previewOverlay.size === 0) return;
const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
this.editor.command.push({ command });
this.previewOverlay.clear();
this.previewTracks = null;
this.updateTracks(afterTracks);
}
discardPreview(): void {
const snapshot = this.previewTracker.end();
if (snapshot !== null) {
this.updateTracks(snapshot);
}
if (this.previewOverlay.size === 0) return;
this.previewOverlay.clear();
this.previewTracks = null;
this.notify();
}
private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
if (this.previewOverlay.size === 0) return tracks;
return tracks.map((track) => {
const hasOverlay = track.elements.some((el) =>
this.previewOverlay.has(el.id),
);
if (!hasOverlay) return track;
const newElements = track.elements.map((el) => {
const overlay = this.previewOverlay.get(el.id);
return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
});
return { ...track, elements: newElements } as TimelineTrack;
});
}
duplicateElements({
@@ -597,16 +673,25 @@ export class TimelineManager {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
getRenderTracks(): TimelineTrack[] {
if (this.previewTracks !== null) return this.previewTracks;
return this.getTracks();
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
this.listeners.forEach((fn) => {
fn();
});
}
updateTracks(newTracks: TimelineTrack[]): void {
this.previewOverlay.clear();
this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}