mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 05:20:58 -05:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470cefe0ab | ||
|
|
b07b8ce3d4 | ||
|
|
027d430245 | ||
|
|
4feda78618 | ||
|
|
e3f4ca5aae | ||
|
|
2cbfe7d292 | ||
|
|
24e62ab7df |
@@ -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"
|
||||
},
|
||||
|
||||
+5
-3
@@ -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";
|
||||
-57
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-14
@@ -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"
|
||||
+7
-7
@@ -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";
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
export * from "./loadPreviewOnCanvas";
|
||||
export * from "./thumbnailHelpers";
|
||||
export * from "./uploadAsset";
|
||||
export * from "./upload3DObjects";
|
||||
export * from "./upload3DObjectsBatch";
|
||||
+4
-20
@@ -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),
|
||||
+3
-8
@@ -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();
|
||||
+11
-12
@@ -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({
|
||||
+2
-3
@@ -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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
+4
-15
@@ -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"
|
||||
+6
-5
@@ -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) => {
|
||||
+6
-13
@@ -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,
|
||||
+13
-9
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
+8
-15
@@ -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"
|
||||
+8
-9
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user