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
@@ -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}new file mode 100644@@ -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();+});new file mode 100644@@ -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.");+}@@ -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}@@ -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 && (@@ -2,3 +2,5 @@ export * from './lib/viewer-3d'; export * from './lib/NodeHierarchyHelper'; export * from './lib/disposeObject3D';++export { SendToStageButton } from "./lib/SendToStageButton";new file mode 100644@@ -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));+ });+});new file mode 100644@@ -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>+ );+}new file mode 100644@@ -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"