Compare commits

...
Author SHA1 Message Date
Tanish Manku 470cefe0ab website: mount pagescene at /pagescene with REST adapter
Adds /pagescene (and /pagescene/:sceneToken) wired through a website-
side PageSceneAdapter that maps the lib's surface onto @storyteller/api
REST calls. Includes the 3D/Image/Splat upload modals via the now-shared
@storyteller/ui-upload-modal exports.

Auth integration: useSession hook mirrors current user token into
usePageSceneStore.setCurrentUserToken so the lib's ownership/save
gating fires; adapter is memoized on user_token so signing in mid-
session rebuilds it.

Adds "Edit 3D" nav entry between "Edit 2D" and "Video" in the navbar.
Fills in tailwind/CSS gaps that lib components reference (brand-
primary/secondary/tertiary, action color, red/green/blue DEFAULTs,
.bg-ui-panel-gradient, .dot-typing animation).

saveScene cover-image flow uses MediaUploadApi.UploadImage +
MediaFilesApi.UpdateCoverImage; uploadPlaneFromMediaToken implemented
via GetMediaFileByToken + RenameMediaFileByToken (mirrors the Tauri
host's uploadPlane.ts). No new endpoints.
2026-05-12 09:09:11 -07:00
Tanish Manku b07b8ce3d4 artcraft host: import upload modals from @storyteller/ui-upload-modal
useTauriPageSceneAdapter, GlobalFileDropHandler, and UploadImagesButton
now pull UploadModal3D/Image/Splat from the shared lib. Removes the
host-local UploadModal3D/UploadModalImage/UploadModalSplat directories
since they're no longer referenced anywhere.

UploadModalMedia/ stays — it backs the host-only image-plane upload
adapter helpers, not the modal slots.
2026-05-12 09:08:55 -07:00
Tanish Manku 027d430245 ui-upload-modal: lift UploadModal3D/Image/Splat into shared lib
Hoists the three feature-specific upload modals out of the artcraft
Tauri host into @storyteller/ui-upload-modal so the website (and any
future host) can render the same flows through PageScene's adapter
slots. Replaces direct `~/Classes/ApiManager` calls with @storyteller/api's
MediaUploadApi/MediaFilesApi.

Removes the old lib-internal UploadModal/UploadFiles/uploadAssets
placeholders (unused) and inlines the host-local enums
(MediaFileAnimationType, UploaderState/States, file-type enums) into
Types.ts. Adds UploadSpzFile to @storyteller/api's MediaUploadApi and
the SPLAT variant to FilterEngineCategories.
2026-05-12 09:08:44 -07:00
Tanish Manku 4feda78618 website: add Edit 2D nav entry pointing to /pagedraw 2026-05-09 17:27:17 -07:00
Tanish Manku e3f4ca5aae website: add /pagedraw route with REST omni adapter
Plug the existing @storyteller/ui-pagedraw lib into the website by
implementing a REST-flavoured PageDrawAdapter:
- enqueueEditImage → OmniGenApi.generateImage (POST /v1/omni_gen/generate/image)
  with polling that bridges results back into the lib's Zustand store
  via addHistoryImageBundle / resolvePendingGeneration.
- enqueueBgRemoval → uploads base64 → media token → POST
  /v1/generate/image/remove_background → polls → finishRemoveBackground.
  Inlined here (not yet in @storyteller/api) until that lib's dist can
  rebuild.
- uploadImage → UploadImageMedia (REST, same as Tauri).
- showToast wired to the website's toast helper.

Inpaint is intentionally stubbed; the omni endpoint doesn't yet accept
a raw mask payload, so we surface a clear toast until that path exists.
2026-05-09 16:23:18 -07:00
Tanish Manku 2cbfe7d292 ui-pagedraw: bundle BaseImageSelector in lib + adapter showToast slot
Move the upload / gallery picker / blank canvas selector out of the
artcraft Tauri host and into @storyteller/ui-pagedraw so any host gets
the same selector chrome (including Help + Cost buttons) without
duplicating UI code. Hosts now only need to provide platform behaviour
(uploadImage, enqueue*, optional showToast); renderBaseImageSelector
becomes an opt-in override for fully custom selectors.
2026-05-09 16:22:46 -07:00
Tanish Manku 24e62ab7df frontend libs: strip unused imports + small type fixes
Cleanup pass to unblock pagedraw lib's source consumption — these were
existing TS errors (unused vars, missing override modifiers, a stale
.value access on a non-signal enum, Provider→GenerationProvider casts in
the deprecated provider-selector) that prevented dist regeneration.
No runtime behaviour changes other than the PromptBox3D
getCurrentAspectRatio fix, which previously always fell through to the
default branch because cameraAspectRatio is an enum, not a signal.
2026-05-09 16:22:07 -07:00
67 changed files with 1429 additions and 876 deletions
@@ -14,6 +14,8 @@ 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 PageDraw from "../pages/pagedraw";
import PageScene from "../pages/pagescene";
//import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import LandingSD2 from "../pages/landing-sd2";
@@ -56,6 +58,9 @@ export function App() {
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/pagedraw" element={<PageDraw />} />
<Route path="/pagescene" element={<PageScene />} />
<Route path="/pagescene/:sceneToken" element={<PageScene />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
<Route path="/download" element={<Download />} />
<Route path="/media" element={<Media />} />
@@ -41,6 +41,8 @@ type NavEntry = NavLeaf | NavGroup;
const NAV_ITEMS: NavEntry[] = [
{ name: "Home", href: "/" },
{ name: "Image", href: "/create-image" },
{ name: "Edit 2D", href: "/pagedraw" },
{ name: "Edit 3D", href: "/pagescene" },
{ name: "Video", href: "/create-video" },
{ name: "BG Change", href: "/background-change" },
{
@@ -0,0 +1 @@
export { default } from "./pagedraw";
@@ -0,0 +1,128 @@
import { JobsApi, MediaFilesApi } from "@storyteller/api";
export interface PolledImage {
media_token: string;
cdn_url: string;
maybe_thumbnail_template?: string;
}
export type PollResult =
| { status: "pending"; images: PolledImage[] }
| { status: "complete"; images: PolledImage[] }
| { status: "failed"; images: PolledImage[]; error: string };
export async function pollJobOnce(jobToken: string): Promise<PollResult> {
const jobsApi = new JobsApi();
const response = await jobsApi.GetJobByToken({ token: jobToken });
if (!response.success || !response.data) {
return { status: "pending", images: [] };
}
const state = response.data;
const statusStr = state.status?.status?.toLowerCase() ?? "";
if (statusStr === "complete_success" || statusStr === "complete") {
const result = state.maybe_result as Record<string, unknown> | undefined;
const mediaLinks = (result as any)?.media_links;
const entityToken = (result as any)?.entity_token as string | undefined;
if (entityToken) {
try {
const mediaApi = new MediaFilesApi();
const mediaFile = await mediaApi.GetMediaFileByToken({
mediaFileToken: entityToken,
});
const batchToken = mediaFile.data?.maybe_batch_token;
if (batchToken) {
const batchResponse = await mediaApi.GetMediaFilesByBatchToken({
batchToken,
});
if (batchResponse.success && batchResponse.data?.length) {
const images: PolledImage[] = batchResponse.data
.map((file: any) => ({
media_token: file.token,
cdn_url: file.media_links?.cdn_url,
maybe_thumbnail_template: file.media_links?.maybe_thumbnail_template,
}))
.filter((img: PolledImage) => img.cdn_url);
if (images.length > 0) return { status: "complete", images };
}
}
} catch {
// fall through to single-image extraction
}
}
if (mediaLinks?.cdn_url) {
return {
status: "complete",
images: [
{
media_token: entityToken ?? jobToken,
cdn_url: mediaLinks.cdn_url,
maybe_thumbnail_template: mediaLinks.maybe_thumbnail_template,
},
],
};
}
return { status: "complete", images: [] };
}
if (
statusStr.includes("fail") ||
statusStr.includes("error") ||
statusStr === "dead"
) {
return {
status: "failed",
images: [],
error:
state.status?.maybe_failure_message ??
state.status?.maybe_extra_status_description ??
"Generation failed",
};
}
return { status: "pending", images: [] };
}
export function startPolling(
jobToken: string,
onComplete: (images: PolledImage[]) => void,
onError: (reason: string) => void,
intervalMs = 3000,
maxAttempts = 120,
): () => void {
let attempts = 0;
let stopped = false;
const poll = async () => {
if (stopped) return;
attempts++;
try {
const result = await pollJobOnce(jobToken);
if (stopped) return;
if (result.status === "complete") return onComplete(result.images);
if (result.status === "failed") return onError(result.error);
if (attempts >= maxAttempts) return onError("Generation timed out");
setTimeout(poll, intervalMs);
} catch {
if (!stopped && attempts < maxAttempts) {
setTimeout(poll, intervalMs * 2);
} else if (!stopped) {
onError("Network error during polling");
}
}
};
setTimeout(poll, 2000);
return () => {
stopped = true;
};
}
@@ -0,0 +1,7 @@
import { PageDraw as PageDrawLib } from "@storyteller/ui-pagedraw";
import { useWebPageDrawAdapter } from "./web-adapter";
export default function PageDraw() {
const adapter = useWebPageDrawAdapter();
return <PageDrawLib adapter={adapter} />;
}
@@ -0,0 +1,222 @@
import { useMemo } from "react";
import {
type PageDrawAdapter,
type ImageBundle,
useSceneStore,
} from "@storyteller/ui-pagedraw";
import {
OmniGenApi,
StorytellerApiHostStore,
UploadImageMedia,
type OmniGenImageRequest,
} from "@storyteller/api";
import { UploaderStates } from "@storyteller/common";
import { showToast } from "../../components/toast/toast";
import { startPolling, type PolledImage } from "./job-polling";
// ─── Local bg removal call ─────────────────────────────────────────────────────
// Inlined (not in @storyteller/api) until that lib's dist can be rebuilt
// against current source — its noEmitOnError gate currently blocks emits.
interface RemoveBgRequest {
uuid_idempotency_token: string;
media_file_token: string;
}
interface RemoveBgResponse {
success: boolean;
inference_job_token: string;
}
const SIGNED_SESSION_KEY = "artcraft_signed_session";
const sessionHeaders = (): Record<string, string> => {
const base: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
try {
const session = localStorage.getItem(SIGNED_SESSION_KEY);
if (session) base.session = session;
} catch {
// localStorage unavailable
}
return base;
};
const removeImageBackground = async (
body: RemoveBgRequest,
): Promise<RemoveBgResponse> => {
const host = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const response = await fetch(`${host}/v1/generate/image/remove_background`, {
method: "POST",
headers: sessionHeaders(),
credentials: "include",
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Background removal HTTP ${response.status}`);
}
return response.json();
};
// ─── Helpers ───────────────────────────────────────────────────────────────────
const polledImagesToBundle = (images: PolledImage[]): ImageBundle => ({
images: images.map((img) => ({
url: img.cdn_url,
mediaToken: img.media_token,
thumbnailUrlTemplate: img.maybe_thumbnail_template,
fullImageUrl: img.cdn_url,
})),
});
const startGenerationPoll = (jobToken: string, subscriberId: string) => {
startPolling(
jobToken,
(images) => {
const store = useSceneStore.getState();
// Skip if user dismissed the placeholder.
if (!store.pendingGenerations.some((p) => p.id === subscriberId)) return;
store.addHistoryImageBundle(polledImagesToBundle(images));
store.resolvePendingGeneration(subscriberId);
},
(reason) => {
showToast("error", reason);
useSceneStore.getState().resolvePendingGeneration(subscriberId);
},
);
};
const base64ToBlob = (base64: string): Blob => {
const commaIdx = base64.indexOf(",");
const raw = commaIdx >= 0 ? base64.slice(commaIdx + 1) : base64;
const headerMatch =
commaIdx >= 0 ? base64.slice(0, commaIdx).match(/data:(.*?);base64/) : null;
const mime = headerMatch?.[1] ?? "image/png";
const binary = atob(raw);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: mime });
};
const uploadBlobAsImage = async (blob: Blob, title: string): Promise<string | undefined> => {
let mediaToken: string | undefined;
const file = new File([blob], `${title}.png`, { type: blob.type });
await UploadImageMedia({
title,
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
mediaToken = state.data;
}
},
});
return mediaToken;
};
const modelIdOf = (model: any): string => {
if (!model) return "";
if (typeof model === "string") return model;
return model.id ?? model.tauriId ?? String(model);
};
// The lib's dist .d.ts still uses the old `canvasImageMediaToken` field name,
// while its source (and runtime) uses `sceneImageMediaToken`. Read both so we
// don't depend on which version is actually loaded by Vite.
const readSceneToken = (req: any): string | undefined =>
req?.sceneImageMediaToken ?? req?.canvasImageMediaToken;
// ─── Adapter ───────────────────────────────────────────────────────────────────
export const useWebPageDrawAdapter = (): PageDrawAdapter => {
return useMemo<PageDrawAdapter>(
() => ({
enqueueEditImage: async (req) => {
const sceneToken = readSceneToken(req);
const refTokens = [
...(sceneToken ? [sceneToken] : []),
...(req.imageMediaTokens ?? []),
].filter((t): t is string => !!t);
const body: OmniGenImageRequest = {
model: modelIdOf(req.model),
prompt: req.prompt ?? null,
idempotency_token: crypto.randomUUID(),
aspect_ratio: req.aspectRatio ?? null,
resolution: req.imageResolution ?? null,
image_batch_count: req.imageCount ?? 1,
image_media_tokens: refTokens.length ? refTokens : null,
};
try {
const api = new OmniGenApi();
const response = await api.generateImage(body);
if (
response.success &&
response.inference_job_token &&
req.frontendSubscriberId
) {
startGenerationPoll(response.inference_job_token, req.frontendSubscriberId);
return { status: "success" };
}
showToast("error", "Generation failed to enqueue");
return { status: "fail" };
} catch (e: any) {
showToast("error", `Generation error: ${e?.message ?? e}`);
return { status: "fail" };
}
},
enqueueInpaint: async (req) => {
// The omni-gen REST endpoint doesn't yet accept a raw mask payload;
// surface clearly until a multipart endpoint or mask-upload step exists.
if (!req.maskImageRawBytes) {
showToast("error", "Inpaint mask missing");
return { status: "fail" };
}
showToast("error", "Inpainting is not yet available on the web");
return { status: "fail" };
},
enqueueBgRemoval: async (base64Image, nodeId) => {
try {
const blob = base64ToBlob(base64Image);
const mediaToken = await uploadBlobAsImage(blob, `bg-removal-${nodeId}`);
if (!mediaToken) {
showToast("error", "Background removal upload failed");
return;
}
const response = await removeImageBackground({
uuid_idempotency_token: crypto.randomUUID(),
media_file_token: mediaToken,
});
if (!response.success || !response.inference_job_token) {
showToast("error", "Background removal failed to enqueue");
return;
}
startPolling(
response.inference_job_token,
async (images) => {
const out = images[0];
if (!out) return;
await useSceneStore
.getState()
.finishRemoveBackground(nodeId, out.media_token, out.cdn_url);
},
(reason) => showToast("error", reason),
);
} catch (e: any) {
showToast("error", `Background removal error: ${e?.message ?? e}`);
}
},
uploadImage: UploadImageMedia,
showToast: (type, message) => {
showToast(type === "info" ? "success" : type, message);
},
}),
[],
);
};
@@ -0,0 +1 @@
export { default } from "./pagescene";
@@ -0,0 +1,38 @@
import { useCallback, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Stage3D,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { useSession } from "../../lib/session";
import { useWebPageSceneAdapter } from "./web-adapter";
export default function PageScene() {
const { sceneToken } = useParams<{ sceneToken?: string }>();
const { user } = useSession();
const navigate = useNavigate();
// Mirror the current user token into the lib's store so ownership-based
// gating (read-only mode, save button visibility) reacts to auth changes.
// Anonymous viewers get undefined here, which the lib already handles.
useEffect(() => {
usePageSceneStore.getState().setCurrentUserToken(user?.user_token);
}, [user?.user_token]);
const navigateToImageTo3D = useCallback(() => {
navigate("/create-image");
}, [navigate]);
const adapter = useWebPageSceneAdapter({
userToken: user?.user_token,
initialSceneToken: sceneToken,
navigateToImageTo3D,
});
return (
<Stage3D
adapter={adapter}
sceneToken={sceneToken}
/>
);
}
@@ -0,0 +1,263 @@
// PageSceneAdapter implementation for the artcraft website.
//
// Mirrors the Tauri host's adapter shape but uses REST calls from
// `@storyteller/api` instead of FetchProxy / GenerateImage Tauri
// commands. Built once per <PageScene> 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<string> => {
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<unknown> => {
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 WebPageSceneAdapterOptions {
userToken: string | undefined;
initialSceneToken: string | undefined;
navigateToImageTo3D: () => void;
}
export const useWebPageSceneAdapter = (
options: WebPageSceneAdapterOptions,
): PageSceneAdapter => {
const { userToken, initialSceneToken, navigateToImageTo3D } = options;
return useMemo<PageSceneAdapter>(
() => ({
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}`,
listUserMediaFiles: async (query) => {
if (!userToken) {
// Anonymous viewers don't have a "my objects" library.
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 ?? "";
},
// Slot renders — asset/scene browser slots are unused by the lib
// today; the three upload modals come from @storyteller/ui-upload-modal,
// same components the Tauri host renders.
renderAssetBrowser: () => null,
renderSceneLoader: () => null,
renderAssetUploader: (props) => (
<UploadModal3D
isOpen={props.isOpen}
onClose={props.onClose}
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
/>
),
renderImageUploader: (props) => (
<UploadModalImage
isOpen={props.isOpen}
onClose={props.onClose}
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
/>
),
renderSplatUploader: (props) => (
<UploadModalSplat
isOpen={props.isOpen}
onClose={props.onClose}
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
/>
),
uploadImage: UploadImageMedia,
// Drag-drop of a gallery item onto the canvas. Tauri host mirrors
// this with an optional rename + success signal; no new endpoint
// needed — the media plane already exists, we just resolve it and
// optionally retitle.
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],
);
};
@@ -80,6 +80,84 @@
background-color: rgba(0, 0, 0, 0.4);
}
/* Gradient panel surface used by pagedraw + 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;
}
}
.icon-auto-contrast {
filter: invert(1);
}
@@ -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",
@@ -37,9 +37,21 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/upload-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/pagescene/tsconfig.lib.json"
},
{
"path": "../../libs/components/pagedraw/tsconfig.lib.json"
},
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
{
"path": "../../libs/components/vfx/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
@@ -93,9 +105,6 @@
},
{
"path": "../../libs/api/tsconfig.lib.json"
},
{
"path": "../../libs/components/vfx/tsconfig.lib.json"
}
]
}
@@ -2,6 +2,15 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/upload-modal"
},
{
"path": "../../libs/components/pagescene"
},
{
"path": "../../libs/components/pagedraw"
},
{
"path": "../../libs/components/slider-v2"
},
@@ -2,9 +2,11 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { faCube, faImages, faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UploadModal3D } from "../reusable/UploadModal3D/UploadModal3D";
import { UploadModalImage } from "../reusable/UploadModalImage/UploadModalImage";
import { UploadModalSplat } from "../reusable/UploadModalSplat/UploadModalSplat";
import {
UploadModal3D,
UploadModalImage,
UploadModalSplat,
} from "@storyteller/ui-upload-modal";
import { FilterEngineCategories } from "../../enums";
type ModalType = "3d" | "image" | "splat" | null;
@@ -1 +0,0 @@
export * from "./UploadModal3D";
@@ -1,57 +0,0 @@
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
import { FilterEngineCategories, MediaFileAnimationType } from "~/enums";
import { getFileExtension } from "~/utilities";
import { v4 as uuidv4 } from "uuid";
export const uploadAsset = async ({
file,
title,
engineCategory,
animationType,
}: {
file: File;
title: string;
engineCategory: FilterEngineCategories;
animationType?: MediaFileAnimationType;
}) => {
// TODO: 'media_uploads' is the name of an ancient table nothing writes to. We shouldn't use this name here to mean the media_files table.
const mediaUploadApi = new MediaUploadApi();
const mediaFileApi = new MediaFilesApi();
const fileExtension = getFileExtension(file);
const assetReponse = await (async () => {
switch (fileExtension) {
case ".spz":
console.log("Uploading asset as SPZ");
return mediaFileApi.UploadSpzFile({
file: file,
fileName: file.name,
uuid: uuidv4(),
maybe_title: title,
});
case ".zip":
console.log("Uploading asset as PMX");
return mediaUploadApi.UploadPmx({
file: file,
fileName: file.name,
engine_category: engineCategory,
maybe_title: title,
maybe_animation_type: animationType,
uuid: uuidv4(),
});
default:
console.log("Uploading asset as generic engine asset");
return mediaUploadApi.UploadNewEngineAsset({
file: file,
fileName: file.name,
engine_category: engineCategory,
maybe_title: title,
maybe_animation_type: animationType,
uuid: uuidv4(),
});
}
})();
return assetReponse;
};
@@ -1 +0,0 @@
export * from "./UploadModalSplat";
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { faUpload, faImages } from "@fortawesome/pro-solid-svg-icons";
import { UploadModalImage } from "../../reusable/UploadModalImage/UploadModalImage";
import { UploadModalImage } from "@storyteller/ui-upload-modal";
interface Props {
className?: string;
@@ -1,177 +0,0 @@
import { faExpand, faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
import toast from "react-hot-toast";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { UploaderStates } from "~/enums";
import { MediaFilesApi } from "@storyteller/api";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
const MAX_GALLERY_SELECTIONS = 1;
type BaseImageSelectorProps = {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading?: boolean;
};
export const BaseImageSelector = ({
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isBlankCanvasModalOpen, setIsBlankCanvasModalOpen] = useState(false);
const handleGalleryClick = () => setIsGalleryModalOpen(true);
const handleGalleryClose = () => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
};
const handleImageSelect = (mediaToken: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
if (prev.length >= MAX_GALLERY_SELECTIONS) {
return MAX_GALLERY_SELECTIONS === 1 ? [mediaToken] : prev;
}
return [...prev, mediaToken];
});
};
const handleUseGalleryImages = (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
const referenceImage: BaseSelectorImage = {
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
};
handleGalleryClose();
onImageSelect(referenceImage);
};
const handleBlankCanvasConfirm = (width: number, height: number) => {
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
};
onImageSelect(blankImage);
};
const handleFileUpload = (files: FileList) => {
setIsLoading(true);
Array.from(files).forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
(async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
if (result.success && result.data) {
finalUrl =
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
const mediaLinks = result.data.media_links as
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
| undefined;
thumbnailUrlTemplate =
mediaLinks?.thumbnail_template || mediaLinks?.maybe_thumbnail_template;
}
} catch (e) {
console.warn("Falling back to data URL for uploaded image", e);
}
const referenceImage: BaseSelectorImage = {
mediaToken,
url: finalUrl,
fullImageUrl: finalUrl,
thumbnailUrlTemplate,
};
toast.success("Image uploaded successfully!");
onImageSelect(referenceImage);
setIsLoading(false);
})();
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
toast.error("Upload failed. Please try again.");
setIsLoading(false);
}
},
});
};
reader.readAsDataURL(file);
});
};
return (
<>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
<div className="aspect-video w-full max-w-5xl bg-ui-background">
<UploadEntryCard
icon={faPencil}
title="Edit Image"
description="Click to upload or drag and drop an image here to edit"
accentBackgroundClass="bg-blue-500/40"
accentBorderClass="border-blue-400/30"
accept="image/*"
multiple
onFilesSelected={handleFileUpload}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleGalleryClick}
tertiaryLabel="Blank Canvas"
tertiaryIcon={faExpand}
onTertiaryClick={() => setIsBlankCanvasModalOpen(true)}
disabled={isLoading || showLoading}
/>
</div>
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ModelPage.ImageEditor} />
<HelpMenuButton />
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={MAX_GALLERY_SELECTIONS}
onUseSelected={handleUseGalleryImages}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
<BlankCanvasModal
isOpen={isBlankCanvasModalOpen}
onClose={() => setIsBlankCanvasModalOpen(false)}
onConfirm={handleBlankCanvasConfirm}
/>
</>
);
};
@@ -15,7 +15,7 @@ import {
import { CommonAspectRatio, CommonResolution } from "@storyteller/model-list";
import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
import { UploadImageMedia } from "@storyteller/api";
import { BaseImageSelector } from "./BaseImageSelector";
import toast from "react-hot-toast";
// ─── Aspect ratio / resolution mappers ────────────────────────────────────────
const mapAspectRatio = (ratio?: string): CommonAspectRatio | undefined => {
@@ -140,12 +140,11 @@ const useTauriAdapter = (): PageDrawAdapter => {
onEnqueueMeta: (meta) => (window as any).__storeTaskEnqueueMeta?.(meta),
renderBaseImageSelector: ({ onImageSelect, showLoading }) => (
<BaseImageSelector
onImageSelect={onImageSelect}
showLoading={showLoading}
/>
),
showToast: (type, message) => {
if (type === "error") toast.error(message);
else if (type === "success") toast.success(message);
else toast(message);
},
}),
[],
);
@@ -25,9 +25,11 @@ import { ToastTypes } from "@storyteller/ui-pagescene";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import { BucketConfig } from "~/api/BucketConfig";
import { MediaFilesApi as ArtcraftMediaFilesApi } from "~/Classes/ApiManager";
import { UploadModal3D } from "~/components/reusable/UploadModal3D";
import { UploadModalImage } from "~/components/reusable/UploadModalImage";
import { UploadModalSplat } from "~/components/reusable/UploadModalSplat";
import {
UploadModal3D,
UploadModalImage,
UploadModalSplat,
} from "@storyteller/ui-upload-modal";
import { uploadImage as hostUploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { uploadPlaneFromMediaToken as hostUploadPlaneFromMediaToken } from "~/components/reusable/UploadModalMedia/uploadPlane";
import { useTabStore } from "~/pages/Stores/TabState";
File diff suppressed because one or more lines are too long
@@ -225,6 +225,27 @@ export class MediaUploadApi extends ApiManager {
return this.Upload({ endpoint, blob: file, fileName, uuid, options });
}
public async UploadSpzFile({
file,
fileName,
uuid,
maybe_title,
maybe_visibility = Visibility.Public,
}: {
file: File;
fileName: string;
uuid: string;
maybe_title?: string;
maybe_visibility?: Visibility;
}): Promise<ApiResponse<string>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/upload/spz`;
const options: Record<string, string | number | undefined> = {
maybe_title,
maybe_visibility: maybe_visibility?.toString(),
};
return this.Upload({ endpoint, blob: file, fileName, uuid, options });
}
public async UploadNewScene({
blob,
fileName,
@@ -23,6 +23,7 @@ export enum FilterEngineCategories {
IMAGE_PLANE = "image_plane",
LOCATION = "location",
OBJECT = "object",
SPLAT = "splat",
SCENE = "scene",
SET_DRESSING = "set_dressing",
SKYBOX = "skybox",
@@ -6,7 +6,6 @@ import {
Model,
VIDEO_MODELS,
IMAGE_MODELS,
IMAGE_MODELS_BY_ID,
SPLAT_MODELS,
OBJECT_3D_MODELS,
} from "@storyteller/model-list";
@@ -48,15 +48,15 @@ export function ProviderSelector({
useEffect(() => {
if (!modelId) return;
if (!selectedProvider && allowedProviders.length > 0) {
setSelectedProvider(page, modelId, allowedProviders[0]);
setSelectedProvider(page, modelId, allowedProviders[0] as any);
}
}, [page, modelId, selectedProvider, allowedProviders, setSelectedProvider]);
const items: Omit<PopoverItem, "selected">[] = useMemo(
() =>
allowedProviders.map((p) => ({
label: getProviderDisplayName(p),
icon: getProviderIcon(p),
label: getProviderDisplayName(p as any),
icon: getProviderIcon(p as any),
model: undefined,
provider: p,
})),
@@ -78,7 +78,7 @@ export function ProviderSelector({
| Provider
| undefined;
if (!prov) return;
setSelectedProvider(page, modelId, prov);
setSelectedProvider(page, modelId, prov as any);
};
return (
@@ -3,6 +3,7 @@
// Main component
export { default as PageDraw } from "./lib/PageDraw";
export { BlankCanvasModal } from "./lib/BlankCanvasModal";
export { BaseImageSelector } from "./lib/BaseImageSelector";
export { HistoryStack } from "./lib/HistoryStack";
// Store
@@ -0,0 +1,301 @@
import { useCallback, useRef, useState } from "react";
import { faExpand, faImages, faPencil, faUpload } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button } from "@storyteller/ui-button";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl, MediaFilesApi } from "@storyteller/api";
import { UploaderStates } from "@storyteller/common";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { BlankCanvasModal } from "./BlankCanvasModal";
import type { PageDrawAdapter } from "./adapter";
import type { BaseSelectorImage } from "./types";
const MAX_GALLERY_SELECTIONS = 1;
interface BaseImageSelectorProps {
adapter: PageDrawAdapter;
onImageSelect: (image: BaseSelectorImage) => void;
showLoading?: boolean;
}
export const BaseImageSelector = ({
adapter,
onImageSelect,
showLoading = false,
}: BaseImageSelectorProps) => {
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [gallerySelectedIds, setGallerySelectedIds] = useState<string[]>([]);
const [isBlankCanvasOpen, setIsBlankCanvasOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const toast = (type: "success" | "error" | "info", message: string) => {
if (adapter.showToast) {
adapter.showToast(type, message);
} else if (type === "error") {
console.error(message);
} else {
console.log(message);
}
};
const handleGallerySelectItem = (mediaToken: string) => {
setGallerySelectedIds((prev) => {
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
return [mediaToken];
});
};
const handleGalleryUseSelected = (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast("error", "No image selected");
return;
}
setIsGalleryOpen(false);
setGallerySelectedIds([]);
onImageSelect({
url: item.fullImage,
mediaToken: item.id,
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
fullImageUrl: item.fullImage,
});
};
const handleBlankCanvasConfirm = (width: number, height: number) => {
setIsBlankCanvasOpen(false);
onImageSelect({
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random()
.toString(36)
.substring(2, 8)}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
});
};
const handleFiles = useCallback(
(files: FileList | File[]) => {
const file = Array.from(files)[0];
if (!file || !adapter.uploadImage) {
if (!adapter.uploadImage) toast("error", "Image upload not available");
return;
}
setIsUploading(true);
const reader = new FileReader();
reader.onloadend = () => {
adapter.uploadImage!({
title: `pagedraw-${Math.random().toString(36).substring(2, 10)}`,
assetFile: file,
progressCallback: async (state) => {
if (state.status === UploaderStates.success && state.data) {
const mediaToken = state.data;
let finalUrl = reader.result as string;
let thumbnailUrlTemplate: string | undefined;
try {
const api = new MediaFilesApi();
const result = await api.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (result.success && result.data) {
finalUrl =
result.data.media_links?.cdn_url ||
result.data.public_bucket_url ||
finalUrl;
const links = result.data.media_links as
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
| undefined;
thumbnailUrlTemplate =
links?.thumbnail_template || links?.maybe_thumbnail_template;
}
} catch {
// fall back to data URL
}
onImageSelect({
url: finalUrl,
mediaToken,
fullImageUrl: finalUrl,
thumbnailUrlTemplate,
});
toast("success", "Image uploaded successfully!");
setIsUploading(false);
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.imageCreateError
) {
toast("error", "Upload failed. Please try again.");
setIsUploading(false);
}
},
});
};
reader.readAsDataURL(file);
},
[adapter, onImageSelect],
);
const disabled = isUploading || showLoading;
return (
<>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
<div className="aspect-video w-full max-w-5xl bg-ui-background">
<UploadCard
disabled={disabled}
onFilesSelected={handleFiles}
onPickFromLibrary={() => setIsGalleryOpen(true)}
onCreateBlankCanvas={() => setIsBlankCanvasOpen(true)}
/>
</div>
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ModelPage.ImageEditor} />
<HelpMenuButton />
</div>
<GalleryModal
isOpen={isGalleryOpen}
onClose={() => {
setIsGalleryOpen(false);
setGallerySelectedIds([]);
}}
mode="select"
selectedItemIds={gallerySelectedIds}
onSelectItem={handleGallerySelectItem}
maxSelections={MAX_GALLERY_SELECTIONS}
onUseSelected={handleGalleryUseSelected}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
<BlankCanvasModal
isOpen={isBlankCanvasOpen}
onClose={() => setIsBlankCanvasOpen(false)}
onConfirm={handleBlankCanvasConfirm}
/>
</>
);
};
// ─── UploadCard (drag/drop entry tile) ─────────────────────────────────────────
interface UploadCardProps {
disabled: boolean;
onFilesSelected: (files: FileList) => void;
onPickFromLibrary: () => void;
onCreateBlankCanvas: () => void;
}
const UploadCard = ({
disabled,
onFilesSelected,
onPickFromLibrary,
onCreateBlankCanvas,
}: UploadCardProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const resetInput = () => {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleFiles = (files?: FileList | null) => {
if (!files || files.length === 0) return;
onFilesSelected(files);
resetInput();
};
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragActive(true);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragActive(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setIsDragActive(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
setIsDragActive(false);
handleFiles(e.dataTransfer?.files);
};
return (
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={twMerge(
"bg-ui-background/60 relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden rounded-2xl border-2 border-dashed border-ui-panel-border p-10 text-center transition-colors",
isDragActive && "border-primary/80 bg-primary/5",
disabled && "pointer-events-none opacity-60",
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-6">
<div className="relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 border-blue-400/30 bg-blue-500/40 shadow-xl backdrop-blur-sm">
<FontAwesomeIcon icon={faPencil} className="text-5xl text-white drop-shadow-lg" />
</div>
<div className="space-y-3">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">Edit Image</h3>
<p className="mx-auto max-w-md text-base leading-relaxed text-base-fg/70">
Click to upload or drag and drop an image here to edit
</p>
</div>
<div className="mt-4 flex flex-wrap justify-center gap-4">
<Button
variant="primary"
icon={faUpload}
onClick={() => !disabled && fileInputRef.current?.click()}
className="px-8 py-3 text-base font-semibold shadow-lg"
disabled={disabled}
>
Select Image
</Button>
<Button
variant="action"
icon={faImages}
onClick={onPickFromLibrary}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
Pick from Library
</Button>
<Button
variant="action"
icon={faExpand}
onClick={onCreateBlankCanvas}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
Blank Canvas
</Button>
</div>
</div>
</div>
);
};
@@ -173,7 +173,3 @@ export class Node {
}
}
}
function generateId(): string {
return `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
@@ -34,6 +34,7 @@ import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import { HistoryStack } from "./HistoryStack";
import { BaseImageSelector } from "./BaseImageSelector";
import { type BaseSelectorImage } from "./types";
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
import { compositeInWorker, maskInWorker } from "./utilities/generatePipeline";
@@ -198,7 +199,7 @@ function DragScrubButton({
interface Edit3DScrubControlsProps {
nodeId: string;
stageRef: { current: Konva.Stage };
overlayHandle: React.RefObject<Model3DOverlayHandle>;
overlayHandle: React.RefObject<Model3DOverlayHandle | null>;
}
const Edit3DScrubControls = memo(function Edit3DScrubControls({
@@ -351,7 +352,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
historyImageBundles,
pendingGenerations,
getAspectRatioDimensions,
finishRemoveBackground,
createImageFromUrl,
createImageFromFile,
createImageFrom3DModel,
@@ -361,7 +361,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
removeHistoryImage,
addHistoryImageBundle,
addPendingGeneration,
resolvePendingGeneration,
removePendingGeneration,
clearPendingGenerations,
setAspectRatioType,
@@ -391,8 +390,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
(state) => state.setGenerationCount,
);
const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt);
const referenceImages = promptStoreProvider((state) => state.referenceImages);
const prompt = promptStoreProvider((state) => state.prompt);
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const baseImageUrl = baseImageInfo?.url;
@@ -524,13 +521,13 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
window.addEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
handleGallery2DDrop as unknown as EventListener,
);
return () => {
window.removeEventListener(
"gallery-2d-drop",
handleGallery2DDrop as EventListener,
handleGallery2DDrop as unknown as EventListener,
);
};
}, [createImageFromUrl, createImageFrom3DModel]);
@@ -1113,6 +1110,16 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
// Display image selector on launch, otherwise hide it.
// Also show the selector when a non-blank-canvas image is set but the bitmap is still loading.
if (!baseImageInfo || (!baseImageBitmap && !baseImageInfo.isBlankCanvas)) {
const selectorProps = {
onImageSelect: (image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
setBaseImageInfo(image);
},
showLoading:
baseImageInfo !== null &&
baseImageBitmap === null &&
!baseImageInfo.isBlankCanvas,
};
return (
<div
className={
@@ -1121,16 +1128,11 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
>
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
{adapter.renderBaseImageSelector({
onImageSelect: (image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
setBaseImageInfo(image);
},
showLoading:
baseImageInfo !== null &&
baseImageBitmap === null &&
!baseImageInfo.isBlankCanvas,
})}
{adapter.renderBaseImageSelector ? (
adapter.renderBaseImageSelector(selectorProps)
) : (
<BaseImageSelector adapter={adapter} {...selectorProps} />
)}
</div>
</div>
</div>
@@ -15,12 +15,10 @@ import {
Line,
Image,
Transformer,
Group,
} from "react-konva";
import Konva from "konva";
import { LineNode, SceneState, useSceneStore } from "./stores/SceneState";
import { Node } from "./Node";
import { useStageSnapshot } from "./hooks/useUpdateSnapshot";
import "./pagedraw.css";
import SplitPane from "./components/ui/SplitPane";
import { useRightPanelLayoutManagement } from "./hooks/useRightPanelLayoutManagement";
@@ -3,11 +3,7 @@ import { ImageStyleSelectorProps } from './types';
const ImageStyleSelector: React.FC<ImageStyleSelectorProps> = ({ onImageSelect }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
@@ -44,11 +44,23 @@ export interface PageDrawAdapter {
/** Platform's image upload function, compatible with PromptBox2D's uploadImage prop. */
uploadImage?: UploadMediaFn;
/** Renders the "no base image selected" state — upload card, gallery picker, etc. */
renderBaseImageSelector(props: {
/**
* Optional override for the "no base image selected" view. When omitted,
* the lib renders its built-in BaseImageSelector (upload + gallery + blank
* canvas). Hosts only need to provide this when they want fully custom
* chrome around the selector.
*/
renderBaseImageSelector?: (props: {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading: boolean;
}): ReactNode;
}) => ReactNode;
/**
* Optional toast hook so the lib can surface upload / generation messages
* without picking a host's toast library. When omitted, messages fall back
* to console logging.
*/
showToast?: (type: "success" | "error" | "info", message: string) => void;
/** Optional telemetry hook called just before an enqueue. */
onEnqueueMeta?: (meta: {
@@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faMousePointer,
faTrash,
faEyeDropper,
faImage,
faUndo,
@@ -1,4 +1,3 @@
import { Vector2d } from "konva/lib/types";
import { useEffect } from "react";
import { DragState } from "../types";
// This piece of code ensures that when you are selecting or dragging and go off the stage,
+20 -17
View File
@@ -5,30 +5,15 @@
{
"path": "../slider-v2"
},
{
"path": "../../api"
},
{
"path": "../promptbox"
},
{
"path": "../../api-enums"
},
{
"path": "../pricing-modal"
},
{
"path": "../help-menu"
},
{
"path": "../model-selector"
},
{
"path": "../../model-list"
},
{
"path": "../../common"
},
{
"path": "../action-reminder-modal"
},
@@ -36,10 +21,28 @@
"path": "../tooltip"
},
{
"path": "../button"
"path": "../modal"
},
{
"path": "../modal"
"path": "../model-selector"
},
{
"path": "../pricing-modal"
},
{
"path": "../help-menu"
},
{
"path": "../../common"
},
{
"path": "../../api"
},
{
"path": "../gallery-modal"
},
{
"path": "../button"
},
{
"path": "./tsconfig.lib.json"
@@ -82,6 +82,9 @@
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
}
]
}
@@ -96,7 +96,6 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) {
const {
currency,
setCurrency,
currencyOption,
formatPrice,
currencyOptions,
} = useCurrency();
@@ -503,7 +503,7 @@ export const PromptBox3D = ({
};
const getCurrentAspectRatio = (): CommonAspectRatio => {
switch (cameraAspectRatio.value) {
switch (cameraAspectRatio) {
case CameraAspectRatio.HORIZONTAL_3_2:
return CommonAspectRatio.Wide;
case CameraAspectRatio.VERTICAL_2_3:
@@ -5,8 +5,6 @@ import {
} from "@fortawesome/pro-regular-svg-icons";
import {
faEdit,
faMessageCheck,
faMessageXmark,
faMousePointer,
faFrame,
faPen,
@@ -19,7 +17,7 @@ import {
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ToggleButton, GenerateButton } from "@storyteller/ui-button";
import { Button, GenerateButton } from "@storyteller/ui-button";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -83,7 +81,6 @@ export const PromptBoxEdit = ({
credits,
}: PromptBoxEditProps) => {
const [prompt, setPrompt] = useState("");
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
@@ -11,17 +11,17 @@ interface State {
}
export class PromptBoxErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, resetKey: 0 };
override state: State = { hasError: false, resetKey: 0 };
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
override componentDidCatch(error: Error, info: ErrorInfo) {
console.error("PromptBox crashed:", error, info.componentStack);
}
render() {
override render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center rounded-xl border border-red-500/30 bg-red-500/10 px-6 py-4 text-sm text-red-300">
@@ -4,21 +4,19 @@ import { JobContextType, UploaderState } from "@storyteller/common";
import { toast } from "@storyteller/ui-toaster";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton, GenerateButton } from "@storyteller/ui-button";
import { Button, GenerateButton } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import {
GenerateImage,
GenerateImageRequest,
} from "@storyteller/tauri-api";
import {
faMessageXmark,
faMessageCheck,
faExpand,
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
import { ImageModel } from "@storyteller/model-list";
import { usePromptImageStore, RefImage, useEnterToGenerateStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { twMerge } from "tailwind-merge";
@@ -29,7 +27,6 @@ import { AspectRatioIcon } from "./common/AspectRatioIcon";
import { GenerationCountPicker } from "./common/GenerationCountPicker";
import { ResolutionPicker } from "./common/ResolutionPicker";
import { QualityPicker } from "./common/QualityPicker";
import { CommonResolution } from "@storyteller/model-list";
interface PromptBoxImageProps {
useJobContext: () => JobContextType;
@@ -87,8 +84,6 @@ export const PromptBoxImage = ({
const prompt = usePromptImageStore((s) => s.prompt);
const setPrompt = usePromptImageStore((s) => s.setPrompt);
const useSystemPrompt = usePromptImageStore((s) => s.useSystemPrompt);
const setUseSystemPrompt = usePromptImageStore((s) => s.setUseSystemPrompt);
const aspectRatio = usePromptImageStore((s) => s.aspectRatio);
const setAspectRatio = usePromptImageStore((s) => s.setAspectRatio);
const resolution = usePromptImageStore((s) => s.resolution);
@@ -239,7 +239,7 @@ export const PromptBoxVideo = ({
aspectRatioOptions = buildAspectRatioOptions(DEFAULT_RESOLUTIONS);
}
const [aspectRatioList, setAspectRatioList] =
const [, setAspectRatioList] =
useState<PopoverItem[]>(aspectRatioOptions);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -1,24 +1,8 @@
// NB(bt,2025-05-27): This is also declared in "editor3d".
// I'm duplicating here to remove a circular dependency.
// We should consolidate definitions eventually.
export enum FilterEngineCategories {
ANIMATION = "animation",
AUDIO = "audio",
CHARACTER = "character",
CREATURE = "creature",
EXPRESSION = "expression",
IMAGE_PLANE = "image_plane",
LOCATION = "location",
OBJECT = "object",
SCENE = "scene",
SET_DRESSING = "set_dressing",
SKYBOX = "skybox",
VIDEO_PLANE = "video_plane",
}
// Re-export canonical FilterEngineCategories from @storyteller/api so the
// upload-modal lib stays in sync with the wire-format enum.
export { FilterEngineCategories } from "@storyteller/api";
// NB(bt,2025-05-27): This is also declared in "editor3d".
// I'm duplicating here to remove a circular dependency.
// We should consolidate definitions eventually.
// Upload pipeline state machine shared by all three upload modals.
export enum UploaderStates {
ready,
uploadingAsset,
@@ -32,18 +16,54 @@ export enum UploaderStates {
imageCreateError,
}
// NB(bt,2025-05-27): This is also declared in "editor3d".
// I'm duplicating here to remove a circular dependency.
// We should consolidate definitions eventually.
export interface UploaderState {
status: UploaderStates;
errorMessage?: string;
data?: string;
uploadProgress?: { current: number; total: number };
}
// NB(bt,2025-05-27): This is also declared in "editor3d".
// I'm duplicating here to remove a circular dependency.
// We should consolidate definitions eventually.
export const initialUploaderState = {
export const initialUploaderState: UploaderState = {
status: UploaderStates.ready,
};
// Animation rig type — used when uploading a Character asset.
// String values are the wire-format the backend expects.
export enum MediaFileAnimationType {
ArKit = "ar_kit",
MikuMikuDance = "miku_miku_dance",
MikuMikuDanceArKit = "miku_miku_dance_ar_kit",
Mixamo = "mixamo",
MixamoArKit = "mixamo_ar_kit",
MocapNet = "mocap_net",
MocapNetArKit = "mocap_net_ar_kit",
MoveAi = "move_ai",
MoveAiArKit = "move_ai_ar_kit",
Rigify = "rigify",
RigifyArKit = "rigify_ar_kit",
Rokoko = "rokoko",
}
// Accepted file extensions (lowercase — the backend rejects uppercase).
export enum OBJECT_FILE_TYPE {
GLB = "glb",
}
export enum IMAGEPLANE_FILE_TYPE {
PNG = "png",
JPG = "jpg",
JPEG = "jpeg",
}
export enum SPLAT_FILE_TYPE {
SPZ = "spz",
}
export const getFileName = (file: File): string =>
file.name.substring(0, file.name.lastIndexOf("."));
export const getFileExtension = (file: File): string =>
file.name.substring(file.name.lastIndexOf("."));
// Per-file row status shown in the multi-file upload sidebar.
export type FileEntryStatus = "idle" | "uploading" | "success" | "error";
@@ -1,152 +0,0 @@
import { ListDropdown } from "@storyteller/ui-list-dropdown";
import { Input } from "@storyteller/ui-input";
import { Button } from "@storyteller/ui-button";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { useState } from "react";
import {
FilterEngineCategories,
} from "@storyteller/api";
import {
THUMBNAILS_FILE_TYPE,
MediaFileAnimationType,
UploaderState
} from "@storyteller/common";
import { uploadAssets } from "./uploadAssets";
interface Props {
title: string;
engineCategory: FilterEngineCategories;
fileTypes: string[];
onClose: () => void;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
onUploadProgress: (newState: UploaderState) => void;
getFileName: (file: File) => string;
getFileExtension: (file: File) => string;
}
export const UploadFiles = ({
fileTypes,
engineCategory,
onClose,
title,
options,
onUploadProgress,
getFileName,
getFileExtension,
}: Props) => {
const fileSubtypes = options?.fileSubtypes;
const hasLength = options?.hasLength;
const hasThumbnailUpload = options?.hasThumbnailUpload;
const [typeOption, setTypeOption] = useState<
MediaFileAnimationType | undefined
>(
fileSubtypes
? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType)
: undefined,
);
const [uploadTitle, setUploadTitle] = useState<{
value: string;
error?: string;
}>({
value: "",
});
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
const [uploadLength, setUploadLength] = useState<number>();
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const handleSubmit = () => {
if (!uploadTitle.value) {
setUploadTitle((curr) => ({
...curr,
error: "Please enter a title.",
}));
return;
}
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
error: "Please select a file to upload.",
}));
return;
}
uploadAssets({
title: uploadTitle.value,
assetFile: assetFile.value,
engineCategory: engineCategory,
animationType: typeOption,
thumbnailFile: thumbnailFile || undefined,
length: uploadLength,
progressCallback: onUploadProgress,
getFileName: getFileName,
getFileExtension: getFileExtension,
});
};
return (
<>
<div className="mb-4 flex flex-col gap-4">
<Input
placeholder="Enter the title here"
errorMessage={uploadTitle.error}
value={uploadTitle.value}
onChange={(event) => setUploadTitle({ value: event.target.value })}
className={uploadTitle.error ? "mb-3" : ""}
/>
{fileSubtypes && fileSubtypes.length > 1 && (
<ListDropdown
list={fileSubtypes}
onSelect={(value) => setTypeOption(value as MediaFileAnimationType)}
/>
)}
{hasLength && (
<Input
type="number"
placeholder="Enter the length in ms (optional)"
value={uploadLength}
onChange={(event) => setUploadLength(parseInt(event.target.value))}
/>
)}
<FileUploader
title={title}
fileTypes={fileTypes}
file={assetFile.value}
setFile={(file: File | null) => {
setAssetFile({
value: file,
});
}}
/>
{assetFile.error && (
<h6 className="z-10 text-red">{assetFile.error}</h6>
)}
{hasThumbnailUpload && (
<FileUploader
title="Upload Thumbnail (optional)"
fileTypes={Object.values(THUMBNAILS_FILE_TYPE)}
file={thumbnailFile}
setFile={setThumbnailFile}
/>
)}
<div className="flex justify-end gap-4">
<Button variant="primary" onClick={handleSubmit}>
Upload
</Button>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</div>
</>
);
};
@@ -1,123 +0,0 @@
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError } from "./UploadAssetError";
import { UploadSuccess } from "./UploadSuccess";
import { UploadFiles } from "./UploadFiles";
import {
FilterEngineCategories,
UploaderStates,
UploaderState,
initialUploaderState
} from "./Types";
interface Props {
onClose: () => void;
onSuccess: () => void;
isOpen: boolean;
title: string;
fileTypes: string[];
type: FilterEngineCategories;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
getFileName: (file: File) => string;
getFileExtension: (file: File) => string;
}
export function UploadModal({
isOpen,
onClose,
onSuccess,
title,
fileTypes,
type,
options,
getFileName,
getFileExtension,
}: Props) {
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
};
const resetModalState = () => {
setUploaderState(initialUploaderState);
};
useEffect(() => {
if (isOpen) {
resetModalState();
}
}, [isOpen]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
case UploaderStates.ready:
return (
<UploadFiles
title={title}
engineCategory={type}
fileTypes={fileTypes}
options={options}
onClose={onClose}
onUploadProgress={updateUploaderState}
getFileName={getFileName}
getFileExtension={getFileExtension}
/>
);
case UploaderStates.uploadingAsset:
case UploaderStates.uploadingCover:
case UploaderStates.settingCover:
return (
<>
<LoadingDots className="mb-1 bg-transparent" />
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success:
return (
<UploadSuccess
title={title}
onOk={() => {
onSuccess();
onClose();
}}
/>
);
case UploaderStates.assetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={type}
errorMessage={uploaderState.errorMessage}
/>
);
case UploaderStates.coverCreateError:
case UploaderStates.coverSetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={"Thumbnail"}
errorMessage={uploaderState.errorMessage}
/>
);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<UploaderModalContent />
</Modal>
);
}
@@ -18,13 +18,13 @@ import * as THREE from "three";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
import { upload3DObjectsBatch } from "./utilities/upload3DObjectsBatch";
import { FileEntryStatus } from "../UploadModalImage/utilities/uploadImagesBatch";
import type { UploaderState } from "../../../models";
import {
FileEntryStatus,
FilterEngineCategories,
MediaFileAnimationType,
UploaderState,
UploaderStates,
} from "../../../enums";
} from "../Types";
interface FileEntry {
file: File;
@@ -117,8 +117,6 @@ export const UploadFiles3D = ({
}
};
// Reload canvas preview whenever the current file or index changes.
// filesVersion ensures this re-runs when the same-count file set is replaced.
useEffect(() => {
const currentFile = fileEntries[previewIndex]?.file;
if (!canvasRef.current || !currentFile) return;
@@ -138,7 +136,6 @@ export const UploadFiles3D = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIndex, filesVersion]);
// Snapshot thumbnail when preview finishes loading
useEffect(() => {
if (previewStatus.type === "OK" && canvasRef.current) {
snapshotCanvasAsThumbnail({
@@ -153,7 +150,6 @@ export const UploadFiles3D = ({
}
}, [previewStatus, previewIndex]);
// Reset subtype when fileSubtypes set changes (e.g. toggling character)
useEffect(() => {
if (!fileSubtypes || fileSubtypes.length === 0) {
setSubtype(undefined);
@@ -227,7 +223,6 @@ export const UploadFiles3D = ({
return;
}
// Only upload files that haven't already succeeded
const pendingEntries = fileEntries
.map((e, i) => ({ entry: e, originalIndex: i }))
.filter(
@@ -244,7 +239,6 @@ export const UploadFiles3D = ({
);
if (files.length === 1 && fileEntries.length === 1) {
// Single file: use parent modal state machine (shows loading spinner)
upload3DObjects({
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
assetFile: files[0],
@@ -256,7 +250,6 @@ export const UploadFiles3D = ({
return;
}
// Multi-file: stay on form, show inline progress
setIsUploading(true);
setOverallProgress({ current: 0, total: files.length });
@@ -357,7 +350,6 @@ export const UploadFiles3D = ({
{isMulti ? (
<PanelGroup orientation="horizontal">
{/* File list sidebar */}
<Panel defaultSize="33%" minSize="20%">
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
{fileEntries.map((entry, i) => (
@@ -424,7 +416,6 @@ export const UploadFiles3D = ({
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
</PanelResizeHandle>
{/* Canvas preview + carousel */}
<Panel defaultSize="67%" minSize="25%">
<div className="flex h-full flex-col gap-2">
<div className="relative w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
@@ -473,7 +464,6 @@ export const UploadFiles3D = ({
</Panel>
</PanelGroup>
) : (
/* Single-file layout: canvas takes full width (original behaviour) */
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full min-h-48 !w-full"
@@ -517,7 +507,6 @@ export const UploadFiles3D = ({
Retry Failed
</Button>
)}
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
{!hasUploadStarted && (
<Button
variant="primary"
@@ -2,15 +2,17 @@ import { useEffect, useMemo, useState } from "react";
import { Checkbox } from "@storyteller/ui-checkbox";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadAssetError } from "../UploadAssetError";
import { UploadSuccess } from "../UploadSuccess";
import { UploadFiles3D } from "./UploadFiles3D";
import { initialUploaderState, UploaderState } from "../../../models";
import {
FilterEngineCategories,
UploaderStates,
OBJECT_FILE_TYPE,
MediaFileAnimationType,
} from "../../../enums";
OBJECT_FILE_TYPE,
UploaderState,
UploaderStates,
initialUploaderState,
} from "../Types";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
@@ -119,8 +121,6 @@ export function UploadModal3D(props: Props) {
<UploadSuccess
title="3D model"
onOk={() => {
// Open the gallery so the user can drag the freshly
// uploaded model onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
@@ -0,0 +1 @@
export { UploadModal3D } from "./UploadModal3D";
@@ -1,3 +1,5 @@
export * from "./loadPreviewOnCanvas";
export * from "./thumbnailHelpers";
export * from "./uploadAsset";
export * from "./upload3DObjects";
export * from "./upload3DObjectsBatch";
@@ -3,7 +3,6 @@ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { SplatFileType, SplatMesh } from "@sparkjsdev/spark";
import { SPLAT_FILE_TYPE } from "~/enums";
interface LoaderInterface {
file: File;
@@ -27,15 +26,12 @@ export const loadPreviewOnCanvas = ({
canvas: HTMLCanvasElement;
statusCallback: (error: { type: string; message?: string }) => void;
}): PreviewReturn => {
// Setup of scene, camera, and renderer in the canvas
const scene = new THREE.Scene();
// Calculate proper aspect ratio from canvas dimensions
const width = canvas.getBoundingClientRect().width || 0;
const height = canvas.getBoundingClientRect().height || 0;
const aspectRatio = width / height;
// Reduce FOV to make objects appear larger
const camera = new THREE.PerspectiveCamera(35, aspectRatio, 0.1, 1000);
camera.position.z = 2;
@@ -53,7 +49,6 @@ export const loadPreviewOnCanvas = ({
const color = 0xfcece7;
const light = new THREE.HemisphereLight(color, 0x8d8d8d, 3.0);
// Add some additional lighting to better show the model
const frontLight = new THREE.DirectionalLight(0xffffff, 2);
frontLight.position.set(0, 0, 10);
scene.add(frontLight);
@@ -62,7 +57,6 @@ export const loadPreviewOnCanvas = ({
let splatMesh: SplatMesh | null = null;
// load the file into the preview mini-scene depending of the file's type
if (file.name.endsWith(".glb")) {
glbLoader({ file, scene, camera, renderer, statusCallback });
} else if (file.name.endsWith(".pmd")) {
@@ -109,7 +103,6 @@ export const loadPreviewOnCanvas = ({
});
}
// Render the loaded data
const animate = function () {
renderer.render(scene, camera);
splatMesh?.rotateY(0.01);
@@ -134,7 +127,6 @@ const glbLoader = ({
child.userData["color"] = "#FFFFFF";
scene.add(child);
// Calculate bounding box for all objects in the scene
const box = new THREE.Box3();
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
@@ -143,20 +135,16 @@ const glbLoader = ({
}
});
// Get the center and size of the bounding box
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
// Calculate the radius of the bounding sphere
const radius = Math.max(size.x, size.y, size.z) * 0.5;
// Position camera to fit the object in view with minimal padding
const fov = camera.fov * (Math.PI / 180);
const distance = (radius * 1.2) / Math.tan(fov * 0.5);
// Position camera at a better angle for preview
camera.position.set(
center.x + distance * 0.6,
center.y + distance * 0.4,
@@ -164,7 +152,6 @@ const glbLoader = ({
);
camera.lookAt(center);
// Adjust the camera's near and far planes to ensure the model is visible
camera.near = distance * 0.01;
camera.far = distance * 100;
camera.updateProjectionMatrix();
@@ -176,9 +163,8 @@ const glbLoader = ({
});
});
},
undefined, // nothing to-do onProgress
undefined,
(loaderError) => {
/*onError*/
statusCallback({
type: "GLB Loader Error",
message: String(loaderError),
@@ -223,9 +209,8 @@ const pmdLoader = ({
message: "Preview should be available",
});
},
undefined, // nothing on Progress
undefined,
(loaderError) => {
/*onError*/
statusCallback({
type: "PMD Loader Error",
message: String(loaderError),
@@ -239,10 +224,9 @@ const imagePlaneLoader = ({ file, scene, statusCallback }: LoaderInterface) => {
const loader = new THREE.TextureLoader();
const texture = loader.load(
URL.createObjectURL(file),
undefined, // nothing to-do onLoad
undefined, // nothing to-do onProgress
undefined,
undefined,
(loaderError) => {
/*onError*/
statusCallback({
type: "Image Plane Loader Error",
message: String(loaderError),
@@ -1,7 +1,6 @@
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
import { v4 as uuidv4 } from "uuid";
import { getFileName } from "~/utilities";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { getFileName } from "../../Types";
export const snapshotCanvasAsThumbnail = async ({
targetNode,
@@ -10,10 +9,7 @@ export const snapshotCanvasAsThumbnail = async ({
targetNode: HTMLCanvasElement | undefined;
resultCallback: (blob: Blob | undefined) => void;
}) => {
if (!targetNode) {
//TODO: Verbose Error
return undefined;
}
if (!targetNode) return undefined;
targetNode.toBlob((blob: Blob | null) => {
resultCallback(blob || undefined);
});
@@ -27,7 +23,6 @@ export const uploadSnapshotAsThumbnail = async ({
blob: Blob;
}) => {
const thumbnailFile = new File([blob], "storyteller-cap.png");
const thumbnailTitle = "thumbnail_" + assetTitle;
const mediaUploadApi = new MediaUploadApi();
@@ -1,12 +1,11 @@
import {
FilterEngineCategories,
MediaFileAnimationType,
UploaderState,
UploaderStates,
} from "~/enums";
} from "../../Types";
import { setThumbnail, uploadSnapshotAsThumbnail } from "./thumbnailHelpers";
import { uploadAsset } from "./uploadAsset";
import { UploaderState } from "~/models";
export const upload3DObjects = async ({
title,
@@ -25,21 +24,21 @@ export const upload3DObjects = async ({
}) => {
progressCallback({ status: UploaderStates.uploadingAsset });
const assetReponse = await uploadAsset({
const assetResponse = await uploadAsset({
file: assetFile,
title: title,
engineCategory: engineCategory,
animationType: animationType,
title,
engineCategory,
animationType,
});
if (!assetReponse.success || !assetReponse.data) {
if (!assetResponse.success || !assetResponse.data) {
progressCallback({
status: UploaderStates.assetError,
errorMessage: assetReponse.errorMessage,
errorMessage: assetResponse.errorMessage,
});
return;
}
const assetToken = assetReponse.data;
const assetToken = assetResponse.data;
if (!thumbnailSnapshot) {
progressCallback({ status: UploaderStates.success });
@@ -62,8 +61,8 @@ export const upload3DObjects = async ({
progressCallback({ status: UploaderStates.settingCover });
const thumbnailToken = thumbnailResponse.data;
const setThumbnailResponse = await setThumbnail({
assetToken: assetToken,
thumbnailToken: thumbnailToken,
assetToken,
thumbnailToken,
});
if (!setThumbnailResponse.success) {
progressCallback({
@@ -1,10 +1,10 @@
import {
FileEntryStatus,
FilterEngineCategories,
MediaFileAnimationType,
UploaderStates,
} from "~/enums";
} from "../../Types";
import { upload3DObjects } from "./upload3DObjects";
import { FileEntryStatus } from "../../UploadModalImage/utilities/uploadImagesBatch";
export const upload3DObjectsBatch = async ({
files,
@@ -73,7 +73,6 @@ export const upload3DObjectsBatch = async ({
),
);
} catch (err) {
// Only mark files that haven't already reached a terminal state
files.forEach((_, i) => {
if (!completedIndices.has(i)) {
onFileStatusChange(
@@ -0,0 +1,50 @@
import { v4 as uuidv4 } from "uuid";
import { MediaUploadApi } from "@storyteller/api";
import {
FilterEngineCategories,
MediaFileAnimationType,
getFileExtension,
} from "../../Types";
export const uploadAsset = async ({
file,
title,
engineCategory,
animationType,
}: {
file: File;
title: string;
engineCategory: FilterEngineCategories;
animationType?: MediaFileAnimationType;
}) => {
const mediaUploadApi = new MediaUploadApi();
const fileExtension = getFileExtension(file);
switch (fileExtension) {
case ".spz":
return mediaUploadApi.UploadSpzFile({
file,
fileName: file.name,
uuid: uuidv4(),
maybe_title: title,
});
case ".zip":
return mediaUploadApi.UploadPmx({
file,
fileName: file.name,
engine_category: engineCategory,
maybe_title: title,
maybe_animation_type: animationType,
uuid: uuidv4(),
});
default:
return mediaUploadApi.UploadNewEngineAsset({
file,
fileName: file.name,
engine_category: engineCategory,
maybe_title: title,
maybe_animation_type: animationType,
uuid: uuidv4(),
});
}
};
@@ -13,12 +13,12 @@ import {
faSpinner,
} from "@fortawesome/pro-solid-svg-icons";
import { uploadImage } from "./utilities/uploadImage";
import { uploadImagesBatch } from "./utilities/uploadImagesBatch";
import {
uploadImagesBatch,
FileEntryStatus,
} from "./utilities/uploadImagesBatch";
import type { UploaderState } from "../../../models";
import { UploaderStates } from "../../../enums";
UploaderState,
UploaderStates,
} from "../Types";
interface FileEntry {
file: File;
@@ -53,8 +53,6 @@ export const UploadFilesImage = ({
const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null);
const [selectionError, setSelectionError] = useState<string | undefined>();
// Create and clean up the object URL for the current preview.
// filesVersion ensures this re-runs when the same-count file set is replaced.
useEffect(() => {
const currentFile = fileEntries[previewIndex]?.file;
if (!currentFile) {
@@ -82,7 +80,6 @@ export const UploadFilesImage = ({
const removeFile = (index: number) => {
setFileEntries((prev) => {
const next = prev.filter((_, i) => i !== index);
// Compute new index inside the updater so it's always based on the post-remove length
setPreviewIndex((prevIdx) => Math.min(prevIdx, Math.max(0, next.length - 1)));
return next;
});
@@ -121,7 +118,6 @@ export const UploadFilesImage = ({
return;
}
// Only upload files that haven't already succeeded
const files = fileEntries
.filter((e) => e.status !== "success")
.map((e) => e.file);
@@ -130,7 +126,6 @@ export const UploadFilesImage = ({
.filter((i) => i !== -1);
if (files.length === 1 && fileEntries.length === 1) {
// Single file: use parent modal state machine (shows loading spinner)
uploadImage({
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
assetFile: files[0],
@@ -139,7 +134,6 @@ export const UploadFilesImage = ({
return;
}
// Multi-file: stay on form, show inline progress
setIsUploading(true);
setOverallProgress({ current: 0, total: files.length });
@@ -159,7 +153,6 @@ export const UploadFilesImage = ({
errorMessage: "All uploads failed.",
});
}
// Partial failure: stay on form, sidebar shows which files are red
},
});
};
@@ -198,7 +191,6 @@ export const UploadFilesImage = ({
const isMulti = fileEntries.length > 1;
const anyFailed = fileEntries.some((e) => e.status === "error");
const anyUploading = fileEntries.some((e) => e.status === "uploading");
// Upload has started if any file has left the idle state
const hasUploadStarted = fileEntries.some((e) => e.status !== "idle");
const allDone =
fileEntries.length > 0 &&
@@ -220,7 +212,6 @@ export const UploadFilesImage = ({
{fileEntries.length > 0 && (
isMulti ? (
<PanelGroup orientation="horizontal">
{/* File list sidebar */}
<Panel defaultSize="33%" minSize="20%">
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
{fileEntries.map((entry, i) => (
@@ -287,7 +278,6 @@ export const UploadFilesImage = ({
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
</PanelResizeHandle>
{/* Preview + carousel navigation */}
<Panel defaultSize="67%" minSize="25%">
<div className="flex h-full flex-col gap-2">
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary flex items-center justify-center">
@@ -360,7 +350,6 @@ export const UploadFilesImage = ({
Retry Failed
</Button>
)}
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
{!hasUploadStarted && (
<Button
variant="primary"
@@ -1,14 +1,16 @@
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { UploadAssetError } from "../UploadAssetError";
import { UploadSuccess } from "../UploadSuccess";
import { UploadFilesImage } from "./UploadFilesImage";
import { initialUploaderState, UploaderState } from "../../../models";
import {
FilterEngineCategories,
UploaderStates,
IMAGEPLANE_FILE_TYPE,
} from "../../../enums";
UploaderState,
UploaderStates,
initialUploaderState,
} from "../Types";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
@@ -31,7 +33,6 @@ export function UploadModalImage(props: Props) {
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
// Category fixed to IMAGE_PLANE
const selectedCategory = FilterEngineCategories.IMAGE_PLANE;
const updateUploaderState = (newState: UploaderState) => {
@@ -1,9 +1,10 @@
import { v4 as uuidv4 } from "uuid";
import { MediaUploadApi } from "../../../../Classes/ApiManager";
import { UploaderStates } from "../../../../enums";
import { UploaderState } from "../../../../models";
import { getFileName } from "../../../../utilities";
import { EIntermediateFile } from "~/enums/EIntermediateFile";
import { MediaUploadApi } from "@storyteller/api";
import {
UploaderState,
UploaderStates,
getFileName,
} from "../../Types";
export const uploadImage = async ({
title,
@@ -16,8 +17,6 @@ export const uploadImage = async ({
}) => {
const mediaUploadApi = new MediaUploadApi();
console.log("Uploading image plane:", assetFile);
progressCallback({ status: UploaderStates.uploadingAsset });
const assetResponse = await mediaUploadApi.UploadImage({
@@ -25,13 +24,9 @@ export const uploadImage = async ({
fileName: getFileName(assetFile),
uuid: uuidv4(),
maybe_title: title,
is_intermediate_system_file: EIntermediateFile.false,
});
console.log("Image plane response:", assetResponse);
if (assetResponse == undefined) {
console.log("Error: Could not upload image plane!");
progressCallback({
status: UploaderStates.assetError,
errorMessage: "Could not upload image plane!",
@@ -40,7 +35,6 @@ export const uploadImage = async ({
}
if (!assetResponse.success || !assetResponse.data) {
console.log("Error:", assetResponse.errorMessage);
progressCallback({
status: UploaderStates.assetError,
errorMessage: assetResponse.errorMessage,
@@ -48,7 +42,6 @@ export const uploadImage = async ({
return;
}
console.log("Upload successful:", assetResponse.data);
progressCallback({
status: UploaderStates.success,
data: assetResponse.data,
@@ -1,8 +1,6 @@
import { UploaderStates } from "~/enums";
import { FileEntryStatus, UploaderStates } from "../../Types";
import { uploadImage } from "./uploadImage";
export type FileEntryStatus = "idle" | "uploading" | "success" | "error";
export const uploadImagesBatch = async ({
files,
onFileStatusChange,
@@ -10,11 +8,14 @@ export const uploadImagesBatch = async ({
onComplete,
}: {
files: File[];
onFileStatusChange: (index: number, status: FileEntryStatus, errorMessage?: string) => void;
onFileStatusChange: (
index: number,
status: FileEntryStatus,
errorMessage?: string,
) => void;
onOverallProgress: (completed: number, total: number) => void;
onComplete: (allSucceeded: boolean, anySucceeded: boolean) => void;
}) => {
// JS is single-threaded; these counters are safe across concurrent async callbacks.
let completedCount = 0;
let successCount = 0;
const completedIndices = new Set<number>();
@@ -47,14 +48,17 @@ export const uploadImagesBatch = async ({
}
}
},
})
)
}),
),
);
} catch (err) {
// Only mark files that haven't already reached a terminal state
files.forEach((_, i) => {
if (!completedIndices.has(i)) {
onFileStatusChange(i, "error", err instanceof Error ? err.message : "Unexpected error");
onFileStatusChange(
i,
"error",
err instanceof Error ? err.message : "Unexpected error",
);
}
});
}
@@ -18,9 +18,13 @@ import { WebGLRenderer, PerspectiveCamera } from "three";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities";
import { upload3DObjects } from "../UploadModal3D/utilities/upload3DObjects";
import { upload3DObjectsBatch } from "../UploadModal3D/utilities/upload3DObjectsBatch";
import { FileEntryStatus } from "../UploadModalImage/utilities/uploadImagesBatch";
import type { UploaderState } from "../../../models";
import { FilterEngineCategories, MediaFileAnimationType, UploaderStates } from "../../../enums";
import {
FileEntryStatus,
FilterEngineCategories,
MediaFileAnimationType,
UploaderState,
UploaderStates,
} from "../Types";
interface FileEntry {
file: File;
@@ -91,7 +95,6 @@ export const UploadFilesSplat = ({
seedFiles.map((f) => ({ file: f, status: "idle" }))
);
const [previewIndex, setPreviewIndex] = useState(0);
// Incremented on every handleFilesChange so useEffect re-runs even when count stays the same
const [filesVersion, setFilesVersion] = useState(0);
const [previewStatus, setPreviewStatus] = useState<{ type: string; message?: string }>({ type: "init" });
const [thumbnails, setThumbnails] = useState<Map<File, Blob>>(new Map());
@@ -107,8 +110,6 @@ export const UploadFilesSplat = ({
}
};
// Reload canvas preview whenever the current file or index changes.
// filesVersion ensures this re-runs when the same-count file set is replaced.
useEffect(() => {
const currentFile = fileEntries[previewIndex]?.file;
if (!canvasRef.current || !currentFile) return;
@@ -128,7 +129,6 @@ export const UploadFilesSplat = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIndex, filesVersion]);
// Snapshot thumbnail when preview finishes loading
// Defer one animation frame so the splat renderer has produced a visible frame
useEffect(() => {
if (previewStatus.type === "OK" && canvasRef.current) {
@@ -146,9 +146,9 @@ export const UploadFilesSplat = ({
});
return () => cancelAnimationFrame(rafId);
}
return undefined;
}, [previewStatus, previewIndex]);
// Reset subtype when fileSubtypes set changes
useEffect(() => {
if (!fileSubtypes || fileSubtypes.length === 0) {
setSubtype(undefined);
@@ -218,7 +218,6 @@ export const UploadFilesSplat = ({
return;
}
// Only upload files that haven't already succeeded
const pendingEntries = fileEntries
.map((e, i) => ({ entry: e, originalIndex: i }))
.filter(({ entry }) => entry.status !== "success");
@@ -231,7 +230,6 @@ export const UploadFilesSplat = ({
);
if (files.length === 1 && fileEntries.length === 1) {
// Single file: use parent modal state machine (shows loading spinner)
upload3DObjects({
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
assetFile: files[0],
@@ -243,7 +241,6 @@ export const UploadFilesSplat = ({
return;
}
// Multi-file: stay on form, show inline progress
setIsUploading(true);
setOverallProgress({ current: 0, total: files.length });
@@ -343,7 +340,6 @@ export const UploadFilesSplat = ({
{isMulti ? (
<PanelGroup orientation="horizontal">
{/* File list sidebar */}
<Panel defaultSize="33%" minSize="20%">
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-lg bg-brand-secondary p-2">
{fileEntries.map((entry, i) => (
@@ -407,7 +403,6 @@ export const UploadFilesSplat = ({
<div className="h-8 w-1 rounded-full bg-white/20 transition-colors hover:bg-white/40" />
</PanelResizeHandle>
{/* Canvas preview + carousel */}
<Panel defaultSize="67%" minSize="25%">
<div className="flex h-full flex-col gap-2">
<div className="relative w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
@@ -456,7 +451,6 @@ export const UploadFilesSplat = ({
</Panel>
</PanelGroup>
) : (
/* Single-file layout: canvas takes full width (original behaviour) */
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full min-h-48 !w-full"
@@ -499,7 +493,6 @@ export const UploadFilesSplat = ({
Retry Failed
</Button>
)}
{/* Hide Upload once any upload has started; only Retry Failed or Close remains */}
{!hasUploadStarted && (
<Button
variant="primary"
@@ -1,20 +1,21 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { initialUploaderState, UploaderState } from "../../../models";
import { UploadAssetError } from "../UploadAssetError";
import { UploadSuccess } from "../UploadSuccess";
import { UploadFilesSplat } from "./UploadFilesSplat";
import {
FilterEngineCategories,
UploaderStates,
MediaFileAnimationType,
SPLAT_FILE_TYPE,
} from "../../../enums";
UploaderState,
UploaderStates,
initialUploaderState,
} from "../Types";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { UploadFilesSplat } from "./UploadFilesSplat";
interface Props {
onClose: () => void;
@@ -91,8 +92,6 @@ export function UploadModalSplat(props: Props) {
<UploadSuccess
title="Splat"
onOk={() => {
// Open the gallery so the user can drag the freshly
// uploaded splat onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
@@ -0,0 +1 @@
export { UploadModalSplat } from "./UploadModalSplat";
@@ -1,4 +1,11 @@
export * from "./UploadModal";
// Generic pieces — small components composed by the feature-specific modals.
export * from "./UploadAssetError";
export * from "./UploadSuccess";
export * from "./Types";
// Feature-specific upload modals — drop-in for PageScene's
// renderAssetUploader / renderImageUploader / renderSplatUploader
// adapter slots.
export * from "./UploadModal3D";
export * from "./UploadModalImage";
export * from "./UploadModalSplat";
@@ -1,106 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import {
FilterEngineCategories,
} from "@storyteller/api";
import {
UploaderStates,
MediaFileAnimationType,
UploaderState
} from "@storyteller/common";
import { MediaUploadApi, MediaFilesApi } from "@storyteller/api";
export const uploadAssets = async ({
title,
assetFile,
thumbnailFile,
engineCategory,
animationType,
length,
progressCallback,
getFileName,
getFileExtension,
}: {
title: string;
assetFile: File;
engineCategory: FilterEngineCategories;
thumbnailFile?: File;
animationType?: MediaFileAnimationType;
length?: number;
progressCallback: (newState: UploaderState) => void;
getFileName: (file: File) => string;
getFileExtension: (file: File) => string;
}) => {
progressCallback({ status: UploaderStates.uploadingAsset });
const mediaUploadApi = new MediaUploadApi();
const fileExtension = getFileExtension(assetFile);
const assetReponse = await (async () => {
switch (fileExtension) {
case ".zip":
return mediaUploadApi.UploadPmx({
file: assetFile,
fileName: assetFile.name,
engine_category: engineCategory,
maybe_title: title,
maybe_animation_type: animationType,
uuid: uuidv4(),
});
default:
return mediaUploadApi.UploadNewEngineAsset({
file: assetFile,
fileName: assetFile.name,
engine_category: engineCategory,
maybe_animation_type: animationType,
maybe_duration_millis: length,
maybe_title: title,
uuid: uuidv4(),
});
}
})();
if (!assetReponse.success || !assetReponse.data) {
progressCallback({
status: UploaderStates.assetError,
errorMessage: assetReponse.errorMessage,
});
return;
}
const assetToken = assetReponse.data;
if (!thumbnailFile) {
progressCallback({ status: UploaderStates.success });
return;
}
progressCallback({ status: UploaderStates.uploadingCover });
const thumbnailResponse = await mediaUploadApi.UploadImage({
uuid: uuidv4(),
blob: thumbnailFile,
fileName: getFileName(thumbnailFile),
maybe_title: "thumbnail_" + title,
});
if (!thumbnailResponse.success || !thumbnailResponse.data) {
progressCallback({
status: UploaderStates.coverCreateError,
errorMessage: thumbnailResponse.errorMessage,
});
return;
}
progressCallback({ status: UploaderStates.settingCover });
const thumbnailToken = thumbnailResponse.data;
const mediaFilesApi = new MediaFilesApi();
const setThumbnailResponse = await mediaFilesApi.UpdateCoverImage({
mediaFileToken: assetToken,
imageToken: thumbnailToken,
});
if (!setThumbnailResponse.success) {
progressCallback({
status: UploaderStates.coverSetError,
errorMessage: setThumbnailResponse.errorMessage,
});
return;
}
progressCallback({ status: UploaderStates.success });
};
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../gallery-modal"
},
{
"path": "../modal"
},
@@ -9,14 +12,11 @@
"path": "../loading"
},
{
"path": "../../common"
"path": "../checkbox"
},
{
"path": "../file-uploader"
},
{
"path": "../input"
},
{
"path": "../list-dropdown"
},
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../gallery-modal/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
},
@@ -50,14 +53,11 @@
"path": "../loading/tsconfig.lib.json"
},
{
"path": "../../common/tsconfig.lib.json"
"path": "../checkbox/tsconfig.lib.json"
},
{
"path": "../file-uploader/tsconfig.lib.json"
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../list-dropdown/tsconfig.lib.json"
},