collective

Browse / github.com/storytold/artcraft / patch

fix(video-editor): keep delayed media imports in their initiating context

fix(video-editor): keep delayed media imports in their initiating context ## What changed Keep delayed media imports bound to their initiating editor context. Clipboard imports now require the same project and scene and a mounted listener before running commands. The shared media manager and add-media command enforce their supplied project IDs; command undo also respects ownership. File/gallery completion counts include only registrations accepted by the manager. ## Why Switching project or scene while an upload resolves could add the previous paste's media and clips to the new editor context. File/gallery imports passed a project ID that the manager discarded. U

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2091, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+268 −8 in 5 files
Review
pending
Shared
2026-10-28
Id
0280871e26db3e27f689c0ed

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/video-editor/src/lib/commands/media/add-media-asset.ts frontend/libs/components/video-editor/src/lib/core/managers/media-manager.ts frontend/libs/components/video-editor/src/lib/media/use-paste-media.spec.tsx frontend/libs/components/video-editor/src/lib/media/use-paste-media.ts frontend/libs/components/video-editor/src/lib/panels/assets/views/assets.tsx

diff --git a/frontend/libs/components/video-editor/src/lib/commands/media/add-media-asset.ts b/frontend/libs/components/video-editor/src/lib/commands/media/add-media-asset.ts--- a/frontend/libs/components/video-editor/src/lib/commands/media/add-media-asset.ts+++ b/frontend/libs/components/video-editor/src/lib/commands/media/add-media-asset.ts@@ -25,6 +25,7 @@ export class AddMediaAssetCommand extends Command {    execute(): CommandResult | undefined {     const editor = EditorCore.getInstance();+    if (editor.project.getActiveOrNull()?.metadata.id !== this.projectId) return;     this.savedAssets = [...editor.media.getAssets()];      editor.media.setAssets({@@ -42,6 +43,7 @@ export class AddMediaAssetCommand extends Command {   undo(): void {     if (!this.savedAssets) return;     const editor = EditorCore.getInstance();+    if (editor.project.getActiveOrNull()?.metadata.id !== this.projectId) return;     editor.media.setAssets({ assets: this.savedAssets });      // If execute() ratcheted the project FPS up to accommodate thisdiff --git a/frontend/libs/components/video-editor/src/lib/core/managers/media-manager.ts b/frontend/libs/components/video-editor/src/lib/core/managers/media-manager.ts--- a/frontend/libs/components/video-editor/src/lib/core/managers/media-manager.ts+++ b/frontend/libs/components/video-editor/src/lib/core/managers/media-manager.ts@@ -26,11 +26,18 @@ export class MediaManager {   // the asset id is already in the bin so re-imports of the same gallery   // token don't produce duplicate rows.   async addMediaAsset({+    projectId,     asset,   }: {     projectId?: string;     asset: MediaAsset;   }): Promise<MediaAsset | null> {+    if (+      projectId !== undefined &&+      this.editor.project.getActiveOrNull()?.metadata.id !== projectId+    ) {+      return null;+    }     const existing = this.assets.find((a) => a.id === asset.id);     if (existing) {       return existing;diff --git a/frontend/libs/components/video-editor/src/lib/media/use-paste-media.spec.tsx b/frontend/libs/components/video-editor/src/lib/media/use-paste-media.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/video-editor/src/lib/media/use-paste-media.spec.tsx@@ -0,0 +1,227 @@+import { act } from "react";+import { createRoot, type Root } from "react-dom/client";+import { usePasteMedia } from "./use-paste-media";+import { MediaManager } from "../core/managers/media-manager";+import { CommandManager } from "../core/managers/commands";+import { AddMediaAssetCommand } from "../commands/media/add-media-asset";+import type { EditorCore } from "../core";+import type { TProject } from "../project/types";+import type { TScene, SceneTracks } from "../timeline/types";+import type { MediaAsset } from "./types";++const fixture = vi.hoisted(() => ({+  editor: null as unknown as EditorCore,+  adapters: null as any,+}));+vi.mock("../core", () => ({ EditorCore: { getInstance: () => fixture.editor } }));+vi.mock("../EditorProvider", () => ({ useEditorAdapters: () => fixture.adapters }));+vi.mock("../commands", async () => await import("../commands/batch-command"));+vi.mock("../commands/media", async () => ({+  ...(await import("../commands/media/add-media-asset")),+  RemoveMediaAssetCommand: class { constructor() { throw new Error("removal is outside this test"); } },+}));+vi.mock("../commands/timeline", async () => await import("../commands/timeline/element/insert-element"));+// Browser decoding is not the subject of these ownership tests. The real+// processor still performs uploadLocalFile, resolveMedia and its async yield.+vi.mock("./mediabunny", () => ({ readVideoFile: async () => ({+  duration: 1, width: 640, height: 360, fps: 30,+  hasAudio: false, codec: "h264", canDecode: true, thumbnailUrl: null,+}) }));+vi.mock("../wasm", () => ({+  TICKS_PER_SECOND: 1000, ZERO_MEDIA_TIME: 0,+  mediaTime: ({ ticks }: { ticks: number }) => ticks,+  mediaTimeFromSeconds: ({ seconds }: { seconds: number }) => seconds * 1000,+  roundMediaTime: ({ time }: { time: number }) => Math.round(time),+}));+vi.mock("../graphics", () => ({+  buildDefaultGraphicInstance: () => { throw new Error("graphics not used"); },+  graphicsRegistry: { has: () => false }, registerDefaultGraphics: () => {},+}));+vi.mock("../effects", () => ({+  buildDefaultEffectInstance: () => { throw new Error("effects not used"); },+}));+vi.mock("../ripple", () => ({+  applyRippleAdjustments: () => { throw new Error("ripple disabled"); },+  computeRippleAdjustments: () => { throw new Error("ripple disabled"); },+}));+vi.mock("../services/video-cache", () => ({ videoCache: { clearVideo: vi.fn(), clearAll: vi.fn() } }));+vi.mock("../services/waveform-cache", () => ({ waveformCache: { clearSource: vi.fn(), clearAll: vi.fn() } }));++let root: Root;+let container: HTMLDivElement;+let activeProject: TProject;+let activeScene: TScene;+let upload: ReturnType<typeof deferred<{ id: string; kind: "video" }>>;+let resolve: ReturnType<typeof deferred<{ url: string; mime: string }>>;++beforeEach(() => {+  globalThis.IS_REACT_ACT_ENVIRONMENT = true;+  activeProject = project("project-a");+  activeScene = scene("scene-a");+  upload = deferred();+  resolve = deferred();+  fixture.adapters = {+    toast: { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() },+    mediaSource: {+      uploadLocalFile: vi.fn(() => upload.promise),+      resolveMedia: vi.fn(() => resolve.promise),+    },+  };+  const editor = {+    project: {+      getActive: () => activeProject,+      getActiveOrNull: () => activeProject,+      ratchetFpsForImportedMedia: () => null,+      updateSettings: ({ settings }: { settings: Partial<TProject["settings"]> }) => {+        activeProject.settings = { ...activeProject.settings, ...settings };+      },+    },+    scenes: {+      getActiveScene: () => activeScene,+      getActiveSceneOrNull: () => activeScene,+    },+    playback: { getCurrentTime: () => 0 },+    timeline: { updateTracks: (tracks: SceneTracks) => { activeScene.tracks = tracks; } },+    selection: { getSnapshot: () => ({}), applySelectionPatch: () => ({}) },+    clipboard: { paste: vi.fn() },+  } as unknown as EditorCore;+  Object.assign(editor, { media: new MediaManager(editor), command: new CommandManager(editor) });+  fixture.editor = editor;+  vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));+  container = document.createElement("div");+  document.body.append(container);+  root = createRoot(container);+  act(() => root.render(<PasteListener />));+});++afterEach(() => {+  act(() => root.unmount());+  container.remove();+  vi.unstubAllGlobals();+});++it("imports into the original project and scene when context remains active", async () => {+  await beginPaste();+  await completePaste();+  expect(fixture.editor.media.getAssets().map((asset) => asset.id)).toEqual(["uploaded"]);+  expect(activeScene.tracks.main.elements).toHaveLength(1);+  expect(fixture.editor.command.canUndo()).toBe(true);+  expect(fixture.adapters.mediaSource.resolveMedia).toHaveBeenCalledWith({ id: "uploaded", kind: "video" });+});++it("does not attach a delayed paste to a different project", async () => {+  await beginPaste();+  activeProject = project("project-b");+  activeScene = scene("scene-b");+  await completePaste();+  expect.soft(fixture.editor.media.getAssets()).toEqual([]);+  expect.soft(activeScene.tracks.main.elements).toEqual([]);+  expect.soft(fixture.editor.command.canUndo()).toBe(false);+});++it("does not insert into a scene selected while media resolves", async () => {+  await beginPaste();+  activeScene = scene("scene-b");+  await completePaste();+  expect.soft(fixture.editor.media.getAssets()).toEqual([]);+  expect.soft(activeScene.tracks.main.elements).toEqual([]);+  expect.soft(fixture.editor.command.canUndo()).toBe(false);+});++it("does not finish a paste after its listener unmounts", async () => {+  await beginPaste();+  act(() => root.render(null));+  await completePaste();+  expect.soft(fixture.editor.media.getAssets()).toEqual([]);+  expect.soft(activeScene.tracks.main.elements).toEqual([]);+  expect.soft(fixture.editor.command.canUndo()).toBe(false);+});++it("rejects an asset submitted by a stale file or gallery import", async () => {+  const result = await fixture.editor.media.addMediaAsset({ projectId: "old-project", asset: asset() });+  expect(result).toBeNull();+  expect(fixture.editor.media.getAssets()).toEqual([]);+});++it("accepts an asset whose requested project is active", async () => {+  const result = await fixture.editor.media.addMediaAsset({ projectId: "project-a", asset: asset() });+  expect(result?.id).toBe("uploaded");+  expect(fixture.editor.media.getAssets()).toHaveLength(1);+});++it("does not execute an add-media command for another project", () => {+  new AddMediaAssetCommand({ projectId: "old-project", asset: asset() }).execute();+  expect(fixture.editor.media.getAssets()).toEqual([]);+});++it("does not undo media into a different project's bin", () => {+  const command = new AddMediaAssetCommand({ projectId: "project-a", asset: asset() });+  command.execute();+  activeProject = project("project-b");+  const otherAsset = { ...asset(), id: "other-project-asset" };+  fixture.editor.media.loadProjectMedia({ assets: [otherAsset] });+  command.undo();+  expect(fixture.editor.media.getAssets()).toEqual([otherAsset]);+});++it("does not accept a stale import just because its asset id exists", async () => {+  fixture.editor.media.loadProjectMedia({ assets: [asset()] });+  const result = await fixture.editor.media.addMediaAsset({ projectId: "old-project", asset: asset() });+  expect(result).toBeNull();+  expect(fixture.editor.media.getAssets()).toHaveLength(1);+});++it("preserves imports that intentionally omit the optional project id", async () => {+  const result = await fixture.editor.media.addMediaAsset({ asset: asset() });+  expect(result?.id).toBe("uploaded");+});++it("preserves internal timeline paste without media files", () => {+  const event = new Event("paste", { cancelable: true });+  Object.defineProperty(event, "clipboardData", { value: { items: [] } });+  window.dispatchEvent(event);+  expect(event.defaultPrevented).toBe(true);+  expect(fixture.editor.clipboard.paste).toHaveBeenCalledOnce();+});++function PasteListener() {+  usePasteMedia();+  return null;+}++async function beginPaste() {+  const file = new File(["synthetic"], "clip.mp4", { type: "video/mp4" });+  const event = new Event("paste", { cancelable: true });+  Object.defineProperty(event, "clipboardData", { value: {+    items: [{ kind: "file", type: file.type, getAsFile: () => file }],+  } });+  window.dispatchEvent(event);+  await vi.waitFor(() => expect(fixture.adapters.mediaSource.uploadLocalFile).toHaveBeenCalledWith(file));+  upload.resolve({ id: "uploaded", kind: "video" });+  await vi.waitFor(() => expect(fixture.adapters.mediaSource.resolveMedia).toHaveBeenCalled());+}++async function completePaste() {+  resolve.resolve({ url: "https://media.invalid/clip.mp4", mime: "video/mp4" });+  await vi.waitFor(() => expect(+    fixture.adapters.toast.success.mock.calls.length + fixture.adapters.toast.warning.mock.calls.length,+  ).toBeGreaterThan(0));+}++function asset(): MediaAsset {+  return { id: "uploaded", name: "clip.mp4", type: "video", url: "https://media.invalid/clip.mp4" };+}++function project(id: string): TProject {+  return { metadata: { id }, settings: { fps: { numerator: 30, denominator: 1 } } } as TProject;+}++function scene(id: string): TScene {+  return { id, tracks: { overlay: [], audio: [], main: { id: "main", type: "video", elements: [] } } } as unknown as TScene;+}++function deferred<T>() {+  let resolve!: (value: T) => void;+  const promise = new Promise<T>((done) => { resolve = done; });+  return { promise, resolve };+}diff --git a/frontend/libs/components/video-editor/src/lib/media/use-paste-media.ts b/frontend/libs/components/video-editor/src/lib/media/use-paste-media.ts--- a/frontend/libs/components/video-editor/src/lib/media/use-paste-media.ts+++ b/frontend/libs/components/video-editor/src/lib/media/use-paste-media.ts@@ -42,6 +42,7 @@ export function usePasteMedia() {   const { toast, mediaSource } = useEditorAdapters();    useEffect(() => {+    let mounted = true;     const handlePaste = async (event: ClipboardEvent) => {       const activeElement = document.activeElement as HTMLElement; @@ -62,22 +63,38 @@ export function usePasteMedia() {        const activeProject = editor.project.getActive();       if (!activeProject) return;+      const activeScene = editor.scenes.getActiveSceneOrNull();+      if (!activeScene) return;+      const projectId = activeProject.metadata.id;+      const sceneId = activeScene.id;+      const isContextActive = () =>+        mounted &&+        editor.project.getActiveOrNull()?.metadata.id === projectId &&+        editor.scenes.getActiveSceneOrNull()?.id === sceneId;        try {         await showMediaUploadToast({           filesCount: files.length,           toast,           promise: async () => {+            if (!isContextActive()) {+              return { uploadedCount: 0, assetNames: [] };+            }             const processedAssets = await processMediaAssets({               files,               toast,               mediaSource,             });+            // Uploads can outlive the listener or the selected project/scene.+            // Never execute their commands against the newly active context.+            if (!isContextActive()) {+              return { uploadedCount: 0, assetNames: [] };+            }             const startTime = editor.playback.getCurrentTime();              for (const asset of processedAssets) {               const addMediaCmd = new AddMediaAssetCommand({-                projectId: activeProject.metadata.id,+                projectId,                 asset,               });               const assetId = addMediaCmd.getAssetId();@@ -119,6 +136,9 @@ export function usePasteMedia() {     };      window.addEventListener("paste", handlePaste);-    return () => window.removeEventListener("paste", handlePaste);+    return () => {+      mounted = false;+      window.removeEventListener("paste", handlePaste);+    };   }, [editor, toast, mediaSource]); }diff --git a/frontend/libs/components/video-editor/src/lib/panels/assets/views/assets.tsx b/frontend/libs/components/video-editor/src/lib/panels/assets/views/assets.tsx--- a/frontend/libs/components/video-editor/src/lib/panels/assets/views/assets.tsx+++ b/frontend/libs/components/video-editor/src/lib/panels/assets/views/assets.tsx@@ -95,15 +95,17 @@ export function MediaView() {             mediaSource,             onProgress: ({ progress }) => setProgress(progress),           });+          const importedAssets: MediaAsset[] = [];           for (const asset of processedAssets) {-            await editor.media.addMediaAsset({+            const importedAsset = await editor.media.addMediaAsset({               projectId: activeProject.metadata.id,               asset,             });+            if (importedAsset) importedAssets.push(importedAsset);           }           return {-            uploadedCount: processedAssets.length,-            assetNames: processedAssets.map((asset) => asset.name),+            uploadedCount: importedAssets.length,+            assetNames: importedAssets.map((asset) => asset.name),           };         },       });@@ -205,15 +207,17 @@ export function MediaView() {             existingResolved: successes.map((entry) => entry.resolved),             onProgress: ({ progress }) => setProgress(progress),           });+          const importedAssets: MediaAsset[] = [];           for (const asset of processedAssets) {-            await editor.media.addMediaAsset({+            const importedAsset = await editor.media.addMediaAsset({               projectId: activeProject.metadata.id,               asset,             });+            if (importedAsset) importedAssets.push(importedAsset);           }           return {-            uploadedCount: processedAssets.length,-            assetNames: processedAssets.map((asset) => asset.name),+            uploadedCount: importedAssets.length,+            assetNames: importedAssets.map((asset) => asset.name),           };         },       });