This commit is contained in:
Maze Winther
2026-01-16 18:07:28 +01:00
parent 0dddf4e13d
commit 0934db2aba
60 changed files with 1900 additions and 1202 deletions
+47 -30
View File
@@ -15,7 +15,11 @@ import {
} from "@/constants/project-constants";
import { buildDefaultScene } from "@/lib/scene-utils";
import { generateThumbnail } from "@/lib/media-processing-utils";
import { CURRENT_VERSION, runMigrations } from "@/lib/migrations";
import {
CURRENT_STORAGE_VERSION,
migrations,
runStorageMigrations,
} from "@/lib/migrations";
export interface MigrationState {
isMigrating: boolean;
@@ -30,6 +34,7 @@ export class ProjectManager {
private isLoading = true;
private isInitialized = false;
private invalidProjectIds = new Set<string>();
private storageMigrationPromise: Promise<void> | null = null;
private listeners = new Set<() => void>();
private migrationState: MigrationState = {
isMigrating: false,
@@ -40,6 +45,43 @@ export class ProjectManager {
constructor(private editor: EditorCore) {}
private async ensureStorageMigrations(): Promise<void> {
if (this.storageMigrationPromise) {
await this.storageMigrationPromise;
return;
}
this.storageMigrationPromise = (async () => {
let hasShownState = false;
await runStorageMigrations({
migrations,
callbacks: {
onMigrationStart: ({ fromVersion, toVersion }) => {
hasShownState = true;
this.setMigrationState({
isMigrating: true,
fromVersion,
toVersion,
projectName: null,
});
},
},
});
if (hasShownState) {
this.setMigrationState({
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
});
}
})();
await this.storageMigrationPromise;
}
async createNewProject({ name }: { name: string }): Promise<string> {
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
const newProject: TProject = {
@@ -59,7 +101,7 @@ export class ProjectManager {
color: DEFAULT_COLOR,
},
},
version: CURRENT_VERSION,
version: CURRENT_STORAGE_VERSION,
};
this.active = newProject;
@@ -88,6 +130,7 @@ export class ProjectManager {
this.notify();
}
await this.ensureStorageMigrations();
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
@@ -97,34 +140,7 @@ export class ProjectManager {
throw new Error(`Project with id ${id} not found`);
}
let project = result.project;
const migrationResult = runMigrations({ project });
if (migrationResult.migrated) {
const startTime = Date.now();
this.setMigrationState({
isMigrating: true,
fromVersion: migrationResult.fromVersion ?? null,
toVersion: migrationResult.toVersion ?? null,
projectName: project.metadata.name,
});
project = migrationResult.project;
await storageService.saveProject({ project });
const elapsed = Date.now() - startTime;
if (elapsed < 300) {
await new Promise((resolve) => setTimeout(resolve, 300 - elapsed));
}
this.setMigrationState({
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
});
}
const project = result.project;
this.active = project;
this.notify();
@@ -173,6 +189,7 @@ export class ProjectManager {
this.notify();
}
await this.ensureStorageMigrations();
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
+2 -127
View File
@@ -1,19 +1,9 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/types/export";
import type { TimelineTrack } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
interface AudioElement {
buffer: AudioBuffer;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
muted: boolean;
}
import { createTimelineAudioBuffer } from "@/lib/audio-utils";
export class RendererManager {
private renderTree: RootNode | null = null;
@@ -58,7 +48,7 @@ export class RendererManager {
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.({ progress: 0.05 });
audioBuffer = await this.createTimelineAudioBuffer({
audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
@@ -128,121 +118,6 @@ export class RendererManager {
}
}
private async createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
sampleRate = 44100,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: number;
sampleRate?: number;
}): Promise<AudioBuffer | null> {
const AudioContextClass =
window.AudioContext ||
(window as typeof window & { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
const audioContext = new AudioContextClass();
const audioElements: AudioElement[] = [];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((m) => [m.id, m]),
);
for (const track of tracks) {
if (track.muted) continue;
for (const element of track.elements) {
if (element.type !== "audio") {
continue;
}
if (element.duration <= 0) continue;
try {
let audioBuffer: AudioBuffer;
if (element.sourceType === "upload") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset || mediaAsset.type !== "audio") {
continue;
}
const arrayBuffer = await mediaAsset.file.arrayBuffer();
audioBuffer = await audioContext.decodeAudioData(
arrayBuffer.slice(0),
);
} else {
// library audio - already has decoded buffer
audioBuffer = element.buffer;
}
audioElements.push({
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || track.muted || false,
});
} catch (error) {
console.warn("Failed to decode audio:", error);
}
}
}
if (audioElements.length === 0) {
return null;
}
const outputChannels = 2;
const outputLength = Math.ceil(duration * sampleRate);
const outputBuffer = audioContext.createBuffer(
outputChannels,
outputLength,
sampleRate,
);
for (const element of audioElements) {
if (element.muted) continue;
const {
buffer,
startTime,
trimStart,
duration: elementDuration,
} = element;
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
const sourceLengthSamples = Math.floor(
elementDuration * buffer.sampleRate,
);
const outputStartSample = Math.floor(startTime * sampleRate);
const resampleRatio = sampleRate / buffer.sampleRate;
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
for (let channel = 0; channel < outputChannels; channel++) {
const outputData = outputBuffer.getChannelData(channel);
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
const sourceData = buffer.getChannelData(sourceChannel);
for (let i = 0; i < resampledLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
if (sourceIndex >= sourceData.length) break;
outputData[outputIndex] += sourceData[sourceIndex];
}
}
}
return outputBuffer;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
+56 -7
View File
@@ -17,6 +17,7 @@ import {
removeBookmarkFromArray,
isBookmarkAtTime,
} from "@/lib/timeline/bookmark-utils";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
export class ScenesManager {
private active: TScene | null = null;
@@ -220,14 +221,34 @@ export class ScenesManager {
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: result.project.scenes,
scenes: ensuredScenes,
currentSceneId: result.project.currentSceneId,
});
this.list = result.project.scenes;
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(),
},
};
await storageService.saveProject({ project: updatedProject });
this.editor.project.setActiveProject({ project: updatedProject });
}
}
}
} catch (error) {
console.error("Failed to load project scenes:", error);
@@ -245,23 +266,26 @@ export class ScenesManager {
currentSceneId?: string;
}): void {
const ensuredScenes = ensureMainScene({ scenes });
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
const currentScene = currentSceneId
? ensuredScenes.find((s) => s.id === currentSceneId)
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainScene({ scenes: ensuredScenes });
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
this.list = ensuredScenes;
this.list = scenesWithMainTracks;
this.active = currentScene || fallbackScene;
this.notify();
if (ensuredScenes.length > scenes.length) {
const hasAddedMainScene = ensuredScenes.length > scenes.length;
if (hasAddedMainScene || hasAddedMainTrack) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
scenes: scenesWithMainTracks,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
@@ -372,6 +396,31 @@ export class ScenesManager {
}
}
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 };
}
private async updateProjectWithScenes({
updatedScenes,
updatedSceneId,