diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx
index e9deea17d5..7499188d4b 100644
--- a/frontend/apps/artcraft-webapp/src/app/app.tsx
+++ b/frontend/apps/artcraft-webapp/src/app/app.tsx
@@ -15,6 +15,7 @@ import { ToastContainer } from "../components/toast/toast";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import CreateVFX from "../pages/create-vfx";
+import PageScene from "../pages/pagescene";
import Pricing from "../pages/pricing";
import Support from "../pages/support/support";
import Login from "../pages/login";
@@ -120,6 +121,8 @@ export function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx
index 2d6bac2e08..7860c478a1 100644
--- a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx
+++ b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx
@@ -4,6 +4,7 @@ import {
faHouse,
faVideo,
faImage,
+ faCube,
faWandMagicSparkles,
faGrid2,
faGraduationCap,
@@ -41,6 +42,7 @@ const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: faHouse }];
const CREATE_ITEMS: NavItem[] = [
{ label: "Image", href: "/create-image", icon: faImage },
+ { label: "Edit 3D", href: "/pagescene", icon: faCube },
{ label: "Video", href: "/create-video", icon: faVideo },
{
label: "BG Change",
diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/index.ts b/frontend/apps/artcraft-webapp/src/pages/pagescene/index.ts
new file mode 100644
index 0000000000..9f5c577702
--- /dev/null
+++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/index.ts
@@ -0,0 +1 @@
+export { default } from "./pagescene";
diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx
new file mode 100644
index 0000000000..3a31c67652
--- /dev/null
+++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx
@@ -0,0 +1,80 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import {
+ Stage3D,
+ usePageSceneStore,
+} from "@storyteller/ui-pagescene";
+import { useSession } from "../../lib/session";
+import { useWebAppPageSceneAdapter } from "./web-adapter";
+
+// Stage3D renders its UI with `position: fixed` so its children
+// (toolbar, prompt editor, history stack, gallery panel) overlay each
+// other within the editor viewport. Inside the webapp's
+// SidebarProvider/SidebarInset layout, those `fixed` elements would
+// escape to the browser viewport and overlap the sidebar/topbar. The
+// wrapper below applies `transform: translateZ(0)` to create a new
+// containing block for fixed-positioned descendants, scoping the lib's
+// UI to the wrapper's box — which is sized by the flex parent
+// (SidebarInset gives us the area below the topbar and right of the
+// sidebar).
+//
+// As a side effect this also gives us a clean rect to feed the adapter's
+// `getViewportSize` so DnD hit-testing matches the visible canvas.
+
+export default function PageScene() {
+ const { sceneToken } = useParams<{ sceneToken?: string }>();
+ const { user } = useSession();
+ const navigate = useNavigate();
+
+ // Mirror current user token into lib store so ownership-based gating
+ // (read-only mode, save button visibility) reacts to auth changes.
+ useEffect(() => {
+ usePageSceneStore.getState().setCurrentUserToken(user?.user_token);
+ }, [user?.user_token]);
+
+ const navigateToImageTo3D = useCallback(() => {
+ navigate("/create-image");
+ }, [navigate]);
+
+ // Track the wrapper's rendered size so the adapter's getViewportSize
+ // returns the actual usable area (not window dimensions, which would
+ // include sidebar + topbar).
+ const wrapperRef = useRef(null);
+ const sizeRef = useRef({ width: window.innerWidth, height: window.innerHeight });
+ const [, forceRender] = useState(0);
+
+ useEffect(() => {
+ const node = wrapperRef.current;
+ if (!node) return;
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (!entry) return;
+ const { width, height } = entry.contentRect;
+ sizeRef.current = { width, height };
+ // Nudge a re-render so any consumer that reads sizeRef during render
+ // sees the new values; the adapter itself reads through the ref.
+ forceRender((n) => (n + 1) % 1024);
+ });
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
+
+ const getViewportSize = useCallback(() => sizeRef.current, []);
+
+ const adapter = useWebAppPageSceneAdapter({
+ userToken: user?.user_token,
+ initialSceneToken: sceneToken,
+ navigateToImageTo3D,
+ getViewportSize,
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx
new file mode 100644
index 0000000000..45e28f4e50
--- /dev/null
+++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx
@@ -0,0 +1,264 @@
+// PageSceneAdapter implementation for the artcraft webapp.
+//
+// Mirrors the artcraft-website's adapter but plugs into the webapp's
+// local session + toast helpers. Built once per mount via
+// useMemo, keyed on `userToken` + `initialSceneToken` so signing in
+// mid-session re-arms ownership-based UI gating without unmounting the
+// editor.
+//
+// Anonymous-friendly: when there's no signed-in user, getCurrentUserToken
+// returns undefined; the lib's read-only gating keeps Save hidden.
+
+import { useMemo } from "react";
+import {
+ GetCdnOrigin,
+ MediaFilesApi,
+ MediaUploadApi,
+ StorytellerApiHostStore,
+ UploadImageMedia,
+ UsersApi,
+} from "@storyteller/api";
+import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
+import { ToastTypes } from "@storyteller/ui-pagescene";
+import {
+ UploadModal3D,
+ UploadModalImage,
+ UploadModalSplat,
+ UploaderStates,
+} from "@storyteller/ui-upload-modal";
+import { showToast } from "../../components/toast/toast";
+import { invalidateSession } from "../../lib/session";
+
+const apiHost = () =>
+ StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
+
+// ─── Scene file IO ─────────────────────────────────────────────────────────
+
+const saveSceneViaApi = async (
+ saveJson: string,
+ sceneTitle: string,
+ sceneToken: string | undefined,
+ sceneThumbnail: Blob | undefined,
+): Promise => {
+ const blob = new Blob([saveJson], { type: "application/json" });
+ const uploadApi = new MediaUploadApi();
+ const uuid = crypto.randomUUID();
+ const fileName = `${sceneTitle}.glb`;
+
+ const uploadResp = sceneToken
+ ? await uploadApi.UploadSavedScene({ blob, fileName, uuid, mediaToken: sceneToken })
+ : await uploadApi.UploadNewScene({
+ blob,
+ fileName,
+ uuid,
+ maybe_title: sceneTitle,
+ });
+
+ if (!uploadResp.success || !uploadResp.data) {
+ showToast("error", uploadResp.errorMessage ?? "Failed to save scene");
+ return "";
+ }
+
+ const newToken = uploadResp.data;
+ if (sceneThumbnail) {
+ try {
+ const coverResp = await uploadApi.UploadImage({
+ blob: sceneThumbnail,
+ fileName: "render.png",
+ uuid: crypto.randomUUID(),
+ maybe_title: "Screenshot",
+ });
+ if (coverResp.success && coverResp.data) {
+ await new MediaFilesApi().UpdateCoverImage({
+ mediaFileToken: newToken,
+ imageToken: coverResp.data,
+ });
+ }
+ } catch {
+ // Cover image is best-effort — the scene is still saved.
+ }
+ }
+ return newToken;
+};
+
+const loadSceneViaApi = async (token: string): Promise => {
+ const mediaApi = new MediaFilesApi();
+ const meta = await mediaApi.GetMediaFileByToken({ mediaFileToken: token });
+ const cdnUrl = meta.data?.media_links?.cdn_url;
+ if (!cdnUrl) throw new Error("Scene CDN URL missing");
+
+ const fileResp = await fetch(cdnUrl);
+ if (!fileResp.ok) throw new Error(`Scene fetch HTTP ${fileResp.status}`);
+ const text = await fileResp.text();
+ return JSON.parse(text);
+};
+
+// ─── Adapter ───────────────────────────────────────────────────────────────
+
+export interface WebAppPageSceneAdapterOptions {
+ userToken: string | undefined;
+ initialSceneToken: string | undefined;
+ navigateToImageTo3D: () => void;
+ // Wrapper size — kept in a ref so the closure sees live values without
+ // rebuilding the adapter on every resize.
+ getViewportSize: () => { width: number; height: number };
+}
+
+export const useWebAppPageSceneAdapter = (
+ options: WebAppPageSceneAdapterOptions,
+): PageSceneAdapter => {
+ const { userToken, initialSceneToken, navigateToImageTo3D, getViewportSize } = options;
+
+ return useMemo(
+ () => ({
+ enqueueGeneration: async () => ({ status: "fail" }),
+
+ saveScene: ({ saveJson, sceneTitle, sceneToken, sceneThumbnail }) =>
+ saveSceneViaApi(saveJson, sceneTitle, sceneToken, sceneThumbnail),
+
+ loadScene: loadSceneViaApi,
+
+ fetchAsset: (url: string) => fetch(url, { mode: "cors" }),
+
+ getCdnOrigin: () => GetCdnOrigin(),
+ getApiSchemeAndHost: apiHost,
+ getCurrentUserToken: () => userToken,
+
+ getCdnUrl: (bucketPath, _width, _quality) =>
+ `${GetCdnOrigin()}${bucketPath}`,
+
+ // The wrapper around creates a fixed-positioning containing
+ // block sized via the webapp's SidebarInset flex layout. Return its
+ // live size so the lib's DnD hit-testing matches what the user sees,
+ // not raw `window.innerWidth/innerHeight`.
+ getViewportSize,
+
+ listUserMediaFiles: async (query) => {
+ if (!userToken) {
+ return { success: false, data: [], pagination: undefined };
+ }
+ const api = new MediaFilesApi();
+ const response = await api.ListUserMediaFiles({
+ username: userToken,
+ page_size: query.pageSize,
+ page_index: query.pageIndex,
+ // Lib's FilterEngineCategories has a SPLAT variant that the API
+ // enum doesn't yet — cast through unknown is safe at runtime
+ // since the wire format is the same string.
+ filter_engine_categories:
+ query.filterEngineCategories as unknown as never,
+ filter_media_type: query.filterMediaTypes as unknown as never,
+ });
+ return {
+ success: response.success,
+ data: response.data as never,
+ pagination: response.pagination,
+ errorMessage: response.errorMessage,
+ };
+ },
+
+ listFeaturedMediaFiles: async (query) => {
+ const api = new MediaFilesApi();
+ const response = await api.ListFeaturedMediaFiles({
+ page_size: query.pageSize,
+ cursor: query.cursor,
+ filter_engine_categories:
+ query.filterEngineCategories as unknown as never,
+ filter_media_type: query.filterMediaTypes as unknown as never,
+ });
+ return {
+ success: response.success,
+ data: response.data as never,
+ pagination: response.pagination,
+ errorMessage: response.errorMessage,
+ };
+ },
+
+ showToast: (level: ToastTypes, message: string) => {
+ showToast(level === ToastTypes.ERROR ? "error" : "success", message);
+ },
+
+ getMediaUrlByToken: async (token) => {
+ const api = new MediaFilesApi();
+ const response = await api.GetMediaFileByToken({ mediaFileToken: token });
+ return response.data?.media_links?.cdn_url ?? "";
+ },
+
+ renderAssetBrowser: () => null,
+ renderSceneLoader: () => null,
+ renderAssetUploader: (props) => (
+
+ ),
+ renderImageUploader: (props) => (
+
+ ),
+ renderSplatUploader: (props) => (
+
+ ),
+
+ uploadImage: UploadImageMedia,
+
+ uploadPlaneFromMediaToken: async ({ title, mediaToken, progressCallback }) => {
+ progressCallback({ status: UploaderStates.uploadingAsset });
+ const mediaApi = new MediaFilesApi();
+ const fileResp = await mediaApi.GetMediaFileByToken({ mediaFileToken: mediaToken });
+ if (!fileResp.success || !fileResp.data) {
+ progressCallback({
+ status: UploaderStates.assetError,
+ errorMessage:
+ fileResp.errorMessage ?? "Could not fetch media file by token.",
+ });
+ return;
+ }
+ if (title && fileResp.data.maybe_title !== title) {
+ const renameResp = await mediaApi.RenameMediaFileByToken({
+ mediaToken,
+ name: title,
+ });
+ if (!renameResp.success) {
+ progressCallback({
+ status: UploaderStates.assetError,
+ errorMessage:
+ renameResp.errorMessage ?? "Could not rename media file.",
+ });
+ return;
+ }
+ }
+ progressCallback({ status: UploaderStates.success });
+ },
+
+ navigateToImageTo3D,
+
+ performLogout: async () => {
+ try {
+ await new UsersApi().Logout();
+ } catch {
+ // Best-effort logout — clear local state regardless.
+ }
+ invalidateSession();
+ window.location.href = "/";
+ },
+
+ initialSceneToken,
+ }),
+ [userToken, initialSceneToken, navigateToImageTo3D, getViewportSize],
+ );
+};
diff --git a/frontend/apps/artcraft-webapp/src/styles.css b/frontend/apps/artcraft-webapp/src/styles.css
index eba4e31d23..5fdc065999 100644
--- a/frontend/apps/artcraft-webapp/src/styles.css
+++ b/frontend/apps/artcraft-webapp/src/styles.css
@@ -98,6 +98,84 @@
filter: invert(1);
}
+/* Gradient panel surface used by pagescene + lib components. */
+.bg-ui-panel-gradient {
+ background:
+ radial-gradient(60.68% 60.68% at 50% 0%,
+ rgba(255, 255, 255, 0.08) 0%,
+ rgba(0, 0, 0, 0) 70%),
+ #1f1f1f;
+ background-color: #1f1f1f;
+}
+
+.prevent-select {
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ pointer-events: none;
+}
+
+/* three-dots loading spinners — used inside upload-modal + pagescene. */
+.dot-typing {
+ position: relative;
+ left: -9999px;
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+ background-color: #ffffff30;
+ color: #ffffff30;
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ animation: dot-typing 1.5s infinite linear;
+}
+
+@keyframes dot-typing {
+ 0% {
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ }
+ 16.667% {
+ box-shadow:
+ 9984px -10px 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ }
+ 33.333% {
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ }
+ 50% {
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px -10px 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ }
+ 66.667% {
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ }
+ 83.333% {
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px -10px 0 0 #ffffff30;
+ }
+ 100% {
+ box-shadow:
+ 9984px 0 0 0 #ffffff30,
+ 9999px 0 0 0 #ffffff30,
+ 10014px 0 0 0 #ffffff30;
+ }
+}
+
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
@apply bg-ui-controls/85 !important;
diff --git a/frontend/apps/artcraft-webapp/tailwind.config.js b/frontend/apps/artcraft-webapp/tailwind.config.js
index 318499180e..f52b14cee2 100644
--- a/frontend/apps/artcraft-webapp/tailwind.config.js
+++ b/frontend/apps/artcraft-webapp/tailwind.config.js
@@ -37,10 +37,10 @@ module.exports = {
white: colors.white,
gray: colors.gray,
black: colors.black,
- red: colors.red,
- blue: colors.blue,
+ red: { ...colors.red, DEFAULT: "#D33242" },
+ blue: { ...colors.blue, DEFAULT: "#2E70FF" },
orange: colors.orange,
- green: colors.green,
+ green: { ...colors.green, DEFAULT: "#308752" },
emerald: colors.emerald,
teal: colors.teal,
yellow: colors.yellow,
@@ -75,6 +75,62 @@ module.exports = {
900: "#64373f",
950: "#361b1e",
},
+ "brand-primary": {
+ DEFAULT: "#2d81ff",
+ 50: "#eef6ff",
+ 100: "#d9ebff",
+ 200: "#bcdcff",
+ 300: "#8ec7ff",
+ 400: "#59a7ff",
+ 500: "#2d81ff",
+ 600: "#1b63f5",
+ 700: "#144ee1",
+ 800: "#173fb6",
+ 900: "#19398f",
+ 950: "#142457",
+ },
+ "brand-secondary": {
+ DEFAULT: "#3E3E41",
+ 50: "#f6f6f6",
+ 100: "#e7e7e7",
+ 200: "#d1d1d1",
+ 300: "#b0b0b0",
+ 400: "#888888",
+ 500: "#6d6d6d",
+ 600: "#5d5d5d",
+ 700: "#4f4f4f",
+ 800: "#454545",
+ 900: "#3d3d3d",
+ 950: "#3E3E41",
+ },
+ "brand-tertiary": {
+ DEFAULT: "#1cb6be",
+ 50: "#eefdfc",
+ 100: "#d4f9f8",
+ 200: "#aef3f3",
+ 300: "#62e4e6",
+ 400: "#38d3d8",
+ 500: "#1cb6be",
+ 600: "#1a94a0",
+ 700: "#1c7682",
+ 800: "#1f616b",
+ 900: "#1e505b",
+ 950: "#0e353e",
+ },
+ action: {
+ DEFAULT: "#3E3E41",
+ 50: "#f6f6f6",
+ 100: "#e7e7e7",
+ 200: "#d1d1d1",
+ 300: "#b0b0b0",
+ 400: "#888888",
+ 500: "#6d6d6d",
+ 600: "#5d5d5d",
+ 700: "#4f4f4f",
+ 800: "#454545",
+ 900: "#3d3d3d",
+ 950: "#3E3E41",
+ },
secondary: {
DEFAULT: "#3E3E41",
50: "#f6f6f6",
diff --git a/frontend/apps/artcraft-webapp/tsconfig.app.json b/frontend/apps/artcraft-webapp/tsconfig.app.json
index df40133c33..01dfbdd56f 100644
--- a/frontend/apps/artcraft-webapp/tsconfig.app.json
+++ b/frontend/apps/artcraft-webapp/tsconfig.app.json
@@ -37,6 +37,12 @@
"src/components/footer"
],
"references": [
+ {
+ "path": "../../libs/components/upload-modal/tsconfig.lib.json"
+ },
+ {
+ "path": "../../libs/components/pagescene/tsconfig.lib.json"
+ },
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
diff --git a/frontend/apps/artcraft-webapp/tsconfig.json b/frontend/apps/artcraft-webapp/tsconfig.json
index 6ee6d86129..9e7a2045bd 100644
--- a/frontend/apps/artcraft-webapp/tsconfig.json
+++ b/frontend/apps/artcraft-webapp/tsconfig.json
@@ -2,6 +2,12 @@
"files": [],
"include": [],
"references": [
+ {
+ "path": "../../libs/components/upload-modal"
+ },
+ {
+ "path": "../../libs/components/pagescene"
+ },
{
"path": "../../libs/components/slider-v2"
},
diff --git a/frontend/apps/artcraft/tsconfig.json b/frontend/apps/artcraft/tsconfig.json
index f61ca26ae3..50f7f82710 100644
--- a/frontend/apps/artcraft/tsconfig.json
+++ b/frontend/apps/artcraft/tsconfig.json
@@ -31,6 +31,9 @@
{
"path": "../../libs/components/badge"
},
+ {
+ "path": "../../libs/components/vfx"
+ },
{
"path": "../../libs/components/slider-v2"
},
@@ -55,9 +58,6 @@
{
"path": "../../libs/components/promptbox"
},
- {
- "path": "../../libs/components/vfx"
- },
{
"path": "../../libs/components/menu-icon-selector"
},
@@ -97,12 +97,6 @@
{
"path": "../../libs/components/gravatar"
},
- {
- "path": "../../libs/components/input"
- },
- {
- "path": "../../libs/components/upload-modal"
- },
{
"path": "../../libs/components/modal"
},
@@ -113,7 +107,7 @@
"path": "../../libs/components/file-uploader"
},
{
- "path": "../../libs/components/list-dropdown"
+ "path": "../../libs/components/input"
},
{
"path": "../../libs/components/select"
@@ -148,6 +142,9 @@
{
"path": "../../libs/components/button"
},
+ {
+ "path": "../../libs/components/upload-modal"
+ },
{
"path": "../../libs/tauri-api"
},
diff --git a/frontend/libs/components/pagedraw/tsconfig.json b/frontend/libs/components/pagedraw/tsconfig.json
index c8649167a3..ef0394b892 100644
--- a/frontend/libs/components/pagedraw/tsconfig.json
+++ b/frontend/libs/components/pagedraw/tsconfig.json
@@ -5,45 +5,42 @@
{
"path": "../slider-v2"
},
+ {
+ "path": "../../api"
+ },
{
"path": "../promptbox"
},
{
"path": "../../api-enums"
},
- {
- "path": "../../model-list"
- },
- {
- "path": "../action-reminder-modal"
- },
- {
- "path": "../tooltip"
- },
- {
- "path": "../modal"
- },
- {
- "path": "../model-selector"
- },
{
"path": "../pricing-modal"
},
{
"path": "../help-menu"
},
+ {
+ "path": "../model-selector"
+ },
+ {
+ "path": "../../model-list"
+ },
{
"path": "../../common"
},
{
- "path": "../../api"
+ "path": "../action-reminder-modal"
},
{
- "path": "../gallery-modal"
+ "path": "../tooltip"
},
{
"path": "../button"
},
+ {
+ "path": "../modal"
+ },
{
"path": "./tsconfig.lib.json"
},