mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: masks, properties refactor, shaders, storage migrations, and more
This commit is contained in:
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user