collective

Browse / github.com/storytold/artcraft / patch

Send 3D library assets and generated results to the stage

Add a button to the 3D asset viewer to "send to stage". Requested by ERA in Discord. Great idea! <img width="1202" height="761" alt="Image" src="https://github.com/user-attachments/assets/52cc3f06-06ec-404f-94b5-74a9af1e1c7e" />

Status
open
Score
0.00
Adopters
0 other operators
Author
channelramble (imported from pull request #2376, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+176 −43 in 9 files
Review
pending
Shared
2026-10-28
Id
395858eed2d27ca837e0d789

How it went

No other operator has reported using it yet.

Diff

raw · frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.ts frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.ts frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx frontend/libs/components/viewer-3d/src/index.ts frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsx frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsx netlify.toml

diff --git a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx--- a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx+++ b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx@@ -1,9 +1,10 @@+import { sendMediaToStage } from "../generation-feed/sendMediaToStage"; import { useEffect, useMemo, useRef, useState } from "react"; import { animated, useSpring } from "@react-spring/web"; import { Button, GenerateButton } from "@storyteller/ui-button"; import { TabSelector } from "@storyteller/ui-tab-selector"; import { Tooltip } from "@storyteller/ui-tooltip";-import { Viewer3D } from "@storyteller/ui-viewer-3d";+import { SendToStageButton, Viewer3D } from "@storyteller/ui-viewer-3d"; import { BoxIcon, ImagesIcon, PlusIcon, UploadIcon, XIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { useImageTo3DStore } from "../../pages/PageImageTo3DObject/ImageTo3DStore";@@ -19,10 +20,6 @@ import { } from "@storyteller/tauri-api"; import { toast } from "react-hot-toast"; import { v4 as uuidv4 } from "uuid";-import { useTabStore } from "../../pages/Stores/TabState";-import { addObject, getActiveEditor } from "@storyteller/ui-pagescene";-import type { MediaItem } from "@storyteller/ui-pagescene";-import { AssetType } from "~/enums"; import { SPLAT_MODELS } from "@storyteller/model-list"; import {   ClassyModelSelector,@@ -959,33 +956,9 @@ export const ImageTo3DExperience = ({               />               {activeResult?.modelUrl && activeResult?.mediaToken && (                 <div className="absolute right-4 top-4 z-10 flex gap-2">-                  <Button-                    variant="primary"-                    className="min-w-[120px]"-                    onClick={() => {-                      useTabStore.getState().setActiveTab("3D");-                      setTimeout(() => {-                        const mediaItem = {-                          version: 1,-                          type: AssetType.OBJECT,-                          media_id: activeResult.mediaToken!,-                          name: activeResult.note || "3D Object",-                          position: { x: 0, y: 0, z: 0 },-                        } as MediaItem & {-                          position: { x: number; y: number; z: number };-                        };-                        const editor = getActiveEditor();-                        if (editor) {-                          void addObject(editor, mediaItem);-                          toast.success("Added to 3D scene");-                        } else {-                          toast.error("3D editor isn't ready yet");-                        }-                      }, 500);-                    }}-                  >-                    Open in 3D Editor-                  </Button>+                  <SendToStageButton+                    onSend={() => sendMediaToStage(activeResult.mediaToken ?? undefined, activeResult.note || "3D Object")}+                  />                   <Button                     variant="action"                     icon={BoxIcon}diff --git a/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.ts b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.ts@@ -0,0 +1,57 @@+import { afterEach, beforeEach, expect, it, vi } from "vitest";+import { sendMediaToStage } from "./sendMediaToStage";+import { useTabStore } from "../../pages/Stores/TabState";++const stage = vi.hoisted(() => ({ editor: vi.fn(), add: vi.fn() }));+vi.mock("@storyteller/ui-pagescene", () => ({ getActiveEditor: stage.editor, addObject: stage.add, AssetType: { OBJECT: "object" } }));++beforeEach(() => {+  vi.useFakeTimers();+  stage.editor.mockReset();+  stage.add.mockReset().mockResolvedValue("object-id");+  useTabStore.setState(useTabStore.getInitialState(), true);+});+afterEach(() => vi.useRealTimers());++it("waits for the saved scene to load before adding the selected media", async () => {+  let loaded = false;+  const editor = { sceneManager: {}, isEngineDataLoaded: () => loaded };+  stage.editor.mockReturnValue(editor);+  const result = sendMediaToStage("mf_selected");+  await vi.advanceTimersByTimeAsync(500);+  expect(stage.add).not.toHaveBeenCalled();+  loaded = true;+  await vi.advanceTimersByTimeAsync(50);+  await result;+  expect(useTabStore.getState().activeTabId).toBe("3D");+  expect(stage.add).toHaveBeenCalledExactlyOnceWith(editor, expect.objectContaining({ media_id: "mf_selected" }));+});++it("times out without inserting an asset when the editor never becomes ready", async () => {+  stage.editor.mockReturnValue(null);+  const result = expect(sendMediaToStage("mf_selected")).rejects.toThrow("still loading");+  await vi.advanceTimersByTimeAsync(5000);+  await result;+  expect(stage.add).not.toHaveBeenCalled();+});++it("does not report success when the scene rejects the asset", async () => {+  stage.editor.mockReturnValue({ sceneManager: {}, isEngineDataLoaded: () => true });+  stage.add.mockResolvedValue(undefined);+  await expect(sendMediaToStage("mf_selected")).rejects.toThrow("could not be added");+});++it("rejects a missing media ID without navigating", async () => {+  await expect(sendMediaToStage()).rejects.toThrow("no media ID");+  expect(useTabStore.getState().activeTabId).toBe("APPS");+});++it("cancels the pending transfer if the user leaves the stage", async () => {+  stage.editor.mockReturnValue(null);+  const result = expect(sendMediaToStage("mf_selected")).rejects.toThrow("switched tabs");+  await vi.advanceTimersByTimeAsync(50);+  await useTabStore.getState().setActiveTab("IMAGE");+  await vi.advanceTimersByTimeAsync(50);+  await result;+  expect(stage.add).not.toHaveBeenCalled();+});diff --git a/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.ts b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.tsnew file mode 100644--- /dev/null+++ b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.ts@@ -0,0 +1,24 @@+import { addObject, AssetType, getActiveEditor } from "@storyteller/ui-pagescene";+import { useTabStore } from "../../pages/Stores/TabState";++/** Add a library asset only after the stage and its saved scene are ready. */+export async function sendMediaToStage(mediaId?: string, name = "3D asset"): Promise<void> {+  if (!mediaId) throw new Error("This asset has no media ID. Please reopen it from your library.");+  if (!await useTabStore.getState().setActiveTab("3D")) {+    throw new Error("Could not open the 3D stage.");+  }+  for (let attempt = 0; attempt < 100; attempt += 1) {+    if (useTabStore.getState().activeTabId !== "3D") {+      throw new Error("Stage transfer cancelled because you switched tabs.");+    }+    const editor = getActiveEditor();+    if (editor?.sceneManager && editor.isEngineDataLoaded()) {+      const objectId = await addObject(editor, { version: 1, type: AssetType.OBJECT, media_id: mediaId, name });+      if (!objectId) throw new Error("The asset could not be added to the stage. Please try again.");+      if (getActiveEditor() !== editor) throw new Error("The stage changed while the asset was loading. Please try again.");+      return;+    }+    await new Promise<void>((resolve) => setTimeout(resolve, 50));+  }+  throw new Error("The 3D stage is still loading. Please try again in a moment.");+}diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx--- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx+++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx@@ -1,3 +1,4 @@+import { sendMediaToStage } from "~/components/generation-feed/sendMediaToStage"; import { CalculatorIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CoinsIcon, GemIcon, HouseIcon, ImagesIcon, MinusIcon, PictureInPicture2Icon, SettingsIcon, SquareIcon, XIcon } from "lucide-react"; import { DynamicIcon } from "@storyteller/icons"; import { signal } from "@preact/signals-react";@@ -704,6 +705,7 @@ export const TopBar = ({ pageName }: Props) => {       <GalleryModal         mode="view"         onDownloadClicked={downloadFile}+        onAddToSceneClicked={(_url, mediaId) => sendMediaToStage(mediaId)}         onEditClicked={handleEditFromGallery}         onTurnIntoVideoClicked={handleTurnIntoVideoFromGallery}         onRemoveBackgroundClicked={handleRemoveBackgroundFromGallery}diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx--- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx+++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx@@ -6,7 +6,7 @@ import { ArrowDownToLineIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightI import { DynamicIcon } from "@storyteller/icons"; import { MediaFileDelete } from "@storyteller/tauri-api"; import { LoadingSpinner } from "@storyteller/ui-loading-spinner";-import { Viewer3D } from "@storyteller/ui-viewer-3d";+import { SendToStageButton, Viewer3D } from "@storyteller/ui-viewer-3d"; import { WaveformAudioPlayer } from "@storyteller/ui-audio-player"; import {   useEffect,@@ -1184,20 +1184,16 @@ export function LightboxModal({                   </Button>                 )} -                {onAddToSceneClicked && actionUrl && (-                  <Button-                    className="w-full py-1.5 text-[13px]"-                    variant="secondary"-                    onClick={async (e) => {-                      e.stopPropagation();+                {onAddToSceneClicked && actionUrl && selectedMediaToken &&+                  ["dimensional", "mesh", "splat"].includes(mediaClass ?? "") && (+                  <SendToStageButton+                    onSend={() => onAddToSceneClicked(actionUrl, selectedMediaToken)}+                    onSent={() => {                       gtagEvent("add_to_scene_clicked");-                      await onAddToSceneClicked(actionUrl, selectedMediaToken);                       onClose();                       onCloseGallery();                     }}-                  >-                    Add to Scene-                  </Button>+                  />                 )}                  {selectedMediaToken && (diff --git a/frontend/libs/components/viewer-3d/src/index.ts b/frontend/libs/components/viewer-3d/src/index.ts--- a/frontend/libs/components/viewer-3d/src/index.ts+++ b/frontend/libs/components/viewer-3d/src/index.ts@@ -2,3 +2,5 @@ export * from './lib/viewer-3d'; export * from './lib/NodeHierarchyHelper'; export * from './lib/disposeObject3D';++export { SendToStageButton } from "./lib/SendToStageButton";diff --git a/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsx b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsx@@ -0,0 +1,38 @@+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import type { ButtonHTMLAttributes } from "react";+import { SendToStageButton } from "./SendToStageButton";++const notify = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));+vi.mock("react-hot-toast", () => ({ default: notify }));+vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, disabled, onClick }: ButtonHTMLAttributes<HTMLButtonElement>) => <button disabled={disabled} onClick={onClick}>{children}</button> }));+beforeEach(() => vi.clearAllMocks());+afterEach(cleanup);++describe("SendToStageButton", () => {+  it("allows one transfer and closes only after confirmed success", async () => {+    let finish!: () => void;+    const onSend = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));+    const onSent = vi.fn();+    render(<SendToStageButton onSend={onSend} onSent={onSent} />);+    fireEvent.click(screen.getByRole("button"));+    fireEvent.click(screen.getByRole("button"));+    expect(onSend).toHaveBeenCalledTimes(1);+    expect(onSent).not.toHaveBeenCalled();+    expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true);+    finish();+    await waitFor(() => expect(onSent).toHaveBeenCalledTimes(1));+    expect(notify.success).toHaveBeenCalledTimes(1);+  });++  it("keeps the viewer open and permits retry after a failed transfer", async () => {+    const onSend = vi.fn().mockRejectedValueOnce(new Error("Model download failed")).mockResolvedValueOnce(undefined);+    const onSent = vi.fn();+    render(<SendToStageButton onSend={onSend} onSent={onSent} />);+    fireEvent.click(screen.getByRole("button"));+    await waitFor(() => expect(notify.error).toHaveBeenCalledWith("Model download failed"));+    expect(onSent).not.toHaveBeenCalled();+    fireEvent.click(screen.getByRole("button"));+    await waitFor(() => expect(onSent).toHaveBeenCalledTimes(1));+  });+});diff --git a/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsx b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsx@@ -0,0 +1,34 @@+import { useRef, useState } from "react";+import { Button } from "@storyteller/ui-button";+import toast from "react-hot-toast";++export function SendToStageButton({ onSend, onSent }: { onSend: () => Promise<void>; onSent?: () => void }) {+  const submitting = useRef(false);+  const [pending, setPending] = useState(false);+  return (+    <Button+      className="w-full py-1.5 text-[13px]"+      variant="secondary"+      disabled={pending}+      loading={pending}+      onClick={async (event) => {+        event.stopPropagation();+        if (submitting.current) return;+        submitting.current = true;+        setPending(true);+        try {+          await onSend();+          toast.success("Added to the 3D stage");+          onSent?.();+        } catch (error) {+          toast.error(error instanceof Error ? error.message : "Could not add this asset to the stage. Please try again.");+        } finally {+          submitting.current = false;+          setPending(false);+        }+      }}+    >+      {pending ? "Sending to stage…" : "Send to stage"}+    </Button>+  );+}diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+  ignore = "exit 0"