summaryrefslogtreecommitdiff
path: root/apps/web/src/commands/media
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/commands/media')
-rw-r--r--apps/web/src/commands/media/add-media-asset.ts135
-rw-r--r--apps/web/src/commands/media/index.ts2
-rw-r--r--apps/web/src/commands/media/remove-media-asset.ts111
3 files changed, 248 insertions, 0 deletions
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<MediaAsset, "id">,
+ ) {
+ 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);
+ }
+ }
+}