From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/commands/media/add-media-asset.ts | 135 ++++++++++++++++++++++ apps/web/src/commands/media/index.ts | 2 + apps/web/src/commands/media/remove-media-asset.ts | 111 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 apps/web/src/commands/media/add-media-asset.ts create mode 100644 apps/web/src/commands/media/index.ts create mode 100644 apps/web/src/commands/media/remove-media-asset.ts (limited to 'apps/web/src/commands/media') diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts new file mode 100644 index 00000000..d22241a2 --- /dev/null +++ b/apps/web/src/commands/media/add-media-asset.ts @@ -0,0 +1,135 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { toast } from "sonner"; +import type { MediaAsset } from "@/media/types"; +import { generateUUID } from "@/utils/id"; +import { storageService } from "@/services/storage/service"; +import type { FrameRate } from "opencut-wasm"; +import { hasMediaId } from "@/timeline/element-utils"; +import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils"; +import { UpdateProjectSettingsCommand } from "@/commands/project"; + +export class AddMediaAssetCommand extends Command { + private assetId: string; + private savedAssets: MediaAsset[] | null = null; + private createdAsset: MediaAsset | null = null; + private previousProjectFps: FrameRate | null = null; + private appliedProjectFps: FrameRate | null = null; + + constructor( + private projectId: string, + private asset: Omit, + ) { + super(); + this.assetId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedAssets = [...editor.media.getAssets()]; + + this.createdAsset = { + ...this.asset, + id: this.assetId, + }; + + editor.media.setAssets({ + assets: [...this.savedAssets, this.createdAsset], + }); + this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null; + this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({ + importedAssets: [this.createdAsset], + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: this.createdAsset, + }) + .catch((error) => { + console.error("Failed to save media item:", error); + + const currentAssets = editor.media.getAssets(); + editor.media.setAssets({ + assets: currentAssets.filter((asset) => asset.id !== this.assetId), + }); + + const currentTracks = editor.scenes.getActiveScene().tracks; + const orphanedElements: Array<{ trackId: string; elementId: string }> = + []; + + for (const track of [ + ...currentTracks.overlay, + currentTracks.main, + ...currentTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + orphanedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + + if (orphanedElements.length > 0) { + editor.timeline.deleteElements({ elements: orphanedElements }); + } + + this.restoreProjectFpsAfterFailedSave({ editor }); + + if (storageService.isQuotaExceededError({ error })) { + toast.error("Not enough browser storage", { + description: error instanceof Error ? error.message : undefined, + }); + } + }); + + return undefined; + } + + undo(): void { + if (this.savedAssets) { + const editor = EditorCore.getInstance(); + editor.media.setAssets({ assets: this.savedAssets }); + + if (this.createdAsset) { + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item on undo:", error); + }); + } + } + } + + getAssetId(): string { + return this.assetId; + } + + private restoreProjectFpsAfterFailedSave({ + editor, + }: { + editor: EditorCore; + }): void { + if (this.previousProjectFps === null || this.appliedProjectFps === null) return; + + const activeProject = editor.project.getActiveOrNull(); + if (!activeProject) return; + if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; + + const highestRemainingVideoFps = getHighestImportedVideoFps({ + mediaAssets: editor.media.getAssets(), + }); + const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator; + if ( + highestRemainingVideoFps !== null && + highestRemainingVideoFps >= appliedFpsFloat + ) { + return; + } + + new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute(); + } +} diff --git a/apps/web/src/commands/media/index.ts b/apps/web/src/commands/media/index.ts new file mode 100644 index 00000000..466222f2 --- /dev/null +++ b/apps/web/src/commands/media/index.ts @@ -0,0 +1,2 @@ +export { AddMediaAssetCommand } from "./add-media-asset"; +export { RemoveMediaAssetCommand } from "./remove-media-asset"; diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts new file mode 100644 index 00000000..8da62181 --- /dev/null +++ b/apps/web/src/commands/media/remove-media-asset.ts @@ -0,0 +1,111 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { MediaAsset } from "@/media/types"; +import { buildWaveformSourceKey } from "@/media/waveform-summary"; +import { storageService } from "@/services/storage/service"; +import { videoCache } from "@/services/video-cache/service"; +import { waveformCache } from "@/services/waveform-cache/service"; +import { hasMediaId } from "@/timeline/element-utils"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveMediaAssetCommand extends Command { + private savedAssets: MediaAsset[] | null = null; + private savedTracks: SceneTracks | null = null; + private removedAsset: MediaAsset | null = null; + + constructor( + private projectId: string, + private assetId: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const assets = editor.media.getAssets(); + + this.savedAssets = [...assets]; + this.savedTracks = editor.scenes.getActiveScene().tracks; + + this.removedAsset = + assets.find((media) => media.id === this.assetId) ?? null; + + if (!this.removedAsset) { + console.error("Media asset not found:", this.assetId); + return; + } + + if (this.removedAsset.url) { + URL.revokeObjectURL(this.removedAsset.url); + } + if (this.removedAsset.thumbnailUrl) { + URL.revokeObjectURL(this.removedAsset.thumbnailUrl); + } + + videoCache.clearVideo({ mediaId: this.assetId }); + waveformCache.clearSource({ + sourceKey: buildWaveformSourceKey({ + kind: "media", + id: this.assetId, + }), + }); + + editor.media.setAssets({ + assets: assets.filter((media) => media.id !== this.assetId), + }); + + const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; + + for (const track of [ + ...this.savedTracks.overlay, + this.savedTracks.main, + ...this.savedTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + elementsToRemove.push({ trackId: track.id, elementId: element.id }); + } + } + } + + if (elementsToRemove.length > 0) { + editor.timeline.deleteElements({ elements: elementsToRemove }); + } + + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item:", error); + }); + } + + undo(): void { + const editor = EditorCore.getInstance(); + + if (this.savedAssets && this.removedAsset) { + const restoredAsset: MediaAsset = { + ...this.removedAsset, + url: URL.createObjectURL(this.removedAsset.file), + }; + + editor.media.setAssets({ + assets: this.savedAssets.map((a) => + a.id === this.assetId ? restoredAsset : a, + ), + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: restoredAsset, + }) + .catch((error) => { + console.error("Failed to restore media item on undo:", error); + }); + } + + if (this.savedTracks) { + editor.timeline.updateTracks(this.savedTracks); + } + } +} -- cgit v1.3.1